From add3e3371dece4af65e7ce65080255265f605501 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 11:39:15 +0100 Subject: [PATCH] feat(02-01): add thread API routes and mount in server - Thread CRUD: GET /, POST /, GET /:id, PUT /:id, DELETE /:id - Candidate CRUD: POST /:id/candidates, PUT/DELETE nested candidates - Resolution: POST /:id/resolve with validation and error handling - Image cleanup on thread/candidate deletion - Routes mounted at /api/threads in server index Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/index.ts | 2 + src/server/routes/threads.ts | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/server/routes/threads.ts diff --git a/src/server/index.ts b/src/server/index.ts index 20fffcb..a22c592 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,7 @@ import { categoryRoutes } from "./routes/categories.ts"; import { totalRoutes } from "./routes/totals.ts"; import { imageRoutes } from "./routes/images.ts"; import { settingsRoutes } from "./routes/settings.ts"; +import { threadRoutes } from "./routes/threads.ts"; // Seed default data on startup seedDefaults(); @@ -23,6 +24,7 @@ app.route("/api/categories", categoryRoutes); app.route("/api/totals", totalRoutes); app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); +app.route("/api/threads", threadRoutes); // Serve uploaded images app.use("/uploads/*", serveStatic({ root: "./" })); diff --git a/src/server/routes/threads.ts b/src/server/routes/threads.ts new file mode 100644 index 0000000..6e64940 --- /dev/null +++ b/src/server/routes/threads.ts @@ -0,0 +1,136 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { + createThreadSchema, + updateThreadSchema, + createCandidateSchema, + updateCandidateSchema, + resolveThreadSchema, +} from "../../shared/schemas.ts"; +import { + getAllThreads, + getThreadWithCandidates, + createThread, + updateThread, + deleteThread, + createCandidate, + updateCandidate, + deleteCandidate, + resolveThread, +} from "../services/thread.service.ts"; +import { unlink } from "node:fs/promises"; +import { join } from "node:path"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +// Thread CRUD + +app.get("/", (c) => { + const db = c.get("db"); + const includeResolved = c.req.query("includeResolved") === "true"; + const threads = getAllThreads(db, includeResolved); + return c.json(threads); +}); + +app.post("/", zValidator("json", createThreadSchema), (c) => { + const db = c.get("db"); + const data = c.req.valid("json"); + const thread = createThread(db, data); + return c.json(thread, 201); +}); + +app.get("/:id", (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + const thread = getThreadWithCandidates(db, id); + if (!thread) return c.json({ error: "Thread not found" }, 404); + return c.json(thread); +}); + +app.put("/:id", zValidator("json", updateThreadSchema), (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + const data = c.req.valid("json"); + const thread = updateThread(db, id, data); + if (!thread) return c.json({ error: "Thread not found" }, 404); + return c.json(thread); +}); + +app.delete("/:id", async (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + const deleted = deleteThread(db, id); + if (!deleted) return c.json({ error: "Thread not found" }, 404); + + // Clean up candidate image files + for (const filename of deleted.candidateImages) { + try { + await unlink(join("uploads", filename)); + } catch { + // File missing is not an error worth failing the delete over + } + } + + return c.json({ success: true }); +}); + +// Candidate CRUD (nested under thread) + +app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => { + const db = c.get("db"); + const threadId = Number(c.req.param("id")); + + // Verify thread exists + const thread = getThreadWithCandidates(db, threadId); + if (!thread) return c.json({ error: "Thread not found" }, 404); + + const data = c.req.valid("json"); + const candidate = createCandidate(db, threadId, data); + return c.json(candidate, 201); +}); + +app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => { + const db = c.get("db"); + const candidateId = Number(c.req.param("candidateId")); + const data = c.req.valid("json"); + const candidate = updateCandidate(db, candidateId, data); + if (!candidate) return c.json({ error: "Candidate not found" }, 404); + return c.json(candidate); +}); + +app.delete("/:threadId/candidates/:candidateId", async (c) => { + const db = c.get("db"); + const candidateId = Number(c.req.param("candidateId")); + const deleted = deleteCandidate(db, candidateId); + if (!deleted) return c.json({ error: "Candidate not found" }, 404); + + // Clean up image file if exists + if (deleted.imageFilename) { + try { + await unlink(join("uploads", deleted.imageFilename)); + } catch { + // File missing is not an error + } + } + + return c.json({ success: true }); +}); + +// Resolution + +app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { + const db = c.get("db"); + const threadId = Number(c.req.param("id")); + const { candidateId } = c.req.valid("json"); + + const result = resolveThread(db, threadId, candidateId); + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json({ success: true, item: result.item }); +}); + +export { app as threadRoutes };