create-nexwire 4.2.3 → 4.2.5

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 (87) hide show
  1. package/express/.vscode/extensions.json +6 -0
  2. package/express/.vscode/settings.json +37 -26
  3. package/express/biome.json +99 -66
  4. package/express/package.json +54 -27
  5. package/express/src/database/migrations/sqlite/0000_init.sql +47 -60
  6. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  7. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  8. package/express/src/database/schema.ts +0 -1
  9. package/express/src/framework/facade.ts +12 -1
  10. package/express/src/framework/maker-cli/db/core.mjs +291 -288
  11. package/express/src/framework/maker-cli/module/core.mjs +125 -87
  12. package/express/src/framework/maker-cli/module/index.mjs +3 -3
  13. package/express/src/framework/maker-cli/stubs/notification/controller.ts.stub +61 -62
  14. package/express/src/framework/maker-cli/stubs/notification/job.ts.stub +11 -13
  15. package/express/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  16. package/express/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  17. package/express/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  18. package/express/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  19. package/express/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  20. package/express/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  21. package/express/src/framework/notification/index.ts +49 -87
  22. package/express/src/framework/providers/adapter.ts +24 -10
  23. package/express/src/framework/providers/contracts.ts +80 -10
  24. package/express/src/framework/providers/registry.ts +27 -1
  25. package/express/src/resources/src/components/Checkbox.vue +32 -29
  26. package/express/src/resources/src/components/Input.vue +243 -223
  27. package/express/src/resources/src/components/InputGroup.vue +147 -145
  28. package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
  29. package/express/src/resources/src/components/Modal.vue +71 -55
  30. package/express/src/resources/src/components/Refresh.vue +19 -0
  31. package/express/src/resources/src/helpers/assets.ts +51 -0
  32. package/express/src/resources/src/main.ts +4 -2
  33. package/express/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  34. package/express/src/resources/src/pages/auth/login.vue +1 -1
  35. package/express/src/resources/src/pages/auth/register.vue +1 -1
  36. package/express/src/resources/src/pages/auth/resetPassword.vue +1 -1
  37. package/express/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  38. package/express/src/resources/src/pages/dashboard/index.vue +66 -66
  39. package/express/src/resources/src/plugins/gum.ts +1 -1
  40. package/express/src/resources/src/stores/auth.ts +184 -164
  41. package/express/src/resources/vite.config.ts +9 -2
  42. package/express/src/storage/database/nexwire.sqlite +0 -0
  43. package/hono/.vscode/extensions.json +6 -0
  44. package/hono/.vscode/settings.json +37 -26
  45. package/hono/biome.json +25 -2
  46. package/hono/package.json +50 -23
  47. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -60
  48. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  49. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  50. package/hono/src/database/schema.ts +0 -1
  51. package/hono/src/framework/facade.ts +12 -1
  52. package/hono/src/framework/maker-cli/db/core.mjs +291 -288
  53. package/hono/src/framework/maker-cli/module/core.mjs +93 -52
  54. package/hono/src/framework/maker-cli/module/index.mjs +3 -3
  55. package/hono/src/framework/maker-cli/stubs/notification/controller.ts.stub +63 -58
  56. package/hono/src/framework/maker-cli/stubs/notification/job.ts.stub +10 -12
  57. package/hono/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  58. package/hono/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  59. package/hono/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  60. package/hono/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  61. package/hono/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  62. package/hono/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  63. package/hono/src/framework/notification/index.ts +49 -87
  64. package/hono/src/framework/providers/adapter.ts +24 -10
  65. package/hono/src/framework/providers/contracts.ts +80 -10
  66. package/hono/src/framework/providers/registry.ts +27 -1
  67. package/hono/src/resources/src/components/Checkbox.vue +32 -29
  68. package/hono/src/resources/src/components/Input.vue +243 -234
  69. package/hono/src/resources/src/components/InputGroup.vue +147 -149
  70. package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
  71. package/hono/src/resources/src/components/Modal.vue +71 -55
  72. package/hono/src/resources/src/components/Refresh.vue +19 -0
  73. package/hono/src/resources/src/helpers/assets.ts +51 -0
  74. package/hono/src/resources/src/main.ts +4 -2
  75. package/hono/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  76. package/hono/src/resources/src/pages/auth/login.vue +1 -1
  77. package/hono/src/resources/src/pages/auth/register.vue +1 -1
  78. package/hono/src/resources/src/pages/auth/resetPassword.vue +1 -1
  79. package/hono/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  80. package/hono/src/resources/src/pages/dashboard/index.vue +66 -66
  81. package/hono/src/resources/src/plugins/gum.ts +1 -1
  82. package/hono/src/resources/src/stores/auth.ts +184 -164
  83. package/hono/src/resources/vite.config.ts +9 -2
  84. package/hono/src/storage/database/nexwire.sqlite +0 -0
  85. package/package.json +10 -10
  86. package/express/src/modules/auth/database/models/notification.ts +0 -24
  87. 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
  *
@@ -519,7 +561,15 @@ 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
574
  const { moduleName, leaf } = resolveModuleRef(rawName, flags);
525
575
  const root = moduleRoot(moduleName);
@@ -528,22 +578,39 @@ export async function makeNotificationModule(rawName = "notification", flags = [
528
578
  throw new Error(`Module already exists: src/modules/${moduleName}. Re-run with --force to overwrite its files.`);
529
579
  }
530
580
  const openApi = openApiEnabled();
581
+ const notifiable = parseNotifiable(flags);
582
+ const vars = { module: moduleName, leaf, notifiable };
531
583
 
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
- });
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);
593
+
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);
598
+ }
599
+
600
+ await writeFiles(root, files);
542
601
 
543
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
+ }
544
610
  if (panelOf(moduleName)) console.log(`Panel: ${panelOf(moduleName)} -> /api/${moduleName}`);
611
+ console.log(`Notifiable type: ${notifiable}`);
545
612
  console.log(`Route style: ${openApi ? "openapi" : "plain"}`);
546
- console.log("See docs (packages/docs/guide/notification.md) for Vue UI integration.");
613
+ console.log("See docs (packages/docs/guide/notification.md) for usage and UI integration.");
547
614
  }
548
615
 
549
616
  /** Soft-delete a module by moving it to storage trash. */
@@ -953,8 +1020,16 @@ export async function generateSchema(options = {}) {
953
1020
  if (!options.silent) console.log(`Generated database schema with ${files.length} model file(s)`);
954
1021
  }
955
1022
 
956
- /** Generate a temporary schema file for a single module (used for module:generate/migrate). */
957
- 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);
958
1033
  await assertModuleExists(moduleName);
959
1034
  const backendSrc = path.resolve(process.cwd(), "src");
960
1035
  const files = await glob(`modules/${moduleName}/database/models/*.{ts,js}`, {
@@ -963,47 +1038,13 @@ async function generateModuleSchemaTemp(moduleName) {
963
1038
  windowsPathsNoEscape: true
964
1039
  });
965
1040
  if (!files.length) throw new Error(`No model files found for module '${moduleName}'.`);
966
- const exports = [];
967
- const tempDir = path.resolve(process.cwd(), "src/storage/tmp");
968
- await fs.mkdir(tempDir, { recursive: true });
969
- // Flatten the panel path: "admin/testme" must not become a nested
970
- // storage/tmp/schema.admin/testme.<ts>.ts path.
971
- const slug = moduleName.replaceAll("/", "-");
972
- const tempSchemaPath = path.join(tempDir, `schema.${slug}.${Date.now()}.ts`);
973
- const tempSchemaDir = path.dirname(tempSchemaPath);
974
- for (const file of files.sort()) {
975
- const absoluteFile = path.join(backendSrc, file);
976
- const relativePath = path
977
- .relative(tempSchemaDir, absoluteFile)
978
- .replace(/\\/g, "/")
979
- .replace(/\.(ts|js)$/, ".ts");
980
- const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
981
- exports.push(`export * from "${importPath}";`);
982
- }
983
- await fs.writeFile(tempSchemaPath, `${exports.join("\n")}\n`);
984
- return { tempSchemaPath, modelCount: files.length };
985
- }
986
-
987
- /** Generate and run migrations for a single module using a temporary schema. */
988
- export async function runModuleMigrate(rawModuleName, rawArgs = []) {
989
- const { moduleName } = resolveModuleRef(rawModuleName, rawArgs);
990
1041
  await syncMigrationDialect();
991
1042
  await ensureMigrationMeta();
992
1043
  await ensureDatabaseDirectory();
993
- const keepTemp = rawArgs.includes("--keep-temp");
994
- const { tempSchemaPath, modelCount } = await generateModuleSchemaTemp(moduleName);
995
- const drizzleSchemaPath = `./${path.relative(process.cwd(), tempSchemaPath).replace(/\\/g, "/")}`;
996
- const previousSchema = process.env.DRIZZLE_SCHEMA;
997
- try {
998
- process.env.DRIZZLE_SCHEMA = drizzleSchemaPath;
999
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), await drizzleGenerateArgs());
1000
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), ["migrate"]);
1001
- console.log(`Module migration complete: ${moduleName} (${modelCount} model file(s))`);
1002
- } finally {
1003
- if (previousSchema == null) delete process.env.DRIZZLE_SCHEMA;
1004
- else process.env.DRIZZLE_SCHEMA = previousSchema;
1005
- if (!keepTemp) await fs.rm(tempSchemaPath, { force: true }).catch(() => { });
1006
- }
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))`);
1007
1048
  }
1008
1049
 
1009
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,86 +1,91 @@
1
1
  import type { Handler } from "hono";
2
- import { and, count, desc, eq, sql } from "drizzle-orm";
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";
4
+
5
+ /** Read the authenticated owner. A missing auth context means the request is anonymous. */
6
+ function targetFrom(c: any): NotifiableTarget | null {
7
+ const auth = c.get("auth");
8
+ if (!auth || auth.id === undefined || auth.id === null) return null;
9
+ return { type: "{{notifiable}}", id: auth.id };
10
+ }
5
11
 
6
12
  export const list: Handler = async (c: any) => {
7
- const userId = c.get("auth").id;
8
- const page = Math.max(1, Number(c.req.query("page")) || 1);
9
- const perPage = Math.min(50, Math.max(1, Number(c.req.query("perPage")) || 20));
10
-
11
- const unread = await db.select({ count: count() }).from(notifications)
12
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
13
+ const provider = await resolveNotificationProvider();
14
+ if (!provider) {
15
+ return c.json(
16
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
17
+ HttpStatusCodes.SERVICE_UNAVAILABLE
18
+ );
19
+ }
13
20
 
14
- const total = await db.select({ count: count() }).from(notifications)
15
- .where(eq(notifications.userId, userId));
21
+ const target = targetFrom(c);
22
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
16
23
 
17
- const rows = await db.select().from(notifications)
18
- .where(eq(notifications.userId, userId))
19
- .orderBy(desc(notifications.createdAt))
20
- .limit(perPage).offset((page - 1) * perPage);
24
+ const page = Math.max(1, Number(c.req.query("page")) || 1);
25
+ const perPage = Math.min(50, Math.max(1, Number(c.req.query("perPage")) || 20));
21
26
 
22
- return c.json({
23
- data: rows.map(normalize),
24
- total: total[0].count,
25
- unread: unread[0].count,
26
- page,
27
- perPage,
28
- });
27
+ return c.json(await provider.list(target, { page, perPage }));
29
28
  };
30
29
 
31
30
  export const unreadCount: Handler = async (c: any) => {
32
- const userId = c.get("auth").id;
33
- const rows = await db.select({ count: count() }).from(notifications)
34
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
35
- return c.json({ count: rows[0].count });
31
+ const provider = await resolveNotificationProvider();
32
+ if (!provider) {
33
+ return c.json(
34
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
35
+ HttpStatusCodes.SERVICE_UNAVAILABLE
36
+ );
37
+ }
38
+
39
+ const target = targetFrom(c);
40
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
41
+
42
+ return c.json({ count: await provider.unreadCount(target) });
36
43
  };
37
44
 
38
45
  export const markRead: Handler = async (c: any) => {
39
- const userId = c.get("auth").id;
40
- const id = Number(c.req.param("id"));
46
+ const provider = await resolveNotificationProvider();
47
+ if (!provider) {
48
+ return c.json(
49
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
50
+ HttpStatusCodes.SERVICE_UNAVAILABLE
51
+ );
52
+ }
41
53
 
42
- await db.update(notifications)
43
- .set({ readAt: sql`now()` })
44
- .where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
54
+ const target = targetFrom(c);
55
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
45
56
 
57
+ await provider.markRead(target, c.req.param("id"));
46
58
  return c.json({ ok: true });
47
59
  };
48
60
 
49
61
  export const markAllRead: Handler = async (c: any) => {
50
- const userId = c.get("auth").id;
62
+ const provider = await resolveNotificationProvider();
63
+ if (!provider) {
64
+ return c.json(
65
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
66
+ HttpStatusCodes.SERVICE_UNAVAILABLE
67
+ );
68
+ }
51
69
 
52
- await db.update(notifications)
53
- .set({ readAt: sql`now()` })
54
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
70
+ const target = targetFrom(c);
71
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
55
72
 
73
+ await provider.markAllRead(target);
56
74
  return c.json({ ok: true });
57
75
  };
58
76
 
59
77
  export const remove: Handler = async (c: any) => {
60
- const userId = c.get("auth").id;
61
- const id = Number(c.req.param("id"));
78
+ const provider = await resolveNotificationProvider();
79
+ if (!provider) {
80
+ return c.json(
81
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
82
+ HttpStatusCodes.SERVICE_UNAVAILABLE
83
+ );
84
+ }
62
85
 
63
- await db.delete(notifications)
64
- .where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
86
+ const target = targetFrom(c);
87
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
65
88
 
89
+ await provider.remove(target, c.req.param("id"));
66
90
  return c.json({ ok: true });
67
91
  };
68
-
69
- function normalize(row: typeof notifications.$inferSelect) {
70
- let parsed: unknown = null;
71
- if (row.data) {
72
- try { parsed = JSON.parse(row.data); } catch {}
73
- }
74
- return {
75
- id: row.id,
76
- userId: row.userId,
77
- type: row.type,
78
- title: row.title,
79
- body: row.body,
80
- data: parsed,
81
- link: row.link,
82
- readAt: row.readAt?.toISOString() || null,
83
- createdAt: row.createdAt.toISOString(),
84
- };
85
- }
86
-
@@ -1,13 +1,11 @@
1
- import { mail } from "@/framework/support/mail.js";
2
- import { logger } from "@/framework/support/logger.js";
3
- import { shouldQueue } from "@/framework/queue/queue.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
- });
1
+ import { mail, shouldQueue } from "@/framework/facade.js";
2
+ import { logger } from "@/framework/support/logger.js";
13
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
+ );
@@ -0,0 +1,174 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { and, count, desc, eq, isNull } from "drizzle-orm";
3
+ import { db } from "@/framework/database/connection.js";
4
+ import type {
5
+ NotifiableTarget,
6
+ NotificationCreateInput,
7
+ NotificationListOptions,
8
+ NotificationPage,
9
+ NotificationProvider,
10
+ NotificationRecord
11
+ } from "@/framework/providers/contracts.js";
12
+ import { registerNotificationProvider } from "@/framework/providers/registry.js";
13
+ import { notifications } from "@/modules/{{module}}/database/models/notification.js";
14
+
15
+ /** Normalize a target into the polymorphic pair stored in the table. */
16
+ function normalizeTarget(target: NotifiableTarget) {
17
+ const type = String(target?.type || "user").trim() || "user";
18
+ if (target?.id === undefined || target?.id === null || target?.id === "") {
19
+ throw new Error("A notification target needs an id.");
20
+ }
21
+ return { type, id: String(target.id) };
22
+ }
23
+
24
+ /** Build the owner scope shared by every read and write. */
25
+ function scope(target: NotifiableTarget) {
26
+ const normalized = normalizeTarget(target);
27
+ return {
28
+ target: normalized,
29
+ where: and(eq(notifications.notifiableType, normalized.type), eq(notifications.notifiableId, normalized.id))
30
+ };
31
+ }
32
+
33
+ function toIso(value: Date | string | number | null | undefined): string | null {
34
+ if (value === null || value === undefined) return null;
35
+ const date = value instanceof Date ? value : new Date(value);
36
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
37
+ }
38
+
39
+ function parseData(value: unknown): Record<string, unknown> | null {
40
+ if (!value) return null;
41
+ if (typeof value === "object") return value as Record<string, unknown>;
42
+ try {
43
+ const parsed = JSON.parse(String(value));
44
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Convert a DB row into the stable shape every consumer receives. */
51
+ function normalizeRow(row: typeof notifications.$inferSelect): NotificationRecord {
52
+ return {
53
+ id: String(row.id),
54
+ notifiableType: row.notifiableType,
55
+ notifiableId: row.notifiableId,
56
+ type: row.type,
57
+ title: row.title,
58
+ body: row.body ?? null,
59
+ data: parseData(row.data),
60
+ link: row.link ?? null,
61
+ readAt: toIso(row.readAt),
62
+ createdAt: toIso(row.createdAt) ?? new Date(0).toISOString()
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Why: Ships a working notification store so `notify()` needs no setup.
68
+ * When: Imported at boot by the provider loader (and lazily by the resolver).
69
+ * Where: `src/modules/notification/providers/notification.ts`.
70
+ * How: Drizzle-backed implementation of `NotificationProvider`. The primary
71
+ * key is a UUID string, so ids never depend on the auth table; numeric
72
+ * targets are accepted and stored as text by the contract.
73
+ */
74
+ export const notificationProvider: NotificationProvider = {
75
+ async create(target: NotifiableTarget, input: NotificationCreateInput): Promise<NotificationRecord> {
76
+ const { target: normalized } = scope(target);
77
+ const id = randomUUID();
78
+
79
+ await db.insert(notifications).values({
80
+ id,
81
+ notifiableType: normalized.type,
82
+ notifiableId: normalized.id,
83
+ type: input.type || "info",
84
+ title: input.title,
85
+ body: input.body ?? null,
86
+ link: input.link ?? null,
87
+ data: input.data ? JSON.stringify(input.data) : null
88
+ });
89
+
90
+ const [row] = await db.select().from(notifications).where(eq(notifications.id, id));
91
+ if (!row) throw new Error("Failed to create notification");
92
+ return normalizeRow(row);
93
+ },
94
+
95
+ async list(target: NotifiableTarget, options: NotificationListOptions = {}): Promise<NotificationPage> {
96
+ const { where } = scope(target);
97
+ const page = Math.max(1, Math.floor(Number(options.page) || 1));
98
+ const perPage = Math.min(100, Math.max(1, Math.floor(Number(options.perPage) || 20)));
99
+
100
+ const [totalRows, unreadRows, rows] = await Promise.all([
101
+ db.select({ count: count() }).from(notifications).where(where),
102
+ db.select({ count: count() }).from(notifications).where(and(where, isNull(notifications.readAt))),
103
+ db
104
+ .select()
105
+ .from(notifications)
106
+ .where(where)
107
+ .orderBy(desc(notifications.createdAt))
108
+ .limit(perPage)
109
+ .offset((page - 1) * perPage)
110
+ ]);
111
+
112
+ return {
113
+ data: rows.map(normalizeRow),
114
+ total: Number(totalRows[0]?.count ?? 0),
115
+ unread: Number(unreadRows[0]?.count ?? 0),
116
+ page,
117
+ perPage
118
+ };
119
+ },
120
+
121
+ async unreadCount(target: NotifiableTarget): Promise<number> {
122
+ const { where } = scope(target);
123
+ const rows = await db
124
+ .select({ count: count() })
125
+ .from(notifications)
126
+ .where(and(where, isNull(notifications.readAt)));
127
+ return Number(rows[0]?.count ?? 0);
128
+ },
129
+
130
+ async markRead(target: NotifiableTarget, id: number | string): Promise<boolean> {
131
+ const { where } = scope(target);
132
+ const notificationWhere = and(where, eq(notifications.id, String(id)));
133
+ const [existing] = await db.select({ id: notifications.id }).from(notifications).where(notificationWhere);
134
+ if (!existing) return false;
135
+
136
+ await db.update(notifications).set({ readAt: new Date() }).where(notificationWhere);
137
+ return true;
138
+ },
139
+
140
+ async markAllRead(target: NotifiableTarget): Promise<number> {
141
+ const { where } = scope(target);
142
+ const unreadWhere = and(where, isNull(notifications.readAt));
143
+ const rows = await db.select({ count: count() }).from(notifications).where(unreadWhere);
144
+ const unread = Number(rows[0]?.count ?? 0);
145
+ if (unread === 0) return 0;
146
+
147
+ await db.update(notifications).set({ readAt: new Date() }).where(unreadWhere);
148
+ return unread;
149
+ },
150
+
151
+ async remove(target: NotifiableTarget, id: number | string): Promise<boolean> {
152
+ const { where } = scope(target);
153
+ const notificationWhere = and(where, eq(notifications.id, String(id)));
154
+ const [existing] = await db.select({ id: notifications.id }).from(notifications).where(notificationWhere);
155
+ if (!existing) return false;
156
+
157
+ await db.delete(notifications).where(notificationWhere);
158
+ return true;
159
+ },
160
+
161
+ async deleteForTarget(target: NotifiableTarget): Promise<number> {
162
+ const { where } = scope(target);
163
+ const rows = await db.select({ count: count() }).from(notifications).where(where);
164
+ const total = Number(rows[0]?.count ?? 0);
165
+ if (total === 0) return 0;
166
+
167
+ await db.delete(notifications).where(where);
168
+ return total;
169
+ }
170
+ };
171
+
172
+ registerNotificationProvider(notificationProvider);
173
+
174
+ export default notificationProvider;
@@ -1,43 +1,45 @@
1
1
  import { createRoute, createRouter, HttpStatusCodes, jsonContent, z } from "@/framework/facade.js";
2
2
  import { authMiddleware } from "@/middlewares/auth-middleware.js";
3
- import { list, markAllRead, markRead, remove, unreadCount } from "../controllers/{{module}}.js";
4
- import { PaginatedNotificationsSchema, UnreadCountSchema } from "../schemas/{{module}}.js";
3
+ import { list, markAllRead, markRead, remove, unreadCount } from "../controllers/{{leaf}}.js";
4
+ import { PaginatedNotificationsSchema, UnreadCountSchema } from "../schemas/{{leaf}}.js";
5
+
6
+ const notificationId = z.object({ id: z.string().min(1) });
5
7
 
6
8
  const listRoute = createRoute({
7
9
  path: "/",
8
10
  method: "get",
9
11
  tags: ["Notifications"],
10
- responses: { [HttpStatusCodes.OK]: jsonContent(PaginatedNotificationsSchema, "Paginated notifications") },
12
+ responses: { [HttpStatusCodes.OK]: jsonContent(PaginatedNotificationsSchema, "Paginated notifications") }
11
13
  });
12
14
 
13
15
  const unreadCountRoute = createRoute({
14
16
  path: "/unread-count",
15
17
  method: "get",
16
18
  tags: ["Notifications"],
17
- responses: { [HttpStatusCodes.OK]: jsonContent(UnreadCountSchema, "Unread count") },
19
+ responses: { [HttpStatusCodes.OK]: jsonContent(UnreadCountSchema, "Unread count") }
18
20
  });
19
21
 
20
22
  const markReadRoute = createRoute({
21
23
  path: "/{id}/read",
22
24
  method: "patch",
23
25
  tags: ["Notifications"],
24
- request: { params: z.object({ id: z.coerce.number() }) },
25
- responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Marked as read") },
26
+ request: { params: notificationId },
27
+ responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Marked as read") }
26
28
  });
27
29
 
28
30
  const markAllReadRoute = createRoute({
29
31
  path: "/read-all",
30
32
  method: "patch",
31
33
  tags: ["Notifications"],
32
- responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "All marked as read") },
34
+ responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "All marked as read") }
33
35
  });
34
36
 
35
37
  const deleteRoute = createRoute({
36
38
  path: "/{id}",
37
39
  method: "delete",
38
40
  tags: ["Notifications"],
39
- request: { params: z.object({ id: z.coerce.number() }) },
40
- responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Deleted") },
41
+ request: { params: notificationId },
42
+ responses: { [HttpStatusCodes.OK]: jsonContent(z.object({ ok: z.boolean() }), "Deleted") }
41
43
  });
42
44
 
43
45
  export default createRouter()
@@ -47,4 +49,3 @@ export default createRouter()
47
49
  .api(markReadRoute, markRead)
48
50
  .api(markAllReadRoute, markAllRead)
49
51
  .api(deleteRoute, remove);
50
-