create-nexwire 4.2.3 → 4.2.4

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 (61) hide show
  1. package/express/package.json +48 -23
  2. package/express/src/database/migrations/sqlite/0000_init.sql +47 -60
  3. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  4. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  5. package/express/src/database/schema.ts +2 -3
  6. package/express/src/framework/facade.ts +12 -1
  7. package/express/src/framework/maker-cli/db/core.mjs +291 -288
  8. package/express/src/framework/maker-cli/module/core.mjs +125 -87
  9. package/express/src/framework/maker-cli/module/index.mjs +3 -3
  10. package/express/src/framework/maker-cli/stubs/notification/controller.ts.stub +61 -62
  11. package/express/src/framework/maker-cli/stubs/notification/job.ts.stub +11 -13
  12. package/express/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  13. package/express/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  14. package/express/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  15. package/express/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  16. package/express/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  17. package/express/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  18. package/express/src/framework/notification/index.ts +49 -87
  19. package/express/src/framework/providers/adapter.ts +24 -10
  20. package/express/src/framework/providers/contracts.ts +80 -10
  21. package/express/src/framework/providers/registry.ts +27 -1
  22. package/express/src/resources/src/main.ts +1 -1
  23. package/express/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  24. package/express/src/resources/src/pages/auth/login.vue +1 -1
  25. package/express/src/resources/src/pages/auth/register.vue +1 -1
  26. package/express/src/resources/src/pages/auth/resetPassword.vue +1 -1
  27. package/express/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  28. package/express/src/resources/src/pages/dashboard/index.vue +66 -66
  29. package/express/src/storage/database/nexwire.sqlite +0 -0
  30. package/hono/package.json +44 -19
  31. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -60
  32. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  33. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  34. package/hono/src/database/schema.ts +2 -3
  35. package/hono/src/framework/facade.ts +12 -1
  36. package/hono/src/framework/maker-cli/db/core.mjs +291 -288
  37. package/hono/src/framework/maker-cli/module/core.mjs +93 -52
  38. package/hono/src/framework/maker-cli/module/index.mjs +3 -3
  39. package/hono/src/framework/maker-cli/stubs/notification/controller.ts.stub +63 -58
  40. package/hono/src/framework/maker-cli/stubs/notification/job.ts.stub +10 -12
  41. package/hono/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  42. package/hono/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  43. package/hono/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  44. package/hono/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  45. package/hono/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  46. package/hono/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  47. package/hono/src/framework/notification/index.ts +49 -87
  48. package/hono/src/framework/providers/adapter.ts +24 -10
  49. package/hono/src/framework/providers/contracts.ts +80 -10
  50. package/hono/src/framework/providers/registry.ts +27 -1
  51. package/hono/src/resources/src/main.ts +1 -1
  52. package/hono/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  53. package/hono/src/resources/src/pages/auth/login.vue +1 -1
  54. package/hono/src/resources/src/pages/auth/register.vue +1 -1
  55. package/hono/src/resources/src/pages/auth/resetPassword.vue +1 -1
  56. package/hono/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  57. package/hono/src/resources/src/pages/dashboard/index.vue +66 -66
  58. package/hono/src/storage/database/nexwire.sqlite +0 -0
  59. package/package.json +10 -10
  60. package/express/src/modules/auth/database/models/notification.ts +0 -24
  61. package/hono/src/modules/auth/database/models/notification.ts +0 -24
@@ -73,6 +73,8 @@ const STUBS = {
73
73
  },
74
74
  routeApi: "notification/route.api.ts.stub",
75
75
  routePlain: "notification/route.plain.ts.stub",
76
+ model: "notification/model.ts.stub",
77
+ provider: "notification/provider.ts.stub",
76
78
  job: "notification/job.ts.stub"
77
79
  },
78
80
  test: {
@@ -162,6 +164,28 @@ function parsePanelPath(flags) {
162
164
  return segments.join("/");
163
165
  }
164
166
 
167
+ /**
168
+ * Parse `--notifiable=<type>` into the owner class written into the generated
169
+ * controller. Accepts both `--notifiable=user` and `--notifiable user`, and
170
+ * defaults to `user` so the common case needs no flag.
171
+ */
172
+ function parseNotifiable(flags) {
173
+ const matches = flags
174
+ .map((flag, index) => ({ flag, index }))
175
+ .filter(({ flag }) => typeof flag === "string" && flag.startsWith("--notifiable"));
176
+ if (matches.length === 0) return "user";
177
+ if (matches.length > 1) throw new Error("--notifiable can only be given once.");
178
+
179
+ const [{ flag, index }] = matches;
180
+ let raw = flag.slice("--notifiable".length).replace(/^=/, "").trim();
181
+ if (!raw && typeof flags[index + 1] === "string" && !flags[index + 1].startsWith("--")) raw = flags[index + 1].trim();
182
+ if (!raw) throw new Error("--notifiable needs a value, e.g. --notifiable=user");
183
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(raw)) {
184
+ throw new Error(`Invalid --notifiable value "${raw}". Use lowercase letters, numbers, dashes and underscores.`);
185
+ }
186
+ return raw;
187
+ }
188
+
165
189
  /** True when the path is a directory holding at least one entry. */
166
190
  async function dirHasFiles(target) {
167
191
  try {
@@ -172,6 +196,24 @@ async function dirHasFiles(target) {
172
196
  }
173
197
  }
174
198
 
199
+ /**
200
+ * Path of the notification store already shipped by any module, or "".
201
+ *
202
+ * The table/model/provider is a singleton: two modules shipping it would
203
+ * generate the same `notifications` table twice and register two providers for
204
+ * one contract. `module:make-notification` therefore writes the store only when
205
+ * no module owns it yet, and otherwise adds just another HTTP surface over it.
206
+ */
207
+ async function findNotificationStore() {
208
+ const backendSrc = path.resolve(process.cwd(), "src");
209
+ const files = await glob("modules/**/database/models/notification.{ts,js}", {
210
+ cwd: backendSrc,
211
+ nodir: true,
212
+ windowsPathsNoEscape: true
213
+ });
214
+ return files.sort()[0] ? path.join(backendSrc, files.sort()[0]) : "";
215
+ }
216
+
175
217
  /**
176
218
  * Resolve the module a subcommand targets.
177
219
  *
@@ -292,8 +334,8 @@ async function serviceFile(moduleName, controllerName, withModel) {
292
334
 
293
335
  /** Build the full default scaffold for module:make. */
294
336
  async function moduleFiles(moduleName, openApi) {
295
- const name = leafName(moduleName);
296
- const vars = moduleFileVars(moduleName, name);
337
+ const name = leafName(moduleName);
338
+ const vars = moduleFileVars(moduleName, name);
297
339
  const controllerStub = openApi ? STUBS.controller.openapi : STUBS.controller.plain;
298
340
  return {
299
341
  "facade.ts": await stub(STUBS.facade.named, vars),
@@ -309,15 +351,15 @@ async function moduleFiles(moduleName, openApi) {
309
351
  * module, so the controller and schema it just created are known to exist.
310
352
  */
311
353
  async function routeTemplate(moduleName, controllerName = moduleName) {
312
- const controller = leafName(controllerName);
313
- const routeStub = openApiEnabled() ? STUBS.route.api : STUBS.route.plain;
314
- return await stub(routeStub, {
315
- module: moduleName,
316
- controller,
317
- ClassName: pascal(controller),
318
- ModuleClass: moduleLabel(moduleName)
319
- });
320
- }
354
+ const controller = leafName(controllerName);
355
+ const routeStub = openApiEnabled() ? STUBS.route.api : STUBS.route.plain;
356
+ return await stub(routeStub, {
357
+ module: moduleName,
358
+ controller,
359
+ ClassName: pascal(controller),
360
+ ModuleClass: moduleLabel(moduleName)
361
+ });
362
+ }
321
363
 
322
364
  /**
323
365
  * Generate a barebone route file that wires to nothing. module:make-route uses
@@ -325,13 +367,13 @@ async function routeTemplate(moduleName, controllerName = moduleName) {
325
367
  */
326
368
  async function standaloneRouteTemplate(moduleName, routeName) {
327
369
  const routeStub = openApiEnabled() ? STUBS.route.standaloneApi : STUBS.route.standalonePlain;
328
- return await stub(routeStub, {
329
- module: moduleName,
330
- controller: routeName,
331
- ClassName: pascal(routeName),
332
- ModuleClass: moduleLabel(moduleName)
333
- });
334
- }
370
+ return await stub(routeStub, {
371
+ module: moduleName,
372
+ controller: routeName,
373
+ ClassName: pascal(routeName),
374
+ ModuleClass: moduleLabel(moduleName)
375
+ });
376
+ }
335
377
 
336
378
  /** Generate a named model file for a module. */
337
379
  async function namedModelTemplate(moduleName, name, dialect) {
@@ -511,7 +553,7 @@ export async function makeRoute(rawModule, rawControllerOrFlag, extraFlags = [])
511
553
  try {
512
554
  await fs.access(routePath);
513
555
  exists = true;
514
- } catch {}
556
+ } catch { }
515
557
  if (exists && !force)
516
558
  throw new Error(`Route file already exists: ${path.relative(process.cwd(), routePath)}. Re-run with --force to overwrite.`);
517
559
  await fs.mkdir(path.dirname(routePath), { recursive: true });
@@ -519,33 +561,58 @@ export async function makeRoute(rawModule, rawControllerOrFlag, extraFlags = [])
519
561
  console.log(`Route ready: ${path.relative(process.cwd(), routePath)}`);
520
562
  }
521
563
 
522
- /** Generate a notification module with controller, routes, and job. */
564
+ /**
565
+ * Generate the notification HTTP surface, plus the shared store the first time.
566
+ *
567
+ * The controller/schema/routes are always written: they are what lets a project
568
+ * expose `/api/notification/*` for a panel. The model, provider and mail job are
569
+ * written only when no module already ships the store, because the store is a
570
+ * singleton — a second copy would create a duplicate `notifications` table and
571
+ * register a second provider for the same contract.
572
+ */
523
573
  export async function makeNotificationModule(rawName = "notification", flags = []) {
524
- const { moduleName, leaf } = resolveModuleRef(rawName, flags);
525
- const root = moduleRoot(moduleName);
526
- const force = hasFlag(flags, "--force") || hasFlag(flags, "--yes");
527
- if (!force && (await dirHasFiles(root))) {
528
- throw new Error(`Module already exists: src/modules/${moduleName}. Re-run with --force to overwrite its files.`);
529
- }
530
- const openApi = openApiEnabled();
531
-
532
- await writeFiles(root, {
533
- [`controllers/${leaf}.ts`]: await stub(STUBS.notification.controller, {
534
- module: moduleName
535
- }),
536
- [`schemas/${leaf}.ts`]: await stub(openApi ? STUBS.notification.schema.openapi : STUBS.notification.schema.plain, {
537
- module: moduleName
538
- }),
539
- "routes/index.ts": await stub(openApi ? STUBS.notification.routeApi : STUBS.notification.routePlain, { module: moduleName }),
540
- [`jobs/${leaf}.ts`]: await stub(STUBS.notification.job, { module: moduleName })
541
- });
574
+ const { moduleName, leaf } = resolveModuleRef(rawName, flags);
575
+ const root = moduleRoot(moduleName);
576
+ const force = hasFlag(flags, "--force") || hasFlag(flags, "--yes");
577
+ if (!force && (await dirHasFiles(root))) {
578
+ throw new Error(`Module already exists: src/modules/${moduleName}. Re-run with --force to overwrite its files.`);
579
+ }
580
+ const openApi = openApiEnabled();
581
+ const notifiable = parseNotifiable(flags);
582
+ const vars = { module: moduleName, leaf, notifiable };
583
+
584
+ const files = {
585
+ [`controllers/${leaf}.ts`]: await stub(STUBS.notification.controller, vars),
586
+ [`schemas/${leaf}.ts`]: await stub(openApi ? STUBS.notification.schema.openapi : STUBS.notification.schema.plain, vars),
587
+ "routes/index.ts": await stub(openApi ? STUBS.notification.routeApi : STUBS.notification.routePlain, vars)
588
+ };
589
+
590
+ const existingStore = await findNotificationStore();
591
+ const ownsStore = existingStore ? path.resolve(existingStore).startsWith(root + path.sep) : false;
592
+ const writesStore = !existingStore || (force && ownsStore);
542
593
 
543
- console.log(`Notification module ready: ${moduleName}`);
544
- if (panelOf(moduleName)) console.log(`Panel: ${panelOf(moduleName)} -> /api/${moduleName}`);
545
- console.log(`Route style: ${openApi ? "openapi" : "plain"}`);
546
- console.log("See docs (packages/docs/guide/notification.md) for Vue UI integration.");
594
+ if (writesStore) {
595
+ files["database/models/notification.ts"] = await stub(STUBS.notification.model, vars);
596
+ files["providers/notification.ts"] = await stub(STUBS.notification.provider, vars);
597
+ files["jobs/mail.ts"] = await stub(STUBS.notification.job, vars);
547
598
  }
548
599
 
600
+ await writeFiles(root, files);
601
+
602
+ console.log(`Notification module ready: ${moduleName}`);
603
+ if (writesStore) {
604
+ console.log("Notification store included: model, provider, mail job");
605
+ const panelFlag = panelOf(moduleName) ? ` --path=${panelOf(moduleName)}` : "";
606
+ console.log(`Next: bun maker module:migrate ${leaf}${panelFlag}`);
607
+ } else {
608
+ console.log(`Notification store reused: ${path.relative(process.cwd(), existingStore)}`);
609
+ }
610
+ if (panelOf(moduleName)) console.log(`Panel: ${panelOf(moduleName)} -> /api/${moduleName}`);
611
+ console.log(`Notifiable type: ${notifiable}`);
612
+ console.log(`Route style: ${openApi ? "openapi" : "plain"}`);
613
+ console.log("See docs (packages/docs/guide/notification.md) for usage and UI integration.");
614
+ }
615
+
549
616
  /** Soft-delete a module by moving it to storage trash. */
550
617
  export async function deleteModule(rawName, flags = []) {
551
618
  const { moduleName: name, leaf } = resolveModuleRef(rawName, flags);
@@ -689,9 +756,7 @@ export async function makeController(rawModule, rawControllerOrFlag, extraFlags
689
756
  const { name, flags } = parseNameArg(rawControllerOrFlag, extraFlags, "Controller name", leaf);
690
757
  const root = await assertModuleExists(moduleName);
691
758
  const lower = name.toLowerCase();
692
- const hasService =
693
- (await pathExists(path.join(root, "services", `${lower}.ts`))) ||
694
- (await pathExists(legacyPath(root, "services", lower)));
759
+ const hasService = (await pathExists(path.join(root, "services", `${lower}.ts`))) || (await pathExists(legacyPath(root, "services", lower)));
695
760
  const content = await controllerFile(moduleName, name, openApiEnabled(), hasService);
696
761
  await writeFileSafe(path.join(root, `controllers/${lower}.ts`), content, flags, "Controller file");
697
762
  }
@@ -715,8 +780,7 @@ export async function makeService(rawModule, rawNameOrFlag, extraFlags = []) {
715
780
  const { name, flags } = parseNameArg(rawNameOrFlag, extraFlags, "Service name", leaf);
716
781
  const root = await assertModuleExists(moduleName);
717
782
  const lower = name.toLowerCase();
718
- const withModel =
719
- hasFlag(flags, "--with-model") || (await pathExists(path.join(root, "database", "models", `${lower}.ts`)));
783
+ const withModel = hasFlag(flags, "--with-model") || (await pathExists(path.join(root, "database", "models", `${lower}.ts`)));
720
784
  const content = await serviceFile(moduleName, name, withModel);
721
785
  await writeFileSafe(path.join(root, `services/${lower}.ts`), content, flags, "Service file");
722
786
  }
@@ -956,8 +1020,16 @@ export async function generateSchema(options = {}) {
956
1020
  if (!options.silent) console.log(`Generated database schema with ${files.length} model file(s)`);
957
1021
  }
958
1022
 
959
- /** Generate a temporary schema file for a single module (used for module:generate/migrate). */
960
- async function generateModuleSchemaTemp(moduleName) {
1023
+ /**
1024
+ * Generate and run migrations that include a module.
1025
+ *
1026
+ * Drizzle diffs the schema against the last snapshot, so generating from a
1027
+ * module-only schema would emit `DROP TABLE` for every other module (auth
1028
+ * included). The full schema is used instead: it adds the module's tables and
1029
+ * keeps the rest of the database intact.
1030
+ */
1031
+ export async function runModuleMigrate(rawModuleName, rawArgs = []) {
1032
+ const { moduleName } = resolveModuleRef(rawModuleName, rawArgs);
961
1033
  await assertModuleExists(moduleName);
962
1034
  const backendSrc = path.resolve(process.cwd(), "src");
963
1035
  const files = await glob(`modules/${moduleName}/database/models/*.{ts,js}`, {
@@ -966,47 +1038,13 @@ async function generateModuleSchemaTemp(moduleName) {
966
1038
  windowsPathsNoEscape: true
967
1039
  });
968
1040
  if (!files.length) throw new Error(`No model files found for module '${moduleName}'.`);
969
- const exports = [];
970
- const tempDir = path.resolve(process.cwd(), "src/storage/tmp");
971
- await fs.mkdir(tempDir, { recursive: true });
972
- // Flatten the panel path: "admin/testme" must not become a nested
973
- // storage/tmp/schema.admin/testme.<ts>.ts path.
974
- const slug = moduleName.replaceAll("/", "-");
975
- const tempSchemaPath = path.join(tempDir, `schema.${slug}.${Date.now()}.ts`);
976
- const tempSchemaDir = path.dirname(tempSchemaPath);
977
- for (const file of files.sort()) {
978
- const absoluteFile = path.join(backendSrc, file);
979
- const relativePath = path
980
- .relative(tempSchemaDir, absoluteFile)
981
- .replace(/\\/g, "/")
982
- .replace(/\.(ts|js)$/, ".ts");
983
- const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
984
- exports.push(`export * from "${importPath}";`);
985
- }
986
- await fs.writeFile(tempSchemaPath, `${exports.join("\n")}\n`);
987
- return { tempSchemaPath, modelCount: files.length };
988
- }
989
-
990
- /** Generate and run migrations for a single module using a temporary schema. */
991
- export async function runModuleMigrate(rawModuleName, rawArgs = []) {
992
- const { moduleName } = resolveModuleRef(rawModuleName, rawArgs);
993
1041
  await syncMigrationDialect();
994
1042
  await ensureMigrationMeta();
995
1043
  await ensureDatabaseDirectory();
996
- const keepTemp = rawArgs.includes("--keep-temp");
997
- const { tempSchemaPath, modelCount } = await generateModuleSchemaTemp(moduleName);
998
- const drizzleSchemaPath = `./${path.relative(process.cwd(), tempSchemaPath).replace(/\\/g, "/")}`;
999
- const previousSchema = process.env.DRIZZLE_SCHEMA;
1000
- try {
1001
- process.env.DRIZZLE_SCHEMA = drizzleSchemaPath;
1002
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), await drizzleGenerateArgs());
1003
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), ["migrate"]);
1004
- console.log(`Module migration complete: ${moduleName} (${modelCount} model file(s))`);
1005
- } finally {
1006
- if (previousSchema == null) delete process.env.DRIZZLE_SCHEMA;
1007
- else process.env.DRIZZLE_SCHEMA = previousSchema;
1008
- if (!keepTemp) await fs.rm(tempSchemaPath, { force: true }).catch(() => {});
1009
- }
1044
+ await generateSchema();
1045
+ await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), await drizzleGenerateArgs());
1046
+ await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), ["migrate"]);
1047
+ console.log(`Module migration complete: ${moduleName} (${files.length} model file(s))`);
1010
1048
  }
1011
1049
 
1012
1050
  /** Generate a unit test file for a module. */
@@ -39,8 +39,9 @@ export function registerModuleCommands(program, rawArgs) {
39
39
  });
40
40
  program
41
41
  .command("module:make-notification [name]")
42
- .description("Generate notification backend module (controller, routes, job)")
42
+ .description("Generate the notification HTTP module, and the shared store the first time")
43
43
  .option("--path <panel>", "Nest the module under a panel folder (e.g. --path=admin)")
44
+ .option("--notifiable <type>", "Owner type recorded with each notification (default: user)")
44
45
  .option("--force", "Overwrite the files of a module that already exists")
45
46
  .allowUnknownOption(true)
46
47
  .action(async (name) => {
@@ -195,8 +196,7 @@ export function registerModuleCommands(program, rawArgs) {
195
196
  });
196
197
  program
197
198
  .command("module:migrate <module>")
198
- .description("Generate module-only schema, generate migration, then run migrate")
199
- .option("--keep-temp", "Keep temporary schema file after migration")
199
+ .description("Generate and run a migration that includes the module")
200
200
  .option("--path <panel>", "Address a module inside a panel folder (e.g. --path=admin)")
201
201
  .allowUnknownOption(true)
202
202
  .action(async (moduleName) => runModuleMigrate(moduleName, rawArgs));
@@ -1,77 +1,76 @@
1
- import { and, count, desc, eq, sql } from "drizzle-orm";
2
1
  import type { Request, Response } from "express";
3
- import { db } from "@/framework/facade.js";
4
- import { notifications } from "@/modules/auth/database/models/notification.js";
2
+ import { HttpStatusCodes, resolveNotificationProvider } from "@/framework/facade.js";
3
+ import type { NotifiableTarget } from "@/framework/providers/contracts.js";
5
4
 
6
- type AuthRequest = Request & { auth: { id: number } };
5
+ /** Read the authenticated owner. A missing auth context means the request is anonymous. */
6
+ function targetFrom(res: Response): NotifiableTarget | null {
7
+ const auth = (res.locals as any)?.auth;
8
+ if (!auth || auth.id === undefined || auth.id === null) return null;
9
+ return { type: "{{notifiable}}", id: auth.id };
10
+ }
11
+
12
+ function unavailable(res: Response) {
13
+ return res.status(HttpStatusCodes.SERVICE_UNAVAILABLE).json({
14
+ message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/."
15
+ });
16
+ }
17
+
18
+ function unauthorized(res: Response) {
19
+ return res.status(HttpStatusCodes.UNAUTHORIZED).json({ message: "Unauthorized" });
20
+ }
21
+
22
+ export const list = async (req: Request, res: Response) => {
23
+ const provider = await resolveNotificationProvider();
24
+ if (!provider) return unavailable(res);
25
+
26
+ const target = targetFrom(res);
27
+ if (!target) return unauthorized(res);
7
28
 
8
- export const list = async (req: AuthRequest, res: Response) => {
9
- const userId = req.auth.id;
10
29
  const page = Math.max(1, Number(req.query.page) || 1);
11
30
  const perPage = Math.min(50, Math.max(1, Number(req.query.perPage) || 20));
12
31
 
13
- const unread = await db.select({ count: count() }).from(notifications)
14
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
15
- const total = await db.select({ count: count() }).from(notifications)
16
- .where(eq(notifications.userId, userId));
17
- const rows = await db.select().from(notifications)
18
- .where(eq(notifications.userId, userId))
19
- .orderBy(desc(notifications.createdAt))
20
- .limit(perPage)
21
- .offset((page - 1) * perPage);
22
-
23
- res.json({
24
- data: rows.map(normalize),
25
- total: total[0].count,
26
- unread: unread[0].count,
27
- page,
28
- perPage
29
- });
32
+ return res.json(await provider.list(target, { page, perPage }));
30
33
  };
31
34
 
32
- export const unreadCount = async (req: AuthRequest, res: Response) => {
33
- const userId = req.auth.id;
34
- const rows = await db.select({ count: count() }).from(notifications)
35
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
36
- res.json({ count: rows[0].count });
37
- };
35
+ export const unreadCount = async (_req: Request, res: Response) => {
36
+ const provider = await resolveNotificationProvider();
37
+ if (!provider) return unavailable(res);
38
38
 
39
- export const markRead = async (req: AuthRequest, res: Response) => {
40
- const id = Number(req.params.id);
41
- await db.update(notifications)
42
- .set({ readAt: sql`now()` })
43
- .where(and(eq(notifications.id, id), eq(notifications.userId, req.auth.id)));
44
- res.json({ ok: true });
39
+ const target = targetFrom(res);
40
+ if (!target) return unauthorized(res);
41
+
42
+ return res.json({ count: await provider.unreadCount(target) });
45
43
  };
46
44
 
47
- export const markAllRead = async (req: AuthRequest, res: Response) => {
48
- await db.update(notifications)
49
- .set({ readAt: sql`now()` })
50
- .where(and(eq(notifications.userId, req.auth.id), sql`${notifications.readAt} IS NULL`));
51
- res.json({ ok: true });
45
+ export const markRead = async (req: Request, res: Response) => {
46
+ const provider = await resolveNotificationProvider();
47
+ if (!provider) return unavailable(res);
48
+
49
+ const target = targetFrom(res);
50
+ if (!target) return unauthorized(res);
51
+
52
+ await provider.markRead(target, String(req.params.id));
53
+ return res.json({ ok: true });
52
54
  };
53
55
 
54
- export const remove = async (req: AuthRequest, res: Response) => {
55
- const id = Number(req.params.id);
56
- await db.delete(notifications)
57
- .where(and(eq(notifications.id, id), eq(notifications.userId, req.auth.id)));
58
- res.json({ ok: true });
56
+ export const markAllRead = async (_req: Request, res: Response) => {
57
+ const provider = await resolveNotificationProvider();
58
+ if (!provider) return unavailable(res);
59
+
60
+ const target = targetFrom(res);
61
+ if (!target) return unauthorized(res);
62
+
63
+ await provider.markAllRead(target);
64
+ return res.json({ ok: true });
59
65
  };
60
66
 
61
- function normalize(row: typeof notifications.$inferSelect) {
62
- let parsed: unknown = null;
63
- if (row.data) {
64
- try { parsed = JSON.parse(row.data); } catch {}
65
- }
66
- return {
67
- id: row.id,
68
- userId: row.userId,
69
- type: row.type,
70
- title: row.title,
71
- body: row.body,
72
- data: parsed,
73
- link: row.link,
74
- readAt: row.readAt?.toISOString() || null,
75
- createdAt: row.createdAt.toISOString(),
76
- };
77
- }
67
+ export const remove = async (req: Request, res: Response) => {
68
+ const provider = await resolveNotificationProvider();
69
+ if (!provider) return unavailable(res);
70
+
71
+ const target = targetFrom(res);
72
+ if (!target) return unauthorized(res);
73
+
74
+ await provider.remove(target, String(req.params.id));
75
+ return res.json({ ok: true });
76
+ };
@@ -1,13 +1,11 @@
1
- import { shouldQueue } from "@/framework/queue/queue.js";
2
- import { logger } from "@/framework/support/logger.js";
3
- import { mail } from "@/framework/support/mail.js";
4
-
5
- shouldQueue("{{module}}:mail", "mail", async (job) => {
6
- const { to, subject, html } = job.data;
7
-
8
- await mail.sendMail({ to, subject, html });
9
- logger.info("Notification mail sent", { to, subject });
10
-
11
- return { ok: true };
12
- });
13
-
1
+ import { mail, shouldQueue } from "@/framework/facade.js";
2
+ import { logger } from "@/framework/support/logger.js";
3
+
4
+ shouldQueue("notification:mail", "mail", async (job) => {
5
+ const { to, subject, html } = job.data;
6
+
7
+ await mail.sendMail({ to, subject, html });
8
+ logger.info("Notification mail sent", { to, subject });
9
+
10
+ return { ok: true };
11
+ });
@@ -0,0 +1,29 @@
1
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * Why: Notifications are stored by a standalone module, not by auth.
5
+ * When: `notify()` writes a row and `/api/notification/*` reads it back.
6
+ * Where: `src/modules/notification/database/models/notification.ts`.
7
+ * How: A polymorphic owner (`notifiable_type` + `notifiable_id`) with no
8
+ * foreign key, so deleting or replacing auth cannot cascade-delete
9
+ * notifications and a UUID or numeric owner id stores the same way.
10
+ */
11
+ export const notifications = sqliteTable(
12
+ "notifications",
13
+ {
14
+ id: text("id").primaryKey(),
15
+ notifiableType: text("notifiable_type").notNull().default("user"),
16
+ notifiableId: text("notifiable_id").notNull(),
17
+ type: text("type").notNull(),
18
+ title: text("title").notNull(),
19
+ body: text("body"),
20
+ data: text("data"),
21
+ link: text("link"),
22
+ readAt: integer("read_at", { mode: "timestamp" }),
23
+ createdAt: integer("created_at", { mode: "timestamp" }).defaultNow().notNull()
24
+ },
25
+ (table) => [
26
+ index("notifications_notifiable_read_idx").on(table.notifiableType, table.notifiableId, table.readAt),
27
+ index("notifications_notifiable_created_idx").on(table.notifiableType, table.notifiableId, table.createdAt)
28
+ ]
29
+ );