From ed8508110f81776b5beffe6e8673f229c3f769e6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 16:31:48 +0100 Subject: [PATCH] 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) --- src/client/hooks/useThreads.ts | 5 +++- src/server/services/thread.service.ts | 8 +++++-- tests/routes/threads.test.ts | 6 ++--- tests/services/thread.service.test.ts | 34 +++++++++++++-------------- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts index b09c7f8..e33386d 100644 --- a/src/client/hooks/useThreads.ts +++ b/src/client/hooks/useThreads.ts @@ -6,6 +6,9 @@ interface ThreadListItem { name: string; status: "active" | "resolved"; resolvedCandidateId: number | null; + categoryId: number; + categoryName: string; + categoryEmoji: string; createdAt: string; updatedAt: string; candidateCount: number; @@ -60,7 +63,7 @@ export function useThread(threadId: number | null) { export function useCreateThread() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name: string }) => + mutationFn: (data: { name: string; categoryId: number }) => apiPost("/api/threads", data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index 6362480..f8e82ba 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -8,7 +8,7 @@ type Db = typeof prodDb; export function createThread(db: Db = prodDb, data: CreateThread) { return db .insert(threads) - .values({ name: data.name }) + .values({ name: data.name, categoryId: data.categoryId }) .returning() .get(); } @@ -20,6 +20,9 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) { name: threads.name, status: threads.status, resolvedCandidateId: threads.resolvedCandidateId, + categoryId: threads.categoryId, + categoryName: categories.name, + categoryEmoji: categories.emoji, createdAt: threads.createdAt, updatedAt: threads.updatedAt, candidateCount: sql`( @@ -36,6 +39,7 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) { )`.as("max_price_cents"), }) .from(threads) + .innerJoin(categories, eq(threads.categoryId, categories.id)) .orderBy(desc(threads.createdAt)); if (!includeResolved) { @@ -73,7 +77,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) { 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) .where(eq(threads.id, threadId)).get(); if (!existing) return null; diff --git a/tests/routes/threads.test.ts b/tests/routes/threads.test.ts index 6ec2741..0a6e3e8 100644 --- a/tests/routes/threads.test.ts +++ b/tests/routes/threads.test.ts @@ -17,11 +17,11 @@ function createTestApp() { 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", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, categoryId }), }); return res.json(); } @@ -48,7 +48,7 @@ describe("Thread Routes", () => { const res = await app.request("/api/threads", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "New Tent" }), + body: JSON.stringify({ name: "New Tent", categoryId: 1 }), }); expect(res.status).toBe(201); diff --git a/tests/services/thread.service.test.ts b/tests/services/thread.service.test.ts index 6a19c6e..b92a666 100644 --- a/tests/services/thread.service.test.ts +++ b/tests/services/thread.service.test.ts @@ -22,7 +22,7 @@ describe("Thread Service", () => { describe("createThread", () => { 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.id).toBeGreaterThan(0); @@ -36,7 +36,7 @@ describe("Thread Service", () => { describe("getAllThreads", () => { 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, { name: "Pack A", categoryId: 1, @@ -57,8 +57,8 @@ describe("Thread Service", () => { }); it("excludes resolved threads by default", () => { - const t1 = createThread(db, { name: "Active Thread" }); - const t2 = createThread(db, { name: "Resolved Thread" }); + const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); + const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const candidate = createCandidate(db, t2.id, { name: "Winner", categoryId: 1, @@ -71,8 +71,8 @@ describe("Thread Service", () => { }); it("includes resolved threads when includeResolved=true", () => { - const t1 = createThread(db, { name: "Active Thread" }); - const t2 = createThread(db, { name: "Resolved Thread" }); + const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); + const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const candidate = createCandidate(db, t2.id, { name: "Winner", categoryId: 1, @@ -86,7 +86,7 @@ describe("Thread Service", () => { describe("getThreadWithCandidates", () => { 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, { name: "Tent A", categoryId: 1, @@ -111,7 +111,7 @@ describe("Thread Service", () => { describe("createCandidate", () => { 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, { name: "Tent A", weightGrams: 1200, @@ -135,7 +135,7 @@ describe("Thread Service", () => { describe("updateCandidate", () => { 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, { name: "Original", categoryId: 1, @@ -159,7 +159,7 @@ describe("Thread Service", () => { describe("deleteCandidate", () => { 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, { name: "To Delete", categoryId: 1, @@ -182,7 +182,7 @@ describe("Thread Service", () => { describe("updateThread", () => { 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" }); expect(updated).toBeDefined(); @@ -197,7 +197,7 @@ describe("Thread Service", () => { describe("deleteThread", () => { 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 }); const deleted = deleteThread(db, thread.id); @@ -217,7 +217,7 @@ describe("Thread Service", () => { describe("resolveThread", () => { 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, { name: "Winner Tent", weightGrams: 1200, @@ -244,7 +244,7 @@ describe("Thread Service", () => { }); 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, { name: "Winner", categoryId: 1, @@ -258,8 +258,8 @@ describe("Thread Service", () => { }); it("fails if candidate is not in thread", () => { - const thread1 = createThread(db, { name: "Thread 1" }); - const thread2 = createThread(db, { name: "Thread 2" }); + const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 }); + const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 }); const candidate = createCandidate(db, thread2.id, { name: "Wrong Thread", categoryId: 1, @@ -271,7 +271,7 @@ describe("Thread Service", () => { }); 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); expect(result.success).toBe(false); expect(result.error).toBeDefined();