generated from nholtware/service-template-express
feat: story CRUD API with validation and publishing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b93d646be0
commit
806e893076
6 changed files with 486 additions and 0 deletions
14
migrations/0002_stories.sql
Normal file
14
migrations/0002_stories.sql
Normal 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;
|
||||
|
|
@ -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
138
src/lib/story-store.ts
Normal 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
104
src/lib/story.ts
Normal 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
109
src/routes/stories.test.ts
Normal 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
116
src/routes/stories.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue