@digimuza/system-core 0.2.0-rc.2 → 0.3.0-rc.1

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 (151) hide show
  1. package/INTEGRATION.md +209 -3
  2. package/README.md +23 -0
  3. package/dist/ai/api.d.ts +120 -0
  4. package/dist/ai/api.d.ts.map +1 -0
  5. package/dist/ai/api.js +446 -0
  6. package/dist/ai/api.js.map +1 -0
  7. package/dist/ai/card.d.ts +27 -0
  8. package/dist/ai/card.d.ts.map +1 -0
  9. package/dist/ai/card.js +84 -0
  10. package/dist/ai/card.js.map +1 -0
  11. package/dist/ai/contract.d.ts +364 -0
  12. package/dist/ai/contract.d.ts.map +1 -0
  13. package/dist/ai/contract.js +101 -0
  14. package/dist/ai/contract.js.map +1 -0
  15. package/dist/ai/display.d.ts +20 -0
  16. package/dist/ai/display.d.ts.map +1 -0
  17. package/dist/ai/display.js +35 -0
  18. package/dist/ai/display.js.map +1 -0
  19. package/dist/ai/index.d.ts +7 -0
  20. package/dist/ai/index.d.ts.map +1 -0
  21. package/dist/ai/index.js +10 -0
  22. package/dist/ai/index.js.map +1 -0
  23. package/dist/ai/manifest.d.ts +25 -0
  24. package/dist/ai/manifest.d.ts.map +1 -0
  25. package/dist/ai/manifest.js +15 -0
  26. package/dist/ai/manifest.js.map +1 -0
  27. package/dist/ai/runner.d.ts +71 -0
  28. package/dist/ai/runner.d.ts.map +1 -0
  29. package/dist/ai/runner.js +246 -0
  30. package/dist/ai/runner.js.map +1 -0
  31. package/dist/ai/tools.d.ts +42 -0
  32. package/dist/ai/tools.d.ts.map +1 -0
  33. package/dist/ai/tools.js +117 -0
  34. package/dist/ai/tools.js.map +1 -0
  35. package/dist/audit/csv.d.ts +31 -0
  36. package/dist/audit/csv.d.ts.map +1 -0
  37. package/dist/audit/csv.js +39 -0
  38. package/dist/audit/csv.js.map +1 -0
  39. package/dist/audit/index.d.ts +5 -0
  40. package/dist/audit/index.d.ts.map +1 -0
  41. package/dist/audit/index.js +6 -0
  42. package/dist/audit/index.js.map +1 -0
  43. package/dist/convex/functions.d.ts +39 -0
  44. package/dist/convex/functions.d.ts.map +1 -1
  45. package/dist/convex/functions.js +38 -6
  46. package/dist/convex/functions.js.map +1 -1
  47. package/dist/convex/index.d.ts +1 -1
  48. package/dist/convex/index.d.ts.map +1 -1
  49. package/dist/convex/index.js.map +1 -1
  50. package/dist/core/access.d.ts +14 -0
  51. package/dist/core/access.d.ts.map +1 -1
  52. package/dist/core/access.js +62 -0
  53. package/dist/core/access.js.map +1 -1
  54. package/dist/core/audit-module.d.ts +5 -0
  55. package/dist/core/audit-module.d.ts.map +1 -0
  56. package/dist/core/audit-module.js +25 -0
  57. package/dist/core/audit-module.js.map +1 -0
  58. package/dist/core/audit.d.ts +31 -0
  59. package/dist/core/audit.d.ts.map +1 -1
  60. package/dist/core/audit.js +46 -2
  61. package/dist/core/audit.js.map +1 -1
  62. package/dist/core/catalog.d.ts +41 -0
  63. package/dist/core/catalog.d.ts.map +1 -1
  64. package/dist/core/catalog.js +45 -0
  65. package/dist/core/catalog.js.map +1 -1
  66. package/dist/core/context.d.ts +8 -2
  67. package/dist/core/context.d.ts.map +1 -1
  68. package/dist/core/context.js +4 -1
  69. package/dist/core/context.js.map +1 -1
  70. package/dist/core/index.d.ts +2 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js +2 -0
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/notify.d.ts +37 -0
  75. package/dist/core/notify.d.ts.map +1 -0
  76. package/dist/core/notify.js +16 -0
  77. package/dist/core/notify.js.map +1 -0
  78. package/dist/core/roles.d.ts.map +1 -1
  79. package/dist/core/roles.js +5 -1
  80. package/dist/core/roles.js.map +1 -1
  81. package/dist/core/system-modules.d.ts +4 -0
  82. package/dist/core/system-modules.d.ts.map +1 -1
  83. package/dist/core/system-modules.js +11 -0
  84. package/dist/core/system-modules.js.map +1 -1
  85. package/dist/finance/ai-tools.d.ts +55 -0
  86. package/dist/finance/ai-tools.d.ts.map +1 -0
  87. package/dist/finance/ai-tools.js +65 -0
  88. package/dist/finance/ai-tools.js.map +1 -0
  89. package/dist/finance/index.d.ts +87 -1
  90. package/dist/finance/index.d.ts.map +1 -1
  91. package/dist/finance/index.js +92 -38
  92. package/dist/finance/index.js.map +1 -1
  93. package/dist/finance/search.d.ts +15 -0
  94. package/dist/finance/search.d.ts.map +1 -0
  95. package/dist/finance/search.js +51 -0
  96. package/dist/finance/search.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/modules/index.d.ts +1 -1
  102. package/dist/modules/index.d.ts.map +1 -1
  103. package/dist/modules/index.js +1 -1
  104. package/dist/modules/index.js.map +1 -1
  105. package/dist/notifications/api.d.ts +130 -0
  106. package/dist/notifications/api.d.ts.map +1 -0
  107. package/dist/notifications/api.js +384 -0
  108. package/dist/notifications/api.js.map +1 -0
  109. package/dist/notifications/contract.d.ts +132 -0
  110. package/dist/notifications/contract.d.ts.map +1 -0
  111. package/dist/notifications/contract.js +59 -0
  112. package/dist/notifications/contract.js.map +1 -0
  113. package/dist/notifications/index.d.ts +6 -0
  114. package/dist/notifications/index.d.ts.map +1 -0
  115. package/dist/notifications/index.js +9 -0
  116. package/dist/notifications/index.js.map +1 -0
  117. package/dist/search/index.d.ts +95 -0
  118. package/dist/search/index.d.ts.map +1 -0
  119. package/dist/search/index.js +151 -0
  120. package/dist/search/index.js.map +1 -0
  121. package/package.json +41 -2
  122. package/src/ai/api.ts +535 -0
  123. package/src/ai/card.ts +91 -0
  124. package/src/ai/contract.ts +245 -0
  125. package/src/ai/display.ts +38 -0
  126. package/src/ai/index.ts +9 -0
  127. package/src/ai/manifest.ts +16 -0
  128. package/src/ai/runner.ts +275 -0
  129. package/src/ai/tools.ts +128 -0
  130. package/src/audit/csv.ts +60 -0
  131. package/src/audit/index.ts +7 -0
  132. package/src/convex/functions.ts +54 -7
  133. package/src/convex/index.ts +1 -1
  134. package/src/core/access.ts +55 -1
  135. package/src/core/audit-module.ts +26 -0
  136. package/src/core/audit.ts +68 -2
  137. package/src/core/catalog.ts +63 -0
  138. package/src/core/context.ts +13 -2
  139. package/src/core/index.ts +2 -0
  140. package/src/core/notify.ts +52 -0
  141. package/src/core/roles.ts +5 -1
  142. package/src/core/system-modules.ts +12 -0
  143. package/src/finance/ai-tools.ts +68 -0
  144. package/src/finance/index.ts +119 -42
  145. package/src/finance/search.ts +58 -0
  146. package/src/index.ts +2 -0
  147. package/src/modules/index.ts +3 -0
  148. package/src/notifications/api.ts +477 -0
  149. package/src/notifications/contract.ts +142 -0
  150. package/src/notifications/index.ts +8 -0
  151. package/src/search/index.ts +198 -0
@@ -0,0 +1,477 @@
1
+ // Notifications (DIG-74): the per-transaction notifier, `notify` for modules,
2
+ // and the Convex functions of `defineNotifications`.
3
+ //
4
+ // Access: notifications are the user's own data, guarded like the profile
5
+ // (`getMyProfile`): a human session plus `profile:own:read` (reads) or
6
+ // `profile:own:update` (changes). Every active user holds both through the
7
+ // protected, implicit Profile role, so no catalog migration is needed; API keys
8
+ // get FORBIDDEN (human-only permissions); suspended users get FORBIDDEN.
9
+ import { internalActionGeneric, internalMutationGeneric, makeFunctionReference, mutationGeneric, queryGeneric } from "convex/server"
10
+ import { v } from "convex/values"
11
+ import { isModuleEnabled, isSuperActor, requireHuman, usersWithPermission, type Actor, type UserActor } from "../core/access.js"
12
+ import { recordAudit } from "../core/audit.js"
13
+ import type { Catalog, NotificationTypeDefinition } from "../core/catalog.js"
14
+ import type { SystemContext } from "../core/context.js"
15
+ import { notFound, validation, type SystemError } from "../core/errors.js"
16
+ import type { Notifier, NotifyRequest, NotificationParams } from "../core/notify.js"
17
+ import { P } from "../core/system-modules.js"
18
+ import { toConvexError, type ConvexNotificationsHook, type ConvexSystem, type DbCtx } from "../convex/functions.js"
19
+ import {
20
+ NOTIFICATION_MARK_ALL_BATCH,
21
+ NOTIFICATION_PARAMS_MAX,
22
+ NOTIFICATION_UNREAD_CAP,
23
+ NOTIFICATIONS_AUDIT,
24
+ NOTIFY_MAX_RECIPIENTS,
25
+ type DeliverNotificationEmail,
26
+ type EmailStatus,
27
+ type NotificationEmailJob as EmailJob,
28
+ type NotificationId,
29
+ type NotificationPreferenceView,
30
+ type NotificationView,
31
+ type RenderNotificationEmail,
32
+ } from "./contract.js"
33
+
34
+ const DEFAULT_LOCALE = "lt"
35
+ const SAFE_HREF = /^\/(?!\/)[^\s\\]*$/
36
+
37
+ // --- Rows & loosely typed db (the app schema contains `notificationTables`) -------------------
38
+
39
+ interface NotificationDoc {
40
+ _id: NotificationId
41
+ _creationTime: number
42
+ userId: string
43
+ type: string
44
+ params: NotificationParams
45
+ href?: string
46
+ inApp: boolean
47
+ readAt?: number
48
+ createdAt: number
49
+ emailStatus: EmailStatus
50
+ operationId: string
51
+ }
52
+
53
+ interface PreferenceDoc {
54
+ _id: string
55
+ userId: string
56
+ type: string
57
+ inApp: boolean
58
+ email: boolean
59
+ updatedAt: number
60
+ }
61
+
62
+ type Docs = { notifications: NotificationDoc; notificationPreferences: PreferenceDoc }
63
+ type Table = keyof Docs
64
+ type IndexQ = { eq(field: string, value: unknown): IndexQ }
65
+ interface Ordered<D> {
66
+ take(n: number): Promise<D[]>
67
+ unique(): Promise<D | null>
68
+ paginate(o: { cursor: string | null; numItems: number }): Promise<{ page: D[]; continueCursor: string; isDone: boolean }>
69
+ }
70
+ interface Query<D> extends Ordered<D> {
71
+ order(o: "asc" | "desc"): Ordered<D>
72
+ }
73
+ interface Db {
74
+ query<T extends Table>(t: T): { withIndex(i: string, f: (q: IndexQ) => unknown): Query<Docs[T]> }
75
+ get<T extends Table>(t: T, id: string): Promise<Docs[T] | null>
76
+ insert<T extends Table>(t: T, doc: Omit<Docs[T], "_id" | "_creationTime">): Promise<Docs[T]["_id"]>
77
+ patch<T extends Table>(t: T, id: string, doc: Partial<Omit<Docs[T], "_id" | "_creationTime">>): Promise<void>
78
+ }
79
+ type Scheduler = { runAfter(ms: number, ref: unknown, args: Record<string, unknown>): Promise<unknown> }
80
+ const db = (ctx: { db: unknown }) => ctx.db as Db
81
+
82
+ // --- Install (the system hook) -----------------------------------------------------------------
83
+
84
+ export interface NotificationsHookOptions {
85
+ /** Convex module path (relative to convex/) of the notifications functions. Default "notifications". */
86
+ functionsModule?: string
87
+ /**
88
+ * Whether emails can be sent at all. Set `false` when the app has no
89
+ * `deliverEmail`: rows then go straight to `skipped` without scheduling an action.
90
+ * Default true (the action marks `skipped` if an adapter is missing).
91
+ */
92
+ email?: boolean
93
+ /** App-local notification types on top of those declared in module manifests. */
94
+ types?: readonly NotificationTypeDefinition[]
95
+ /** Locale for users without a profile locale. Default "lt". */
96
+ defaultLocale?: string
97
+ }
98
+
99
+ /** Created by `notificationsHook`; pass it to `defineConvexSystem({ notifications })`. */
100
+ export class NotificationsInstall implements ConvexNotificationsHook {
101
+ readonly functionsModule: string
102
+ readonly email: boolean
103
+ readonly defaultLocale: string
104
+ private readonly extraTypes: readonly NotificationTypeDefinition[]
105
+ private readonly cache = new WeakMap<Catalog, ReadonlyMap<string, NotificationTypeDefinition>>()
106
+
107
+ constructor(options: NotificationsHookOptions = {}) {
108
+ this.functionsModule = options.functionsModule ?? "notifications"
109
+ this.email = options.email ?? true
110
+ this.defaultLocale = options.defaultLocale ?? DEFAULT_LOCALE
111
+ this.extraTypes = options.types ?? []
112
+ }
113
+
114
+ /** All notification types of the system: module manifests + `types`. Throws on unknown modules or collisions. */
115
+ typesOf(catalog: Catalog): ReadonlyMap<string, NotificationTypeDefinition> {
116
+ let map = this.cache.get(catalog)
117
+ if (map) return map
118
+ const out = new Map(catalog.notificationTypes)
119
+ for (const t of this.extraTypes) {
120
+ if (!catalog.modules.has(t.moduleId)) throw new Error(`notificationsHook: type ${t.id} belongs to unknown module ${t.moduleId}`)
121
+ if (out.has(t.id)) throw new Error(`notificationsHook: duplicate notification type ${t.id}`)
122
+ out.set(t.id, t)
123
+ }
124
+ map = out
125
+ this.cache.set(catalog, map)
126
+ return map
127
+ }
128
+
129
+ deliverRef() {
130
+ return makeFunctionReference<"action">(`${this.functionsModule}:_deliverNotificationEmail`)
131
+ }
132
+
133
+ bind(ctx: DbCtx, sys: SystemContext): Notifier {
134
+ return new BoundNotifier(this, ctx, sys)
135
+ }
136
+ }
137
+
138
+ class BoundNotifier implements Notifier {
139
+ constructor(
140
+ readonly install: NotificationsInstall,
141
+ private readonly ctx: DbCtx,
142
+ private readonly sys: SystemContext,
143
+ ) {}
144
+
145
+ notify(request: NotifyRequest): Promise<number> {
146
+ return send(this.ctx, this.sys, this.install, request)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The system hook that installs notifications:
152
+ *
153
+ * defineConvexSystem({ modules, notifications: notificationsHook() })
154
+ *
155
+ * Every mutation's SystemContext then carries a notifier, so core services
156
+ * (role assignment) and modules (finance) notify through `notifyIfInstalled`.
157
+ */
158
+ export function notificationsHook(options: NotificationsHookOptions = {}): NotificationsInstall {
159
+ return new NotificationsInstall(options)
160
+ }
161
+
162
+ // --- Sending -----------------------------------------------------------------------------------
163
+
164
+ const isPlainObject = (x: unknown): x is Record<string, unknown> => Object.prototype.toString.call(x) === "[object Object]"
165
+
166
+ function fail(sys: SystemContext, e: SystemError): never {
167
+ e.operationId ??= sys.operationId
168
+ throw e
169
+ }
170
+
171
+ /** The acting human (an agent acts for its owner). */
172
+ const actingUserId = (actor: NotifyRequest["actor"]) => (!actor || actor === "system" ? null : actor.kind === "user" ? actor.userId : actor.ownerId)
173
+
174
+ async function recipients(sys: SystemContext, request: NotifyRequest): Promise<string[]> {
175
+ const to = request.to
176
+ if ("userIds" in to) {
177
+ const ids = [...new Set(to.userIds)]
178
+ if (ids.length > NOTIFY_MAX_RECIPIENTS) fail(sys, validation("too_many_recipients", ["to"]))
179
+ const out: string[] = []
180
+ for (const id of ids) {
181
+ const user = await sys.store.get("users", id)
182
+ if (user?.status === "active") out.push(id)
183
+ }
184
+ return out
185
+ }
186
+ if (!sys.def.catalog.permissions.has(to.permission)) fail(sys, validation("unknown_permission", ["to"]))
187
+ const exclude = request.includeActor ? null : actingUserId(request.actor)
188
+ const { userIds, truncated } = await usersWithPermission(sys, to.permission, NOTIFY_MAX_RECIPIENTS + 1)
189
+ const out = userIds.filter((id) => id !== exclude).slice(0, NOTIFY_MAX_RECIPIENTS)
190
+ if (truncated || userIds.filter((id) => id !== exclude).length > NOTIFY_MAX_RECIPIENTS) {
191
+ sys.logger.warn("notify_recipients_capped", { type: request.type, max: NOTIFY_MAX_RECIPIENTS })
192
+ }
193
+ return out
194
+ }
195
+
196
+ async function preference(ctx: { db: unknown }, userId: string, type: string): Promise<PreferenceDoc | null> {
197
+ return db(ctx)
198
+ .query("notificationPreferences")
199
+ .withIndex("by_user_type", (q) => q.eq("userId", userId).eq("type", type))
200
+ .unique()
201
+ }
202
+
203
+ /**
204
+ * Validates, resolves recipients, applies each recipient's preferences, inserts
205
+ * rows and schedules emails. Returns the number of recipients notified.
206
+ */
207
+ async function send(ctx: { db: unknown; scheduler?: unknown }, sys: SystemContext, install: NotificationsInstall, request: NotifyRequest): Promise<number> {
208
+ const type = install.typesOf(sys.def.catalog).get(request.type)
209
+ if (!type) fail(sys, validation("unknown_notification_type", ["type"]))
210
+ if (!isPlainObject(request.params)) fail(sys, validation("invalid_params", ["params"]))
211
+ let json: string
212
+ try {
213
+ json = JSON.stringify(request.params)
214
+ } catch {
215
+ fail(sys, validation("invalid_params", ["params"]))
216
+ }
217
+ if (json.length > NOTIFICATION_PARAMS_MAX) fail(sys, validation("params_too_large", ["params"]))
218
+ const params = JSON.parse(json) as NotificationParams
219
+ if (request.href !== undefined && (request.href.length > 500 || !SAFE_HREF.test(request.href))) fail(sys, validation("invalid_href", ["href"]))
220
+ const writer = db(ctx) as Partial<Db>
221
+ if (typeof writer.insert !== "function") throw new Error("notify must run inside a mutation")
222
+
223
+ let userIds = await recipients(sys, request)
224
+ // A disabled module's notifications reach only those who can still use it (an effective Super).
225
+ if (!isModuleEnabled(sys, await sys.store.get("modules", type.moduleId))) {
226
+ const supers: string[] = []
227
+ for (const id of userIds) if (await isSuperActor(sys, { kind: "user", userId: id })) supers.push(id)
228
+ userIds = supers
229
+ }
230
+
231
+ const scheduler = (ctx as { scheduler?: Scheduler }).scheduler
232
+ const now = sys.now()
233
+ let count = 0
234
+ for (const userId of userIds) {
235
+ const pref = await preference(ctx, userId, type.id)
236
+ const inApp = pref?.inApp ?? type.defaultChannels.inApp
237
+ const email = pref?.email ?? type.defaultChannels.email
238
+ if (!inApp && !email) continue
239
+ const emailStatus: EmailStatus = !email ? "none" : install.email && scheduler ? "pending" : "skipped"
240
+ const row: Omit<NotificationDoc, "_id" | "_creationTime"> = { userId, type: type.id, params, inApp, createdAt: now, emailStatus, operationId: sys.operationId }
241
+ if (request.href !== undefined) row.href = request.href
242
+ const id = await db(ctx).insert("notifications", row)
243
+ if (emailStatus === "pending") await scheduler!.runAfter(0, install.deliverRef(), { notificationId: id })
244
+ count++
245
+ }
246
+ sys.logger.info("notifications_sent", { type: type.id, count })
247
+ return count
248
+ }
249
+
250
+ /**
251
+ * Sends a notification from any mutation of a module:
252
+ *
253
+ * await notify(ctx, sys, { to: { permission: "crm:lead:assign" }, type: "crm.lead_assigned", params: { name }, href: "/app/crm", actor })
254
+ *
255
+ * `to.permission` → active users holding it now (via `decide`; module state and
256
+ * role view apply), without the acting user unless `includeActor`. At most
257
+ * NOTIFY_MAX_RECIPIENTS. Each recipient's preferences apply; emails are scheduled
258
+ * only for those with email on. Returns the count; 0 when notifications are not
259
+ * installed. Throws VALIDATION for an unknown type, bad params or href.
260
+ * (Core services and package modules use the swallowing `notifyIfInstalled`.)
261
+ */
262
+ export async function notify(ctx: { db: unknown; scheduler?: unknown }, sys: SystemContext, request: NotifyRequest): Promise<number> {
263
+ const notifier = sys.notifier
264
+ if (!(notifier instanceof BoundNotifier)) return 0
265
+ return send(ctx, sys, notifier.install, request)
266
+ }
267
+
268
+ // --- Public functions ----------------------------------------------------------------------------
269
+
270
+ export interface NotificationsOptions {
271
+ /** Renders subject and text from type + params in the recipient's locale. */
272
+ renderEmail?: RenderNotificationEmail
273
+ /** Sends the email. Without it (or without `renderEmail`) email stays `skipped`. */
274
+ deliverEmail?: DeliverNotificationEmail
275
+ }
276
+
277
+ const PAGE_DEFAULT = 20
278
+ const PAGE_MAX = 100
279
+ const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.string()) }
280
+
281
+ /**
282
+ * The notifications Convex functions, bound to the app's system (which must have
283
+ * `notifications: notificationsHook()`):
284
+ *
285
+ * // convex/notifications.ts
286
+ * export const { listNotifications, …, _deliverNotificationEmail, _claimNotificationEmail, _recordNotificationEmail } =
287
+ * defineNotifications(system, { renderEmail, deliverEmail }).functions
288
+ */
289
+ export function defineNotifications(system: ConvexSystem, options: NotificationsOptions = {}) {
290
+ const install = system.notifications
291
+ if (!(install instanceof NotificationsInstall)) {
292
+ throw new Error("defineNotifications: install the hook first: defineConvexSystem({ notifications: notificationsHook() })")
293
+ }
294
+ const types = install.typesOf(system.definition.catalog)
295
+ const ref = <T extends "query" | "mutation">(name: string) => makeFunctionReference<T>(`${install.functionsModule}:${name}`)
296
+ const canEmail = !!options.renderEmail && !!options.deliverEmail
297
+
298
+ /** Own data, like the profile: human session + profile:own:read / profile:own:update. */
299
+ async function guard(ctx: DbCtx, a: { apiKey?: string | undefined; operationId?: string | undefined }, permission: string): Promise<{ sys: SystemContext; actor: UserActor }> {
300
+ const { sys, actor } = await system.requirePermission(ctx, permission, { apiKey: a.apiKey, operationId: a.operationId })
301
+ try {
302
+ return { sys, actor: requireHuman(sys, actor) }
303
+ } catch (e) {
304
+ throw toConvexError(e, sys.operationId)
305
+ }
306
+ }
307
+
308
+ const moduleOf = (type: string) => types.get(type)?.moduleId ?? type.split(".", 1)[0]!
309
+
310
+ const view = (n: NotificationDoc): NotificationView => ({
311
+ id: n._id,
312
+ type: n.type,
313
+ moduleId: moduleOf(n.type),
314
+ params: n.params,
315
+ href: n.href ?? null,
316
+ readAt: n.readAt ?? null,
317
+ createdAt: n.createdAt,
318
+ emailStatus: n.emailStatus,
319
+ })
320
+
321
+ /** Types whose module the actor can use now (an effective Super sees disabled modules too). */
322
+ async function visibleTypes(sys: SystemContext, actor: Actor): Promise<NotificationTypeDefinition[]> {
323
+ const isSuper = await isSuperActor(sys, actor)
324
+ const out: NotificationTypeDefinition[] = []
325
+ for (const t of types.values()) {
326
+ if (isSuper || isModuleEnabled(sys, await sys.store.get("modules", t.moduleId))) out.push(t)
327
+ }
328
+ return out.sort((a, b) => a.moduleId.localeCompare(b.moduleId) || a.id.localeCompare(b.id))
329
+ }
330
+
331
+ const prefView = (t: NotificationTypeDefinition, pref: PreferenceDoc | null): NotificationPreferenceView => ({
332
+ type: t.id,
333
+ moduleId: t.moduleId,
334
+ description: t.description ?? null,
335
+ params: [...(t.params ?? [])],
336
+ defaultChannels: { inApp: t.defaultChannels.inApp, email: t.defaultChannels.email },
337
+ inApp: pref?.inApp ?? t.defaultChannels.inApp,
338
+ email: pref?.email ?? t.defaultChannels.email,
339
+ customized: !!pref,
340
+ })
341
+
342
+ const unread = (ctx: { db: unknown }, userId: string) =>
343
+ db(ctx)
344
+ .query("notifications")
345
+ .withIndex("by_user_unread", (q) => q.eq("userId", userId).eq("inApp", true).eq("readAt", undefined))
346
+
347
+ const functions = {
348
+ // --- Public ------------------------------------------------------------------------------
349
+
350
+ listNotifications: queryGeneric({
351
+ args: { ...actorArgs, cursor: v.optional(v.union(v.string(), v.null())), limit: v.optional(v.number()), unreadOnly: v.optional(v.boolean()) },
352
+ handler: async (ctx, a): Promise<{ items: NotificationView[]; cursor: string | null; done: boolean }> => {
353
+ const { actor } = await guard(ctx, a, P.profileRead)
354
+ const numItems = Math.min(Math.max(1, Math.floor(a.limit ?? PAGE_DEFAULT)), PAGE_MAX)
355
+ const q = a.unreadOnly ? unread(ctx, actor.userId) : db(ctx).query("notifications").withIndex("by_user", (q) => q.eq("userId", actor.userId).eq("inApp", true))
356
+ const res = await q.order("desc").paginate({ cursor: a.cursor ?? null, numItems })
357
+ return { items: res.page.map(view), cursor: res.isDone ? null : res.continueCursor, done: res.isDone }
358
+ },
359
+ }),
360
+
361
+ unreadNotificationCount: queryGeneric({
362
+ args: { ...actorArgs },
363
+ handler: async (ctx, a): Promise<number> => {
364
+ const { actor } = await guard(ctx, a, P.profileRead)
365
+ return (await unread(ctx, actor.userId).take(NOTIFICATION_UNREAD_CAP)).length
366
+ },
367
+ }),
368
+
369
+ markNotificationRead: mutationGeneric({
370
+ args: { ...actorArgs, notificationId: v.id("notifications") },
371
+ handler: async (ctx, a): Promise<{ read: boolean }> => {
372
+ const { sys, actor } = await guard(ctx, a, P.profileUpdate)
373
+ const row = await db(ctx).get("notifications", a.notificationId)
374
+ // Someone else's notification and a missing one look the same.
375
+ if (!row || row.userId !== actor.userId) throw toConvexError(notFound("notification_not_found"), sys.operationId)
376
+ if (row.readAt !== undefined) return { read: false }
377
+ await db(ctx).patch("notifications", row._id, { readAt: sys.now() })
378
+ return { read: true }
379
+ },
380
+ }),
381
+
382
+ markAllNotificationsRead: mutationGeneric({
383
+ args: { ...actorArgs },
384
+ handler: async (ctx, a): Promise<{ marked: number; done: boolean }> => {
385
+ const { sys, actor } = await guard(ctx, a, P.profileUpdate)
386
+ const rows = await unread(ctx, actor.userId).take(NOTIFICATION_MARK_ALL_BATCH + 1)
387
+ const batch = rows.slice(0, NOTIFICATION_MARK_ALL_BATCH)
388
+ const now = sys.now()
389
+ for (const r of batch) await db(ctx).patch("notifications", r._id, { readAt: now })
390
+ return { marked: batch.length, done: rows.length <= NOTIFICATION_MARK_ALL_BATCH }
391
+ },
392
+ }),
393
+
394
+ getNotificationPreferences: queryGeneric({
395
+ args: { ...actorArgs },
396
+ handler: async (ctx, a): Promise<NotificationPreferenceView[]> => {
397
+ const { sys, actor } = await guard(ctx, a, P.profileRead)
398
+ const out: NotificationPreferenceView[] = []
399
+ for (const t of await visibleTypes(sys, actor)) out.push(prefView(t, await preference(ctx, actor.userId, t.id)))
400
+ return out
401
+ },
402
+ }),
403
+
404
+ setNotificationPreference: mutationGeneric({
405
+ args: { ...actorArgs, type: v.string(), inApp: v.boolean(), email: v.boolean() },
406
+ handler: async (ctx, a): Promise<NotificationPreferenceView> => {
407
+ const { sys, actor } = await guard(ctx, a, P.profileUpdate)
408
+ const type = (await visibleTypes(sys, actor)).find((t) => t.id === a.type)
409
+ if (!type) throw toConvexError(validation("unknown_notification_type", ["type"]), sys.operationId)
410
+ const existing = await preference(ctx, actor.userId, type.id)
411
+ const now = sys.now()
412
+ if (existing) await db(ctx).patch("notificationPreferences", existing._id, { inApp: a.inApp, email: a.email, updatedAt: now })
413
+ else await db(ctx).insert("notificationPreferences", { userId: actor.userId, type: type.id, inApp: a.inApp, email: a.email, updatedAt: now })
414
+ await recordAudit(sys, actor, {
415
+ action: NOTIFICATIONS_AUDIT.preferenceUpdate,
416
+ targetType: "notification_type",
417
+ targetId: type.id,
418
+ changes: { inApp: a.inApp, email: a.email },
419
+ })
420
+ return prefView(type, { _id: existing?._id ?? "", userId: actor.userId, type: type.id, inApp: a.inApp, email: a.email, updatedAt: now })
421
+ },
422
+ }),
423
+
424
+ // --- Internal (email delivery) -------------------------------------------------------------
425
+
426
+ /** Scheduled by `notify` for each row with email `pending`. Never throws. */
427
+ _deliverNotificationEmail: internalActionGeneric({
428
+ args: { notificationId: v.id("notifications") },
429
+ handler: async (ctx, a): Promise<EmailStatus> => {
430
+ const job = (await ctx.runMutation(ref<"mutation">("_claimNotificationEmail"), { notificationId: a.notificationId })) as EmailJob | null
431
+ if (!job) return "skipped"
432
+ let ok = false
433
+ try {
434
+ const email = await options.renderEmail!(job.type, job.params, job.locale, { href: job.href, notificationId: a.notificationId })
435
+ if (!email || typeof email.subject !== "string" || typeof email.text !== "string") throw new Error("renderEmail returned no subject/text")
436
+ const res = await options.deliverEmail!({ to: job.to, subject: email.subject, text: email.text, type: job.type, notificationId: a.notificationId, operationId: job.operationId })
437
+ ok = res !== false
438
+ } catch (error) {
439
+ // The adapter's error may contain the address: log only its name.
440
+ system.definition.logger.warn("notification_email_failed", { notificationId: a.notificationId, type: job.type, errorName: error instanceof Error ? error.name : "unknown" })
441
+ }
442
+ await ctx.runMutation(ref<"mutation">("_recordNotificationEmail"), { notificationId: a.notificationId, ok })
443
+ return ok ? "sent" : "failed"
444
+ },
445
+ }),
446
+
447
+ /** → the email job, or null (and `skipped`) when there is nothing to send. */
448
+ _claimNotificationEmail: internalMutationGeneric({
449
+ args: { notificationId: v.id("notifications") },
450
+ handler: async (ctx, a): Promise<EmailJob | null> => {
451
+ const row = await db(ctx).get("notifications", a.notificationId)
452
+ if (!row || row.emailStatus !== "pending") return null
453
+ const { sys } = await system.context(ctx, { operationId: row.operationId })
454
+ const user = await sys.store.get("users", row.userId)
455
+ if (!canEmail || !types.has(row.type) || !user || user.status !== "active" || !user.emailVerified) {
456
+ await db(ctx).patch("notifications", row._id, { emailStatus: "skipped" })
457
+ return null
458
+ }
459
+ return { to: user.email, locale: user.locale ?? install.defaultLocale, type: row.type, params: row.params, href: row.href ?? null, operationId: row.operationId }
460
+ },
461
+ }),
462
+
463
+ _recordNotificationEmail: internalMutationGeneric({
464
+ args: { notificationId: v.id("notifications"), ok: v.boolean() },
465
+ handler: async (ctx, a): Promise<null> => {
466
+ const row = await db(ctx).get("notifications", a.notificationId)
467
+ if (row?.emailStatus === "pending") await db(ctx).patch("notifications", row._id, { emailStatus: a.ok ? "sent" : "failed" })
468
+ return null
469
+ },
470
+ }),
471
+ }
472
+
473
+ return { functions, types }
474
+ }
475
+
476
+
477
+ export type NotificationsSystem = ReturnType<typeof defineNotifications>
@@ -0,0 +1,142 @@
1
+ // Shared contract of `@digimuza/system-core/notifications` (DIG-74): tables,
2
+ // views, adapters and function names. No server-only imports: safe in Convex's
3
+ // default runtime and in the browser (types).
4
+ import { defineTable } from "convex/server"
5
+ import { v, type GenericId, type Infer } from "convex/values"
6
+ import type { NotificationParams } from "../core/notify.js"
7
+
8
+ export const emailStatus = v.union(v.literal("none"), v.literal("pending"), v.literal("sent"), v.literal("failed"), v.literal("skipped"))
9
+
10
+ /** Spread into the app schema next to `systemTables`. */
11
+ export const notificationTables = {
12
+ notifications: defineTable({
13
+ /** system-core user key of the recipient. Only they can see it. */
14
+ userId: v.string(),
15
+ /** Notification type id, e.g. `finance.invoice_awaiting_approval`. */
16
+ type: v.string(),
17
+ /** JSON params; the app renders the text (LT/EN) from them. */
18
+ params: v.any(),
19
+ /** App-relative link. */
20
+ href: v.optional(v.string()),
21
+ /** False for email-only rows (the user turned the in-app channel off): never listed. */
22
+ inApp: v.boolean(),
23
+ readAt: v.optional(v.number()),
24
+ createdAt: v.number(),
25
+ /** none: email off · pending: scheduled · sent · failed · skipped: no adapter, no verified address. */
26
+ emailStatus,
27
+ /** Operation that caused it (the business mutation's). */
28
+ operationId: v.string(),
29
+ })
30
+ .index("by_user", ["userId", "inApp", "createdAt"])
31
+ .index("by_user_unread", ["userId", "inApp", "readAt"]),
32
+
33
+ notificationPreferences: defineTable({
34
+ userId: v.string(),
35
+ type: v.string(),
36
+ inApp: v.boolean(),
37
+ email: v.boolean(),
38
+ updatedAt: v.number(),
39
+ }).index("by_user_type", ["userId", "type"]),
40
+ }
41
+
42
+ export type EmailStatus = Infer<typeof emailStatus>
43
+ export type NotificationId = GenericId<"notifications">
44
+
45
+ // --- Views returned by public functions ---------------------------------------------------
46
+
47
+ export interface NotificationView {
48
+ id: NotificationId
49
+ type: string
50
+ /** Owning module of the type (`finance`, `roles`, …). */
51
+ moduleId: string
52
+ params: NotificationParams
53
+ href: string | null
54
+ readAt: number | null
55
+ createdAt: number
56
+ emailStatus: EmailStatus
57
+ }
58
+
59
+ export interface NotificationPreferenceView {
60
+ type: string
61
+ moduleId: string
62
+ /** Fallback text; the app translates by `type`. */
63
+ description: string | null
64
+ /** Param names the type carries. */
65
+ params: string[]
66
+ defaultChannels: { inApp: boolean; email: boolean }
67
+ /** Effective channels (the user's preference, else the defaults). */
68
+ inApp: boolean
69
+ email: boolean
70
+ /** The user has set a preference for this type. */
71
+ customized: boolean
72
+ }
73
+
74
+ // --- Email adapters (app side, like `inviteDelivery`) ---------------------------------------
75
+
76
+ export interface RenderedEmail {
77
+ subject: string
78
+ text: string
79
+ }
80
+
81
+ /**
82
+ * Renders a notification email in the recipient's locale (profile locale, else
83
+ * the default, `lt`). Build absolute links from `href` with the app's SITE_URL.
84
+ */
85
+ export type RenderNotificationEmail = (
86
+ type: string,
87
+ params: NotificationParams,
88
+ locale: string,
89
+ context: { href: string | null; notificationId: string },
90
+ ) => RenderedEmail | Promise<RenderedEmail>
91
+
92
+ export interface NotificationEmailMessage {
93
+ /** Verified address of the recipient. Never logged by core. */
94
+ to: string
95
+ subject: string
96
+ text: string
97
+ type: string
98
+ notificationId: string
99
+ /** Stable per notification: use it as an idempotency key with the provider. */
100
+ operationId: string
101
+ }
102
+
103
+ /** Sends one email. Throw (or return false) on failure: the row becomes `failed`. */
104
+ // biome-ignore lint/suspicious/noConfusingVoidType: adapters may return nothing on success
105
+ export type DeliverNotificationEmail = (message: NotificationEmailMessage) => Promise<void | boolean>
106
+
107
+ /** What `_claimNotificationEmail` hands the delivery action. */
108
+ export interface NotificationEmailJob {
109
+ to: string
110
+ locale: string
111
+ type: string
112
+ params: NotificationParams
113
+ href: string | null
114
+ operationId: string
115
+ }
116
+
117
+ // --- Limits & function names ---------------------------------------------------------------
118
+
119
+ /** Most recipients of one `notify` call. Explicit `userIds` above it are VALIDATION. */
120
+ export const NOTIFY_MAX_RECIPIENTS = 100
121
+ /** `unreadNotificationCount` stops counting here (show "99+"). */
122
+ export const NOTIFICATION_UNREAD_CAP = 100
123
+ /** Rows one `markAllNotificationsRead` call marks; call again while `done` is false. */
124
+ export const NOTIFICATION_MARK_ALL_BATCH = 200
125
+ /** Serialized params size limit (characters of JSON). */
126
+ export const NOTIFICATION_PARAMS_MAX = 4000
127
+
128
+ /** Public functions in `functionsModule` (default convex/notifications.ts). */
129
+ export const NOTIFICATIONS_PUBLIC_FUNCTIONS = [
130
+ "listNotifications", // ({ cursor?, limit?, unreadOnly? }) → { items: NotificationView[], cursor, done }
131
+ "unreadNotificationCount", // () → number (≤ NOTIFICATION_UNREAD_CAP)
132
+ "markNotificationRead", // ({ notificationId }) → { read } (NOT_FOUND unless own)
133
+ "markAllNotificationsRead", // () → { marked, done }
134
+ "getNotificationPreferences", // () → NotificationPreferenceView[]
135
+ "setNotificationPreference", // ({ type, inApp, email }) → NotificationPreferenceView
136
+ ] as const
137
+
138
+ /** Internal functions in `functionsModule` (email delivery). */
139
+ export const NOTIFICATIONS_INTERNAL_FUNCTIONS = ["_deliverNotificationEmail", "_claimNotificationEmail", "_recordNotificationEmail"] as const
140
+
141
+ /** Audit action of a preference change. */
142
+ export const NOTIFICATIONS_AUDIT = { preferenceUpdate: "profile.notification_preference.update" } as const
@@ -0,0 +1,8 @@
1
+ // `@digimuza/system-core/notifications` (DIG-74): in-app + email notifications,
2
+ // a shared primitive every module uses. Core stores type + JSON params; the app
3
+ // renders LT/EN text (UI and email) from them.
4
+ export * from "./contract.js"
5
+ export { defineNotifications, notificationsHook, notify, NotificationsInstall, type NotificationsHookOptions, type NotificationsOptions, type NotificationsSystem } from "./api.js"
6
+ export { defineNotificationType, NOTIFICATION_TYPE_ID, type NotificationTypeDefinition } from "../core/catalog.js"
7
+ export { notifyIfInstalled, type Notifier, type NotifyRequest, type NotifyTarget, type NotificationParams } from "../core/notify.js"
8
+ export { CORE_NOTIFICATION_TYPES } from "../core/system-modules.js"