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 { healthRouter } from "./routes/health.js";
|
||||||
import { logDemoRouter } from "./routes/log-demo.js";
|
import { logDemoRouter } from "./routes/log-demo.js";
|
||||||
import { rootRouter } from "./routes/root.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 {
|
export interface Deps {
|
||||||
config: Config;
|
config: Config;
|
||||||
|
|
@ -22,6 +24,7 @@ export interface Deps {
|
||||||
cache?: Cache;
|
cache?: Cache;
|
||||||
flags?: Flags;
|
flags?: Flags;
|
||||||
runtimeConfig?: RuntimeConfig;
|
runtimeConfig?: RuntimeConfig;
|
||||||
|
store?: StoryStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp({
|
export function createApp({
|
||||||
|
|
@ -31,6 +34,7 @@ export function createApp({
|
||||||
cache,
|
cache,
|
||||||
flags,
|
flags,
|
||||||
runtimeConfig = staticRuntimeConfig(),
|
runtimeConfig = staticRuntimeConfig(),
|
||||||
|
store = db ? pgStore(db) : memoryStore(),
|
||||||
}: Deps) {
|
}: Deps) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
|
|
@ -59,6 +63,7 @@ export function createApp({
|
||||||
|
|
||||||
app.use(healthRouter({ db, cache }));
|
app.use(healthRouter({ db, cache }));
|
||||||
app.use(rootRouter({ config, db, cache, flags, runtimeConfig }));
|
app.use(rootRouter({ config, db, cache, flags, runtimeConfig }));
|
||||||
|
app.use(storiesRouter({ store }));
|
||||||
app.use(adminRouter({ runtimeConfig }));
|
app.use(adminRouter({ runtimeConfig }));
|
||||||
app.use(logDemoRouter());
|
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