create-nwire 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/scaffold.test.d.ts +8 -3
- package/dist/__tests__/scaffold.test.d.ts.map +1 -1
- package/dist/__tests__/scaffold.test.js +49 -22
- package/dist/__tests__/scaffold.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/L1/README.md +34 -3
- package/templates/L1/__tests__/hello.test.ts +54 -0
- package/templates/L1/app/api.ts +17 -13
- package/templates/L1/app/main.ts +11 -1
- package/templates/L1/app/routes/hello.ts +29 -0
- package/templates/L1/tsconfig.json +1 -1
- package/templates/L2/README.md +33 -13
- package/templates/L2/__tests__/todo-api.test.ts +115 -0
- package/templates/L2/app/api.ts +20 -115
- package/templates/L2/app/{errors.ts → errors/todo-errors.ts} +6 -6
- package/templates/L2/app/main.ts +7 -7
- package/templates/L2/app/middleware/require-user.ts +29 -0
- package/templates/L2/app/{model.ts → resources/todo.ts} +21 -5
- package/templates/L2/app/routes/complete-todo.ts +36 -0
- package/templates/L2/app/routes/create-todo.ts +27 -0
- package/templates/L2/app/routes/delete-todo.ts +31 -0
- package/templates/L2/app/routes/list-todos.ts +28 -0
- package/templates/L2/app/{store.ts → store/todo-store.ts} +13 -5
- package/templates/L2/tsconfig.json +1 -1
- package/templates/L4/README.md +67 -17
- package/templates/L4/__tests__/auto-moderate.test.ts +96 -0
- package/templates/L4/__tests__/submit-flow.test.ts +80 -0
- package/templates/L4/app/api.ts +19 -109
- package/templates/L4/app/app.ts +4 -4
- package/templates/L4/app/main.ts +11 -12
- package/templates/L4/modules/posts/actions/approve-post.ts +28 -0
- package/templates/L4/modules/posts/actions/reject-post.ts +29 -0
- package/templates/L4/modules/posts/actions/submit-post.ts +36 -0
- package/templates/L4/modules/posts/events/post-was-approved.ts +22 -0
- package/templates/L4/modules/posts/events/post-was-rejected.ts +20 -0
- package/templates/L4/modules/posts/events/post-was-submitted.ts +27 -0
- package/templates/L4/modules/posts/posts.module.ts +35 -0
- package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
- package/templates/L4/modules/posts/routes/approve-post.ts +26 -0
- package/templates/L4/modules/posts/routes/get-post.ts +20 -0
- package/templates/L4/modules/posts/routes/list-queue.ts +22 -0
- package/templates/L4/modules/posts/routes/reject-post.ts +29 -0
- package/templates/L4/modules/posts/routes/submit-post.ts +28 -0
- package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts} +13 -20
- package/templates/L4/tsconfig.json +1 -1
- package/templates/L2/app/middleware.ts +0 -32
- package/templates/L4/app/actions.ts +0 -81
- package/templates/L4/app/events.ts +0 -53
- package/templates/L4/app/moderation.module.ts +0 -28
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Happy path — submit a post via HTTP, verify the action runs, the
|
|
3
|
+
* workflow + projection update the dashboard.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import type { Server } from "node:http";
|
|
9
|
+
import { app } from "../app/app";
|
|
10
|
+
import { api } from "../app/api";
|
|
11
|
+
|
|
12
|
+
let server: Server;
|
|
13
|
+
let url: string;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
await app.start();
|
|
17
|
+
server = http.createServer(api.compile());
|
|
18
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
19
|
+
const addr = server.address() as { port: number };
|
|
20
|
+
url = `http://127.0.0.1:${addr.port}`;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await new Promise<void>((resolve, reject) =>
|
|
25
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
26
|
+
);
|
|
27
|
+
await app.stop();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
31
|
+
|
|
32
|
+
async function req(
|
|
33
|
+
path: string,
|
|
34
|
+
init: RequestInit = {},
|
|
35
|
+
): Promise<{ status: number; body: unknown }> {
|
|
36
|
+
const res = await fetch(`${url}${path}`, {
|
|
37
|
+
...init,
|
|
38
|
+
headers: {
|
|
39
|
+
"content-type": "application/json",
|
|
40
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
let body: unknown = text;
|
|
45
|
+
try {
|
|
46
|
+
body = JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
/* keep as text */
|
|
49
|
+
}
|
|
50
|
+
return { status: res.status, body };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("submit flow", () => {
|
|
54
|
+
it("accepts a submission and the dashboard reflects it", async () => {
|
|
55
|
+
const submit = await req("/api/posts", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: JSON.stringify({ authorId: "alice", body: "hi" }), // length<5 → "human"
|
|
58
|
+
});
|
|
59
|
+
expect(submit.status).toBe(202);
|
|
60
|
+
|
|
61
|
+
await idle();
|
|
62
|
+
await idle();
|
|
63
|
+
|
|
64
|
+
const queue = await req("/api/queue");
|
|
65
|
+
const items = (queue.body as { items: Array<{ authorId: string; status: string }> }).items;
|
|
66
|
+
const alice = items.find((i) => i.authorId === "alice");
|
|
67
|
+
expect(alice).toBeDefined();
|
|
68
|
+
expect(alice?.status).toBe("pending");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("400s on empty body (structured validation error)", async () => {
|
|
72
|
+
const res = await req("/api/posts", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: JSON.stringify({ authorId: "alice", body: "" }),
|
|
75
|
+
});
|
|
76
|
+
expect(res.status).toBe(400);
|
|
77
|
+
const body = res.body as { error: { code: string } };
|
|
78
|
+
expect(body.error.code).toBe("validation_failed");
|
|
79
|
+
});
|
|
80
|
+
});
|
package/templates/L4/app/api.ts
CHANGED
|
@@ -1,118 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HTTP interface — one `.wire(Route, Handler)` per operation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* action through the full pipeline (validation, retry, DLQ, event
|
|
7
|
-
* recording, workflow notification, projection folding). `query(name,
|
|
8
|
-
* input)` reads the projection's materialized state.
|
|
4
|
+
* The route + handler pairs all live in `modules/posts/routes/`. The
|
|
5
|
+
* interface stays a 1-line-per-route reducer.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Once `createApp` is also extracted to `@nwire/app` (v1.1), these
|
|
16
|
-
* resolvers will use `ctx.execute(action, input)` + `ctx.query(...)`
|
|
17
|
-
* directly. For v1.0 we route through the runtime explicitly.
|
|
7
|
+
* `.provide(app.runtime.getContainer())` hands the forge runtime's
|
|
8
|
+
* container to the http layer so handlers can `resolve()` from any
|
|
9
|
+
* binding the runtime registered (e.g. dispatch, query, the runtime
|
|
10
|
+
* itself for ad-hoc reads).
|
|
18
11
|
*/
|
|
19
12
|
|
|
20
|
-
import { httpInterface
|
|
21
|
-
import { z } from "zod";
|
|
13
|
+
import { httpInterface } from "@nwire/http";
|
|
22
14
|
import { app } from "./app";
|
|
23
|
-
import { submitPost, approvePost, rejectPost } from "./actions";
|
|
24
15
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*/
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
-
const runtime = (app as any).runtime;
|
|
16
|
+
import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
|
|
17
|
+
import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/approve-post";
|
|
18
|
+
import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
|
|
19
|
+
import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
|
|
20
|
+
import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
|
|
33
21
|
|
|
34
22
|
export const api = httpInterface({ prefix: "/api" })
|
|
35
|
-
|
|
36
|
-
.wire(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}),
|
|
42
|
-
}),
|
|
43
|
-
async ({ input }) => {
|
|
44
|
-
await runtime.dispatch(submitPost, input);
|
|
45
|
-
/**
|
|
46
|
-
* Return 202 — accepted, not done. The submitPost action
|
|
47
|
-
* recorded PostWasSubmitted; the auto-moderate workflow + the
|
|
48
|
-
* dashboard projection will run downstream of that. By the time
|
|
49
|
-
* the client polls GET /queue the post may already be auto-decided.
|
|
50
|
-
*/
|
|
51
|
-
return { $status: 202, body: { accepted: true } };
|
|
52
|
-
},
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
// POST /api/posts/:postId/approve — human moderator approves
|
|
56
|
-
.wire(
|
|
57
|
-
post("/posts/:postId/approve", {
|
|
58
|
-
params: z.object({ postId: z.string() }),
|
|
59
|
-
body: z.object({ moderatorId: z.string() }),
|
|
60
|
-
}),
|
|
61
|
-
async ({ input }) => {
|
|
62
|
-
const { postId, moderatorId } = input as { postId: string; moderatorId: string };
|
|
63
|
-
await runtime.dispatch(approvePost, {
|
|
64
|
-
postId,
|
|
65
|
-
approvedBy: moderatorId,
|
|
66
|
-
});
|
|
67
|
-
return { $status: 202, body: { accepted: true } };
|
|
68
|
-
},
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
// POST /api/posts/:postId/reject — human moderator rejects with reason
|
|
72
|
-
.wire(
|
|
73
|
-
post("/posts/:postId/reject", {
|
|
74
|
-
params: z.object({ postId: z.string() }),
|
|
75
|
-
body: z.object({
|
|
76
|
-
moderatorId: z.string(),
|
|
77
|
-
reason: z.string().min(1).max(500),
|
|
78
|
-
}),
|
|
79
|
-
}),
|
|
80
|
-
async ({ input }) => {
|
|
81
|
-
const { postId, moderatorId, reason } = input as {
|
|
82
|
-
postId: string;
|
|
83
|
-
moderatorId: string;
|
|
84
|
-
reason: string;
|
|
85
|
-
};
|
|
86
|
-
await runtime.dispatch(rejectPost, {
|
|
87
|
-
postId,
|
|
88
|
-
rejectedBy: moderatorId,
|
|
89
|
-
reason,
|
|
90
|
-
});
|
|
91
|
-
return { $status: 202, body: { accepted: true } };
|
|
92
|
-
},
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
// GET /api/queue — dashboard view
|
|
96
|
-
.wire(
|
|
97
|
-
get("/queue", {
|
|
98
|
-
query: z.object({
|
|
99
|
-
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
100
|
-
}),
|
|
101
|
-
}),
|
|
102
|
-
async ({ input }) => ({
|
|
103
|
-
items: await runtime.query("moderation.list-pending", input),
|
|
104
|
-
totals: await runtime.query("moderation.queue-totals", {}),
|
|
105
|
-
}),
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
// GET /api/posts/:postId — single post lookup
|
|
109
|
-
.wire(
|
|
110
|
-
get("/posts/:postId", {
|
|
111
|
-
params: z.object({ postId: z.string() }),
|
|
112
|
-
}),
|
|
113
|
-
async ({ input }) => {
|
|
114
|
-
const post = await runtime.query("moderation.get-post", input);
|
|
115
|
-
if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
|
|
116
|
-
return post;
|
|
117
|
-
},
|
|
118
|
-
);
|
|
23
|
+
.provide(app.runtime.getContainer())
|
|
24
|
+
.wire(submitPostRoute, submitPostHandler)
|
|
25
|
+
.wire(approvePostRoute, approvePostHandler)
|
|
26
|
+
.wire(rejectPostRoute, rejectPostHandler)
|
|
27
|
+
.wire(listQueueRoute, listQueueHandler)
|
|
28
|
+
.wire(getPostRoute, getPostHandler);
|
package/templates/L4/app/app.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createApp` composes the
|
|
2
|
+
* `createApp` composes the posts module into a runnable app.
|
|
3
3
|
*
|
|
4
|
-
* Why createApp at
|
|
4
|
+
* Why createApp at L4 (when L2 skipped it):
|
|
5
5
|
*
|
|
6
6
|
* - We have a workflow that subscribes to events on the bus. The
|
|
7
7
|
* workflow needs the runtime to register its subscription and
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createApp } from "@nwire/forge";
|
|
24
|
-
import {
|
|
24
|
+
import { postsModule } from "../modules/posts/posts.module";
|
|
25
25
|
|
|
26
26
|
export const app = createApp({
|
|
27
|
-
modules: [
|
|
27
|
+
modules: [postsModule],
|
|
28
28
|
});
|
package/templates/L4/app/main.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry — boot the
|
|
2
|
+
* Entry — boot the forge app and run the http interface under endpoint().
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The lifecycle:
|
|
5
5
|
*
|
|
6
|
-
* 1. `app` is a configured-but-unbooted forge app — modules
|
|
7
|
-
* workflow subscribers wired, projection reducers
|
|
8
|
-
* nothing's
|
|
6
|
+
* 1. `app` (./app.ts) is a configured-but-unbooted forge app — modules
|
|
7
|
+
* registered, workflow subscribers wired, projection reducers
|
|
8
|
+
* attached, but nothing's running yet.
|
|
9
9
|
* 2. `app.start()` boots the runtime — workflows subscribe to the bus,
|
|
10
10
|
* projection state initializes, framework events fire.
|
|
11
|
-
* 3. `
|
|
11
|
+
* 3. `api.inspect(app)` mounts the `/_nwire/*` introspection surface
|
|
12
|
+
* that Studio's Live + Trace pages consume.
|
|
13
|
+
* 4. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
|
|
12
14
|
*
|
|
13
15
|
* Once running:
|
|
14
16
|
* - HTTP `POST /api/posts` → submitPost action → PostWasSubmitted event
|
|
15
|
-
* - autoModerate workflow picks up the event, runs auto-check, dispatches
|
|
17
|
+
* - `autoModerate` workflow picks up the event, runs auto-check, dispatches
|
|
16
18
|
* approve/reject if obvious
|
|
17
|
-
* - queueDashboard projection picks up ALL three events, updates state
|
|
19
|
+
* - `queueDashboard` projection picks up ALL three events, updates state
|
|
18
20
|
* - HTTP `GET /api/queue` reads from the projection
|
|
19
21
|
*
|
|
20
22
|
* Run: pnpm dev
|
|
@@ -30,10 +32,7 @@ import { api } from "./api";
|
|
|
30
32
|
|
|
31
33
|
await app.start();
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
// Studio's Live + Trace pages consume — live telemetry SSE stream,
|
|
35
|
-
// actor browser, projection viewer, DLQ list, manual dispatch.
|
|
36
|
-
// Off-by-default; opt in to demonstrate the dev-mode introspection.
|
|
35
|
+
// Off-by-default in production; opt in here so Studio's Live page works.
|
|
37
36
|
api.inspect(app);
|
|
38
37
|
|
|
39
38
|
await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approvePost — approve a post.
|
|
3
|
+
*
|
|
4
|
+
* Called from BOTH a human moderator route (POST /posts/:id/approve) AND
|
|
5
|
+
* from the auto-moderation workflow when the auto-check decides "clean".
|
|
6
|
+
* That reuse across callers is exactly why actions are extracted from
|
|
7
|
+
* route handlers in the first place.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { defineAction } from "@nwire/forge";
|
|
12
|
+
import { PostWasApproved } from "../events/post-was-approved";
|
|
13
|
+
|
|
14
|
+
export const approvePost = defineAction({
|
|
15
|
+
name: "posts.approve-post",
|
|
16
|
+
description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
|
|
17
|
+
schema: z.object({
|
|
18
|
+
postId: z.string(),
|
|
19
|
+
approvedBy: z.string(),
|
|
20
|
+
}),
|
|
21
|
+
emits: [PostWasApproved],
|
|
22
|
+
handler: async (input) =>
|
|
23
|
+
PostWasApproved({
|
|
24
|
+
postId: input.postId,
|
|
25
|
+
approvedBy: input.approvedBy,
|
|
26
|
+
approvedAt: new Date().toISOString(),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rejectPost — reject a post with a stated reason.
|
|
3
|
+
*
|
|
4
|
+
* Called from BOTH a human moderator route (POST /posts/:id/reject) AND
|
|
5
|
+
* from the auto-moderation workflow when the auto-check matches a known
|
|
6
|
+
* spam pattern.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { defineAction } from "@nwire/forge";
|
|
11
|
+
import { PostWasRejected } from "../events/post-was-rejected";
|
|
12
|
+
|
|
13
|
+
export const rejectPost = defineAction({
|
|
14
|
+
name: "posts.reject-post",
|
|
15
|
+
description: "Reject a post with a stated reason.",
|
|
16
|
+
schema: z.object({
|
|
17
|
+
postId: z.string(),
|
|
18
|
+
rejectedBy: z.string(),
|
|
19
|
+
reason: z.string().min(1).max(500),
|
|
20
|
+
}),
|
|
21
|
+
emits: [PostWasRejected],
|
|
22
|
+
handler: async (input) =>
|
|
23
|
+
PostWasRejected({
|
|
24
|
+
postId: input.postId,
|
|
25
|
+
rejectedBy: input.rejectedBy,
|
|
26
|
+
reason: input.reason,
|
|
27
|
+
rejectedAt: new Date().toISOString(),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* submitPost — author submits a draft to the moderation queue.
|
|
3
|
+
*
|
|
4
|
+
* Actions are the use cases the BC exposes. Each is callable from:
|
|
5
|
+
*
|
|
6
|
+
* - A route handler (HTTP dispatch via `runtime.dispatch(action, input)`)
|
|
7
|
+
* - A workflow (the auto-moderation workflow dispatches via `send(...)`)
|
|
8
|
+
* - Tests (via the harness — exercises the action without booting HTTP)
|
|
9
|
+
*
|
|
10
|
+
* The action's job is to validate input and return the event(s) that
|
|
11
|
+
* describe what happened. The framework persists the events, notifies
|
|
12
|
+
* subscribed workflows, and folds them into projections. Actions don't
|
|
13
|
+
* know who's listening — that's the bus's job.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { defineAction } from "@nwire/forge";
|
|
19
|
+
import { PostWasSubmitted } from "../events/post-was-submitted";
|
|
20
|
+
|
|
21
|
+
export const submitPost = defineAction({
|
|
22
|
+
name: "posts.submit-post",
|
|
23
|
+
description: "Author submits a draft to the moderation queue.",
|
|
24
|
+
schema: z.object({
|
|
25
|
+
authorId: z.string(),
|
|
26
|
+
body: z.string().min(1).max(2000),
|
|
27
|
+
}),
|
|
28
|
+
emits: [PostWasSubmitted],
|
|
29
|
+
handler: async (input) =>
|
|
30
|
+
PostWasSubmitted({
|
|
31
|
+
postId: randomUUID(),
|
|
32
|
+
authorId: input.authorId,
|
|
33
|
+
body: input.body,
|
|
34
|
+
submittedAt: new Date().toISOString(),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostWasApproved — a moderator (or the auto-moderation workflow) approves a post.
|
|
3
|
+
*
|
|
4
|
+
* `approvedBy` carries either a human moderator id or the literal "auto"
|
|
5
|
+
* for workflow-driven approvals — the dashboard renders the badge
|
|
6
|
+
* differently in the two cases.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { defineEvent } from "@nwire/messages";
|
|
11
|
+
|
|
12
|
+
export const PostWasApproved = defineEvent({
|
|
13
|
+
name: "posts.post-was-approved",
|
|
14
|
+
description: "A moderator (or the auto-moderation workflow) approves a post.",
|
|
15
|
+
outcome: "success",
|
|
16
|
+
audience: ["product", "ops"],
|
|
17
|
+
schema: z.object({
|
|
18
|
+
postId: z.string(),
|
|
19
|
+
approvedBy: z.string().describe("Moderator id, or 'auto' for workflow-driven approval"),
|
|
20
|
+
approvedAt: z.string().datetime(),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostWasRejected — a moderator (or the auto-moderation workflow) rejects
|
|
3
|
+
* a post with a stated reason. The reason becomes part of the audit trail.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { defineEvent } from "@nwire/messages";
|
|
8
|
+
|
|
9
|
+
export const PostWasRejected = defineEvent({
|
|
10
|
+
name: "posts.post-was-rejected",
|
|
11
|
+
description: "A moderator (or the auto-moderation workflow) rejects a post.",
|
|
12
|
+
outcome: "failure",
|
|
13
|
+
audience: ["product", "ops"],
|
|
14
|
+
schema: z.object({
|
|
15
|
+
postId: z.string(),
|
|
16
|
+
rejectedBy: z.string(),
|
|
17
|
+
reason: z.string(),
|
|
18
|
+
rejectedAt: z.string().datetime(),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostWasSubmitted — an author drafts a post and submits it for moderation.
|
|
3
|
+
*
|
|
4
|
+
* Past-tense by convention: events name FACTS that happened, not commands
|
|
5
|
+
* to do something. The grammar is the whole semantic: events = past,
|
|
6
|
+
* actions = present-imperative.
|
|
7
|
+
*
|
|
8
|
+
* `outcome` and `audience` are Studio-aware metadata — the scanner picks
|
|
9
|
+
* them up so the dashboard can render the event with the right semantics
|
|
10
|
+
* ("milestone", "success", "failure") and route to the right subscribers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { defineEvent } from "@nwire/messages";
|
|
15
|
+
|
|
16
|
+
export const PostWasSubmitted = defineEvent({
|
|
17
|
+
name: "posts.post-was-submitted",
|
|
18
|
+
description: "An author drafts a post and submits it for moderation.",
|
|
19
|
+
outcome: "milestone",
|
|
20
|
+
audience: ["product", "ops"],
|
|
21
|
+
schema: z.object({
|
|
22
|
+
postId: z.string(),
|
|
23
|
+
authorId: z.string(),
|
|
24
|
+
body: z.string(),
|
|
25
|
+
submittedAt: z.string().datetime(),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The posts module — collects every domain piece for this bounded
|
|
3
|
+
* context. `defineModule` is purely organizational: it groups events,
|
|
4
|
+
* actions, workflows, projections, and queries that belong to one part
|
|
5
|
+
* of the business, marks which are public (callable from other modules /
|
|
6
|
+
* apps), and gives the runtime a single value to register.
|
|
7
|
+
*
|
|
8
|
+
* `.public()` controls cross-module visibility. Here every action +
|
|
9
|
+
* query is public because we have one module; in a multi-module app
|
|
10
|
+
* you'd be more selective — only what's part of the BC's contract
|
|
11
|
+
* stays public.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineModule } from "@nwire/forge";
|
|
15
|
+
|
|
16
|
+
import { PostWasSubmitted } from "./events/post-was-submitted";
|
|
17
|
+
import { PostWasApproved } from "./events/post-was-approved";
|
|
18
|
+
import { PostWasRejected } from "./events/post-was-rejected";
|
|
19
|
+
|
|
20
|
+
import { submitPost } from "./actions/submit-post";
|
|
21
|
+
import { approvePost } from "./actions/approve-post";
|
|
22
|
+
import { rejectPost } from "./actions/reject-post";
|
|
23
|
+
|
|
24
|
+
import { autoModerate } from "./workflows/auto-moderate";
|
|
25
|
+
|
|
26
|
+
import { queueDashboard, listPending, queueTotals, getPost } from "./projections/queue-dashboard";
|
|
27
|
+
|
|
28
|
+
export const postsModule = defineModule("posts", {
|
|
29
|
+
description: "Post moderation queue — submit, auto-check, decide, dashboard.",
|
|
30
|
+
events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
|
|
31
|
+
actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
|
|
32
|
+
workflows: [autoModerate],
|
|
33
|
+
projections: [queueDashboard],
|
|
34
|
+
queries: [listPending.public(), queueTotals.public(), getPost.public()],
|
|
35
|
+
});
|
package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts}
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* queueDashboard — the read model for the moderation queue.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* NOTE THE SYMMETRY:
|
|
5
5
|
*
|
|
6
|
-
* - The autoModerate WORKFLOW
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* - The autoModerate WORKFLOW subscribes to events via
|
|
7
|
+
* `on(PostWasSubmitted, (event) => ...)`. Its job is to produce SIDE
|
|
8
|
+
* EFFECTS — dispatch more actions.
|
|
9
9
|
*
|
|
10
10
|
* - This PROJECTION subscribes to events via the same `on` map shape.
|
|
11
11
|
* Its job is to fold events into a denormalized state that queries
|
|
@@ -15,32 +15,25 @@
|
|
|
15
15
|
* intent. If you understand workflows, you understand projections —
|
|
16
16
|
* just write to state instead of dispatching.
|
|
17
17
|
*
|
|
18
|
-
* ## Why a projection vs. computing on every read
|
|
19
|
-
*
|
|
20
|
-
* The HTTP `GET /queue?status=pending` could query the event log + fold
|
|
21
|
-
* matching events on every request. That works for low traffic. For a
|
|
22
|
-
* busy moderation dashboard with thousands of moderators each polling
|
|
23
|
-
* every 5 seconds, that's wasted CPU.
|
|
24
|
-
*
|
|
25
|
-
* The projection folds incrementally: every time an event fires, it
|
|
26
|
-
* updates the state. Queries read the materialized state directly.
|
|
27
|
-
* Trade-off: more memory, less CPU; eventually-consistent (the
|
|
28
|
-
* projection lags the event by milliseconds at worst). For dashboards
|
|
29
|
-
* that's the right trade.
|
|
30
|
-
*
|
|
31
18
|
* ## When this lives in a database
|
|
32
19
|
*
|
|
33
20
|
* The in-memory state here is for example clarity. In production the
|
|
34
21
|
* projection's `on` reducers would write to Postgres / Mongo / Redis,
|
|
35
22
|
* and the query would read from there. The PRIMITIVE is the same —
|
|
36
23
|
* `on(Event) → mutate state`. Where state lives is a deployment choice.
|
|
24
|
+
*
|
|
25
|
+
* Queries (`listPending`, `queueTotals`, `getPost`) live in this file
|
|
26
|
+
* too because they're tightly coupled to the projection's state shape.
|
|
27
|
+
* Split them out if/when queries grow beyond the projection's surface.
|
|
37
28
|
*/
|
|
38
29
|
|
|
39
30
|
import { defineProjection, defineQuery } from "@nwire/forge";
|
|
40
31
|
import { z } from "zod";
|
|
41
|
-
import { PostWasSubmitted
|
|
32
|
+
import { PostWasSubmitted } from "../events/post-was-submitted";
|
|
33
|
+
import { PostWasApproved } from "../events/post-was-approved";
|
|
34
|
+
import { PostWasRejected } from "../events/post-was-rejected";
|
|
42
35
|
|
|
43
|
-
// ───
|
|
36
|
+
// ─── State shape ─────────────────────────────────────────────────
|
|
44
37
|
|
|
45
38
|
interface QueueItem {
|
|
46
39
|
postId: string;
|
|
@@ -59,7 +52,7 @@ interface QueueState {
|
|
|
59
52
|
totals: { pending: number; approved: number; rejected: number };
|
|
60
53
|
}
|
|
61
54
|
|
|
62
|
-
// ───
|
|
55
|
+
// ─── Projection — `on` map matches the workflow's shape ─────────
|
|
63
56
|
|
|
64
57
|
export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
65
58
|
description: "The moderation dashboard read model — pending queue + decision totals.",
|
|
@@ -70,11 +63,6 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
|
70
63
|
totals: { pending: 0, approved: 0, rejected: 0 },
|
|
71
64
|
}),
|
|
72
65
|
on: {
|
|
73
|
-
/**
|
|
74
|
-
* Same shape as a workflow's `on(PostWasSubmitted, ...)`. The
|
|
75
|
-
* difference: this returns state, the workflow returns void
|
|
76
|
-
* (and produces side effects).
|
|
77
|
-
*/
|
|
78
66
|
[PostWasSubmitted.name]: (state, event) => ({
|
|
79
67
|
byId: {
|
|
80
68
|
...state.byId,
|
|
@@ -137,10 +125,10 @@ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
|
|
|
137
125
|
},
|
|
138
126
|
});
|
|
139
127
|
|
|
140
|
-
// ─── Queries — read the projection's state
|
|
128
|
+
// ─── Queries — read the projection's state ──────────────────────
|
|
141
129
|
|
|
142
130
|
export const listPending = defineQuery(queueDashboard, {
|
|
143
|
-
name: "
|
|
131
|
+
name: "posts.list-pending",
|
|
144
132
|
schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
|
|
145
133
|
execute: (state, { limit }) =>
|
|
146
134
|
state.pendingIds
|
|
@@ -150,13 +138,13 @@ export const listPending = defineQuery(queueDashboard, {
|
|
|
150
138
|
});
|
|
151
139
|
|
|
152
140
|
export const queueTotals = defineQuery(queueDashboard, {
|
|
153
|
-
name: "
|
|
141
|
+
name: "posts.queue-totals",
|
|
154
142
|
schema: z.object({}),
|
|
155
143
|
execute: (state) => state.totals,
|
|
156
144
|
});
|
|
157
145
|
|
|
158
146
|
export const getPost = defineQuery(queueDashboard, {
|
|
159
|
-
name: "
|
|
147
|
+
name: "posts.get-post",
|
|
160
148
|
schema: z.object({ postId: z.string() }),
|
|
161
149
|
execute: (state, { postId }) => state.byId[postId] ?? null,
|
|
162
150
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/posts/:postId/approve — human moderator approves a post.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches the SAME action that the auto-moderate workflow dispatches
|
|
5
|
+
* — that reuse is exactly why approvePost lives in `actions/` and not
|
|
6
|
+
* inline in the route handler.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { post, type HttpHandler } from "@nwire/http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { app } from "../../../app/app";
|
|
12
|
+
import { approvePost } from "../actions/approve-post";
|
|
13
|
+
|
|
14
|
+
const Params = z.object({ postId: z.string() });
|
|
15
|
+
const Body = z.object({ moderatorId: z.string() });
|
|
16
|
+
type Input = z.infer<typeof Params> & z.infer<typeof Body>;
|
|
17
|
+
|
|
18
|
+
export const approvePostRoute = post("/posts/:postId/approve", { params: Params, body: Body });
|
|
19
|
+
|
|
20
|
+
export const approvePostHandler: HttpHandler<Input> = async ({ input }) => {
|
|
21
|
+
await app.runtime.dispatch(approvePost, {
|
|
22
|
+
postId: input.postId,
|
|
23
|
+
approvedBy: input.moderatorId,
|
|
24
|
+
});
|
|
25
|
+
return { $status: 202, body: { accepted: true } };
|
|
26
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/posts/:postId — single-post lookup.
|
|
3
|
+
*
|
|
4
|
+
* Returns the projection's record. 404 when the post isn't in the
|
|
5
|
+
* dashboard (never submitted, or projection state was reset).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { get, type HttpHandler } from "@nwire/http";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { app } from "../../../app/app";
|
|
11
|
+
|
|
12
|
+
const Params = z.object({ postId: z.string() });
|
|
13
|
+
|
|
14
|
+
export const getPostRoute = get("/posts/:postId", { params: Params });
|
|
15
|
+
|
|
16
|
+
export const getPostHandler: HttpHandler<z.infer<typeof Params>> = async ({ input }) => {
|
|
17
|
+
const post = await app.runtime.query("posts.get-post", input);
|
|
18
|
+
if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
|
|
19
|
+
return post;
|
|
20
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/queue — dashboard view (pending list + totals).
|
|
3
|
+
*
|
|
4
|
+
* Reads from the queueDashboard projection via two queries. The
|
|
5
|
+
* projection has been folding events in the background — this is a
|
|
6
|
+
* straight state read, no recomputation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { get, type HttpHandler } from "@nwire/http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { app } from "../../../app/app";
|
|
12
|
+
|
|
13
|
+
const Query = z.object({
|
|
14
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const listQueueRoute = get("/queue", { query: Query });
|
|
18
|
+
|
|
19
|
+
export const listQueueHandler: HttpHandler<z.infer<typeof Query>> = async ({ input }) => ({
|
|
20
|
+
items: await app.runtime.query("posts.list-pending", input),
|
|
21
|
+
totals: await app.runtime.query("posts.queue-totals", {}),
|
|
22
|
+
});
|