feat: story CRUD API with validation and publishing
Some checks failed
/ checks (push) Failing after 35s
/ deploy (push) Has been skipped

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 12:43:26 -06:00
parent b93d646be0
commit 806e893076
6 changed files with 486 additions and 0 deletions

View file

@ -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;

View file

@ -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());

138
src/lib/story-store.ts Normal file
View file

@ -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<Story, "doc"> & { nodeCount: number };
export interface StoryStore {
list(opts: { publishedOnly: boolean }): Promise<StorySummary[]>;
get(id: string): Promise<Story | null>;
create(input: StoryInput): Promise<Story>;
update(id: string, input: StoryInput): Promise<Story | null>;
setPublished(id: string, published: boolean): Promise<Story | null>;
remove(id: string): Promise<boolean>;
}
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<string, Story>();
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<Row>(
`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<Row>(
`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<Row>(
`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<Row>(
`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<Row>(
`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;
},
};
}

104
src/lib/story.ts Normal file
View file

@ -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<typeof docSchema>;
export type StoryInput = z.infer<typeof storyInputSchema>;
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<string>();
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");
}

109
src/routes/stories.test.ts Normal file
View file

@ -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");
});
});

116
src/routes/stories.ts Normal file
View file

@ -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;
}