@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.
- package/INTEGRATION.md +209 -3
- package/README.md +23 -0
- package/dist/ai/api.d.ts +120 -0
- package/dist/ai/api.d.ts.map +1 -0
- package/dist/ai/api.js +446 -0
- package/dist/ai/api.js.map +1 -0
- package/dist/ai/card.d.ts +27 -0
- package/dist/ai/card.d.ts.map +1 -0
- package/dist/ai/card.js +84 -0
- package/dist/ai/card.js.map +1 -0
- package/dist/ai/contract.d.ts +364 -0
- package/dist/ai/contract.d.ts.map +1 -0
- package/dist/ai/contract.js +101 -0
- package/dist/ai/contract.js.map +1 -0
- package/dist/ai/display.d.ts +20 -0
- package/dist/ai/display.d.ts.map +1 -0
- package/dist/ai/display.js +35 -0
- package/dist/ai/display.js.map +1 -0
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +10 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/manifest.d.ts +25 -0
- package/dist/ai/manifest.d.ts.map +1 -0
- package/dist/ai/manifest.js +15 -0
- package/dist/ai/manifest.js.map +1 -0
- package/dist/ai/runner.d.ts +71 -0
- package/dist/ai/runner.d.ts.map +1 -0
- package/dist/ai/runner.js +246 -0
- package/dist/ai/runner.js.map +1 -0
- package/dist/ai/tools.d.ts +42 -0
- package/dist/ai/tools.d.ts.map +1 -0
- package/dist/ai/tools.js +117 -0
- package/dist/ai/tools.js.map +1 -0
- package/dist/audit/csv.d.ts +31 -0
- package/dist/audit/csv.d.ts.map +1 -0
- package/dist/audit/csv.js +39 -0
- package/dist/audit/csv.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/convex/functions.d.ts +39 -0
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +38 -6
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/core/access.d.ts +14 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +62 -0
- package/dist/core/access.js.map +1 -1
- package/dist/core/audit-module.d.ts +5 -0
- package/dist/core/audit-module.d.ts.map +1 -0
- package/dist/core/audit-module.js +25 -0
- package/dist/core/audit-module.js.map +1 -0
- package/dist/core/audit.d.ts +31 -0
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +46 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/catalog.d.ts +41 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +45 -0
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/context.d.ts +8 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/notify.d.ts +37 -0
- package/dist/core/notify.d.ts.map +1 -0
- package/dist/core/notify.js +16 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/roles.d.ts.map +1 -1
- package/dist/core/roles.js +5 -1
- package/dist/core/roles.js.map +1 -1
- package/dist/core/system-modules.d.ts +4 -0
- package/dist/core/system-modules.d.ts.map +1 -1
- package/dist/core/system-modules.js +11 -0
- package/dist/core/system-modules.js.map +1 -1
- package/dist/finance/ai-tools.d.ts +55 -0
- package/dist/finance/ai-tools.d.ts.map +1 -0
- package/dist/finance/ai-tools.js +65 -0
- package/dist/finance/ai-tools.js.map +1 -0
- package/dist/finance/index.d.ts +87 -1
- package/dist/finance/index.d.ts.map +1 -1
- package/dist/finance/index.js +92 -38
- package/dist/finance/index.js.map +1 -1
- package/dist/finance/search.d.ts +15 -0
- package/dist/finance/search.d.ts.map +1 -0
- package/dist/finance/search.js +51 -0
- package/dist/finance/search.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/notifications/api.d.ts +130 -0
- package/dist/notifications/api.d.ts.map +1 -0
- package/dist/notifications/api.js +384 -0
- package/dist/notifications/api.js.map +1 -0
- package/dist/notifications/contract.d.ts +132 -0
- package/dist/notifications/contract.d.ts.map +1 -0
- package/dist/notifications/contract.js +59 -0
- package/dist/notifications/contract.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +9 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/search/index.d.ts +95 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +151 -0
- package/dist/search/index.js.map +1 -0
- package/package.json +41 -2
- package/src/ai/api.ts +535 -0
- package/src/ai/card.ts +91 -0
- package/src/ai/contract.ts +245 -0
- package/src/ai/display.ts +38 -0
- package/src/ai/index.ts +9 -0
- package/src/ai/manifest.ts +16 -0
- package/src/ai/runner.ts +275 -0
- package/src/ai/tools.ts +128 -0
- package/src/audit/csv.ts +60 -0
- package/src/audit/index.ts +7 -0
- package/src/convex/functions.ts +54 -7
- package/src/convex/index.ts +1 -1
- package/src/core/access.ts +55 -1
- package/src/core/audit-module.ts +26 -0
- package/src/core/audit.ts +68 -2
- package/src/core/catalog.ts +63 -0
- package/src/core/context.ts +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/notify.ts +52 -0
- package/src/core/roles.ts +5 -1
- package/src/core/system-modules.ts +12 -0
- package/src/finance/ai-tools.ts +68 -0
- package/src/finance/index.ts +119 -42
- package/src/finance/search.ts +58 -0
- package/src/index.ts +2 -0
- package/src/modules/index.ts +3 -0
- package/src/notifications/api.ts +477 -0
- package/src/notifications/contract.ts +142 -0
- package/src/notifications/index.ts +8 -0
- 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"
|