diff --git a/migrations/0002_stories.sql b/migrations/0002_stories.sql new file mode 100644 index 0000000..733c247 --- /dev/null +++ b/migrations/0002_stories.sql @@ -0,0 +1,14 @@ +-- Up Migration +CREATE TABLE stories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + doc JSONB NOT NULL, + published BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX stories_published_idx ON stories (published, updated_at DESC); + +-- Down Migration +DROP TABLE stories; diff --git a/src/app.ts b/src/app.ts index 46b66e4..0e573c9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,8 @@ import { adminRouter } from "./routes/admin.js"; import { healthRouter } from "./routes/health.js"; import { logDemoRouter } from "./routes/log-demo.js"; import { rootRouter } from "./routes/root.js"; +import { storiesRouter } from "./routes/stories.js"; +import { memoryStore, pgStore, type StoryStore } from "./lib/story-store.js"; export interface Deps { config: Config; @@ -22,6 +24,7 @@ export interface Deps { cache?: Cache; flags?: Flags; runtimeConfig?: RuntimeConfig; + store?: StoryStore; } export function createApp({ @@ -31,6 +34,7 @@ export function createApp({ cache, flags, runtimeConfig = staticRuntimeConfig(), + store = db ? pgStore(db) : memoryStore(), }: Deps) { const app = express(); app.disable("x-powered-by"); @@ -59,6 +63,7 @@ export function createApp({ app.use(healthRouter({ db, cache })); app.use(rootRouter({ config, db, cache, flags, runtimeConfig })); + app.use(storiesRouter({ store })); app.use(adminRouter({ runtimeConfig })); app.use(logDemoRouter()); diff --git a/src/lib/story-store.ts b/src/lib/story-store.ts new file mode 100644 index 0000000..a3e176a --- /dev/null +++ b/src/lib/story-store.ts @@ -0,0 +1,138 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "./db.js"; +import type { StoryDoc, StoryInput } from "./story.js"; + +export interface Story { + id: string; + title: string; + description: string; + doc: StoryDoc; + published: boolean; + createdAt: string; + updatedAt: string; +} +export type StorySummary = Omit & { nodeCount: number }; + +export interface StoryStore { + list(opts: { publishedOnly: boolean }): Promise; + get(id: string): Promise; + create(input: StoryInput): Promise; + update(id: string, input: StoryInput): Promise; + setPublished(id: string, published: boolean): Promise; + remove(id: string): Promise; +} + +export const isUuid = (s: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); + +const summarize = ({ doc, ...rest }: Story): StorySummary => ({ + ...rest, + nodeCount: Object.keys(doc.nodes).length, +}); + +export function memoryStore(): StoryStore { + const rows = new Map(); + return { + async list({ publishedOnly }) { + return [...rows.values()] + .filter((s) => !publishedOnly || s.published) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map(summarize); + }, + async get(id) { + return rows.get(id) ?? null; + }, + async create(input) { + const now = new Date().toISOString(); + const s: Story = { + id: randomUUID(), + ...input, + published: false, + createdAt: now, + updatedAt: now, + }; + rows.set(s.id, s); + return s; + }, + async update(id, input) { + const cur = rows.get(id); + if (!cur) return null; + const s = { ...cur, ...input, updatedAt: new Date().toISOString() }; + rows.set(id, s); + return s; + }, + async setPublished(id, published) { + const cur = rows.get(id); + if (!cur) return null; + const s = { ...cur, published, updatedAt: new Date().toISOString() }; + rows.set(id, s); + return s; + }, + async remove(id) { + return rows.delete(id); + }, + }; +} + +interface Row { + id: string; + title: string; + description: string; + doc: StoryDoc; + published: boolean; + created_at: Date; + updated_at: Date; +} +const COLS = "id, title, description, doc, published, created_at, updated_at"; +const toStory = (r: Row): Story => ({ + id: r.id, + title: r.title, + description: r.description, + doc: r.doc, + published: r.published, + createdAt: r.created_at.toISOString(), + updatedAt: r.updated_at.toISOString(), +}); + +export function pgStore(db: Db): StoryStore { + return { + async list({ publishedOnly }) { + const r = await db.query( + `SELECT ${COLS} FROM stories ${publishedOnly ? "WHERE published" : ""} ORDER BY updated_at DESC LIMIT 500`, + ); + return r.rows.map((row) => summarize(toStory(row))); + }, + async get(id) { + const r = await db.query( + `SELECT ${COLS} FROM stories WHERE id = $1`, + [id], + ); + return r.rows[0] ? toStory(r.rows[0]) : null; + }, + async create(i) { + const r = await db.query( + `INSERT INTO stories (title, description, doc) VALUES ($1, $2, $3) RETURNING ${COLS}`, + [i.title, i.description, JSON.stringify(i.doc)], + ); + return toStory(r.rows[0]!); + }, + async update(id, i) { + const r = await db.query( + `UPDATE stories SET title=$2, description=$3, doc=$4, updated_at=now() WHERE id=$1 RETURNING ${COLS}`, + [id, i.title, i.description, JSON.stringify(i.doc)], + ); + return r.rows[0] ? toStory(r.rows[0]) : null; + }, + async setPublished(id, published) { + const r = await db.query( + `UPDATE stories SET published=$2, updated_at=now() WHERE id=$1 RETURNING ${COLS}`, + [id, published], + ); + return r.rows[0] ? toStory(r.rows[0]) : null; + }, + async remove(id) { + const r = await db.query("DELETE FROM stories WHERE id = $1", [id]); + return (r.rowCount ?? 0) > 0; + }, + }; +} diff --git a/src/lib/story.ts b/src/lib/story.ts new file mode 100644 index 0000000..977d4b6 --- /dev/null +++ b/src/lib/story.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; + +const key = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/, "invalid node key"); + +export const choiceSchema = z.object({ + label: z.string().trim().min(1).max(200), + to: key, +}); + +export const nodeSchema = z.object({ + title: z.string().trim().min(1).max(120), + text: z.string().max(5000).default(""), + ending: z.enum(["win", "lose", "neutral"]).optional(), + choices: z.array(choiceSchema).max(10).default([]), +}); + +export const docSchema = z.object({ + start: key, + nodes: z.record(key, nodeSchema).refine((n) => Object.keys(n).length <= 200, { + message: "at most 200 nodes", + }), +}); + +export const storyInputSchema = z.object({ + title: z.string().trim().min(1).max(120), + description: z.string().max(1000).default(""), + doc: docSchema, +}); + +export type StoryDoc = z.infer; +export type StoryInput = z.infer; + +export interface Issue { + level: "error" | "warning"; + node?: string; + message: string; +} + +/** Structural checks. Any "error" blocks publishing; warnings are advisory. */ +export function analyze(doc: StoryDoc): Issue[] { + const issues: Issue[] = []; + const nodes = doc.nodes; + if (!nodes[doc.start]) { + issues.push({ + level: "error", + message: `start node "${doc.start}" does not exist`, + }); + } + for (const [k, n] of Object.entries(nodes)) { + for (const c of n.choices) { + if (!nodes[c.to]) { + issues.push({ + level: "error", + node: k, + message: `choice "${c.label}" points to missing node "${c.to}"`, + }); + } + } + if (!n.ending && n.choices.length === 0) { + issues.push({ + level: "error", + node: k, + message: "dead end: no choices and not marked as an ending", + }); + } + if (n.ending && n.choices.length > 0) { + issues.push({ + level: "warning", + node: k, + message: "ending node has choices that will never be shown", + }); + } + } + if (nodes[doc.start]) { + const seen = new Set(); + const stack = [doc.start]; + while (stack.length) { + const k = stack.pop()!; + if (seen.has(k) || !nodes[k]) continue; + seen.add(k); + if (!nodes[k].ending) for (const c of nodes[k].choices) stack.push(c.to); + } + for (const k of Object.keys(nodes)) { + if (!seen.has(k)) { + issues.push({ + level: "warning", + node: k, + message: "unreachable from the start node", + }); + } + } + if (![...seen].some((k) => nodes[k]?.ending)) { + issues.push({ + level: "error", + message: "no ending is reachable from the start node", + }); + } + } + return issues; +} + +export function hasErrors(issues: Issue[]): boolean { + return issues.some((i) => i.level === "error"); +} diff --git a/src/routes/stories.test.ts b/src/routes/stories.test.ts new file mode 100644 index 0000000..5d5dd5f --- /dev/null +++ b/src/routes/stories.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import request from "supertest"; +import pino from "pino"; +import { createApp } from "../app.js"; +import { parseConfig } from "../lib/config.js"; + +const mk = () => + createApp({ + config: parseConfig({ SERVICE_NAME: "t", GIT_SHA: "x" }), + logger: pino({ level: "silent" }), + }); + +const good = { + title: "Cave", + description: "d", + doc: { + start: "a", + nodes: { + a: { + title: "Entrance", + text: "Dark.", + choices: [ + { label: "Go in", to: "b" }, + { label: "Leave", to: "c" }, + ], + }, + b: { title: "Treasure", text: "Gold!", ending: "win" }, + c: { title: "Home", text: "Boring.", ending: "neutral" }, + }, + }, +}; + +describe("stories API", () => { + it("creates, reads, lists drafts only with mine=1", async () => { + const app = mk(); + const c = await request(app).post("/stories").send(good); + expect(c.status).toBe(201); + const id = c.body.story.id; + expect(c.body.issues).toEqual([]); + expect((await request(app).get(`/stories/${id}`)).body.story.title).toBe( + "Cave", + ); + expect((await request(app).get("/stories")).body.stories).toHaveLength(0); + const mine = await request(app).get("/stories?mine=1"); + expect(mine.body.stories[0]).toMatchObject({ id, nodeCount: 3 }); + expect(mine.body.stories[0].doc).toBeUndefined(); + }); + + it("publishes a valid story and lists it publicly", async () => { + const app = mk(); + const id = (await request(app).post("/stories").send(good)).body.story.id; + const p = await request(app).post(`/stories/${id}/publish`); + expect(p.status).toBe(200); + expect(p.body.story.published).toBe(true); + expect((await request(app).get("/stories")).body.stories).toHaveLength(1); + await request(app).post(`/stories/${id}/unpublish`); + expect((await request(app).get("/stories")).body.stories).toHaveLength(0); + }); + + it("refuses to publish a story with errors", async () => { + const app = mk(); + const bad = structuredClone(good); + bad.doc.nodes.a.choices[0]!.to = "nope"; + const id = (await request(app).post("/stories").send(bad)).body.story.id; + const p = await request(app).post(`/stories/${id}/publish`); + expect(p.status).toBe(422); + expect(p.body.issues[0].message).toMatch(/missing node "nope"/); + }); + + it("validates input shape", async () => { + const app = mk(); + const r = await request(app) + .post("/stories") + .send({ title: "", doc: { start: "A!", nodes: {} } }); + expect(r.status).toBe(400); + }); + + it("updates and deletes; 404s for unknown or malformed ids", async () => { + const app = mk(); + const id = (await request(app).post("/stories").send(good)).body.story.id; + const u = await request(app) + .put(`/stories/${id}`) + .send({ ...good, title: "New" }); + expect(u.body.story.title).toBe("New"); + expect((await request(app).delete(`/stories/${id}`)).status).toBe(204); + expect((await request(app).get(`/stories/${id}`)).status).toBe(404); + expect((await request(app).get("/stories/not-a-uuid")).status).toBe(404); + }); + + it("reports dead ends, unreachable nodes and missing endings", async () => { + const app = mk(); + const doc = { + title: "t", + doc: { + start: "a", + nodes: { + a: { title: "A", text: "", choices: [{ label: "x", to: "b" }] }, + b: { title: "B", text: "" }, + z: { title: "Z", text: "", ending: "win" }, + }, + }, + }; + const r = await request(app).post("/stories").send(doc); + const msgs = r.body.issues.map((i: { message: string }) => i.message); + expect(msgs).toContain("dead end: no choices and not marked as an ending"); + expect(msgs).toContain("unreachable from the start node"); + expect(msgs).toContain("no ending is reachable from the start node"); + }); +}); diff --git a/src/routes/stories.ts b/src/routes/stories.ts new file mode 100644 index 0000000..85be0d9 --- /dev/null +++ b/src/routes/stories.ts @@ -0,0 +1,116 @@ +import express, { Router } from "express"; +import { analyze, hasErrors, storyInputSchema } from "../lib/story.js"; +import { isUuid, type StoryStore } from "../lib/story-store.js"; + +export function storiesRouter({ store }: { store: StoryStore }): Router { + const router = Router(); + const json = express.json({ limit: "1mb" }); + + // Param guard: non-UUID ids are a 404, never a Postgres cast error. + router.param("id", (req, res, next, id) => { + if (!isUuid(String(id))) { + res.status(404).json({ error: "story not found" }); + return; + } + next(); + }); + + // ?mine=1 is the builder's view: drafts included. No auth (homelab). + router.get("/stories", async (req, res, next) => { + try { + const stories = await store.list({ + publishedOnly: req.query.mine !== "1", + }); + res.json({ stories }); + } catch (e) { + next(e); + } + }); + + router.get("/stories/:id", async (req, res, next) => { + try { + const story = await store.get(req.params.id); + if (!story) + return void res.status(404).json({ error: "story not found" }); + res.json({ story, issues: analyze(story.doc) }); + } catch (e) { + next(e); + } + }); + + router.post("/stories", json, async (req, res, next) => { + try { + const parsed = storyInputSchema.safeParse(req.body); + if (!parsed.success) { + return void res + .status(400) + .json({ error: "invalid story", details: parsed.error.issues }); + } + const story = await store.create(parsed.data); + res.status(201).json({ story, issues: analyze(story.doc) }); + } catch (e) { + next(e); + } + }); + + router.put("/stories/:id", json, async (req, res, next) => { + try { + const parsed = storyInputSchema.safeParse(req.body); + if (!parsed.success) { + return void res + .status(400) + .json({ error: "invalid story", details: parsed.error.issues }); + } + const story = await store.update(req.params.id, parsed.data); + if (!story) + return void res.status(404).json({ error: "story not found" }); + res.json({ story, issues: analyze(story.doc) }); + } catch (e) { + next(e); + } + }); + + router.post("/stories/:id/publish", async (req, res, next) => { + try { + const story = await store.get(req.params.id); + if (!story) + return void res.status(404).json({ error: "story not found" }); + const issues = analyze(story.doc); + if (hasErrors(issues)) { + return void res + .status(422) + .json({ + error: "story has errors; fix them before publishing", + issues, + }); + } + const updated = await store.setPublished(story.id, true); + res.json({ story: updated, issues }); + } catch (e) { + next(e); + } + }); + + router.post("/stories/:id/unpublish", async (req, res, next) => { + try { + const story = await store.setPublished(req.params.id, false); + if (!story) + return void res.status(404).json({ error: "story not found" }); + res.json({ story, issues: analyze(story.doc) }); + } catch (e) { + next(e); + } + }); + + router.delete("/stories/:id", async (req, res, next) => { + try { + const ok = await store.remove(req.params.id); + if (!ok) return void res.status(404).json({ error: "story not found" }); + res.status(204).end(); + } catch (e) { + next(e); + } + }); + + return router; +}