import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { ulid } from "ulid"; import { requireAuth, canAccessProject, } from "./auth.js"; import { auditLog } from "./db.js"; import { toProject, toProjectMeta, toBook, toChapter, toBoard, toPlotBeat, toAISession, toEntity, entityToRow, entitySearchText, wordCount, type ProjectRow, type ShareRow, type BookRow, type ChapterRow, type EntityRow, type BoardRow, type PlotBeatRow, type AISessionRow, } from "./mapping.js"; // ---- input schemas ---- const projectCreate = z.object({ title: z.string().min(1).max(200) }); const projectPatch = z.object({ title: z.string().min(1).max(200).optional(), settings: z.record(z.unknown()).optional(), cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(), }); const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) }); const sharePatch = z.object({ perm: z.enum(["read", "edit"]) }); const transferReq = z.object({ userId: z.string() }); const bookCreate = z.object({ title: z.string().min(1).max(200) }); const bookPatch = z.object({ title: z.string().min(1).max(200).optional(), order: z.number().int().optional(), }); const chapterCreate = z.object({ title: z.string().min(1).max(200) }); const chapterPatch = z.object({ title: z.string().min(1).max(200).optional(), doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }).optional(), }); const autosaveBody = z.object({ rev: z.number().int(), doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }), wordCount: z.number().int().optional(), }); const entityCreate = z.object({ id: z.string().optional(), kind: z.enum(["character", "world", "note"]), name: z.string().optional(), body: z.string().optional(), fields: z.record(z.string()).optional(), role: z.string().optional(), bio: z.string().optional(), appearance: z.string().optional(), voice: z.string().optional(), relationships: z.array(z.any()).optional(), arc: z.string().optional(), status: z.enum(["active", "archived"]).optional(), tags: z.array(z.string()).optional(), pinned: z.boolean().optional(), linkedTo: z.array(z.string()).optional(), }); const boardCreate = z.object({ id: z.string().optional(), nodes: z.array(z.any()).default([]), edges: z.array(z.any()).default([]), }); const boardPatch = z.object({ nodes: z.array(z.any()).optional(), edges: z.array(z.any()).optional(), }); const beatCreate = z.object({ id: z.string().optional(), title: z.string().default(""), body: z.string().default(""), act: z.number().int().min(1).default(1), type: z.enum(["setup", "conflict", "turn", "climax", "resolution", "scene", "note"]).default("note"), order: z.number().int().min(0).default(0), status: z.enum(["draft", "revised", "locked"]).default("draft"), chapterId: z.string().optional(), entityIds: z.array(z.string()).optional(), }); const aiSessionCreate = z.object({ id: z.string().optional(), title: z.string().default(""), kind: z.enum(["session", "agent"]).default("session"), messages: z.array(z.any()).default([]), suggestions: z.array(z.any()).default([]), notes: z.array(z.any()).default([]), createdAt: z.string().optional(), updatedAt: z.string().optional(), lastMessageAt: z.string().nullable().optional(), }); const aiMessageAppend = z.object({ role: z.string(), content: z.string() }); const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() }); // ---- helpers ---- function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void { requireAuth(req); if (!canAccessProject(app.db, req.user, projectId, needWrite)) { throw app.httpErrors.create(403, { error: "forbidden" }); } } function now(): string { return new Date().toISOString(); } function sharesFor(db: any, projectId: string): ShareRow[] { return db.prepare("SELECT project_id, user_id, perm FROM project_shares WHERE project_id = ?").all(projectId) as ShareRow[]; } function projectRow(db: any, id: string): ProjectRow | undefined { return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined; } export function registerRoutes(app: FastifyInstance): void { const db = app.db; // ---------- Projects ---------- app.get("/api/projects", async (req) => { requireAuth(req); const u = req.user!; const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[]; const shared = db .prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?") .all(u.id) as ProjectRow[]; const byId = new Map(); for (const r of [...owned, ...shared]) byId.set(r.id, r); return { projects: [...byId.values()].map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) }; }); app.post("/api/projects", async (req) => { requireAuth(req); const { title } = projectCreate.parse(req.body); const id = ulid(); const t = now(); db.prepare( "INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)", ).run(id, req.user!.id, title, t, t); const row = projectRow(db, id)!; return { project: toProject(row, []) }; }); app.get("/api/projects/:id", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id); const row = projectRow(db, id); if (!row) throw app.httpErrors.create(404, { error: "not_found" }); return { project: toProject(row, sharesFor(db, id)) }; }); app.patch("/api/projects/:id", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const body = projectPatch.parse(req.body); const row = projectRow(db, id); if (!row) throw app.httpErrors.create(404, { error: "not_found" }); const title = body.title ?? row.title; const settings = body.settings ? JSON.stringify(body.settings) : row.settings; const cover = body.cover ? JSON.stringify(body.cover) : row.cover; const t = now(); db.prepare("UPDATE projects SET title = ?, settings = ?, cover = ?, updated_at = ? WHERE id = ?").run( title, settings, cover, t, id, ); return { project: toProject({ ...row, title, settings, cover, updated_at: t }, sharesFor(db, id)) }; }); app.delete("/api/projects/:id", async (req) => { const { id } = req.params as { id: string }; requireAuth(req); const row = projectRow(db, id); if (!row) throw app.httpErrors.create(404, { error: "not_found" }); if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" }); db.prepare("DELETE FROM projects WHERE id = ?").run(id); return { ok: true }; }); app.post("/api/projects/:id/shares", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const { userId, perm } = shareAdd.parse(req.body); db.prepare("INSERT OR REPLACE INTO project_shares (project_id, user_id, perm) VALUES (?, ?, ?)").run(id, userId, perm); auditLog(db, "share_grant", req.user!.id, { projectId: id, userId, perm }); return { ok: true }; }); app.delete("/api/projects/:id/shares/:userId", async (req) => { const { id, userId } = req.params as { id: string; userId: string }; requireProjectAccess(app, req, id, true); db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId); auditLog(db, "share_revoke", req.user!.id, { projectId: id, userId }); return { ok: true }; }); app.patch("/api/projects/:id/shares/:userId", async (req) => { const { id, userId } = req.params as { id: string; userId: string }; requireProjectAccess(app, req, id, true); const { perm } = sharePatch.parse(req.body); const existing = db .prepare("SELECT 1 FROM project_shares WHERE project_id = ? AND user_id = ?") .get(id, userId) as { id: string } | undefined; if (!existing) throw app.httpErrors.create(404, { error: "share_not_found" }); db.prepare("UPDATE project_shares SET perm = ? WHERE project_id = ? AND user_id = ?").run(perm, id, userId); auditLog(db, "share_update", req.user!.id, { projectId: id, userId, perm }); return { ok: true }; }); app.post("/api/projects/:id/transfer", async (req) => { const { id } = req.params as { id: string }; const { userId } = transferReq.parse(req.body); requireAuth(req); const row = projectRow(db, id); if (!row) throw app.httpErrors.create(404, { error: "not_found" }); if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" }); const target = db .prepare("SELECT id FROM users WHERE id = ? AND disabled = 0") .get(userId) as { id: string } | undefined; if (!target) throw app.httpErrors.create(404, { error: "user_not_found" }); db.prepare("UPDATE projects SET owner_id = ? WHERE id = ?").run(userId, id); auditLog(db, "project_transfer", req.user!.id, { projectId: id, newOwnerId: userId }); return { ok: true }; }); // ---------- Books ---------- app.get("/api/projects/:id/books", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id); const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(id) as BookRow[]; return { books: books.map((b) => { const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(b.id) as ChapterRow[]; return toBook(b, chapters); }), }; }); app.post("/api/projects/:id/books", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const { title } = bookCreate.parse(req.body); const bid = ulid(); const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM books WHERE project_id = ?").get(id) as { n: number }).n; db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, ?, ?)").run(bid, id, title, ord); const row = db.prepare("SELECT * FROM books WHERE id = ?").get(bid) as BookRow; return { book: toBook(row, []) }; }); app.patch("/api/books/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const body = bookPatch.parse(req.body); const title = body.title ?? row.title; const ord = body.order ?? row.ord; db.prepare("UPDATE books SET title = ?, ord = ? WHERE id = ?").run(title, ord, id); const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(id) as ChapterRow[]; return { book: toBook({ ...row, title, ord }, chapters) }; }); app.delete("/api/books/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); db.prepare("DELETE FROM books WHERE id = ?").run(id); return { ok: true }; }); // ---------- Chapters ---------- app.get("/api/books/:id/chapters", async (req) => { const { id } = req.params as { id: string }; const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined; if (!book) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, book.project_id); const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY ord").all(id) as ChapterRow[]; return { chapters: chapters.map(toChapter) }; }); app.post("/api/books/:id/chapters", async (req) => { const { id } = req.params as { id: string }; const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined; if (!book) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, book.project_id, true); const { title } = chapterCreate.parse(req.body); const cid = ulid(); const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?").get(id) as { n: number }).n; const t = now(); db.prepare( "INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, '{\"type\":\"doc\",\"content\":[]}', 0, ?, ?)", ).run(cid, id, book.project_id, title, ord, t); const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(cid) as ChapterRow; return { chapter: toChapter(row) }; }); app.get("/api/chapters/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id); return { chapter: toChapter(row) }; }); app.patch("/api/chapters/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const body = chapterPatch.parse(req.body); const title = body.title ?? row.title; const doc = body.doc ? JSON.stringify(body.doc) : row.doc; const t = now(); db.prepare("UPDATE chapters SET title = ?, doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(title, doc, t, id); const updated = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow; return { chapter: toChapter(updated) }; }); // Optimistic autosave (CHUNK #2 acceptance criterion). app.post("/api/chapters/:id/autosave", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body); if (rev === row.rev) { const t = now(); const count = wc ?? wordCount(doc); db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id); return { ok: true, rev: row.rev + 1, wordCount: count }; } // Stale client — return the server's current state so it can reconcile. throw app.httpErrors.create(409, { error: "conflict", serverRev: row.rev, serverDoc: JSON.parse(row.doc), }); }); app.delete("/api/chapters/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); db.prepare("DELETE FROM chapters WHERE id = ?").run(id); return { ok: true }; }); // ---------- Entities (characters / world / notes) ---------- app.get("/api/projects/:id/entities", async (req) => { const { id } = req.params as { id: string }; const { kind, q } = req.query as { kind?: string; q?: string }; requireProjectAccess(app, req, id); let rows: EntityRow[]; if (kind) { rows = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ?").all(id, kind) as EntityRow[]; } else { rows = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(id) as EntityRow[]; } let entities = rows.map(toEntity); if (q) { const needle = q.toLowerCase(); entities = entities.filter((e) => entitySearchText(e).toLowerCase().includes(needle)); } return { entities }; }); app.post("/api/projects/:id/entities", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const body = entityCreate.parse(req.body); const eid = body.id ?? ulid(); const t = now(); // Build the core Entity from the input, then map to a row. let entity: any; if (body.kind === "character") { entity = { id: eid, projectId: id, name: body.name ?? "", role: body.role ?? "", bio: body.bio ?? "", appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [], arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [], }; } else if (body.kind === "world") { entity = { id: eid, projectId: id, kind: body.fields?.kind ?? "location", name: body.name ?? "", fields: body.fields ?? {} }; } else { entity = { id: eid, projectId: id, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] }; } const row = entityToRow(entity); db.prepare( `INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, kind, id) DO UPDATE SET name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`, ).run(eid, id, row.kind, row.name, row.fields, t, t); const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow; return { entity: toEntity(stored) }; }); app.get("/api/projects/:projectId/entities/:kind/:id", async (req) => { const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string }; requireProjectAccess(app, req, projectId); const row = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); return { entity: toEntity(row) }; }); app.patch("/api/projects/:projectId/entities/:kind/:id", async (req) => { const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string }; requireProjectAccess(app, req, projectId, true); const existing = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined; if (!existing) throw app.httpErrors.create(404, { error: "not_found" }); const body = entityCreate.parse(req.body); let entity: any; if (kind === "character") { entity = { id, projectId, name: body.name ?? existing.name, role: body.role ?? "", bio: body.bio ?? "", appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [], arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [], }; } else if (kind === "world") { entity = { id, projectId, kind: body.fields?.kind ?? "location", name: body.name ?? existing.name, fields: body.fields ?? {} }; } else { entity = { id, projectId, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] }; } const row = entityToRow(entity); const t = now(); db.prepare( "UPDATE entities SET name = ?, fields = ?, updated_at = ? WHERE project_id = ? AND kind = ? AND id = ?", ).run(row.name, row.fields, t, projectId, kind, id); const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow; return { entity: toEntity(stored) }; }); app.delete("/api/projects/:projectId/entities/:kind/:id", async (req) => { const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string }; requireProjectAccess(app, req, projectId, true); const res = db.prepare("DELETE FROM entities WHERE project_id = ? AND kind = ? AND id = ?").run(projectId, kind, id); if (res.changes === 0) throw app.httpErrors.create(404, { error: "not_found" }); return { ok: true }; }); // ---------- Boards ---------- app.get("/api/projects/:id/boards", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id); const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(id) as BoardRow[]; return { boards: boards.map(toBoard) }; }); app.post("/api/projects/:id/boards", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const body = boardCreate.parse(req.body); const bid = body.id ?? ulid(); const t = now(); db.prepare(`INSERT INTO boards (id, project_id, nodes, edges, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, nodes = excluded.nodes, edges = excluded.edges, updated_at = excluded.updated_at`).run( bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t, ); const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow; return { board: toBoard(row) }; }); app.get("/api/boards/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id); return { board: toBoard(row) }; }); app.patch("/api/boards/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const body = boardPatch.parse(req.body); const nodes = body.nodes ? JSON.stringify(body.nodes) : row.nodes; const edges = body.edges ? JSON.stringify(body.edges) : row.edges; const t = now(); db.prepare("UPDATE boards SET nodes = ?, edges = ?, updated_at = ? WHERE id = ?").run(nodes, edges, t, id); const updated = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow; return { board: toBoard(updated) }; }); app.delete("/api/boards/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); db.prepare("DELETE FROM boards WHERE id = ?").run(id); return { ok: true }; }); // ---------- Plot beats ---------- app.get("/api/projects/:id/beats", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id); const rows = db .prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order, id") .all(id) as PlotBeatRow[]; return { beats: rows.map(toPlotBeat) }; }); app.post("/api/projects/:id/beats", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const body = beatCreate.parse(req.body); const bid = body.id ?? ulid(); const t = now(); db.prepare(`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, status, chapter_id, entity_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, id) DO UPDATE SET title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type, beat_order = excluded.beat_order, status = excluded.status, chapter_id = excluded.chapter_id, entity_ids = excluded.entity_ids, updated_at = excluded.updated_at`).run( bid, id, body.title, body.body, body.act, body.type, body.order, body.status ?? "draft", body.chapterId ?? null, JSON.stringify(body.entityIds ?? []), t, t, ); const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(bid, id) as PlotBeatRow; return { beat: toPlotBeat(row) }; }); app.delete("/api/projects/:id/beats/:beatId", async (req) => { const { id, beatId } = req.params as { id: string; beatId: string }; requireProjectAccess(app, req, id, true); const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(beatId, id) as | PlotBeatRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); db.prepare("DELETE FROM plot_beats WHERE id = ? AND project_id = ?").run(beatId, id); return { ok: true }; }); // ---------- AI sessions ---------- app.get("/api/projects/:id/ai/sessions", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id); const rows = db.prepare("SELECT * FROM ai_sessions WHERE project_id = ?").all(id) as AISessionRow[]; return { sessions: rows.map(toAISession) }; }); app.post("/api/projects/:id/ai/sessions", async (req) => { const { id } = req.params as { id: string }; requireProjectAccess(app, req, id, true); const body = aiSessionCreate.parse(req.body); const sid = body.id ?? ulid(); const t = now(); db.prepare( `INSERT INTO ai_sessions (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, kind = excluded.kind, messages = excluded.messages, suggestions = excluded.suggestions, notes = excluded.notes, updated_at = excluded.updated_at, last_message_at = excluded.last_message_at`, ).run(sid, id, body.title, body.kind, JSON.stringify(body.messages), JSON.stringify(body.suggestions), JSON.stringify(body.notes), body.createdAt ?? t, t, body.lastMessageAt ?? null); const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow; return { session: toAISession(row) }; }); app.get("/api/ai/sessions/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id); return { session: toAISession(row) }; }); app.patch("/api/ai/sessions/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const body = z.object({ title: z.string().optional(), kind: z.enum(["session", "agent"]).optional(), messages: z.array(z.any()).optional(), suggestions: z.array(z.any()).optional(), notes: z.array(z.any()).optional(), lastMessageAt: z.string().nullable().optional(), }).parse(req.body); const t = now(); db.prepare( `UPDATE ai_sessions SET title = ?, kind = ?, messages = ?, suggestions = ?, notes = ?, last_message_at = ?, updated_at = ? WHERE id = ?` ).run( body.title ?? row.title, body.kind ?? row.kind, JSON.stringify(body.messages ?? JSON.parse(row.messages)), JSON.stringify(body.suggestions ?? JSON.parse(row.suggestions)), JSON.stringify(body.notes ?? JSON.parse(row.notes)), body.lastMessageAt ?? row.last_message_at, t, id, ); const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; return { session: toAISession(updated) }; }); app.delete("/api/ai/sessions/:id", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); db.prepare("DELETE FROM ai_sessions WHERE id = ?").run(id); return { ok: true }; }); app.post("/api/ai/sessions/:id/messages", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const { role, content } = aiMessageAppend.parse(req.body); const messages = JSON.parse(row.messages) as any[]; messages.push({ role, content, at: now() }); const t = now(); db.prepare("UPDATE ai_sessions SET messages = ?, last_message_at = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, t, id); const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; return { session: toAISession(updated) }; }); app.post("/api/ai/sessions/:id/suggestions", async (req) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const { kind, text } = aiSuggestionAdd.parse(req.body); const suggestions = JSON.parse(row.suggestions) as any[]; const sug = { id: ulid(), kind, text, at: now() }; suggestions.push(sug); const t = now(); db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id); const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; return { session: toAISession(updated) }; }); app.delete("/api/ai/sessions/:id/suggestions/:sid", async (req) => { const { id, sid } = req.params as { id: string; sid: string }; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); requireProjectAccess(app, req, row.project_id, true); const suggestions = (JSON.parse(row.suggestions) as any[]).filter((s) => s.id !== sid); const t = now(); db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id); const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; return { session: toAISession(updated) }; }); }