@beignet/cli 0.0.26 → 0.0.28

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +59 -15
  3. package/dist/check.d.ts +61 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +164 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/choices.d.ts +8 -0
  8. package/dist/choices.d.ts.map +1 -1
  9. package/dist/choices.js +14 -0
  10. package/dist/choices.js.map +1 -1
  11. package/dist/db.d.ts +8 -0
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +4 -4
  14. package/dist/db.js.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +156 -21
  17. package/dist/index.js.map +1 -1
  18. package/dist/inspect.d.ts.map +1 -1
  19. package/dist/inspect.js +121 -3
  20. package/dist/inspect.js.map +1 -1
  21. package/dist/make/inbox.d.ts +63 -0
  22. package/dist/make/inbox.d.ts.map +1 -0
  23. package/dist/make/inbox.js +1747 -0
  24. package/dist/make/inbox.js.map +1 -0
  25. package/dist/make/payments.d.ts +61 -0
  26. package/dist/make/payments.d.ts.map +1 -0
  27. package/dist/make/payments.js +1900 -0
  28. package/dist/make/payments.js.map +1 -0
  29. package/dist/make/shared.d.ts +592 -0
  30. package/dist/make/shared.d.ts.map +1 -0
  31. package/dist/make/shared.js +2233 -0
  32. package/dist/make/shared.js.map +1 -0
  33. package/dist/make/tenancy.d.ts +101 -0
  34. package/dist/make/tenancy.d.ts.map +1 -0
  35. package/dist/make/tenancy.js +4409 -0
  36. package/dist/make/tenancy.js.map +1 -0
  37. package/dist/make.d.ts +8 -44
  38. package/dist/make.d.ts.map +1 -1
  39. package/dist/make.js +57 -3689
  40. package/dist/make.js.map +1 -1
  41. package/dist/mcp.d.ts.map +1 -1
  42. package/dist/mcp.js +45 -4
  43. package/dist/mcp.js.map +1 -1
  44. package/dist/provider-add.d.ts +19 -0
  45. package/dist/provider-add.d.ts.map +1 -0
  46. package/dist/provider-add.js +691 -0
  47. package/dist/provider-add.js.map +1 -0
  48. package/dist/provider-audit.d.ts.map +1 -1
  49. package/dist/provider-audit.js +77 -1
  50. package/dist/provider-audit.js.map +1 -1
  51. package/dist/registry-edits.d.ts +9 -0
  52. package/dist/registry-edits.d.ts.map +1 -1
  53. package/dist/registry-edits.js +32 -0
  54. package/dist/registry-edits.js.map +1 -1
  55. package/dist/task.d.ts +2 -0
  56. package/dist/task.d.ts.map +1 -1
  57. package/dist/task.js +2 -0
  58. package/dist/task.js.map +1 -1
  59. package/dist/templates/agents.d.ts.map +1 -1
  60. package/dist/templates/agents.js +29 -11
  61. package/dist/templates/agents.js.map +1 -1
  62. package/dist/templates/base.d.ts.map +1 -1
  63. package/dist/templates/base.js +42 -12
  64. package/dist/templates/base.js.map +1 -1
  65. package/dist/templates/db/sqlite.d.ts.map +1 -1
  66. package/dist/templates/db/sqlite.js +7 -1
  67. package/dist/templates/db/sqlite.js.map +1 -1
  68. package/dist/templates/server.d.ts.map +1 -1
  69. package/dist/templates/server.js +44 -37
  70. package/dist/templates/server.js.map +1 -1
  71. package/dist/templates/shared.d.ts +5 -0
  72. package/dist/templates/shared.d.ts.map +1 -1
  73. package/dist/templates/shared.js +5 -0
  74. package/dist/templates/shared.js.map +1 -1
  75. package/package.json +2 -2
  76. package/skills/app-structure/SKILL.md +1 -1
  77. package/src/check.ts +246 -0
  78. package/src/choices.ts +28 -0
  79. package/src/db.ts +4 -4
  80. package/src/index.ts +232 -21
  81. package/src/inspect.ts +168 -2
  82. package/src/make/inbox.ts +2015 -0
  83. package/src/make/payments.ts +2182 -0
  84. package/src/make/shared.ts +3638 -0
  85. package/src/make/tenancy.ts +4809 -0
  86. package/src/make.ts +388 -5355
  87. package/src/mcp.ts +66 -3
  88. package/src/provider-add.ts +926 -0
  89. package/src/provider-audit.ts +95 -3
  90. package/src/registry-edits.ts +47 -0
  91. package/src/task.ts +8 -10
  92. package/src/templates/agents.ts +29 -11
  93. package/src/templates/base.ts +43 -12
  94. package/src/templates/db/sqlite.ts +7 -1
  95. package/src/templates/server.ts +44 -37
  96. package/src/templates/shared.ts +5 -0
  97. package/src/test-helpers/generated-app.ts +168 -0
@@ -0,0 +1,1747 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadBeignetConfig, resolveConfig, } from "../config.js";
4
+ import { addNamedValueImport, appendToNamedArray } from "../registry-edits.js";
5
+ import { aliasModule, appendSharedErrorEntries, assertStandardApp, detectResourceDatabase, detectResourcePersistence, detectUiShell, drizzleDialects, drizzleRepositoriesPath, drizzleSchemaIndexPath, fileExists, infrastructureDir, missingCapabilityBuilderFiles, notificationPortWirings, planGeneratedFiles, readOptionalFile, resourceNames, resourceSharedErrorsPath, seedEntrypointPath, seedNames, uniqueStrings, updateDatabaseProviderPick, updateDatabaseResetTables, updateDrizzleRepositories, updateDrizzleSchemaIndexExports, updateInfrastructureDeferredPorts, updateOpenApiRouteContracts, updatePackageJson, updatePackageScripts, updatePortsIndex, updateSeedEntrypoint, updateServerRoutes, updateTransactionPortsPick, wirePortProviders, writePlannedGeneratedFile, } from "./shared.js";
6
+ const sidebarFile = "components/app-sidebar.tsx";
7
+ const sidebarNavEntry = '{ href: "/inbox", label: "Inbox", icon: InboxIcon }';
8
+ /**
9
+ * Contract export names registered with array-literal
10
+ * `createOpenAPIHandler([...])` routes. The starter documents
11
+ * `contractsFromRoutes(routes)` instead, which needs no edit.
12
+ */
13
+ const inboxContractNames = [
14
+ "listInbox",
15
+ "unreadCount",
16
+ "markRead",
17
+ "markAllRead",
18
+ ];
19
+ /**
20
+ * The inbox channel writes rows through `ctx.ports.inbox`, so only the
21
+ * notifications half of the notification wiring is required — no mailer.
22
+ */
23
+ function inboxPortWirings() {
24
+ return notificationPortWirings("make inbox").filter((wiring) => wiring.portKey === "notifications");
25
+ }
26
+ /**
27
+ * Generate an actor-scoped in-app inbox slice: notification schemas, an
28
+ * `InboxRepository` port with a Drizzle adapter, cursor-paginated contracts
29
+ * and use cases, an in-app notification channel, seeds, tests, and — when the
30
+ * React Query shell is present — inbox UI plus a sidebar link.
31
+ *
32
+ * The inbox is personal (scoped by the signed-in user id), so it requires no
33
+ * tenancy setup.
34
+ */
35
+ export async function makeInbox(options) {
36
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
37
+ const config = options.config
38
+ ? resolveConfig(options.config)
39
+ : await loadBeignetConfig(targetDir);
40
+ await assertStandardApp(targetDir, config);
41
+ const persistence = await detectResourcePersistence(targetDir, config);
42
+ if (persistence !== "drizzle") {
43
+ throw new Error("beignet make inbox expects a Drizzle-backed Beignet app because inbox notifications must be durable. Add a database provider first, then run make inbox again.");
44
+ }
45
+ const sharedErrorsFile = resourceSharedErrorsPath(config);
46
+ if (!(await fileExists(path.join(targetDir, sharedErrorsFile)))) {
47
+ throw new Error(`beignet make inbox expects the shared error catalog at ${sharedErrorsFile}. Restore the generated error catalog, then run make inbox again.`);
48
+ }
49
+ const database = await detectResourceDatabase(targetDir, config);
50
+ const ui = await detectUiShell(targetDir, config);
51
+ const generatedFiles = [
52
+ ...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
53
+ ...inboxFiles(config, database, { ui }),
54
+ ];
55
+ const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
56
+ force: Boolean(options.force),
57
+ });
58
+ const inboxNames = resourceNames("inbox");
59
+ // Plan every edit with dryRun: true before writing anything so an anchor
60
+ // miss aborts with manual instructions instead of leaving partial wiring.
61
+ const plannedInboxUpdates = await updateInboxWiring(targetDir, inboxNames, config, { dryRun: true, ui });
62
+ const plannedProviderUpdates = (await wirePortProviders(targetDir, config, inboxPortWirings(), {
63
+ dryRun: true,
64
+ })).updatedFiles;
65
+ const createdFiles = plannedFiles
66
+ .filter((file) => file.result === "created")
67
+ .map((file) => file.path);
68
+ const updatedFiles = plannedFiles
69
+ .filter((file) => file.result === "updated")
70
+ .map((file) => file.path);
71
+ const skippedFiles = plannedFiles
72
+ .filter((file) => file.result === "skipped")
73
+ .map((file) => file.path);
74
+ const files = generatedFiles.map((file) => file.path);
75
+ const dryRun = Boolean(options.dryRun);
76
+ const inboxSeedNames = seedNames("inbox/demo-inbox");
77
+ const seedScript = { "db:seed": `bun ${seedEntrypointPath(config)}` };
78
+ const recordSeedEntrypoint = (result) => {
79
+ const entrypoint = seedEntrypointPath(config);
80
+ if (result === "created")
81
+ createdFiles.push(entrypoint);
82
+ if (result === "updated")
83
+ updatedFiles.push(entrypoint);
84
+ if (result === "skipped")
85
+ skippedFiles.push(entrypoint);
86
+ if (!files.includes(entrypoint))
87
+ files.push(entrypoint);
88
+ };
89
+ if (dryRun) {
90
+ updatedFiles.push(...plannedInboxUpdates, ...plannedProviderUpdates);
91
+ recordSeedEntrypoint(await updateSeedEntrypoint(targetDir, inboxSeedNames, config, {
92
+ dryRun: true,
93
+ }));
94
+ if (await updatePackageScripts(targetDir, seedScript, { dryRun: true })) {
95
+ updatedFiles.push("package.json");
96
+ }
97
+ }
98
+ else {
99
+ for (const file of plannedFiles) {
100
+ await writePlannedGeneratedFile(targetDir, file);
101
+ }
102
+ updatedFiles.push(...(await updateInboxWiring(targetDir, inboxNames, config, {
103
+ dryRun: false,
104
+ ui,
105
+ })));
106
+ updatedFiles.push(...(await wirePortProviders(targetDir, config, inboxPortWirings(), {
107
+ dryRun: false,
108
+ })).updatedFiles);
109
+ recordSeedEntrypoint(await updateSeedEntrypoint(targetDir, inboxSeedNames, config, {
110
+ dryRun: false,
111
+ }));
112
+ if (await updatePackageScripts(targetDir, seedScript, { dryRun: false })) {
113
+ updatedFiles.push("package.json");
114
+ }
115
+ }
116
+ return {
117
+ schemaVersion: 1,
118
+ name: "inbox",
119
+ targetDir,
120
+ dryRun,
121
+ files,
122
+ createdFiles,
123
+ updatedFiles: uniqueStrings(updatedFiles),
124
+ skippedFiles,
125
+ };
126
+ }
127
+ /**
128
+ * Post-generate guidance for `beignet make inbox`, one step per entry.
129
+ */
130
+ export function makeInboxNextStepLines() {
131
+ return [
132
+ "Run beignet db generate and beignet db migrate so inbox_notifications exists.",
133
+ 'Add an in-app channel to any feature notification: import { defineInboxNotificationChannel } from "@/features/inbox/channel" and add inApp: defineInboxNotificationChannel(({ payload }) => ({ userId: payload.userId, type: "your-type", title: "Something happened" })) to its channels.',
134
+ "Render the unread badge from features/inbox/components/unread-badge.tsx next to your Inbox nav link for a live unread count.",
135
+ "Run your app's test, lint, and typecheck commands, then beignet lint and beignet doctor.",
136
+ ];
137
+ }
138
+ export async function updateInboxWiring(targetDir, inboxNames, config, options) {
139
+ const updated = new Set();
140
+ if (await updatePackageJson(targetDir, options)) {
141
+ updated.add("package.json");
142
+ }
143
+ if (await updatePortsIndex(targetDir, inboxNames, config, {
144
+ ...options,
145
+ generationOptions: {
146
+ auth: false,
147
+ tenant: false,
148
+ events: false,
149
+ softDelete: false,
150
+ },
151
+ })) {
152
+ updated.add(config.paths.ports);
153
+ }
154
+ if (await updateInfrastructureDeferredPorts(targetDir, inboxNames, config, options)) {
155
+ updated.add(config.paths.infrastructurePorts);
156
+ }
157
+ if (await updateDrizzleSchemaIndexExports(targetDir, config, { exports: ["inboxNotifications"], module: "./inbox" }, options)) {
158
+ updated.add(drizzleSchemaIndexPath(config));
159
+ }
160
+ if (await updateDrizzleRepositories(targetDir, inboxNames, config, options)) {
161
+ updated.add(drizzleRepositoriesPath(config));
162
+ }
163
+ if (await updateDatabaseProviderPick(targetDir, config, ["inbox"], options)) {
164
+ updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
165
+ }
166
+ if (await updateTransactionPortsPick(targetDir, config, ["inbox"], "make inbox", options)) {
167
+ updated.add(path.join(infrastructureDir(config), "db/transaction-ports.ts"));
168
+ }
169
+ if (await updateDatabaseResetTables(targetDir, config, ["inbox_notifications"], options)) {
170
+ updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
171
+ }
172
+ if (await updateInboxSharedErrors(targetDir, config, options)) {
173
+ updated.add(resourceSharedErrorsPath(config));
174
+ }
175
+ const routesFile = await updateServerRoutes(targetDir, inboxNames, config, options);
176
+ if (routesFile)
177
+ updated.add(routesFile);
178
+ if (await updateOpenApiRouteContracts(targetDir, config, {
179
+ contractNames: inboxContractNames,
180
+ contractsModule: aliasModule(path.join(config.paths.features, "inbox/contracts.ts")),
181
+ }, options)) {
182
+ updated.add(config.paths.openapiRoute);
183
+ }
184
+ if (options.ui && (await updateSidebarNavLinks(targetDir, options))) {
185
+ updated.add(sidebarFile);
186
+ }
187
+ return [...updated];
188
+ }
189
+ export async function updateInboxSharedErrors(targetDir, config, options) {
190
+ return appendSharedErrorEntries(targetDir, config, [
191
+ {
192
+ name: "NotificationNotFound",
193
+ entry: `NotificationNotFound: {
194
+ \t\tcode: "NOTIFICATION_NOT_FOUND",
195
+ \t\tstatus: 404,
196
+ \t\tmessage: "Notification not found",
197
+ \t},`,
198
+ },
199
+ ], "make inbox", options);
200
+ }
201
+ export async function updateSidebarNavLinks(targetDir, options) {
202
+ const filePath = path.join(targetDir, sidebarFile);
203
+ const original = await readOptionalFile(filePath);
204
+ const manual = `Add ${sidebarNavEntry} to your navigation manually with import { InboxIcon } from "lucide-react", or restore the generated sidebar before running make inbox.`;
205
+ if (original === undefined) {
206
+ throw new Error(`Could not find ${sidebarFile}. ${manual}`);
207
+ }
208
+ if (original.includes('href: "/inbox"'))
209
+ return false;
210
+ let next = addNamedValueImport(original, "InboxIcon", "lucide-react");
211
+ const appended = appendToNamedArray(next, "navLinks", sidebarNavEntry);
212
+ if (appended.kind === "missing") {
213
+ throw new Error(`Could not find the navLinks array in ${sidebarFile}. ${manual}`);
214
+ }
215
+ if (appended.kind === "updated")
216
+ next = appended.source;
217
+ if (next === original)
218
+ return false;
219
+ if (!options.dryRun)
220
+ await writeFile(filePath, next);
221
+ return true;
222
+ }
223
+ export function inboxFiles(config, database, options) {
224
+ const featureDir = (file) => path.join(config.paths.features, "inbox", file);
225
+ const files = [
226
+ { path: featureDir("schemas.ts"), content: inboxSchemasFile() },
227
+ { path: featureDir("ports.ts"), content: inboxPortsFile() },
228
+ { path: featureDir("channel.ts"), content: inboxChannelFile(config) },
229
+ { path: featureDir("contracts.ts"), content: inboxContractsFile(config) },
230
+ {
231
+ path: featureDir("use-cases/list-inbox.ts"),
232
+ content: inboxListUseCaseFile(config),
233
+ },
234
+ {
235
+ path: featureDir("use-cases/get-unread-count.ts"),
236
+ content: inboxUnreadCountUseCaseFile(config),
237
+ },
238
+ {
239
+ path: featureDir("use-cases/mark-read.ts"),
240
+ content: inboxMarkReadUseCaseFile(config),
241
+ },
242
+ {
243
+ path: featureDir("use-cases/mark-all-read.ts"),
244
+ content: inboxMarkAllReadUseCaseFile(config),
245
+ },
246
+ {
247
+ path: featureDir("use-cases/index.ts"),
248
+ content: inboxUseCasesIndexFile(),
249
+ },
250
+ { path: featureDir("routes.ts"), content: inboxRoutesFile(config) },
251
+ {
252
+ path: featureDir("notifications/index.ts"),
253
+ content: inboxNotificationsFile(config),
254
+ },
255
+ { path: featureDir("seeds/index.ts"), content: inboxSeedsFile(config) },
256
+ {
257
+ path: featureDir("tests/factories/inbox-notification.ts"),
258
+ content: inboxFactoryFile(config),
259
+ },
260
+ { path: featureDir("tests/inbox.test.ts"), content: inboxTestFile(config) },
261
+ {
262
+ path: featureDir("tests/channel.test.ts"),
263
+ content: inboxChannelTestFile(config),
264
+ },
265
+ {
266
+ path: path.join(infrastructureDir(config), "inbox/drizzle-inbox-repository.ts"),
267
+ content: inboxDrizzleRepositoryFile(config, database),
268
+ },
269
+ {
270
+ path: path.join(infrastructureDir(config), "db/schema/inbox.ts"),
271
+ content: inboxDrizzleSchemaFile(database),
272
+ },
273
+ ];
274
+ if (options.ui) {
275
+ files.push({
276
+ path: featureDir("client/queries.ts"),
277
+ content: inboxClientQueriesFile(config),
278
+ }, {
279
+ path: featureDir("components/unread-badge.tsx"),
280
+ content: inboxUnreadBadgeFile(config),
281
+ }, {
282
+ path: featureDir("components/inbox-item.tsx"),
283
+ content: inboxItemComponentFile(config),
284
+ }, {
285
+ path: featureDir("components/inbox-list.tsx"),
286
+ content: inboxListComponentFile(config),
287
+ }, {
288
+ path: featureDir("components/index.ts"),
289
+ content: inboxComponentsIndexFile(),
290
+ }, { path: "app/(app)/inbox/page.tsx", content: inboxPageFile(config) });
291
+ }
292
+ return files;
293
+ }
294
+ export function inboxSchemasFile() {
295
+ return `import { z } from "zod";
296
+
297
+ /**
298
+ * Response metadata for cursor-paginated lists. Matches \`CursorPageInfo\`
299
+ * from \`@beignet/core/pagination\`.
300
+ */
301
+ export const cursorPageSchema = z.object({
302
+ \tkind: z.literal("cursor"),
303
+ \tlimit: z.number().int().min(1),
304
+ \tcursor: z.string().nullable(),
305
+ \tnextCursor: z.string().nullable(),
306
+ \thasMore: z.boolean(),
307
+ });
308
+
309
+ /**
310
+ * Notification type is an open string so any feature can deliver its own
311
+ * kinds without editing this schema. Example values: "welcome", "mention",
312
+ * "assignment", "status_change".
313
+ */
314
+ export const inboxNotificationTypeSchema = z.string().min(1);
315
+
316
+ export const inboxNotificationSchema = z.object({
317
+ \tid: z.string().min(1),
318
+ \tuserId: z.string().min(1),
319
+ \ttype: inboxNotificationTypeSchema,
320
+ \ttitle: z.string().min(1),
321
+ \tbody: z.string().nullable(),
322
+ \tresourceKey: z.string().min(1).nullable(),
323
+ \tactorUserId: z.string().min(1).nullable(),
324
+ \tactorName: z.string().nullable(),
325
+ \treadAt: z.string().nullable(),
326
+ \tcreatedAt: z.string(),
327
+ });
328
+
329
+ export const listInboxQuerySchema = z.object({
330
+ \tlimit: z.coerce.number().int().min(1).max(50).default(20),
331
+ \tcursor: z.string().min(1).optional(),
332
+ \tunread: z.coerce.boolean().default(false),
333
+ });
334
+
335
+ export const listInboxOutputSchema = z.object({
336
+ \titems: z.array(inboxNotificationSchema),
337
+ \tpage: cursorPageSchema,
338
+ });
339
+
340
+ export const unreadCountOutputSchema = z.object({
341
+ \tcount: z.number().int().min(0),
342
+ });
343
+
344
+ export const markReadPathSchema = z.object({
345
+ \tnotificationId: z.string().min(1),
346
+ });
347
+
348
+ export const markAllReadOutputSchema = z.object({
349
+ \tupdated: z.number().int().min(0),
350
+ });
351
+
352
+ export type InboxNotificationType = z.infer<typeof inboxNotificationTypeSchema>;
353
+ export type InboxNotification = z.infer<typeof inboxNotificationSchema>;
354
+ `;
355
+ }
356
+ export function inboxPortsFile() {
357
+ return `import type {
358
+ \tCursorPage,
359
+ \tCursorPageInfo,
360
+ \tPageResult,
361
+ } from "@beignet/core/pagination";
362
+ import type { InboxNotification, InboxNotificationType } from "./schemas";
363
+
364
+ export type { InboxNotification, InboxNotificationType };
365
+
366
+ /**
367
+ * A user's inbox is personal: rows are scoped by the recipient's user id, so
368
+ * no tenant is required.
369
+ */
370
+ export type InboxUserScope = {
371
+ \tuserId: string;
372
+ };
373
+
374
+ /**
375
+ * Input written by the in-app notification channel
376
+ * (\`features/inbox/channel.ts\`) and by seeds.
377
+ */
378
+ export type InboxNotificationInput = {
379
+ \tuserId: string;
380
+ \ttype: InboxNotificationType;
381
+ \ttitle: string;
382
+ \tbody?: string;
383
+ \t/** Optional key linking the notification to an app resource. */
384
+ \tresourceKey?: string;
385
+ \tactorUserId?: string;
386
+ \tactorName?: string;
387
+ };
388
+
389
+ export interface InboxRepository {
390
+ \tfindMany(
391
+ \t\tparams: InboxUserScope & {
392
+ \t\t\tunreadOnly?: boolean;
393
+ \t\t\tpage: CursorPage;
394
+ \t\t},
395
+ \t): Promise<PageResult<InboxNotification, CursorPageInfo>>;
396
+ \tunreadCount(scope: InboxUserScope): Promise<number>;
397
+ \tcreate(input: InboxNotificationInput): Promise<InboxNotification>;
398
+ \tmarkRead(
399
+ \t\tlookup: InboxUserScope & { id: string },
400
+ \t): Promise<InboxNotification | null>;
401
+ \t/** Returns the number of notifications marked read. */
402
+ \tmarkAllRead(scope: InboxUserScope): Promise<number>;
403
+ }
404
+ `;
405
+ }
406
+ export function inboxChannelFile(config) {
407
+ const portsModule = aliasModule(path.join(config.paths.features, "inbox/ports.ts"));
408
+ return `import type {
409
+ \tInferSchemaOutput,
410
+ \tNotificationChannelHandler,
411
+ \tStandardSchema,
412
+ } from "@beignet/core/notifications";
413
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
414
+ import type { InboxNotificationInput } from "${portsModule}";
415
+
416
+ export type { InboxNotificationInput };
417
+
418
+ /**
419
+ * In-app notification channel that writes rows through the inbox repository
420
+ * port. Return \`null\` (or an empty array) from \`render\` to skip delivery.
421
+ *
422
+ * Used as a \`channels\` entry in \`defineNotification(...)\`, alongside
423
+ * \`defineMailNotificationChannel(...)\`.
424
+ */
425
+ export function defineInboxNotificationChannel<Payload extends StandardSchema>(
426
+ \trender: (args: {
427
+ \t\tpayload: InferSchemaOutput<Payload>;
428
+ \t\tctx: AppContext;
429
+ \t}) => InboxNotificationInput | InboxNotificationInput[] | null,
430
+ ): NotificationChannelHandler<Payload, AppContext> {
431
+ \treturn async ({ payload, ctx, channel }) => {
432
+ \t\tconst rendered = render({ payload, ctx });
433
+ \t\tconst entries =
434
+ \t\t\trendered == null ? [] : Array.isArray(rendered) ? rendered : [rendered];
435
+
436
+ \t\tif (entries.length === 0) {
437
+ \t\t\treturn {
438
+ \t\t\t\tchannel,
439
+ \t\t\t\tstatus: "skipped",
440
+ \t\t\t\treason: "No inbox entries to write.",
441
+ \t\t\t};
442
+ \t\t}
443
+
444
+ \t\tconst created = await Promise.all(
445
+ \t\t\tentries.map((entry) => ctx.ports.inbox.create(entry)),
446
+ \t\t);
447
+
448
+ \t\treturn {
449
+ \t\t\tchannel,
450
+ \t\t\tstatus: "sent",
451
+ \t\t\tdetails: {
452
+ \t\t\t\tnotificationIds: created.map((notification) => notification.id),
453
+ \t\t\t},
454
+ \t\t};
455
+ \t};
456
+ }
457
+ `;
458
+ }
459
+ export function inboxContractsFile(config) {
460
+ const schemasModule = aliasModule(path.join(config.paths.features, "inbox/schemas.ts"));
461
+ const errorsModule = aliasModule(resourceSharedErrorsPath(config));
462
+ return `import { defineContractGroup } from "@beignet/core/contracts";
463
+ import {
464
+ \tinboxNotificationSchema,
465
+ \tlistInboxOutputSchema,
466
+ \tlistInboxQuerySchema,
467
+ \tmarkAllReadOutputSchema,
468
+ \tmarkReadPathSchema,
469
+ \tunreadCountOutputSchema,
470
+ } from "${schemasModule}";
471
+ import { errors } from "${errorsModule}";
472
+
473
+ const inbox = defineContractGroup().namespace("inbox").prefix("/api/inbox");
474
+
475
+ export const listInbox = inbox
476
+ \t.get("/")
477
+ \t.query(listInboxQuerySchema)
478
+ \t.meta({ auth: "required" })
479
+ \t.errors({ Unauthorized: errors.Unauthorized })
480
+ \t.responses({
481
+ \t\t200: listInboxOutputSchema,
482
+ \t})
483
+ \t.openapi({ summary: "List inbox notifications", tags: ["inbox"] });
484
+
485
+ export const unreadCount = inbox
486
+ \t.get("/unread-count")
487
+ \t.meta({ auth: "required" })
488
+ \t.errors({ Unauthorized: errors.Unauthorized })
489
+ \t.responses({
490
+ \t\t200: unreadCountOutputSchema,
491
+ \t})
492
+ \t.openapi({ summary: "Count unread inbox notifications", tags: ["inbox"] });
493
+
494
+ export const markRead = inbox
495
+ \t.post("/:notificationId/read")
496
+ \t.pathParams(markReadPathSchema)
497
+ \t.meta({ auth: "required" })
498
+ \t.errors({
499
+ \t\tNotificationNotFound: errors.NotificationNotFound,
500
+ \t\tUnauthorized: errors.Unauthorized,
501
+ \t})
502
+ \t.responses({
503
+ \t\t200: inboxNotificationSchema,
504
+ \t})
505
+ \t.openapi({ summary: "Mark an inbox notification read", tags: ["inbox"] });
506
+
507
+ export const markAllRead = inbox
508
+ \t.post("/read-all")
509
+ \t.meta({ auth: "required" })
510
+ \t.errors({ Unauthorized: errors.Unauthorized })
511
+ \t.responses({
512
+ \t\t200: markAllReadOutputSchema,
513
+ \t})
514
+ \t.openapi({ summary: "Mark all inbox notifications read", tags: ["inbox"] });
515
+
516
+ export const inboxContracts = [listInbox, unreadCount, markRead, markAllRead];
517
+ `;
518
+ }
519
+ export function inboxListUseCaseFile(config) {
520
+ return `import { normalizeCursorPage } from "@beignet/core/pagination";
521
+ import { requireUserId } from "@beignet/core/ports";
522
+ import { useCase } from "${aliasModule(config.paths.useCaseBuilder)}";
523
+ import { listInboxOutputSchema, listInboxQuerySchema } from "../schemas";
524
+
525
+ /**
526
+ * Cursor-paginated inbox for the signed-in user, newest first. Rows are
527
+ * scoped to the caller's user id — a user can only ever see their own inbox,
528
+ * so no policy is involved.
529
+ */
530
+ export const listInboxUseCase = useCase
531
+ \t.query("inbox.list")
532
+ \t.input(listInboxQuerySchema)
533
+ \t.output(listInboxOutputSchema)
534
+ \t.run(async ({ ctx, input }) => {
535
+ \t\tconst userId = requireUserId(ctx);
536
+ \t\tconst page = normalizeCursorPage(
537
+ \t\t\t{ limit: input.limit, cursor: input.cursor ?? null },
538
+ \t\t\t{ defaultLimit: 20, maxLimit: 50 },
539
+ \t\t);
540
+
541
+ \t\tconst result = await ctx.ports.inbox.findMany({
542
+ \t\t\tuserId,
543
+ \t\t\tunreadOnly: input.unread,
544
+ \t\t\tpage,
545
+ \t\t});
546
+
547
+ \t\treturn { items: [...result.items], page: result.page };
548
+ \t});
549
+ `;
550
+ }
551
+ export function inboxUnreadCountUseCaseFile(config) {
552
+ return `import { requireUserId } from "@beignet/core/ports";
553
+ import { z } from "zod";
554
+ import { useCase } from "${aliasModule(config.paths.useCaseBuilder)}";
555
+ import { unreadCountOutputSchema } from "../schemas";
556
+
557
+ /**
558
+ * Unread inbox count for the signed-in user; polled by the unread badge.
559
+ */
560
+ export const getUnreadCountUseCase = useCase
561
+ \t.query("inbox.unread-count")
562
+ \t.input(z.object({}))
563
+ \t.output(unreadCountOutputSchema)
564
+ \t.run(async ({ ctx }) => {
565
+ \t\tconst userId = requireUserId(ctx);
566
+ \t\tconst count = await ctx.ports.inbox.unreadCount({ userId });
567
+
568
+ \t\treturn { count };
569
+ \t});
570
+ `;
571
+ }
572
+ export function inboxMarkReadUseCaseFile(config) {
573
+ const errorsModule = aliasModule(resourceSharedErrorsPath(config));
574
+ return `import { requireUserId } from "@beignet/core/ports";
575
+ import { appError } from "${errorsModule}";
576
+ import { useCase } from "${aliasModule(config.paths.useCaseBuilder)}";
577
+ import { inboxNotificationSchema, markReadPathSchema } from "../schemas";
578
+
579
+ /**
580
+ * Marks one of the caller's notifications read. The repository lookup is
581
+ * scoped by (user, id), so another user's notification reads as not found
582
+ * rather than forbidden.
583
+ */
584
+ export const markReadUseCase = useCase
585
+ \t.command("inbox.mark-read")
586
+ \t.input(markReadPathSchema)
587
+ \t.output(inboxNotificationSchema)
588
+ \t.run(async ({ ctx, input }) => {
589
+ \t\tconst userId = requireUserId(ctx);
590
+
591
+ \t\treturn ctx.ports.uow.transaction(async (tx) => {
592
+ \t\t\tconst notification = await tx.inbox.markRead({
593
+ \t\t\t\tuserId,
594
+ \t\t\t\tid: input.notificationId,
595
+ \t\t\t});
596
+ \t\t\tif (!notification) {
597
+ \t\t\t\tthrow appError("NotificationNotFound", {
598
+ \t\t\t\t\tdetails: { notificationId: input.notificationId },
599
+ \t\t\t\t});
600
+ \t\t\t}
601
+
602
+ \t\t\treturn notification;
603
+ \t\t});
604
+ \t});
605
+ `;
606
+ }
607
+ export function inboxMarkAllReadUseCaseFile(config) {
608
+ return `import { requireUserId } from "@beignet/core/ports";
609
+ import { z } from "zod";
610
+ import { useCase } from "${aliasModule(config.paths.useCaseBuilder)}";
611
+ import { markAllReadOutputSchema } from "../schemas";
612
+
613
+ /**
614
+ * Marks every unread notification in the caller's inbox as read.
615
+ */
616
+ export const markAllReadUseCase = useCase
617
+ \t.command("inbox.mark-all-read")
618
+ \t.input(z.object({}))
619
+ \t.output(markAllReadOutputSchema)
620
+ \t.run(async ({ ctx }) => {
621
+ \t\tconst userId = requireUserId(ctx);
622
+
623
+ \t\treturn ctx.ports.uow.transaction(async (tx) => {
624
+ \t\t\tconst updated = await tx.inbox.markAllRead({ userId });
625
+
626
+ \t\t\treturn { updated };
627
+ \t\t});
628
+ \t});
629
+ `;
630
+ }
631
+ export function inboxUseCasesIndexFile() {
632
+ return `export { getUnreadCountUseCase } from "./get-unread-count";
633
+ export { listInboxUseCase } from "./list-inbox";
634
+ export { markAllReadUseCase } from "./mark-all-read";
635
+ export { markReadUseCase } from "./mark-read";
636
+ `;
637
+ }
638
+ export function inboxRoutesFile(config) {
639
+ const contractsModule = aliasModule(path.join(config.paths.features, "inbox/contracts.ts"));
640
+ const useCasesModule = aliasModule(path.join(config.paths.features, "inbox/use-cases/index.ts"));
641
+ return `import { defineRouteGroup } from "@beignet/next";
642
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
643
+ import {
644
+ \tlistInbox,
645
+ \tmarkAllRead,
646
+ \tmarkRead,
647
+ \tunreadCount,
648
+ } from "${contractsModule}";
649
+ import {
650
+ \tgetUnreadCountUseCase,
651
+ \tlistInboxUseCase,
652
+ \tmarkAllReadUseCase,
653
+ \tmarkReadUseCase,
654
+ } from "${useCasesModule}";
655
+
656
+ export const inboxRoutes = defineRouteGroup<AppContext>()({
657
+ \tname: "inbox",
658
+ \troutes: [
659
+ \t\t{ contract: listInbox, useCase: listInboxUseCase },
660
+ \t\t{ contract: unreadCount, useCase: getUnreadCountUseCase },
661
+ \t\t{ contract: markRead, useCase: markReadUseCase },
662
+ \t\t{ contract: markAllRead, useCase: markAllReadUseCase },
663
+ \t],
664
+ });
665
+ `;
666
+ }
667
+ export function inboxNotificationsFile(config) {
668
+ const channelModule = aliasModule(path.join(config.paths.features, "inbox/channel.ts"));
669
+ return `import { z } from "zod";
670
+ import { defineInboxNotificationChannel } from "${channelModule}";
671
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
672
+
673
+ const welcomePayloadSchema = z.object({
674
+ \tuserId: z.string().min(1),
675
+ \tname: z.string().optional(),
676
+ });
677
+
678
+ /**
679
+ * Sample in-app notification demonstrating the inbox channel. Send it with
680
+ * \`ctx.ports.notifications.send(InboxWelcomeNotification, { userId })\`, and
681
+ * add an \`inApp\` channel like this one to any feature's notification to
682
+ * deliver it to the inbox.
683
+ */
684
+ export const InboxWelcomeNotification = defineNotification("inbox.welcome", {
685
+ \tpayload: welcomePayloadSchema,
686
+ \tdescription: "Welcomes a new user in their in-app inbox.",
687
+ \tchannels: {
688
+ \t\tinApp: defineInboxNotificationChannel(({ payload }) => ({
689
+ \t\t\tuserId: payload.userId,
690
+ \t\t\ttype: "welcome",
691
+ \t\t\ttitle: payload.name ? \`Welcome, \${payload.name}!\` : "Welcome!",
692
+ \t\t\tbody: "This notification was delivered to your in-app inbox.",
693
+ \t\t})),
694
+ \t},
695
+ });
696
+
697
+ export const inboxNotifications = [InboxWelcomeNotification] as const;
698
+ `;
699
+ }
700
+ export function inboxSeedsFile(config) {
701
+ const portsModule = aliasModule(path.join(config.paths.features, "inbox/ports.ts"));
702
+ return `import { normalizeCursorPage } from "@beignet/core/pagination";
703
+ import { defineSeed } from "@beignet/core/testing";
704
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
705
+ import type { InboxNotificationInput } from "${portsModule}";
706
+
707
+ /**
708
+ * Demo inbox rows for local development. The starter has no seeded users, so
709
+ * rows target a placeholder user id; change DEMO_USER_ID to a real user id
710
+ * (for example your first sign-up) to see them in the app.
711
+ */
712
+ const DEMO_USER_ID = "demo-user";
713
+
714
+ const rows: readonly (InboxNotificationInput & { read?: boolean })[] = [
715
+ \t{
716
+ \t\tuserId: DEMO_USER_ID,
717
+ \t\ttype: "welcome",
718
+ \t\ttitle: "Welcome to your inbox",
719
+ \t\tbody: "In-app notifications from your features land here.",
720
+ \t},
721
+ \t{
722
+ \t\tuserId: DEMO_USER_ID,
723
+ \t\ttype: "mention",
724
+ \t\ttitle: "You were mentioned",
725
+ \t\tbody: "Demo data seeded by beignet make inbox.",
726
+ \t\tresourceKey: "demo-resource",
727
+ \t},
728
+ \t{
729
+ \t\tuserId: DEMO_USER_ID,
730
+ \t\ttype: "status_change",
731
+ \t\ttitle: "A resource you follow changed",
732
+ \t\tread: true,
733
+ \t},
734
+ ];
735
+
736
+ export const inboxDemoSeed = defineSeed<"inbox.demo", AppContext>(
737
+ \t"inbox.demo",
738
+ \t{
739
+ \t\tdescription: "Demo inbox notifications for local development.",
740
+ \t\tasync run(ctx) {
741
+ \t\t\tconst existing = await ctx.ports.inbox.findMany({
742
+ \t\t\t\tuserId: DEMO_USER_ID,
743
+ \t\t\t\tpage: normalizeCursorPage(
744
+ \t\t\t\t\t{ limit: 50 },
745
+ \t\t\t\t\t{ defaultLimit: 50, maxLimit: 50 },
746
+ \t\t\t\t),
747
+ \t\t\t});
748
+
749
+ \t\t\tfor (const { read, ...input } of rows) {
750
+ \t\t\t\t// Idempotent: skip rows that were already seeded.
751
+ \t\t\t\tif (existing.items.some((item) => item.title === input.title)) {
752
+ \t\t\t\t\tcontinue;
753
+ \t\t\t\t}
754
+
755
+ \t\t\t\tconst created = await ctx.ports.inbox.create(input);
756
+ \t\t\t\tif (read) {
757
+ \t\t\t\t\tawait ctx.ports.inbox.markRead({
758
+ \t\t\t\t\t\tuserId: input.userId,
759
+ \t\t\t\t\t\tid: created.id,
760
+ \t\t\t\t\t});
761
+ \t\t\t\t}
762
+ \t\t\t}
763
+ \t\t},
764
+ \t},
765
+ );
766
+
767
+ export const inboxSeeds = [inboxDemoSeed] as const;
768
+ `;
769
+ }
770
+ export function inboxFactoryFile(config) {
771
+ const portsModule = aliasModule(path.join(config.paths.features, "inbox/ports.ts"));
772
+ return `import { cursorPageResult } from "@beignet/core/pagination";
773
+ import { createFactory } from "@beignet/core/testing";
774
+ import type { InboxNotification, InboxRepository } from "${portsModule}";
775
+
776
+ export const inboxNotificationFactory = createFactory("inbox.notification", {
777
+ \tdefaults: ({ sequence, clock }): InboxNotification => ({
778
+ \t\tid: \`00000000-0000-4000-a000-\${String(sequence).padStart(12, "0")}\`,
779
+ \t\tuserId: "user_test",
780
+ \t\ttype: "mention",
781
+ \t\ttitle: \`Notification \${sequence}\`,
782
+ \t\tbody: null,
783
+ \t\tresourceKey: null,
784
+ \t\tactorUserId: null,
785
+ \t\tactorName: null,
786
+ \t\treadAt: null,
787
+ \t\t// Spread timestamps out so cursor ordering (created_at desc, id desc)
788
+ \t\t// is deterministic per sequence.
789
+ \t\tcreatedAt: new Date(clock.now().getTime() + sequence * 1_000).toISOString(),
790
+ \t}),
791
+ });
792
+
793
+ export type MemoryInboxRepository = InboxRepository & {
794
+ \trows: InboxNotification[];
795
+ };
796
+
797
+ /**
798
+ * In-memory InboxRepository mirroring the Drizzle adapter: newest first,
799
+ * cursor is the last row's id, lookups scoped by user id.
800
+ */
801
+ export function createMemoryInboxRepository(
802
+ \tseed: readonly InboxNotification[] = [],
803
+ ): MemoryInboxRepository {
804
+ \tconst rows: InboxNotification[] = [...seed];
805
+ \tlet sequence = rows.length;
806
+
807
+ \tconst scoped = (userId: string) =>
808
+ \t\trows
809
+ \t\t\t.filter((row) => row.userId === userId)
810
+ \t\t\t.sort((a, b) =>
811
+ \t\t\t\ta.createdAt === b.createdAt
812
+ \t\t\t\t\t? b.id.localeCompare(a.id)
813
+ \t\t\t\t\t: b.createdAt.localeCompare(a.createdAt),
814
+ \t\t\t);
815
+
816
+ \treturn {
817
+ \t\trows,
818
+
819
+ \t\tasync findMany(params) {
820
+ \t\t\tlet matches = scoped(params.userId);
821
+ \t\t\tif (params.unreadOnly) {
822
+ \t\t\t\tmatches = matches.filter((row) => row.readAt === null);
823
+ \t\t\t}
824
+ \t\t\tif (params.page.cursor) {
825
+ \t\t\t\tconst index = matches.findIndex(
826
+ \t\t\t\t\t(row) => row.id === params.page.cursor,
827
+ \t\t\t\t);
828
+ \t\t\t\tmatches = index === -1 ? [] : matches.slice(index + 1);
829
+ \t\t\t}
830
+
831
+ \t\t\tconst items = matches.slice(0, params.page.limit);
832
+ \t\t\tconst hasMore = matches.length > params.page.limit;
833
+ \t\t\tconst lastItem = items[items.length - 1];
834
+ \t\t\treturn cursorPageResult(
835
+ \t\t\t\titems,
836
+ \t\t\t\tparams.page,
837
+ \t\t\t\thasMore && lastItem ? lastItem.id : null,
838
+ \t\t\t);
839
+ \t\t},
840
+
841
+ \t\tasync unreadCount({ userId }) {
842
+ \t\t\treturn scoped(userId).filter((row) => row.readAt === null).length;
843
+ \t\t},
844
+
845
+ \t\tasync create(input) {
846
+ \t\t\tsequence += 1;
847
+ \t\t\tconst row: InboxNotification = {
848
+ \t\t\t\tid: \`inbox-\${sequence}\`,
849
+ \t\t\t\tuserId: input.userId,
850
+ \t\t\t\ttype: input.type,
851
+ \t\t\t\ttitle: input.title,
852
+ \t\t\t\tbody: input.body ?? null,
853
+ \t\t\t\tresourceKey: input.resourceKey ?? null,
854
+ \t\t\t\tactorUserId: input.actorUserId ?? null,
855
+ \t\t\t\tactorName: input.actorName ?? null,
856
+ \t\t\t\treadAt: null,
857
+ \t\t\t\tcreatedAt: new Date().toISOString(),
858
+ \t\t\t};
859
+ \t\t\trows.push(row);
860
+ \t\t\treturn row;
861
+ \t\t},
862
+
863
+ \t\tasync markRead({ userId, id }) {
864
+ \t\t\tconst row = rows.find(
865
+ \t\t\t\t(candidate) => candidate.userId === userId && candidate.id === id,
866
+ \t\t\t);
867
+ \t\t\tif (!row) return null;
868
+ \t\t\tif (row.readAt === null) row.readAt = new Date().toISOString();
869
+ \t\t\treturn row;
870
+ \t\t},
871
+
872
+ \t\tasync markAllRead({ userId }) {
873
+ \t\t\tconst unread = scoped(userId).filter((row) => row.readAt === null);
874
+ \t\t\tconst readAt = new Date().toISOString();
875
+ \t\t\tfor (const row of unread) row.readAt = readAt;
876
+ \t\t\treturn unread.length;
877
+ \t\t},
878
+ \t};
879
+ }
880
+ `;
881
+ }
882
+ export function inboxTestFile(config) {
883
+ return `import { describe, expect, it } from "bun:test";
884
+ import { createUseCaseTester } from "@beignet/core/application";
885
+ import { createAnonymousActor } from "@beignet/core/ports";
886
+ import { createTestUserActor } from "@beignet/core/ports/testing";
887
+ import {
888
+ \tcreateTestContextFactory,
889
+ \tcreateTestPorts,
890
+ } from "@beignet/core/testing";
891
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
892
+ import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
893
+ import type { AppTransactionPorts } from "${aliasModule(config.paths.ports)}";
894
+ import { getUnreadCountUseCase } from "../use-cases/get-unread-count";
895
+ import { listInboxUseCase } from "../use-cases/list-inbox";
896
+ import { markAllReadUseCase } from "../use-cases/mark-all-read";
897
+ import { markReadUseCase } from "../use-cases/mark-read";
898
+ import {
899
+ \tcreateMemoryInboxRepository,
900
+ \tinboxNotificationFactory,
901
+ \ttype MemoryInboxRepository,
902
+ } from "./factories/inbox-notification";
903
+
904
+ function createHarness(
905
+ \toptions: {
906
+ \t\tactor?: AppContext["actor"];
907
+ \t\tinbox?: MemoryInboxRepository;
908
+ \t} = {},
909
+ ) {
910
+ \tconst inbox = options.inbox ?? createMemoryInboxRepository();
911
+ \tconst actor = options.actor ?? createTestUserActor("user_test");
912
+ \tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
913
+ \t\tbase: appPorts,
914
+ \t\toverrides: {
915
+ \t\t\tgate: appPorts.gate,
916
+ \t\t\tinbox,
917
+ \t\t},
918
+ \t});
919
+ \tconst createContext = createTestContextFactory<
920
+ \t\tAppContext,
921
+ \t\tAppContext["ports"]
922
+ \t>({
923
+ \t\tports: fixture.ports,
924
+ \t\tactor,
925
+ \t\tauth:
926
+ \t\t\tactor.type === "user" && actor.id
927
+ \t\t\t\t? { user: { id: actor.id }, session: { id: "session_test" } }
928
+ \t\t\t\t: null,
929
+ \t});
930
+
931
+ \treturn { inbox, tester: createUseCaseTester<AppContext>(createContext) };
932
+ }
933
+
934
+ function seededInbox() {
935
+ \treturn createMemoryInboxRepository([
936
+ \t\t// Rows for the caller: three unread, one read.
937
+ \t\t...inboxNotificationFactory.buildList(3, { userId: "user_test" }),
938
+ \t\tinboxNotificationFactory.build({
939
+ \t\t\tuserId: "user_test",
940
+ \t\t\treadAt: "2026-01-01T00:00:00.000Z",
941
+ \t\t}),
942
+ \t\t// A row belonging to someone else; it must never leak into results.
943
+ \t\tinboxNotificationFactory.build({
944
+ \t\t\tid: "inbox-other-1",
945
+ \t\t\tuserId: "user_other",
946
+ \t\t}),
947
+ \t]);
948
+ }
949
+
950
+ describe("listInboxUseCase", () => {
951
+ \tit("requires an authenticated user", async () => {
952
+ \t\tconst harness = createHarness({ actor: createAnonymousActor() });
953
+
954
+ \t\tawait expect(
955
+ \t\t\tharness.tester.run(listInboxUseCase, { limit: 20, unread: false }),
956
+ \t\t).rejects.toMatchObject({ code: "UNAUTHORIZED" });
957
+ \t});
958
+
959
+ \tit("returns only the caller's rows, newest first, with cursor paging", async () => {
960
+ \t\tconst harness = createHarness({ inbox: seededInbox() });
961
+
962
+ \t\tconst firstPage = await harness.tester.run(listInboxUseCase, {
963
+ \t\t\tlimit: 3,
964
+ \t\t\tunread: false,
965
+ \t\t});
966
+ \t\texpect(firstPage.items).toHaveLength(3);
967
+ \t\texpect(firstPage.items.every((item) => item.userId === "user_test")).toBe(
968
+ \t\t\ttrue,
969
+ \t\t);
970
+ \t\texpect(firstPage.page.hasMore).toBe(true);
971
+ \t\texpect(firstPage.page.nextCursor).not.toBeNull();
972
+
973
+ \t\tconst secondPage = await harness.tester.run(listInboxUseCase, {
974
+ \t\t\tlimit: 3,
975
+ \t\t\tcursor: firstPage.page.nextCursor ?? undefined,
976
+ \t\t\tunread: false,
977
+ \t\t});
978
+ \t\texpect(secondPage.items).toHaveLength(1);
979
+ \t\texpect(secondPage.page.hasMore).toBe(false);
980
+ \t\texpect(
981
+ \t\t\tsecondPage.items.every(
982
+ \t\t\t\t(item) => !firstPage.items.some((seen) => seen.id === item.id),
983
+ \t\t\t),
984
+ \t\t).toBe(true);
985
+ \t});
986
+
987
+ \tit("filters to unread rows when asked", async () => {
988
+ \t\tconst harness = createHarness({ inbox: seededInbox() });
989
+
990
+ \t\tconst result = await harness.tester.run(listInboxUseCase, {
991
+ \t\t\tlimit: 20,
992
+ \t\t\tunread: true,
993
+ \t\t});
994
+
995
+ \t\texpect(result.items).toHaveLength(3);
996
+ \t\texpect(result.items.every((item) => item.readAt === null)).toBe(true);
997
+ \t});
998
+ });
999
+
1000
+ describe("getUnreadCountUseCase", () => {
1001
+ \tit("counts the caller's unread rows only", async () => {
1002
+ \t\tconst harness = createHarness({ inbox: seededInbox() });
1003
+
1004
+ \t\tawait expect(
1005
+ \t\t\tharness.tester.run(getUnreadCountUseCase, {}),
1006
+ \t\t).resolves.toEqual({ count: 3 });
1007
+ \t});
1008
+ });
1009
+
1010
+ describe("markReadUseCase", () => {
1011
+ \tit("marks one of the caller's notifications read", async () => {
1012
+ \t\tconst inbox = seededInbox();
1013
+ \t\tconst harness = createHarness({ inbox });
1014
+
1015
+ \t\tconst target = inbox.rows.find(
1016
+ \t\t\t(row) => row.userId === "user_test" && row.readAt === null,
1017
+ \t\t);
1018
+ \t\tif (!target) throw new Error("Expected an unread row.");
1019
+
1020
+ \t\tconst updated = await harness.tester.run(markReadUseCase, {
1021
+ \t\t\tnotificationId: target.id,
1022
+ \t\t});
1023
+
1024
+ \t\texpect(updated.readAt).not.toBeNull();
1025
+ \t\tawait expect(
1026
+ \t\t\tharness.tester.run(getUnreadCountUseCase, {}),
1027
+ \t\t).resolves.toEqual({ count: 2 });
1028
+ \t});
1029
+
1030
+ \tit("treats other users' notifications as not found", async () => {
1031
+ \t\tconst harness = createHarness({ inbox: seededInbox() });
1032
+
1033
+ \t\tawait expect(
1034
+ \t\t\tharness.tester.run(markReadUseCase, {
1035
+ \t\t\t\tnotificationId: "inbox-other-1",
1036
+ \t\t\t}),
1037
+ \t\t).rejects.toMatchObject({ code: "NOTIFICATION_NOT_FOUND" });
1038
+ \t});
1039
+ });
1040
+
1041
+ describe("markAllReadUseCase", () => {
1042
+ \tit("marks every unread row read and reports the count", async () => {
1043
+ \t\tconst inbox = seededInbox();
1044
+ \t\tconst harness = createHarness({ inbox });
1045
+
1046
+ \t\tawait expect(
1047
+ \t\t\tharness.tester.run(markAllReadUseCase, {}),
1048
+ \t\t).resolves.toEqual({ updated: 3 });
1049
+ \t\tawait expect(
1050
+ \t\t\tharness.tester.run(getUnreadCountUseCase, {}),
1051
+ \t\t).resolves.toEqual({ count: 0 });
1052
+
1053
+ \t\t// The other user's row is untouched.
1054
+ \t\tconst otherRow = inbox.rows.find((row) => row.userId === "user_other");
1055
+ \t\texpect(otherRow?.readAt).toBeNull();
1056
+ \t});
1057
+ });
1058
+ `;
1059
+ }
1060
+ export function inboxChannelTestFile(config) {
1061
+ const channelModule = aliasModule(path.join(config.paths.features, "inbox/channel.ts"));
1062
+ return `import { describe, expect, it } from "bun:test";
1063
+ import { createTestSystemActor } from "@beignet/core/ports/testing";
1064
+ import {
1065
+ \tcreateTestContextFactory,
1066
+ \tcreateTestPorts,
1067
+ } from "@beignet/core/testing";
1068
+ import { z } from "zod";
1069
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1070
+ import {
1071
+ \tdefineInboxNotificationChannel,
1072
+ \ttype InboxNotificationInput,
1073
+ } from "${channelModule}";
1074
+ import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
1075
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
1076
+ import type { AppTransactionPorts } from "${aliasModule(config.paths.ports)}";
1077
+ import { createMemoryInboxRepository } from "./factories/inbox-notification";
1078
+
1079
+ const payloadSchema = z.object({
1080
+ \tuserIds: z.array(z.string()),
1081
+ \ttitle: z.string(),
1082
+ });
1083
+
1084
+ const testNotification = defineNotification("inbox.channel-test", {
1085
+ \tpayload: payloadSchema,
1086
+ \tchannels: {
1087
+ \t\tinApp: defineInboxNotificationChannel(({ payload }) =>
1088
+ \t\t\tpayload.userIds.map(
1089
+ \t\t\t\t(userId): InboxNotificationInput => ({
1090
+ \t\t\t\t\tuserId,
1091
+ \t\t\t\t\ttype: "test",
1092
+ \t\t\t\t\ttitle: payload.title,
1093
+ \t\t\t\t}),
1094
+ \t\t\t),
1095
+ \t\t),
1096
+ \t},
1097
+ });
1098
+
1099
+ const skippingNotification = defineNotification("inbox.channel-skip-test", {
1100
+ \tpayload: payloadSchema,
1101
+ \tchannels: {
1102
+ \t\tinApp: defineInboxNotificationChannel(() => null),
1103
+ \t},
1104
+ });
1105
+
1106
+ function createHarness() {
1107
+ \tconst inbox = createMemoryInboxRepository();
1108
+ \tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
1109
+ \t\tbase: appPorts,
1110
+ \t\toverrides: {
1111
+ \t\t\tgate: appPorts.gate,
1112
+ \t\t\tinbox,
1113
+ \t\t},
1114
+ \t});
1115
+ \tconst createContext = createTestContextFactory<
1116
+ \t\tAppContext,
1117
+ \t\tAppContext["ports"]
1118
+ \t>({
1119
+ \t\tports: fixture.ports,
1120
+ \t\tactor: createTestSystemActor("test-channel"),
1121
+ \t\tauth: null,
1122
+ \t});
1123
+
1124
+ \treturn { ctx: createContext(), inbox };
1125
+ }
1126
+
1127
+ describe("defineInboxNotificationChannel", () => {
1128
+ \tit("writes one inbox row per rendered entry through the repository port", async () => {
1129
+ \t\tconst { ctx, inbox } = createHarness();
1130
+
1131
+ \t\tconst handler = testNotification.channels.inApp;
1132
+ \t\tconst result = await handler({
1133
+ \t\t\tnotification: testNotification,
1134
+ \t\t\tpayload: {
1135
+ \t\t\t\tuserIds: ["user_a", "user_b"],
1136
+ \t\t\t\ttitle: "Hello from the inbox channel",
1137
+ \t\t\t},
1138
+ \t\t\tctx,
1139
+ \t\t\tchannel: "inApp",
1140
+ \t\t});
1141
+
1142
+ \t\texpect(result).toMatchObject({ channel: "inApp", status: "sent" });
1143
+ \t\texpect(inbox.rows).toHaveLength(2);
1144
+ \t\texpect(inbox.rows.map((row) => row.userId)).toEqual([
1145
+ \t\t\t"user_a",
1146
+ \t\t\t"user_b",
1147
+ \t\t]);
1148
+ \t\texpect(inbox.rows[0]).toMatchObject({
1149
+ \t\t\ttype: "test",
1150
+ \t\t\ttitle: "Hello from the inbox channel",
1151
+ \t\t\treadAt: null,
1152
+ \t\t});
1153
+ \t\texpect(result?.details).toMatchObject({
1154
+ \t\t\tnotificationIds: inbox.rows.map((row) => row.id),
1155
+ \t\t});
1156
+ \t});
1157
+
1158
+ \tit("skips delivery when the renderer returns null", async () => {
1159
+ \t\tconst { ctx, inbox } = createHarness();
1160
+
1161
+ \t\tconst handler = skippingNotification.channels.inApp;
1162
+ \t\tconst result = await handler({
1163
+ \t\t\tnotification: skippingNotification,
1164
+ \t\t\tpayload: { userIds: [], title: "Nothing" },
1165
+ \t\t\tctx,
1166
+ \t\t\tchannel: "inApp",
1167
+ \t\t});
1168
+
1169
+ \t\texpect(result).toMatchObject({ channel: "inApp", status: "skipped" });
1170
+ \t\texpect(inbox.rows).toHaveLength(0);
1171
+ \t});
1172
+ });
1173
+ `;
1174
+ }
1175
+ export function inboxDrizzleSchemaFile(database) {
1176
+ const dialect = drizzleDialects[database];
1177
+ const column = database === "mysql"
1178
+ ? (columnName) => `varchar("${columnName}", { length: 255 })`
1179
+ : (columnName) => `text("${columnName}")`;
1180
+ // Notification bodies can exceed 255 characters, so MySQL stores them as
1181
+ // text while shorter identifying columns stay varchar.
1182
+ const bodyColumn = database === "mysql" ? 'text("body")' : column("body");
1183
+ const imports = database === "mysql"
1184
+ ? "index, mysqlTable, text, varchar"
1185
+ : `index, ${dialect.tableFunction}, text`;
1186
+ return `import { ${imports} } from "${dialect.columnModule}";
1187
+
1188
+ export const inboxNotifications = ${dialect.tableFunction}(
1189
+ \t"inbox_notifications",
1190
+ \t{
1191
+ \t\tid: ${column("id")}.primaryKey(),
1192
+ \t\tuserId: ${column("user_id")}.notNull(),
1193
+ \t\ttype: ${column("type")}.notNull(),
1194
+ \t\ttitle: ${column("title")}.notNull(),
1195
+ \t\tbody: ${bodyColumn},
1196
+ \t\tresourceKey: ${column("resource_key")},
1197
+ \t\tactorUserId: ${column("actor_user_id")},
1198
+ \t\tactorName: ${column("actor_name")},
1199
+ \t\treadAt: ${column("read_at")},
1200
+ \t\tcreatedAt: ${column("created_at")}.notNull(),
1201
+ \t},
1202
+ \t(table) => ({
1203
+ \t\tunreadIdx: index("inbox_notifications_unread_idx").on(
1204
+ \t\t\ttable.userId,
1205
+ \t\t\ttable.readAt,
1206
+ \t\t),
1207
+ \t\tcursorIdx: index("inbox_notifications_cursor_idx").on(
1208
+ \t\t\ttable.userId,
1209
+ \t\t\ttable.createdAt,
1210
+ \t\t\ttable.id,
1211
+ \t\t),
1212
+ \t}),
1213
+ );
1214
+ `;
1215
+ }
1216
+ export function inboxDrizzleRepositoryFile(config, database) {
1217
+ const dialect = drizzleDialects[database];
1218
+ const portsModule = aliasModule(path.join(config.paths.features, "inbox/ports.ts"));
1219
+ const schemaModule = aliasModule(path.join(infrastructureDir(config), "db/schema/index.ts"));
1220
+ return `import { cursorPageResult } from "@beignet/core/pagination";
1221
+ import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
1222
+ import { and, count, desc, eq, isNull, lt, or, type SQL } from "drizzle-orm";
1223
+ import type { InboxNotification, InboxRepository } from "${portsModule}";
1224
+ import * as schema from "${schemaModule}";
1225
+
1226
+ type InboxRow = typeof schema.inboxNotifications.$inferSelect;
1227
+
1228
+ function toNotification(row: InboxRow): InboxNotification {
1229
+ \treturn {
1230
+ \t\tid: row.id,
1231
+ \t\tuserId: row.userId,
1232
+ \t\ttype: row.type,
1233
+ \t\ttitle: row.title,
1234
+ \t\tbody: row.body,
1235
+ \t\tresourceKey: row.resourceKey,
1236
+ \t\tactorUserId: row.actorUserId,
1237
+ \t\tactorName: row.actorName,
1238
+ \t\treadAt: row.readAt,
1239
+ \t\tcreatedAt: row.createdAt,
1240
+ \t};
1241
+ }
1242
+
1243
+ type CursorPayload = {
1244
+ \tcreatedAt: string;
1245
+ \tid: string;
1246
+ };
1247
+
1248
+ function encodeCursor(payload: CursorPayload): string {
1249
+ \treturn Buffer.from(JSON.stringify(payload)).toString("base64url");
1250
+ }
1251
+
1252
+ function decodeCursor(cursor: string): CursorPayload | null {
1253
+ \ttry {
1254
+ \t\tconst parsed: unknown = JSON.parse(
1255
+ \t\t\tBuffer.from(cursor, "base64url").toString("utf8"),
1256
+ \t\t);
1257
+ \t\tif (typeof parsed !== "object" || parsed === null) return null;
1258
+ \t\tconst candidate = parsed as Record<string, unknown>;
1259
+ \t\tif (
1260
+ \t\t\ttypeof candidate.createdAt !== "string" ||
1261
+ \t\t\ttypeof candidate.id !== "string"
1262
+ \t\t) {
1263
+ \t\t\treturn null;
1264
+ \t\t}
1265
+ \t\treturn { createdAt: candidate.createdAt, id: candidate.id };
1266
+ \t} catch {
1267
+ \t\treturn null;
1268
+ \t}
1269
+ }
1270
+
1271
+ export function createDrizzleInboxRepository(
1272
+ \tdb: ${dialect.dbTypeName}<typeof schema>,
1273
+ ): InboxRepository {
1274
+ \treturn {
1275
+ \t\tasync findMany(params) {
1276
+ \t\t\tconst filters: SQL<unknown>[] = [
1277
+ \t\t\t\teq(schema.inboxNotifications.userId, params.userId),
1278
+ \t\t\t];
1279
+ \t\t\tif (params.unreadOnly) {
1280
+ \t\t\t\tfilters.push(isNull(schema.inboxNotifications.readAt));
1281
+ \t\t\t}
1282
+
1283
+ \t\t\tconst cursor = params.page.cursor
1284
+ \t\t\t\t? decodeCursor(params.page.cursor)
1285
+ \t\t\t\t: null;
1286
+ \t\t\tif (cursor) {
1287
+ \t\t\t\tfilters.push(
1288
+ \t\t\t\t\tor(
1289
+ \t\t\t\t\t\tlt(schema.inboxNotifications.createdAt, cursor.createdAt),
1290
+ \t\t\t\t\t\tand(
1291
+ \t\t\t\t\t\t\teq(schema.inboxNotifications.createdAt, cursor.createdAt),
1292
+ \t\t\t\t\t\t\tlt(schema.inboxNotifications.id, cursor.id),
1293
+ \t\t\t\t\t\t),
1294
+ \t\t\t\t\t) as SQL<unknown>,
1295
+ \t\t\t\t);
1296
+ \t\t\t}
1297
+
1298
+ \t\t\tconst rows = await db
1299
+ \t\t\t\t.select()
1300
+ \t\t\t\t.from(schema.inboxNotifications)
1301
+ \t\t\t\t.where(and(...filters))
1302
+ \t\t\t\t.orderBy(
1303
+ \t\t\t\t\tdesc(schema.inboxNotifications.createdAt),
1304
+ \t\t\t\t\tdesc(schema.inboxNotifications.id),
1305
+ \t\t\t\t)
1306
+ \t\t\t\t.limit(params.page.limit + 1);
1307
+
1308
+ \t\t\tconst pageRows = rows.slice(0, params.page.limit);
1309
+ \t\t\tconst hasMore = rows.length > params.page.limit;
1310
+ \t\t\tconst items = pageRows.map(toNotification);
1311
+ \t\t\tconst lastItem = items[items.length - 1];
1312
+ \t\t\tconst nextCursor =
1313
+ \t\t\t\thasMore && lastItem
1314
+ \t\t\t\t\t? encodeCursor({ createdAt: lastItem.createdAt, id: lastItem.id })
1315
+ \t\t\t\t\t: null;
1316
+
1317
+ \t\t\treturn cursorPageResult(items, params.page, nextCursor);
1318
+ \t\t},
1319
+
1320
+ \t\tasync unreadCount({ userId }) {
1321
+ \t\t\tconst [row] = await db
1322
+ \t\t\t\t.select({ total: count() })
1323
+ \t\t\t\t.from(schema.inboxNotifications)
1324
+ \t\t\t\t.where(
1325
+ \t\t\t\t\tand(
1326
+ \t\t\t\t\t\teq(schema.inboxNotifications.userId, userId),
1327
+ \t\t\t\t\t\tisNull(schema.inboxNotifications.readAt),
1328
+ \t\t\t\t\t),
1329
+ \t\t\t\t);
1330
+
1331
+ \t\t\treturn row?.total ?? 0;
1332
+ \t\t},
1333
+
1334
+ \t\tasync create(input) {
1335
+ \t\t\t// The row is built locally so every dialect (including MySQL, which
1336
+ \t\t\t// has no INSERT ... RETURNING) returns the same shape.
1337
+ \t\t\tconst row: InboxRow = {
1338
+ \t\t\t\tid: crypto.randomUUID(),
1339
+ \t\t\t\tuserId: input.userId,
1340
+ \t\t\t\ttype: input.type,
1341
+ \t\t\t\ttitle: input.title,
1342
+ \t\t\t\tbody: input.body ?? null,
1343
+ \t\t\t\tresourceKey: input.resourceKey ?? null,
1344
+ \t\t\t\tactorUserId: input.actorUserId ?? null,
1345
+ \t\t\t\tactorName: input.actorName ?? null,
1346
+ \t\t\t\treadAt: null,
1347
+ \t\t\t\tcreatedAt: new Date().toISOString(),
1348
+ \t\t\t};
1349
+
1350
+ \t\t\tawait db.insert(schema.inboxNotifications).values(row);
1351
+
1352
+ \t\t\treturn toNotification(row);
1353
+ \t\t},
1354
+
1355
+ \t\tasync markRead({ userId, id }) {
1356
+ \t\t\tconst [existing] = await db
1357
+ \t\t\t\t.select()
1358
+ \t\t\t\t.from(schema.inboxNotifications)
1359
+ \t\t\t\t.where(
1360
+ \t\t\t\t\tand(
1361
+ \t\t\t\t\t\teq(schema.inboxNotifications.userId, userId),
1362
+ \t\t\t\t\t\teq(schema.inboxNotifications.id, id),
1363
+ \t\t\t\t\t),
1364
+ \t\t\t\t)
1365
+ \t\t\t\t.limit(1);
1366
+
1367
+ \t\t\tif (!existing) return null;
1368
+ \t\t\tif (existing.readAt) return toNotification(existing);
1369
+
1370
+ \t\t\tconst readAt = new Date().toISOString();
1371
+ \t\t\tawait db
1372
+ \t\t\t\t.update(schema.inboxNotifications)
1373
+ \t\t\t\t.set({ readAt })
1374
+ \t\t\t\t.where(
1375
+ \t\t\t\t\tand(
1376
+ \t\t\t\t\t\teq(schema.inboxNotifications.userId, userId),
1377
+ \t\t\t\t\t\teq(schema.inboxNotifications.id, id),
1378
+ \t\t\t\t\t),
1379
+ \t\t\t\t);
1380
+
1381
+ \t\t\treturn toNotification({ ...existing, readAt });
1382
+ \t\t},
1383
+
1384
+ \t\tasync markAllRead({ userId }) {
1385
+ \t\t\tconst unread = await db
1386
+ \t\t\t\t.select({ id: schema.inboxNotifications.id })
1387
+ \t\t\t\t.from(schema.inboxNotifications)
1388
+ \t\t\t\t.where(
1389
+ \t\t\t\t\tand(
1390
+ \t\t\t\t\t\teq(schema.inboxNotifications.userId, userId),
1391
+ \t\t\t\t\t\tisNull(schema.inboxNotifications.readAt),
1392
+ \t\t\t\t\t),
1393
+ \t\t\t\t);
1394
+ \t\t\tif (unread.length === 0) return 0;
1395
+
1396
+ \t\t\tawait db
1397
+ \t\t\t\t.update(schema.inboxNotifications)
1398
+ \t\t\t\t.set({ readAt: new Date().toISOString() })
1399
+ \t\t\t\t.where(
1400
+ \t\t\t\t\tand(
1401
+ \t\t\t\t\t\teq(schema.inboxNotifications.userId, userId),
1402
+ \t\t\t\t\t\tisNull(schema.inboxNotifications.readAt),
1403
+ \t\t\t\t\t),
1404
+ \t\t\t\t);
1405
+
1406
+ \t\t\treturn unread.length;
1407
+ \t\t},
1408
+ \t};
1409
+ }
1410
+ `;
1411
+ }
1412
+ export function inboxClientQueriesFile(config) {
1413
+ const contractsModule = aliasModule(path.join(config.paths.features, "inbox/contracts.ts"));
1414
+ return `import { cursorPagination } from "@beignet/react-query";
1415
+ import { keepPreviousData, type QueryClient } from "@tanstack/react-query";
1416
+ import { rq } from "@/client";
1417
+ import {
1418
+ \tlistInbox,
1419
+ \tmarkAllRead,
1420
+ \tmarkRead,
1421
+ \tunreadCount,
1422
+ } from "${contractsModule}";
1423
+
1424
+ const PAGE_SIZE = 20;
1425
+
1426
+ export function listInboxInfiniteQueryOptions(
1427
+ \toptions: { unread?: boolean } = {},
1428
+ ) {
1429
+ \treturn rq(listInbox).infiniteQueryOptions({
1430
+ \t\tquery: { limit: PAGE_SIZE, unread: options.unread ?? false },
1431
+ \t\t...cursorPagination(),
1432
+ \t\tplaceholderData: keepPreviousData,
1433
+ \t});
1434
+ }
1435
+
1436
+ export function unreadCountQueryOptions() {
1437
+ \treturn rq(unreadCount).queryOptions();
1438
+ }
1439
+
1440
+ export function markReadMutationOptions() {
1441
+ \treturn rq(markRead).mutationOptions();
1442
+ }
1443
+
1444
+ export function markAllReadMutationOptions() {
1445
+ \treturn rq(markAllRead).mutationOptions();
1446
+ }
1447
+
1448
+ export function invalidateInbox(queryClient: QueryClient) {
1449
+ \treturn Promise.all([
1450
+ \t\trq(listInbox).invalidate(queryClient),
1451
+ \t\trq(unreadCount).invalidate(queryClient),
1452
+ \t]);
1453
+ }
1454
+ `;
1455
+ }
1456
+ export function inboxUnreadBadgeFile(config) {
1457
+ const queriesModule = aliasModule(path.join(config.paths.features, "inbox/client/queries.ts"));
1458
+ return `"use client";
1459
+
1460
+ import { useQuery } from "@tanstack/react-query";
1461
+ import { unreadCountQueryOptions } from "${queriesModule}";
1462
+
1463
+ const POLL_INTERVAL_MS = 30_000;
1464
+
1465
+ /**
1466
+ * Unread inbox count badge. Polls GET /api/inbox/unread-count while mounted;
1467
+ * render it next to your Inbox nav link.
1468
+ */
1469
+ export function UnreadBadge() {
1470
+ \tconst countQuery = useQuery({
1471
+ \t\t...unreadCountQueryOptions(),
1472
+ \t\tstaleTime: POLL_INTERVAL_MS,
1473
+ \t\trefetchInterval: POLL_INTERVAL_MS,
1474
+ \t\tretry: false,
1475
+ \t});
1476
+
1477
+ \tconst count = countQuery.data?.count ?? 0;
1478
+ \tif (countQuery.isError || count === 0) return null;
1479
+
1480
+ \treturn (
1481
+ \t\t<span
1482
+ \t\t\trole="status"
1483
+ \t\t\tclassName="inline-flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-xs font-medium leading-none text-primary-foreground"
1484
+ \t\t\taria-label={\`\${count} unread notifications\`}
1485
+ \t\t>
1486
+ \t\t\t{count > 99 ? "99+" : count}
1487
+ \t\t</span>
1488
+ \t);
1489
+ }
1490
+ `;
1491
+ }
1492
+ export function inboxItemComponentFile(config) {
1493
+ const queriesModule = aliasModule(path.join(config.paths.features, "inbox/client/queries.ts"));
1494
+ const schemasModule = aliasModule(path.join(config.paths.features, "inbox/schemas.ts"));
1495
+ return `"use client";
1496
+
1497
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
1498
+ import { BellIcon } from "lucide-react";
1499
+ import { Button } from "@/components/ui/button";
1500
+ import {
1501
+ \tinvalidateInbox,
1502
+ \tmarkReadMutationOptions,
1503
+ } from "${queriesModule}";
1504
+ import type { InboxNotification } from "${schemasModule}";
1505
+ import { cn } from "@/lib/utils";
1506
+
1507
+ function formatDate(value: string) {
1508
+ \treturn new Date(value).toLocaleDateString(undefined, {
1509
+ \t\tmonth: "short",
1510
+ \t\tday: "numeric",
1511
+ \t});
1512
+ }
1513
+
1514
+ export function InboxItem({
1515
+ \tnotification,
1516
+ }: {
1517
+ \tnotification: InboxNotification;
1518
+ }) {
1519
+ \tconst queryClient = useQueryClient();
1520
+ \tconst unread = notification.readAt === null;
1521
+
1522
+ \tconst markReadMutation = useMutation({
1523
+ \t\t...markReadMutationOptions(),
1524
+ \t\tonSuccess: () => invalidateInbox(queryClient),
1525
+ \t});
1526
+
1527
+ \treturn (
1528
+ \t\t<div
1529
+ \t\t\tclassName={cn(
1530
+ \t\t\t\t"flex items-start gap-3 rounded-lg border bg-card p-3",
1531
+ \t\t\t\tunread && "border-primary/40",
1532
+ \t\t\t)}
1533
+ \t\t>
1534
+ \t\t\t<BellIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
1535
+ \t\t\t<div className="min-w-0 flex-1">
1536
+ \t\t\t\t<div className="flex items-center gap-2">
1537
+ \t\t\t\t\t{unread ? (
1538
+ \t\t\t\t\t\t<span className="size-1.5 shrink-0 rounded-full bg-primary">
1539
+ \t\t\t\t\t\t\t<span className="sr-only">Unread</span>
1540
+ \t\t\t\t\t\t</span>
1541
+ \t\t\t\t\t) : null}
1542
+ \t\t\t\t\t<span className="truncate text-sm font-medium">
1543
+ \t\t\t\t\t\t{notification.title}
1544
+ \t\t\t\t\t</span>
1545
+ \t\t\t\t\t<span className="ml-auto shrink-0 text-xs text-muted-foreground">
1546
+ \t\t\t\t\t\t{formatDate(notification.createdAt)}
1547
+ \t\t\t\t\t</span>
1548
+ \t\t\t\t</div>
1549
+ \t\t\t\t{notification.body ? (
1550
+ \t\t\t\t\t<p className="mt-0.5 truncate text-sm text-muted-foreground">
1551
+ \t\t\t\t\t\t{notification.body}
1552
+ \t\t\t\t\t</p>
1553
+ \t\t\t\t) : null}
1554
+ \t\t\t</div>
1555
+ \t\t\t{unread ? (
1556
+ \t\t\t\t<Button
1557
+ \t\t\t\t\ttype="button"
1558
+ \t\t\t\t\tvariant="ghost"
1559
+ \t\t\t\t\tsize="sm"
1560
+ \t\t\t\t\tdisabled={markReadMutation.isPending}
1561
+ \t\t\t\t\tonClick={() =>
1562
+ \t\t\t\t\t\tmarkReadMutation.mutate({
1563
+ \t\t\t\t\t\t\tpath: { notificationId: notification.id },
1564
+ \t\t\t\t\t\t})
1565
+ \t\t\t\t\t}
1566
+ \t\t\t\t>
1567
+ \t\t\t\t\tMark read
1568
+ \t\t\t\t</Button>
1569
+ \t\t\t) : null}
1570
+ \t\t</div>
1571
+ \t);
1572
+ }
1573
+ `;
1574
+ }
1575
+ export function inboxListComponentFile(config) {
1576
+ const queriesModule = aliasModule(path.join(config.paths.features, "inbox/client/queries.ts"));
1577
+ return `"use client";
1578
+
1579
+ import {
1580
+ \tuseInfiniteQuery,
1581
+ \tuseMutation,
1582
+ \tuseQueryClient,
1583
+ } from "@tanstack/react-query";
1584
+ import { useState } from "react";
1585
+ import { Button } from "@/components/ui/button";
1586
+ import {
1587
+ \tinvalidateInbox,
1588
+ \tlistInboxInfiniteQueryOptions,
1589
+ \tmarkAllReadMutationOptions,
1590
+ } from "${queriesModule}";
1591
+ import { cn } from "@/lib/utils";
1592
+ import { InboxItem } from "./inbox-item";
1593
+
1594
+ export function InboxList() {
1595
+ \tconst queryClient = useQueryClient();
1596
+ \tconst [unreadOnly, setUnreadOnly] = useState(false);
1597
+ \t// The infinite query accumulates cursor pages; pageIndex picks which
1598
+ \t// fetched page is on screen, so Previous/Next stays a paged UX. Refetches
1599
+ \t// (mark read, poll) refresh every fetched page under the same key.
1600
+ \tconst [pageIndex, setPageIndex] = useState(0);
1601
+
1602
+ \tconst inboxQuery = useInfiniteQuery(
1603
+ \t\tlistInboxInfiniteQueryOptions({ unread: unreadOnly }),
1604
+ \t);
1605
+
1606
+ \tconst markAllReadMutation = useMutation({
1607
+ \t\t...markAllReadMutationOptions(),
1608
+ \t\tonSuccess: async () => {
1609
+ \t\t\tsetPageIndex(0);
1610
+ \t\t\tawait invalidateInbox(queryClient);
1611
+ \t\t},
1612
+ \t});
1613
+
1614
+ \tconst pages = inboxQuery.data?.pages ?? [];
1615
+ \tconst currentPage = pages[pageIndex] ?? pages[pages.length - 1];
1616
+ \tconst items = currentPage?.items ?? [];
1617
+
1618
+ \tconst setFilter = (unread: boolean) => {
1619
+ \t\tsetUnreadOnly(unread);
1620
+ \t\tsetPageIndex(0);
1621
+ \t};
1622
+
1623
+ \tconst goToNextPage = () => {
1624
+ \t\tif (pageIndex < pages.length - 1) {
1625
+ \t\t\tsetPageIndex(pageIndex + 1);
1626
+ \t\t\treturn;
1627
+ \t\t}
1628
+ \t\tvoid inboxQuery.fetchNextPage().then(() => setPageIndex(pageIndex + 1));
1629
+ \t};
1630
+
1631
+ \treturn (
1632
+ \t\t<div className="flex flex-col gap-3">
1633
+ \t\t\t<div className="flex items-center gap-2">
1634
+ \t\t\t\t<div className="flex rounded-lg border p-0.5">
1635
+ \t\t\t\t\t<button
1636
+ \t\t\t\t\t\ttype="button"
1637
+ \t\t\t\t\t\tclassName={cn(
1638
+ \t\t\t\t\t\t\t"rounded-md px-2.5 py-1 text-sm",
1639
+ \t\t\t\t\t\t\t!unreadOnly
1640
+ \t\t\t\t\t\t\t\t? "bg-secondary text-secondary-foreground"
1641
+ \t\t\t\t\t\t\t\t: "text-muted-foreground hover:text-foreground",
1642
+ \t\t\t\t\t\t)}
1643
+ \t\t\t\t\t\tonClick={() => setFilter(false)}
1644
+ \t\t\t\t\t>
1645
+ \t\t\t\t\t\tAll
1646
+ \t\t\t\t\t</button>
1647
+ \t\t\t\t\t<button
1648
+ \t\t\t\t\t\ttype="button"
1649
+ \t\t\t\t\t\tclassName={cn(
1650
+ \t\t\t\t\t\t\t"rounded-md px-2.5 py-1 text-sm",
1651
+ \t\t\t\t\t\t\tunreadOnly
1652
+ \t\t\t\t\t\t\t\t? "bg-secondary text-secondary-foreground"
1653
+ \t\t\t\t\t\t\t\t: "text-muted-foreground hover:text-foreground",
1654
+ \t\t\t\t\t\t)}
1655
+ \t\t\t\t\t\tonClick={() => setFilter(true)}
1656
+ \t\t\t\t\t>
1657
+ \t\t\t\t\t\tUnread
1658
+ \t\t\t\t\t</button>
1659
+ \t\t\t\t</div>
1660
+ \t\t\t\t<Button
1661
+ \t\t\t\t\ttype="button"
1662
+ \t\t\t\t\tvariant="outline"
1663
+ \t\t\t\t\tsize="sm"
1664
+ \t\t\t\t\tclassName="ml-auto"
1665
+ \t\t\t\t\tdisabled={markAllReadMutation.isPending}
1666
+ \t\t\t\t\tonClick={() => markAllReadMutation.mutate({})}
1667
+ \t\t\t\t>
1668
+ \t\t\t\t\tMark all read
1669
+ \t\t\t\t</Button>
1670
+ \t\t\t</div>
1671
+
1672
+ \t\t\t{inboxQuery.isPending ? (
1673
+ \t\t\t\t<p className="text-sm text-muted-foreground">Loading inbox...</p>
1674
+ \t\t\t) : inboxQuery.isError ? (
1675
+ \t\t\t\t<p className="text-sm text-destructive">Could not load your inbox.</p>
1676
+ \t\t\t) : items.length === 0 ? (
1677
+ \t\t\t\t<p className="text-sm text-muted-foreground">
1678
+ \t\t\t\t\t{unreadOnly ? "No unread notifications." : "Your inbox is empty."}
1679
+ \t\t\t\t</p>
1680
+ \t\t\t) : (
1681
+ \t\t\t\t<div className="flex flex-col gap-2">
1682
+ \t\t\t\t\t{items.map((notification) => (
1683
+ \t\t\t\t\t\t<InboxItem key={notification.id} notification={notification} />
1684
+ \t\t\t\t\t))}
1685
+ \t\t\t\t</div>
1686
+ \t\t\t)}
1687
+
1688
+ \t\t\t{pageIndex > 0 || pages.length > 1 || inboxQuery.hasNextPage ? (
1689
+ \t\t\t\t<div className="flex items-center gap-2">
1690
+ \t\t\t\t\t<Button
1691
+ \t\t\t\t\t\ttype="button"
1692
+ \t\t\t\t\t\tvariant="outline"
1693
+ \t\t\t\t\t\tsize="sm"
1694
+ \t\t\t\t\t\tdisabled={pageIndex <= 0 || inboxQuery.isFetching}
1695
+ \t\t\t\t\t\tonClick={() => setPageIndex(pageIndex - 1)}
1696
+ \t\t\t\t\t>
1697
+ \t\t\t\t\t\tPrevious
1698
+ \t\t\t\t\t</Button>
1699
+ \t\t\t\t\t<Button
1700
+ \t\t\t\t\t\ttype="button"
1701
+ \t\t\t\t\t\tvariant="outline"
1702
+ \t\t\t\t\t\tsize="sm"
1703
+ \t\t\t\t\t\tdisabled={
1704
+ \t\t\t\t\t\t\t(pageIndex >= pages.length - 1 && !inboxQuery.hasNextPage) ||
1705
+ \t\t\t\t\t\t\tinboxQuery.isFetching
1706
+ \t\t\t\t\t\t}
1707
+ \t\t\t\t\t\tonClick={goToNextPage}
1708
+ \t\t\t\t\t>
1709
+ \t\t\t\t\t\tNext
1710
+ \t\t\t\t\t</Button>
1711
+ \t\t\t\t</div>
1712
+ \t\t\t) : null}
1713
+ \t\t</div>
1714
+ \t);
1715
+ }
1716
+ `;
1717
+ }
1718
+ export function inboxComponentsIndexFile() {
1719
+ return `export { InboxItem } from "./inbox-item";
1720
+ export { InboxList } from "./inbox-list";
1721
+ export { UnreadBadge } from "./unread-badge";
1722
+ `;
1723
+ }
1724
+ export function inboxPageFile(config) {
1725
+ const componentsModule = aliasModule(path.join(config.paths.features, "inbox/components/inbox-list.tsx"));
1726
+ return `import { InboxList } from "${componentsModule}";
1727
+
1728
+ export const metadata = {
1729
+ \ttitle: "Inbox",
1730
+ };
1731
+
1732
+ export default function InboxPage() {
1733
+ \treturn (
1734
+ \t\t<div className="flex flex-col gap-6">
1735
+ \t\t\t<div className="flex flex-col gap-1">
1736
+ \t\t\t\t<h1 className="font-heading text-xl font-semibold">Inbox</h1>
1737
+ \t\t\t\t<p className="text-sm text-muted-foreground">
1738
+ \t\t\t\t\tIn-app notifications delivered through the inbox channel.
1739
+ \t\t\t\t</p>
1740
+ \t\t\t</div>
1741
+ \t\t\t<InboxList />
1742
+ \t\t</div>
1743
+ \t);
1744
+ }
1745
+ `;
1746
+ }
1747
+ //# sourceMappingURL=inbox.js.map