Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed

Complete the server-backed storage layer for PubScript so the app can run
against a real Fastify + SQLite backend instead of only local IndexedDB.

Server (@pubscript/server):
- db.ts: better-sqlite3 schema + migrations (users, shares, projects, books,
  chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent
  admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD.
- auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes.
- routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions
  with multi-tenant isolation and optimistic concurrency (baseRev).
- ws.ts: Fastify websocket hub for autosave + presence events.
- mapping.ts: row <-> core type conversion.

Core (@pubscript/core):
- Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions)
  exported from index.
- storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters
  implementing the StorageAdapter contract.

Tests & tooling:
- storage.contract.test.ts: parameterized over both adapters; wired as
  `pnpm contract` and added to CI.
- seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor"
  — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board.
- Dockerfile + compose wiring for the server runtime.

Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
Schwitzz
2026-10-24 18:31:20 -05:00
parent 53f4c5b4c7
commit 1c9b531996
95 changed files with 10006 additions and 16 deletions
+584
View File
@@ -0,0 +1,584 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { ulid } from "ulid";
import {
requireAuth,
requireAdmin,
canAccessProject,
} from "./auth.js";
import {
toProject,
toProjectMeta,
toBook,
toChapter,
toBoard,
toAISession,
toEntity,
entityToRow,
wordCount,
type ProjectRow,
type ShareRow,
type BookRow,
type ChapterRow,
type EntityRow,
type BoardRow,
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 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.string().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 aiSessionCreate = z.object({
id: z.string().optional(),
role: z.string().min(1),
model: z.string().min(1),
settings: z.record(z.unknown()).default({}),
messages: z.array(z.any()).optional(),
suggestions: z.array(z.any()).optional(),
});
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
const userPatch = z.object({
role: z.enum(["admin", "author"]).optional(),
disabled: z.boolean().optional(),
displayName: z.string().min(1).max(80).optional(),
});
// ---- 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<string, ProjectRow>();
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);
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);
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 } = req.query as { kind?: 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[];
}
return { entities: rows.map(toEntity) };
});
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 };
});
// ---------- 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, role, model, settings, messages, suggestions, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, role = excluded.role, model = excluded.model,
settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions,
updated_at = excluded.updated_at`,
).run(sid, id, body.role, body.model, JSON.stringify(body.settings),
JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t);
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({ settings: z.record(z.unknown()).optional(), model: z.string().optional() }).parse(req.body);
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
const model = body.model ?? row.model;
const t = now();
db.prepare("UPDATE ai_sessions SET settings = ?, model = ?, updated_at = ? WHERE id = ?").run(settings, model, 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 = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), 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) };
});
// ---------- Admin ----------
app.get("/api/admin/users", async (req) => {
requireAdmin(req);
const rows = db.prepare("SELECT id, email, display_name, role, disabled, created_at FROM users").all() as any[];
return { users: rows };
});
app.patch("/api/admin/users/:id", async (req) => {
requireAdmin(req);
const { id } = req.params as { id: string };
const body = userPatch.parse(req.body);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
const role = body.role ?? row.role;
const disabled = body.disabled ? 1 : row.disabled;
const displayName = body.displayName ?? row.display_name;
db.prepare("UPDATE users SET role = ?, disabled = ?, display_name = ? WHERE id = ?").run(role, disabled, displayName, id);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.user_update', ?)").run(
ulid(), now(), req.user!.id, JSON.stringify({ target: id, role, disabled: !!disabled }),
);
return { user: { id, email: row.email, displayName, role, disabled: !!disabled, createdAt: row.created_at } };
});
app.get("/api/admin/audit", async (req) => {
requireAdmin(req);
const limit = Math.min(Number((req.query as any).limit ?? 100), 500);
const rows = db.prepare("SELECT id, at, user_id, action, detail FROM audit ORDER BY at DESC LIMIT ?").all(limit) as any[];
return { audit: rows.map((r) => ({ ...r, detail: JSON.parse(r.detail) })) };
});
}