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:
2026-03-15 16:31:48 +01:00
parent 629e14f60c
commit ed8508110f
4 changed files with 30 additions and 23 deletions

View File

@@ -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"] });

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();