create-nexwire 4.2.2 → 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.
- package/express/package.json +48 -23
- package/express/src/database/migrations/sqlite/0000_init.sql +47 -60
- package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
- package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/express/src/database/schema.ts +2 -3
- package/express/src/framework/database/app-schema.ts +37 -0
- package/express/src/framework/database/connection.ts +198 -196
- package/express/src/framework/database/seed.ts +35 -22
- package/express/src/framework/facade.ts +35 -5
- package/express/src/framework/kernel.ts +54 -52
- package/express/src/framework/maker-cli/db/core.mjs +291 -288
- package/express/src/framework/maker-cli/index.mjs +2 -0
- package/express/src/framework/maker-cli/module/core.mjs +155 -87
- package/express/src/framework/maker-cli/module/index.mjs +13 -4
- package/express/src/framework/maker-cli/provider/core.mjs +49 -0
- package/express/src/framework/maker-cli/provider/index.mjs +12 -0
- package/express/src/framework/maker-cli/stubs/notification/controller.ts.stub +76 -77
- package/express/src/framework/maker-cli/stubs/notification/job.ts.stub +11 -13
- package/express/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
- package/express/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
- package/express/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
- package/express/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
- package/express/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
- package/express/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
- package/express/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
- package/express/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
- package/express/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
- package/express/src/framework/maker-cli/utils/help.mjs +6 -1
- package/express/src/framework/notification/index.ts +65 -47
- package/express/src/framework/providers/adapter.ts +139 -0
- package/express/src/framework/providers/contracts.ts +126 -0
- package/express/src/framework/providers/loader.ts +105 -0
- package/express/src/framework/providers/registry.ts +88 -0
- package/express/src/middlewares/auth-middleware.ts +65 -38
- package/express/src/providers/README.md +107 -0
- package/express/src/resources/src/main.ts +1 -1
- package/express/src/resources/src/pages/auth/forgetPassword.vue +1 -1
- package/express/src/resources/src/pages/auth/login.vue +1 -1
- package/express/src/resources/src/pages/auth/register.vue +1 -1
- package/express/src/resources/src/pages/auth/resetPassword.vue +1 -1
- package/express/src/resources/src/pages/auth/verifyEmail.vue +1 -1
- package/express/src/resources/src/pages/dashboard/index.vue +66 -66
- package/express/src/storage/database/nexwire.sqlite +0 -0
- package/hono/package.json +44 -19
- package/hono/src/database/migrations/sqlite/0000_init.sql +47 -60
- package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
- package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/hono/src/database/schema.ts +2 -3
- package/hono/src/framework/database/app-schema.ts +37 -0
- package/hono/src/framework/database/connection.ts +198 -196
- package/hono/src/framework/database/seed.ts +35 -22
- package/hono/src/framework/facade.ts +59 -29
- package/hono/src/framework/kernel.ts +50 -48
- package/hono/src/framework/maker-cli/db/core.mjs +291 -288
- package/hono/src/framework/maker-cli/index.mjs +2 -0
- package/hono/src/framework/maker-cli/module/core.mjs +123 -52
- package/hono/src/framework/maker-cli/module/index.mjs +13 -4
- package/hono/src/framework/maker-cli/provider/core.mjs +49 -0
- package/hono/src/framework/maker-cli/provider/index.mjs +12 -0
- package/hono/src/framework/maker-cli/stubs/notification/controller.ts.stub +90 -85
- package/hono/src/framework/maker-cli/stubs/notification/job.ts.stub +10 -12
- package/hono/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
- package/hono/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
- package/hono/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
- package/hono/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
- package/hono/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
- package/hono/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
- package/hono/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
- package/hono/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
- package/hono/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
- package/hono/src/framework/maker-cli/utils/help.mjs +6 -1
- package/hono/src/framework/notification/index.ts +65 -47
- package/hono/src/framework/providers/adapter.ts +139 -0
- package/hono/src/framework/providers/contracts.ts +126 -0
- package/hono/src/framework/providers/loader.ts +105 -0
- package/hono/src/framework/providers/registry.ts +88 -0
- package/hono/src/middlewares/auth-middleware.ts +65 -38
- package/hono/src/providers/README.md +107 -0
- package/hono/src/resources/src/main.ts +1 -1
- package/hono/src/resources/src/pages/auth/forgetPassword.vue +1 -1
- package/hono/src/resources/src/pages/auth/login.vue +1 -1
- package/hono/src/resources/src/pages/auth/register.vue +1 -1
- package/hono/src/resources/src/pages/auth/resetPassword.vue +1 -1
- package/hono/src/resources/src/pages/auth/verifyEmail.vue +1 -1
- package/hono/src/resources/src/pages/dashboard/index.vue +66 -66
- package/hono/src/storage/database/nexwire.sqlite +0 -0
- package/package.json +10 -10
- package/src/index.js +14 -6
- package/express/src/modules/auth/database/models/notification.ts +0 -24
- package/hono/src/modules/auth/database/models/notification.ts +0 -24
|
@@ -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/{{
|
|
4
|
-
import { PaginatedNotificationsSchema, UnreadCountSchema } from "../schemas/{{
|
|
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:
|
|
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:
|
|
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
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { group } from "@/framework/facade.js";
|
|
2
2
|
import { authMiddleware } from "@/middlewares/auth-middleware.js";
|
|
3
|
-
import { list, unreadCount, markRead, markAllRead, remove } from "../controllers/{{
|
|
3
|
+
import { list, unreadCount, markRead, markAllRead, remove } from "../controllers/{{leaf}}.js";
|
|
4
4
|
|
|
5
5
|
export default group()
|
|
6
6
|
.use(authMiddleware)
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { z } from "@/framework/facade.js";
|
|
2
|
-
|
|
3
|
-
export const NotificationSchema = z.object({
|
|
4
|
-
id: z.number(),
|
|
5
|
-
userId: z.number(),
|
|
6
|
-
type: z.string(),
|
|
7
|
-
title: z.string(),
|
|
8
|
-
body: z.string().nullable(),
|
|
9
|
-
data: z.any().nullable(),
|
|
10
|
-
link: z.string().nullable(),
|
|
11
|
-
readAt: z.string().nullable(),
|
|
12
|
-
createdAt: z.string(),
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
export const UnreadCountSchema = z.object({
|
|
16
|
-
count: z.number(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const PaginatedNotificationsSchema = z.object({
|
|
20
|
-
data: z.array(NotificationSchema),
|
|
21
|
-
total: z.number(),
|
|
22
|
-
unread: z.number(),
|
|
23
|
-
page: z.number(),
|
|
24
|
-
perPage: z.number(),
|
|
25
|
-
});
|
|
1
|
+
import { z } from "@/framework/facade.js";
|
|
26
2
|
|
|
3
|
+
export const NotificationSchema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
notifiableType: z.string(),
|
|
6
|
+
notifiableId: z.string(),
|
|
7
|
+
type: z.string(),
|
|
8
|
+
title: z.string(),
|
|
9
|
+
body: z.string().nullable(),
|
|
10
|
+
data: z.any().nullable(),
|
|
11
|
+
link: z.string().nullable(),
|
|
12
|
+
readAt: z.string().nullable(),
|
|
13
|
+
createdAt: z.string()
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const UnreadCountSchema = z.object({
|
|
17
|
+
count: z.number()
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const PaginatedNotificationsSchema = z.object({
|
|
21
|
+
data: z.array(NotificationSchema),
|
|
22
|
+
total: z.number(),
|
|
23
|
+
unread: z.number(),
|
|
24
|
+
page: z.number(),
|
|
25
|
+
perPage: z.number()
|
|
26
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { z } from "
|
|
2
|
-
|
|
3
|
-
export const NotificationSchema = z.object({
|
|
4
|
-
id: z.number(),
|
|
5
|
-
userId: z.number(),
|
|
6
|
-
type: z.string(),
|
|
7
|
-
title: z.string(),
|
|
8
|
-
body: z.string().nullable(),
|
|
9
|
-
data: z.any().nullable(),
|
|
10
|
-
link: z.string().nullable(),
|
|
11
|
-
readAt: z.string().nullable(),
|
|
12
|
-
createdAt: z.string(),
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
export const UnreadCountSchema = z.object({
|
|
16
|
-
count: z.number(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const PaginatedNotificationsSchema = z.object({
|
|
20
|
-
data: z.array(NotificationSchema),
|
|
21
|
-
total: z.number(),
|
|
22
|
-
unread: z.number(),
|
|
23
|
-
page: z.number(),
|
|
24
|
-
perPage: z.number(),
|
|
25
|
-
});
|
|
1
|
+
import { z } from "@/framework/facade.js";
|
|
26
2
|
|
|
3
|
+
export const NotificationSchema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
notifiableType: z.string(),
|
|
6
|
+
notifiableId: z.string(),
|
|
7
|
+
type: z.string(),
|
|
8
|
+
title: z.string(),
|
|
9
|
+
body: z.string().nullable(),
|
|
10
|
+
data: z.any().nullable(),
|
|
11
|
+
link: z.string().nullable(),
|
|
12
|
+
readAt: z.string().nullable(),
|
|
13
|
+
createdAt: z.string()
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const UnreadCountSchema = z.object({
|
|
17
|
+
count: z.number()
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const PaginatedNotificationsSchema = z.object({
|
|
21
|
+
data: z.array(NotificationSchema),
|
|
22
|
+
total: z.number(),
|
|
23
|
+
unread: z.number(),
|
|
24
|
+
page: z.number(),
|
|
25
|
+
perPage: z.number()
|
|
26
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers for the {{module}} module.
|
|
3
|
+
*
|
|
4
|
+
* This file is imported automatically at boot, so whatever it registers is
|
|
5
|
+
* available to the whole application. Use this to plug implementations (e.g.
|
|
6
|
+
* auth, payments) into the app - not to export the module's API to other
|
|
7
|
+
* modules (that is what facade.ts is for):
|
|
8
|
+
*
|
|
9
|
+
* import { registerProvider } from "@/framework/facade.js";
|
|
10
|
+
*
|
|
11
|
+
* registerProvider("{{module}}", { ... });
|
|
12
|
+
*
|
|
13
|
+
* A module with more than one provider keeps them in providers/*.ts instead -
|
|
14
|
+
* those files are imported at boot too.
|
|
15
|
+
*
|
|
16
|
+
* Rule: a module never imports another module's provider implementation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { registerProvider } from "@/framework/facade.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why: Plugs the {{name}} implementation into the application.
|
|
5
|
+
* When: Imported automatically at boot from each module's providers folder.
|
|
6
|
+
* Where: src/modules/{{module}}/providers/{{name}}.ts
|
|
7
|
+
*
|
|
8
|
+
* Reach it from anywhere in the app:
|
|
9
|
+
*
|
|
10
|
+
* import { getProvider } from "@/framework/facade.js";
|
|
11
|
+
* const {{camel}} = getProvider<typeof {{camel}}Provider>("{{name}}");
|
|
12
|
+
*
|
|
13
|
+
* Rule: a provider never imports another module's provider implementation.
|
|
14
|
+
*/
|
|
15
|
+
export const {{camel}}Provider = {
|
|
16
|
+
async handle(input: unknown) {
|
|
17
|
+
return input;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
registerProvider("{{name}}", {{camel}}Provider);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { registerProvider } from "@/framework/facade.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why: Plugs the {{name}} implementation into the application.
|
|
5
|
+
* When: Imported automatically at boot from src/providers/.
|
|
6
|
+
* Where: src/providers/{{name}}.ts
|
|
7
|
+
*
|
|
8
|
+
* Reach it from anywhere in the app:
|
|
9
|
+
*
|
|
10
|
+
* import { getProvider } from "@/framework/facade.js";
|
|
11
|
+
* const {{camel}} = getProvider<typeof {{camel}}Provider>("{{name}}");
|
|
12
|
+
*
|
|
13
|
+
* Rule: a provider never imports another provider's implementation.
|
|
14
|
+
*/
|
|
15
|
+
export const {{camel}}Provider = {
|
|
16
|
+
async handle(input: unknown) {
|
|
17
|
+
return input;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
registerProvider("{{name}}", {{camel}}Provider);
|
|
@@ -89,7 +89,11 @@ const COMMAND_GROUPS = [
|
|
|
89
89
|
// `module:make-middleware` writes inside one module and applies only to it.
|
|
90
90
|
// In the same section the two look interchangeable, which is exactly the
|
|
91
91
|
// mistake a developer is about to make.
|
|
92
|
-
|
|
92
|
+
//
|
|
93
|
+
// `provider:make` follows the same split: it writes to src/providers and
|
|
94
|
+
// applies to the whole app, `module:make-provider` writes inside one
|
|
95
|
+
// module and applies only to it.
|
|
96
|
+
commands: ["middleware:make", "provider:make"]
|
|
93
97
|
},
|
|
94
98
|
{
|
|
95
99
|
title: "Generators - modules",
|
|
@@ -107,6 +111,7 @@ const COMMAND_GROUPS = [
|
|
|
107
111
|
"module:make-test",
|
|
108
112
|
"module:make-helper",
|
|
109
113
|
"module:make-middleware",
|
|
114
|
+
"module:make-provider",
|
|
110
115
|
"module:make-type"
|
|
111
116
|
]
|
|
112
117
|
},
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
2
|
-
import { db } from "@/framework/database/connection.js";
|
|
3
1
|
import { dispatchEvent } from "@/framework/events/dispatcher.js";
|
|
2
|
+
import { resolveAuthProvider, resolveNotificationProvider } from "@/framework/providers/adapter.js";
|
|
3
|
+
import type { NotifiableTarget, NotificationRecord } from "@/framework/providers/contracts.js";
|
|
4
4
|
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
5
5
|
import { mail } from "@/framework/support/mail.js";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
|
|
7
|
+
let missingProviderWarned = false;
|
|
8
|
+
|
|
9
|
+
/** A numeric id (default auth), a string id (UUID/ULID), or an explicit target. */
|
|
10
|
+
export type NotificationTarget = number | string | NotifiableTarget;
|
|
8
11
|
|
|
9
12
|
/** Extra JSON metadata attached to the notification (e.g. entity IDs, links). */
|
|
10
13
|
export type NotificationOptions = {
|
|
@@ -22,36 +25,62 @@ export type NotificationOptions = {
|
|
|
22
25
|
broadcast?: boolean;
|
|
23
26
|
/** Email delivery options */
|
|
24
27
|
mail?: {
|
|
28
|
+
/** Explicit recipient. Skips the auth provider contact lookup. */
|
|
29
|
+
to?: string;
|
|
25
30
|
subject?: string;
|
|
26
31
|
html?: string;
|
|
27
32
|
};
|
|
28
33
|
};
|
|
29
34
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
/** Turn the public target argument into the polymorphic owner pair. */
|
|
36
|
+
function normalizeTarget(target: NotificationTarget): Required<Pick<NotifiableTarget, "id">> & { type: string } {
|
|
37
|
+
if (target !== null && typeof target === "object") {
|
|
38
|
+
return {
|
|
39
|
+
type: String(target.type || "user").trim() || "user",
|
|
40
|
+
id: target.id
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { type: "user", id: target as number | string };
|
|
44
|
+
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Why: Persists a notification through the registered provider.
|
|
48
|
+
* When: A feature wants to record something for a user (or another notifiable).
|
|
49
|
+
* Where: Application code, through the framework facade.
|
|
50
|
+
* How: The provider owns storage; broadcasting and email stay here so a custom
|
|
51
|
+
* store cannot silently disable them. A missing provider is reported once
|
|
52
|
+
* and returns `null` instead of throwing, matching the framework's
|
|
53
|
+
* "optional module" behaviour.
|
|
54
|
+
*/
|
|
55
|
+
export async function notify(target: NotificationTarget, options: NotificationOptions): Promise<NotificationRecord | null> {
|
|
56
|
+
const provider = await resolveNotificationProvider();
|
|
57
|
+
if (!provider) {
|
|
58
|
+
if (!missingProviderWarned) {
|
|
59
|
+
missingProviderWarned = true;
|
|
60
|
+
console.warn(
|
|
61
|
+
'[notification] skipped: no notification provider is registered. Run "bun maker module:make-notification notification" then "bun maker module:migrate notification", or register a provider in src/providers/.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
43
66
|
|
|
44
|
-
const
|
|
45
|
-
const normalized =
|
|
67
|
+
const normalizedTarget = normalizeTarget(target);
|
|
68
|
+
const normalized = await provider.create(normalizedTarget, {
|
|
69
|
+
type: options.type,
|
|
70
|
+
title: options.title,
|
|
71
|
+
body: options.body ?? null,
|
|
72
|
+
link: options.link ?? null,
|
|
73
|
+
data: options.data ?? null
|
|
74
|
+
});
|
|
46
75
|
|
|
47
76
|
if (options.broadcast) {
|
|
48
77
|
await dispatchEvent("notification.created", normalized, {
|
|
49
|
-
broadcast: { users: [
|
|
78
|
+
broadcast: { users: [normalizedTarget.id] }
|
|
50
79
|
});
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
if (options.mail) {
|
|
54
|
-
await sendMailChannel(
|
|
83
|
+
await sendMailChannel(normalizedTarget, normalized, options);
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
return normalized;
|
|
@@ -59,13 +88,23 @@ export async function notify(userId: number, options: NotificationOptions) {
|
|
|
59
88
|
|
|
60
89
|
/** Deliver an email notification, either queued (if Redis is available) or direct. */
|
|
61
90
|
async function sendMailChannel(
|
|
62
|
-
|
|
63
|
-
notification:
|
|
91
|
+
target: { type: string; id: number | string },
|
|
92
|
+
notification: NotificationRecord,
|
|
64
93
|
options: NotificationOptions
|
|
65
94
|
) {
|
|
66
|
-
|
|
95
|
+
let to: string | null = options.mail?.to || null;
|
|
67
96
|
|
|
68
|
-
if (!
|
|
97
|
+
if (!to) {
|
|
98
|
+
try {
|
|
99
|
+
const provider = await resolveAuthProvider();
|
|
100
|
+
if (provider?.getUserContact) {
|
|
101
|
+
const contact = await provider.getUserContact(target.id);
|
|
102
|
+
if (contact?.email) to = contact.email as string;
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!to) return;
|
|
69
108
|
|
|
70
109
|
const subject = options.mail?.subject || notification.title;
|
|
71
110
|
const html = options.mail?.html || defaultMailHtml(notification.title, notification.body);
|
|
@@ -74,14 +113,14 @@ async function sendMailChannel(
|
|
|
74
113
|
await dispatchEvent(
|
|
75
114
|
"notification:mail",
|
|
76
115
|
{
|
|
77
|
-
to
|
|
116
|
+
to,
|
|
78
117
|
subject,
|
|
79
118
|
html
|
|
80
119
|
},
|
|
81
120
|
{ queue: "mail" }
|
|
82
121
|
);
|
|
83
122
|
} else {
|
|
84
|
-
await mail.sendMail({ to
|
|
123
|
+
await mail.sendMail({ to, subject, html });
|
|
85
124
|
}
|
|
86
125
|
}
|
|
87
126
|
|
|
@@ -93,24 +132,3 @@ function defaultMailHtml(title: string, body: string | null) {
|
|
|
93
132
|
function escapeHtml(str: string) {
|
|
94
133
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
95
134
|
}
|
|
96
|
-
|
|
97
|
-
/** Convert a DB row into the normalized response shape, parsing the `data` JSON column. */
|
|
98
|
-
function normalize(row: typeof notifications.$inferSelect) {
|
|
99
|
-
let parsed: unknown = null;
|
|
100
|
-
if (row.data) {
|
|
101
|
-
try {
|
|
102
|
-
parsed = JSON.parse(row.data);
|
|
103
|
-
} catch {}
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
id: row.id,
|
|
107
|
-
userId: row.userId,
|
|
108
|
-
type: row.type,
|
|
109
|
-
title: row.title,
|
|
110
|
-
body: row.body,
|
|
111
|
-
data: parsed,
|
|
112
|
-
link: row.link,
|
|
113
|
-
readAt: row.readAt?.toISOString() || null,
|
|
114
|
-
createdAt: row.createdAt.toISOString()
|
|
115
|
-
};
|
|
116
|
-
}
|