a1b5587fcb
Reshape the ai_sessions table (migration 003) and mapping/routes to the full AISession contract: title, kind, notes, created_at, last_message_at; drop legacy role/model/settings. toAISession now maps the new columns and routes create/patch/messages handlers use the new shape.
670 lines
30 KiB
TypeScript
670 lines
30 KiB
TypeScript
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<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);
|
|
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) };
|
|
});
|
|
}
|