create-nwire 0.7.1 → 0.8.17
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/package.json +2 -2
- 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/package.json +5 -5
- package/templates/L2/tsconfig.json +1 -1
- package/templates/L4/README.md +73 -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 +41 -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 +36 -0
- package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
- package/templates/L4/modules/posts/queries/posts-by-author.ts +53 -0
- 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/package.json +6 -6
- 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,36 @@
|
|
|
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
|
+
import { postsByAuthor } from "./queries/posts-by-author";
|
|
28
|
+
|
|
29
|
+
export const postsModule = defineModule("posts", {
|
|
30
|
+
description: "Post moderation queue — submit, auto-check, decide, dashboard.",
|
|
31
|
+
events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
|
|
32
|
+
actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
|
|
33
|
+
workflows: [autoModerate],
|
|
34
|
+
projections: [queueDashboard],
|
|
35
|
+
queries: [listPending.public(), queueTotals.public(), getPost.public(), postsByAuthor.public()],
|
|
36
|
+
});
|
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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postsByAuthor — handler-form query (no projection).
|
|
3
|
+
*
|
|
4
|
+
* Most queries in this template are projection-driven — `defineQuery` is
|
|
5
|
+
* handed a projection and reads from its folded state. That's the right
|
|
6
|
+
* default when the read can be eventually-consistent over events you
|
|
7
|
+
* publish in-process.
|
|
8
|
+
*
|
|
9
|
+
* Sometimes the read lives somewhere else entirely: a Postgres table the
|
|
10
|
+
* domain doesn't own, a search index, a cache. For those, defineQuery
|
|
11
|
+
* accepts a flat handler form — no projection, no state. The handler
|
|
12
|
+
* receives validated input plus a `QueryContext` that can resolve DI
|
|
13
|
+
* bindings (a `db` connection, an http client, anything the runtime
|
|
14
|
+
* registered).
|
|
15
|
+
*
|
|
16
|
+
* defineQuery({ name, schema, handler })
|
|
17
|
+
*
|
|
18
|
+
* The handler below returns a small mock list so the template boots
|
|
19
|
+
* out of the box. The shape of a real implementation is in the comment
|
|
20
|
+
* inside `handler` — swap the mock for a `ctx.resolve<DrizzleDb>("db")`
|
|
21
|
+
* call (or whatever your store binding is named) and you're done.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { defineQuery } from "@nwire/forge";
|
|
26
|
+
|
|
27
|
+
interface AuthorPost {
|
|
28
|
+
postId: string;
|
|
29
|
+
authorId: string;
|
|
30
|
+
status: "pending" | "approved" | "rejected";
|
|
31
|
+
submittedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const postsByAuthor = defineQuery({
|
|
35
|
+
name: "posts.by-author",
|
|
36
|
+
description: "All posts an author has submitted, across statuses.",
|
|
37
|
+
schema: z.object({
|
|
38
|
+
authorId: z.string(),
|
|
39
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
40
|
+
}),
|
|
41
|
+
handler: async ({ authorId, limit }, _ctx): Promise<AuthorPost[]> => {
|
|
42
|
+
// Production form:
|
|
43
|
+
// const db = _ctx.resolve<DrizzleDb>("db");
|
|
44
|
+
// return db.select().from(posts).where(eq(posts.authorId, authorId)).limit(limit);
|
|
45
|
+
//
|
|
46
|
+
// Template form — small in-memory mock so this boots without a DB:
|
|
47
|
+
const all: AuthorPost[] = [
|
|
48
|
+
{ postId: "p-1", authorId, status: "approved", submittedAt: "2026-01-01T00:00:00Z" },
|
|
49
|
+
{ postId: "p-2", authorId, status: "pending", submittedAt: "2026-01-02T00:00:00Z" },
|
|
50
|
+
];
|
|
51
|
+
return all.slice(0, limit);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/posts/:postId/reject — human moderator rejects with a reason.
|
|
3
|
+
*
|
|
4
|
+
* The reason is mandatory and becomes part of the audit trail via the
|
|
5
|
+
* PostWasRejected event.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { post, type HttpHandler } from "@nwire/http";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { app } from "../../../app/app";
|
|
11
|
+
import { rejectPost } from "../actions/reject-post";
|
|
12
|
+
|
|
13
|
+
const Params = z.object({ postId: z.string() });
|
|
14
|
+
const Body = z.object({
|
|
15
|
+
moderatorId: z.string(),
|
|
16
|
+
reason: z.string().min(1).max(500),
|
|
17
|
+
});
|
|
18
|
+
type Input = z.infer<typeof Params> & z.infer<typeof Body>;
|
|
19
|
+
|
|
20
|
+
export const rejectPostRoute = post("/posts/:postId/reject", { params: Params, body: Body });
|
|
21
|
+
|
|
22
|
+
export const rejectPostHandler: HttpHandler<Input> = async ({ input }) => {
|
|
23
|
+
await app.runtime.dispatch(rejectPost, {
|
|
24
|
+
postId: input.postId,
|
|
25
|
+
rejectedBy: input.moderatorId,
|
|
26
|
+
reason: input.reason,
|
|
27
|
+
});
|
|
28
|
+
return { $status: 202, body: { accepted: true } };
|
|
29
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/posts — author submits a draft for moderation.
|
|
3
|
+
*
|
|
4
|
+
* Returns 202 (Accepted) — the action recorded PostWasSubmitted; the
|
|
5
|
+
* auto-moderate workflow + dashboard projection run downstream. By the
|
|
6
|
+
* time the client polls GET /api/queue the post may already be auto-decided.
|
|
7
|
+
*
|
|
8
|
+
* The route handler is a thin shim: translate request → action input,
|
|
9
|
+
* dispatch, return the tagged response. All orchestration happens
|
|
10
|
+
* downstream of the event the action emits — never inside the handler.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { post, type HttpHandler } from "@nwire/http";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { app } from "../../../app/app";
|
|
16
|
+
import { submitPost } from "../actions/submit-post";
|
|
17
|
+
|
|
18
|
+
const Body = z.object({
|
|
19
|
+
authorId: z.string(),
|
|
20
|
+
body: z.string().min(1).max(2000),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const submitPostRoute = post("/posts", { body: Body });
|
|
24
|
+
|
|
25
|
+
export const submitPostHandler: HttpHandler<z.infer<typeof Body>> = async ({ input }) => {
|
|
26
|
+
await app.runtime.dispatch(submitPost, input);
|
|
27
|
+
return { $status: 202, body: { accepted: true } };
|
|
28
|
+
};
|
package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts}
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* autoModerate — workflow that runs cheap auto-moderation on every
|
|
3
3
|
* submitted post and decides whether to bypass the human queue.
|
|
4
4
|
*
|
|
5
5
|
* Pattern: workflows are subscribers — they react to events with the
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
* Same subscription primitive; different intent. The symmetry is real —
|
|
15
15
|
* if you have one, you have the shape to add the other.
|
|
16
16
|
*
|
|
17
|
-
* ## Why a workflow here vs. doing it inline in the
|
|
17
|
+
* ## Why a workflow here vs. doing it inline in the route handler
|
|
18
18
|
*
|
|
19
|
-
* The HTTP
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* The HTTP `POST /posts` route should return quickly — the user is
|
|
20
|
+
* waiting on a response. Running auto-moderation (which might call an
|
|
21
|
+
* LLM, scan a profanity list, lookup author reputation) takes time we
|
|
22
|
+
* can't afford in the request path.
|
|
23
23
|
*
|
|
24
24
|
* The workflow decouples the two: the action returns immediately after
|
|
25
25
|
* recording PostWasSubmitted; the workflow picks up the event async and
|
|
@@ -29,15 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { defineWorkflow } from "@nwire/forge";
|
|
32
|
-
import { PostWasSubmitted } from "
|
|
33
|
-
import { approvePost
|
|
32
|
+
import { PostWasSubmitted } from "../events/post-was-submitted";
|
|
33
|
+
import { approvePost } from "../actions/approve-post";
|
|
34
|
+
import { rejectPost } from "../actions/reject-post";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Trivial moderation check — in a real app this would call an LLM,
|
|
37
38
|
* check against a banned-words list, look up author reputation, etc.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
*
|
|
40
|
+
* - "approve" for clean content
|
|
41
|
+
* - `{ reject: reason }` for obvious violations
|
|
42
|
+
* - "human" when content is ambiguous and a moderator should decide
|
|
41
43
|
*/
|
|
42
44
|
function autoCheck(body: string): "approve" | { reject: string } | "human" {
|
|
43
45
|
if (body.toLowerCase().includes("spam-marker")) return { reject: "Auto-detected spam" };
|
|
@@ -47,15 +49,6 @@ function autoCheck(body: string): "approve" | { reject: string } | "human" {
|
|
|
47
49
|
|
|
48
50
|
export const autoModerate = defineWorkflow("auto-moderate", ({ on, send }) => {
|
|
49
51
|
on(PostWasSubmitted, async (event) => {
|
|
50
|
-
/**
|
|
51
|
-
* Three possible outcomes from the auto-check. Each maps to either
|
|
52
|
-
* dispatching the matching action (approve/reject) or doing nothing
|
|
53
|
-
* and letting a human moderator handle it via the HTTP endpoints.
|
|
54
|
-
*
|
|
55
|
-
* `send(action, input)` is the workflow's way to dispatch — it
|
|
56
|
-
* runs the action through the same pipeline as an HTTP-triggered
|
|
57
|
-
* dispatch, including retry/DLQ/idempotency.
|
|
58
|
-
*/
|
|
59
52
|
const decision = autoCheck(event.body);
|
|
60
53
|
|
|
61
54
|
if (decision === "approve") {
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
"test": "vitest run"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@nwire/app": "^0.
|
|
12
|
-
"@nwire/endpoint": "^0.
|
|
13
|
-
"@nwire/forge": "^0.
|
|
14
|
-
"@nwire/http": "^0.
|
|
15
|
-
"@nwire/messages": "^0.
|
|
11
|
+
"@nwire/app": "^0.8.17",
|
|
12
|
+
"@nwire/endpoint": "^0.8.17",
|
|
13
|
+
"@nwire/forge": "^0.8.17",
|
|
14
|
+
"@nwire/http": "^0.8.17",
|
|
15
|
+
"@nwire/messages": "^0.8.17",
|
|
16
16
|
"zod": "^4.0.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@nwire/test-kit": "^0.
|
|
19
|
+
"@nwire/test-kit": "^0.8.17",
|
|
20
20
|
"@types/node": "^22.19.9",
|
|
21
21
|
"typescript": "^5.9.0",
|
|
22
22
|
"vite-node": "^3.2.4",
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny `requireUser` middleware — proves the chain pattern.
|
|
3
|
-
*
|
|
4
|
-
* A real app would use `@nwire/auth` + an IdP adapter (Logto,
|
|
5
|
-
* better-auth, etc.) to extract `user` from a JWT in the Authorization
|
|
6
|
-
* header. For this example we trust the `x-user-id` header so the test
|
|
7
|
-
* suite stays focused on the framework shape, not on JWT handling.
|
|
8
|
-
*
|
|
9
|
-
* The middleware does two things:
|
|
10
|
-
*
|
|
11
|
-
* 1. Throws `NoUserId` if the header is absent — surfaces as 401 JSON.
|
|
12
|
-
* 2. Attaches the user id to the request-scoped container so resolvers
|
|
13
|
-
* can `ctx.resolve<string>("userId")` without re-reading the header.
|
|
14
|
-
*
|
|
15
|
-
* Resolvers chain it with `.use(requireUser)`; auth lives at the
|
|
16
|
-
* interface seam, not inside business logic.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { HttpMiddleware } from "@nwire/http";
|
|
20
|
-
import { NoUserId } from "./errors";
|
|
21
|
-
|
|
22
|
-
export const requireUser: HttpMiddleware = async (ctx, next) => {
|
|
23
|
-
const userId = ctx.request.headers["x-user-id"];
|
|
24
|
-
if (typeof userId !== "string" || userId.length === 0) {
|
|
25
|
-
throw NoUserId;
|
|
26
|
-
}
|
|
27
|
-
// Stash on Koa state so resolvers can read it via _raw.state.userId,
|
|
28
|
-
// OR they can read the header directly via _raw.request.headers. We
|
|
29
|
-
// attach to state so the handler's signature is clean.
|
|
30
|
-
ctx.state.userId = userId;
|
|
31
|
-
await next();
|
|
32
|
-
};
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Domain actions — the use cases the moderation system exposes.
|
|
3
|
-
*
|
|
4
|
-
* Each action is a named function with input validation, executable from:
|
|
5
|
-
*
|
|
6
|
-
* - A resolver (HTTP handler dispatches via `execute(action, input)`)
|
|
7
|
-
* - A workflow (the auto-moderation workflow dispatches via `send(action, ...)`)
|
|
8
|
-
* - Tests (via the harness — exercises the action without booting HTTP)
|
|
9
|
-
*
|
|
10
|
-
* That reuse across callers is exactly why actions are extracted from
|
|
11
|
-
* resolvers. In the todo-app example we didn't extract — each CRUD
|
|
12
|
-
* resolver did its own DB write. Here `approvePost` is called both from
|
|
13
|
-
* the HTTP `POST /posts/:id/approve` endpoint AND from the workflow's
|
|
14
|
-
* auto-approve branch. Extracting it keeps the logic in one place.
|
|
15
|
-
*
|
|
16
|
-
* The actions emit events, the framework records them, and the implicit
|
|
17
|
-
* bus delivers them to subscribed workflows + projections.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { randomUUID } from "node:crypto";
|
|
21
|
-
import { z } from "zod";
|
|
22
|
-
import { defineAction } from "@nwire/forge";
|
|
23
|
-
import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
|
|
24
|
-
|
|
25
|
-
export const submitPost = defineAction({
|
|
26
|
-
name: "moderation.submit-post",
|
|
27
|
-
description: "Author submits a draft to the moderation queue.",
|
|
28
|
-
schema: z.object({
|
|
29
|
-
authorId: z.string(),
|
|
30
|
-
body: z.string().min(1).max(2000),
|
|
31
|
-
}),
|
|
32
|
-
emits: [PostWasSubmitted],
|
|
33
|
-
handler: async (input) => {
|
|
34
|
-
/**
|
|
35
|
-
* The action's job is to validate the input and return the event(s)
|
|
36
|
-
* that describe what happened. The framework persists the events,
|
|
37
|
-
* notifies workflow subscribers, and folds them into projections.
|
|
38
|
-
* Notice: the action doesn't know who's listening. That's the bus.
|
|
39
|
-
*/
|
|
40
|
-
return PostWasSubmitted({
|
|
41
|
-
postId: randomUUID(),
|
|
42
|
-
authorId: input.authorId,
|
|
43
|
-
body: input.body,
|
|
44
|
-
submittedAt: new Date().toISOString(),
|
|
45
|
-
});
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
export const approvePost = defineAction({
|
|
50
|
-
name: "moderation.approve-post",
|
|
51
|
-
description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
|
|
52
|
-
schema: z.object({
|
|
53
|
-
postId: z.string(),
|
|
54
|
-
approvedBy: z.string(),
|
|
55
|
-
}),
|
|
56
|
-
emits: [PostWasApproved],
|
|
57
|
-
handler: async (input) =>
|
|
58
|
-
PostWasApproved({
|
|
59
|
-
postId: input.postId,
|
|
60
|
-
approvedBy: input.approvedBy,
|
|
61
|
-
approvedAt: new Date().toISOString(),
|
|
62
|
-
}),
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
export const rejectPost = defineAction({
|
|
66
|
-
name: "moderation.reject-post",
|
|
67
|
-
description: "Reject a post with a stated reason.",
|
|
68
|
-
schema: z.object({
|
|
69
|
-
postId: z.string(),
|
|
70
|
-
rejectedBy: z.string(),
|
|
71
|
-
reason: z.string().min(1).max(500),
|
|
72
|
-
}),
|
|
73
|
-
emits: [PostWasRejected],
|
|
74
|
-
handler: async (input) =>
|
|
75
|
-
PostWasRejected({
|
|
76
|
-
postId: input.postId,
|
|
77
|
-
rejectedBy: input.rejectedBy,
|
|
78
|
-
reason: input.reason,
|
|
79
|
-
rejectedAt: new Date().toISOString(),
|
|
80
|
-
}),
|
|
81
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Domain events for the moderation queue.
|
|
3
|
-
*
|
|
4
|
-
* Each event is past-tense — it represents a fact that happened, not a
|
|
5
|
-
* command to do something. `WasSubmitted`, `WasApproved`, `WasRejected`.
|
|
6
|
-
* The tense is the whole grammar: events name the past, actions name the
|
|
7
|
-
* present-imperative.
|
|
8
|
-
*
|
|
9
|
-
* `outcome` and `audience` are Studio-aware metadata — the scanner picks
|
|
10
|
-
* them up so the dashboard can render the event with the right semantics
|
|
11
|
-
* ("milestone", "success", "failure") and route to the right subscribers.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { z } from "zod";
|
|
15
|
-
import { defineEvent } from "@nwire/messages";
|
|
16
|
-
|
|
17
|
-
export const PostWasSubmitted = defineEvent({
|
|
18
|
-
name: "moderation.post-was-submitted",
|
|
19
|
-
description: "An author drafts a post and submits it for moderation.",
|
|
20
|
-
outcome: "milestone",
|
|
21
|
-
audience: ["product", "ops"],
|
|
22
|
-
schema: z.object({
|
|
23
|
-
postId: z.string(),
|
|
24
|
-
authorId: z.string(),
|
|
25
|
-
body: z.string(),
|
|
26
|
-
submittedAt: z.string().datetime(),
|
|
27
|
-
}),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const PostWasApproved = defineEvent({
|
|
31
|
-
name: "moderation.post-was-approved",
|
|
32
|
-
description: "A moderator (or the auto-moderation workflow) approves a post.",
|
|
33
|
-
outcome: "success",
|
|
34
|
-
audience: ["product", "ops"],
|
|
35
|
-
schema: z.object({
|
|
36
|
-
postId: z.string(),
|
|
37
|
-
approvedBy: z.string().describe("Moderator id, or 'auto' for workflow-driven approval"),
|
|
38
|
-
approvedAt: z.string().datetime(),
|
|
39
|
-
}),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
export const PostWasRejected = defineEvent({
|
|
43
|
-
name: "moderation.post-was-rejected",
|
|
44
|
-
description: "A moderator (or the auto-moderation workflow) rejects a post.",
|
|
45
|
-
outcome: "failure",
|
|
46
|
-
audience: ["product", "ops"],
|
|
47
|
-
schema: z.object({
|
|
48
|
-
postId: z.string(),
|
|
49
|
-
rejectedBy: z.string(),
|
|
50
|
-
reason: z.string(),
|
|
51
|
-
rejectedAt: z.string().datetime(),
|
|
52
|
-
}),
|
|
53
|
-
});
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The moderation module — collects all the domain pieces for this
|
|
3
|
-
* bounded context. `defineModule` is purely organizational: it groups
|
|
4
|
-
* actions / events / workflows / projections / queries that belong
|
|
5
|
-
* to one part of the business, marks which ones are public (callable
|
|
6
|
-
* from other modules / apps), and gives the runtime a single value
|
|
7
|
-
* to register.
|
|
8
|
-
*
|
|
9
|
-
* `.public()` controls cross-module visibility — see
|
|
10
|
-
* `architecture-primitives.html` for the full rules. Here every action
|
|
11
|
-
* and query is public because we have one module; in a multi-module
|
|
12
|
-
* app you'd be more selective.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { defineModule } from "@nwire/forge";
|
|
16
|
-
import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
|
|
17
|
-
import { submitPost, approvePost, rejectPost } from "./actions";
|
|
18
|
-
import { autoModerate } from "./auto-moderate.workflow";
|
|
19
|
-
import { queueDashboard, listPending, queueTotals, getPost } from "./queue.projection";
|
|
20
|
-
|
|
21
|
-
export const moderationModule = defineModule("moderation", {
|
|
22
|
-
description: "Post moderation queue — submit, auto-check, decide, dashboard.",
|
|
23
|
-
events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
|
|
24
|
-
actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
|
|
25
|
-
workflows: [autoModerate],
|
|
26
|
-
projections: [queueDashboard],
|
|
27
|
-
queries: [listPending.public(), queueTotals.public(), getPost.public()],
|
|
28
|
-
});
|