feat(04-01): update thread service, routes, and hooks for categoryId
- createThread now inserts categoryId from data - getAllThreads joins categories table, returns categoryName/categoryEmoji - updateThread accepts optional categoryId - ThreadListItem interface includes category fields - useCreateThread hook sends categoryId - Fix test files to pass categoryId when creating threads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ interface ThreadListItem {
|
|||||||
name: string;
|
name: string;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
categoryEmoji: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
candidateCount: number;
|
candidateCount: number;
|
||||||
@@ -60,7 +63,7 @@ export function useThread(threadId: number | null) {
|
|||||||
export function useCreateThread() {
|
export function useCreateThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { name: string }) =>
|
mutationFn: (data: { name: string; categoryId: number }) =>
|
||||||
apiPost<ThreadListItem>("/api/threads", data),
|
apiPost<ThreadListItem>("/api/threads", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Db = typeof prodDb;
|
|||||||
export function createThread(db: Db = prodDb, data: CreateThread) {
|
export function createThread(db: Db = prodDb, data: CreateThread) {
|
||||||
return db
|
return db
|
||||||
.insert(threads)
|
.insert(threads)
|
||||||
.values({ name: data.name })
|
.values({ name: data.name, categoryId: data.categoryId })
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,9 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) {
|
|||||||
name: threads.name,
|
name: threads.name,
|
||||||
status: threads.status,
|
status: threads.status,
|
||||||
resolvedCandidateId: threads.resolvedCandidateId,
|
resolvedCandidateId: threads.resolvedCandidateId,
|
||||||
|
categoryId: threads.categoryId,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryEmoji: categories.emoji,
|
||||||
createdAt: threads.createdAt,
|
createdAt: threads.createdAt,
|
||||||
updatedAt: threads.updatedAt,
|
updatedAt: threads.updatedAt,
|
||||||
candidateCount: sql<number>`(
|
candidateCount: sql<number>`(
|
||||||
@@ -36,6 +39,7 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) {
|
|||||||
)`.as("max_price_cents"),
|
)`.as("max_price_cents"),
|
||||||
})
|
})
|
||||||
.from(threads)
|
.from(threads)
|
||||||
|
.innerJoin(categories, eq(threads.categoryId, categories.id))
|
||||||
.orderBy(desc(threads.createdAt));
|
.orderBy(desc(threads.createdAt));
|
||||||
|
|
||||||
if (!includeResolved) {
|
if (!includeResolved) {
|
||||||
@@ -73,7 +77,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
|||||||
return { ...thread, candidates: candidateList };
|
return { ...thread, candidates: candidateList };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string }>) {
|
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) {
|
||||||
const existing = db.select({ id: threads.id }).from(threads)
|
const existing = db.select({ id: threads.id }).from(threads)
|
||||||
.where(eq(threads.id, threadId)).get();
|
.where(eq(threads.id, threadId)).get();
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ function createTestApp() {
|
|||||||
return { app, db };
|
return { app, db };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createThreadViaAPI(app: Hono, name: string) {
|
async function createThreadViaAPI(app: Hono, name: string, categoryId = 1) {
|
||||||
const res = await app.request("/api/threads", {
|
const res = await app.request("/api/threads", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, categoryId }),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ describe("Thread Routes", () => {
|
|||||||
const res = await app.request("/api/threads", {
|
const res = await app.request("/api/threads", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: "New Tent" }),
|
body: JSON.stringify({ name: "New Tent", categoryId: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("createThread", () => {
|
describe("createThread", () => {
|
||||||
it("creates thread with name, returns thread with id/status/timestamps", () => {
|
it("creates thread with name, returns thread with id/status/timestamps", () => {
|
||||||
const thread = createThread(db, { name: "New Tent" });
|
const thread = createThread(db, { name: "New Tent", categoryId: 1 });
|
||||||
|
|
||||||
expect(thread).toBeDefined();
|
expect(thread).toBeDefined();
|
||||||
expect(thread.id).toBeGreaterThan(0);
|
expect(thread.id).toBeGreaterThan(0);
|
||||||
@@ -36,7 +36,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("getAllThreads", () => {
|
describe("getAllThreads", () => {
|
||||||
it("returns active threads with candidateCount and price range", () => {
|
it("returns active threads with candidateCount and price range", () => {
|
||||||
const thread = createThread(db, { name: "Backpack Options" });
|
const thread = createThread(db, { name: "Backpack Options", categoryId: 1 });
|
||||||
createCandidate(db, thread.id, {
|
createCandidate(db, thread.id, {
|
||||||
name: "Pack A",
|
name: "Pack A",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -57,8 +57,8 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("excludes resolved threads by default", () => {
|
it("excludes resolved threads by default", () => {
|
||||||
const t1 = createThread(db, { name: "Active Thread" });
|
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
||||||
const t2 = createThread(db, { name: "Resolved Thread" });
|
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, t2.id, {
|
const candidate = createCandidate(db, t2.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -71,8 +71,8 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes resolved threads when includeResolved=true", () => {
|
it("includes resolved threads when includeResolved=true", () => {
|
||||||
const t1 = createThread(db, { name: "Active Thread" });
|
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
||||||
const t2 = createThread(db, { name: "Resolved Thread" });
|
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, t2.id, {
|
const candidate = createCandidate(db, t2.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -86,7 +86,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("getThreadWithCandidates", () => {
|
describe("getThreadWithCandidates", () => {
|
||||||
it("returns thread with nested candidates array including category info", () => {
|
it("returns thread with nested candidates array including category info", () => {
|
||||||
const thread = createThread(db, { name: "Tent Options" });
|
const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
|
||||||
createCandidate(db, thread.id, {
|
createCandidate(db, thread.id, {
|
||||||
name: "Tent A",
|
name: "Tent A",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -111,7 +111,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("createCandidate", () => {
|
describe("createCandidate", () => {
|
||||||
it("adds candidate to thread with all item-compatible fields", () => {
|
it("adds candidate to thread with all item-compatible fields", () => {
|
||||||
const thread = createThread(db, { name: "Tent Options" });
|
const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "Tent A",
|
name: "Tent A",
|
||||||
weightGrams: 1200,
|
weightGrams: 1200,
|
||||||
@@ -135,7 +135,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("updateCandidate", () => {
|
describe("updateCandidate", () => {
|
||||||
it("updates candidate fields, returns updated candidate", () => {
|
it("updates candidate fields, returns updated candidate", () => {
|
||||||
const thread = createThread(db, { name: "Test" });
|
const thread = createThread(db, { name: "Test", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "Original",
|
name: "Original",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -159,7 +159,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("deleteCandidate", () => {
|
describe("deleteCandidate", () => {
|
||||||
it("removes candidate, returns deleted candidate", () => {
|
it("removes candidate, returns deleted candidate", () => {
|
||||||
const thread = createThread(db, { name: "Test" });
|
const thread = createThread(db, { name: "Test", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "To Delete",
|
name: "To Delete",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -182,7 +182,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("updateThread", () => {
|
describe("updateThread", () => {
|
||||||
it("updates thread name", () => {
|
it("updates thread name", () => {
|
||||||
const thread = createThread(db, { name: "Original" });
|
const thread = createThread(db, { name: "Original", categoryId: 1 });
|
||||||
const updated = updateThread(db, thread.id, { name: "Renamed" });
|
const updated = updateThread(db, thread.id, { name: "Renamed" });
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
@@ -197,7 +197,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("deleteThread", () => {
|
describe("deleteThread", () => {
|
||||||
it("removes thread and cascading candidates", () => {
|
it("removes thread and cascading candidates", () => {
|
||||||
const thread = createThread(db, { name: "To Delete" });
|
const thread = createThread(db, { name: "To Delete", categoryId: 1 });
|
||||||
createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 });
|
createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 });
|
||||||
|
|
||||||
const deleted = deleteThread(db, thread.id);
|
const deleted = deleteThread(db, thread.id);
|
||||||
@@ -217,7 +217,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("resolveThread", () => {
|
describe("resolveThread", () => {
|
||||||
it("atomically creates collection item from candidate data and archives thread", () => {
|
it("atomically creates collection item from candidate data and archives thread", () => {
|
||||||
const thread = createThread(db, { name: "Tent Decision" });
|
const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "Winner Tent",
|
name: "Winner Tent",
|
||||||
weightGrams: 1200,
|
weightGrams: 1200,
|
||||||
@@ -244,7 +244,7 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails if thread is not active", () => {
|
it("fails if thread is not active", () => {
|
||||||
const thread = createThread(db, { name: "Already Resolved" });
|
const thread = createThread(db, { name: "Already Resolved", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -258,8 +258,8 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails if candidate is not in thread", () => {
|
it("fails if candidate is not in thread", () => {
|
||||||
const thread1 = createThread(db, { name: "Thread 1" });
|
const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 });
|
||||||
const thread2 = createThread(db, { name: "Thread 2" });
|
const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, thread2.id, {
|
const candidate = createCandidate(db, thread2.id, {
|
||||||
name: "Wrong Thread",
|
name: "Wrong Thread",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -271,7 +271,7 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails if candidate not found", () => {
|
it("fails if candidate not found", () => {
|
||||||
const thread = createThread(db, { name: "Test" });
|
const thread = createThread(db, { name: "Test", categoryId: 1 });
|
||||||
const result = resolveThread(db, thread.id, 9999);
|
const result = resolveThread(db, thread.id, 9999);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBeDefined();
|
expect(result.error).toBeDefined();
|
||||||
|
|||||||
Reference in New Issue
Block a user