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:
@@ -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) })) };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user