@objectstack/service-messaging 7.4.0
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/LICENSE +202 -0
- package/LICENSE.apache +202 -0
- package/dist/index.cjs +1826 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +15589 -0
- package/dist/index.d.ts +15589 -0
- package/dist/index.js +1765 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1826 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DEFAULT_LOCALE: () => DEFAULT_LOCALE,
|
|
24
|
+
DEFAULT_RETENTION_TARGETS: () => DEFAULT_RETENTION_TARGETS,
|
|
25
|
+
DELIVERY_OBJECT: () => DELIVERY_OBJECT,
|
|
26
|
+
EMAIL_USER_OBJECT: () => USER_OBJECT2,
|
|
27
|
+
INBOX_OBJECT: () => INBOX_OBJECT,
|
|
28
|
+
InboxMessage: () => InboxMessage,
|
|
29
|
+
MEMBER_OBJECT: () => MEMBER_OBJECT,
|
|
30
|
+
MemoryNotificationOutbox: () => MemoryNotificationOutbox,
|
|
31
|
+
MessagingService: () => MessagingService,
|
|
32
|
+
MessagingServicePlugin: () => MessagingServicePlugin,
|
|
33
|
+
NOTIFICATION_EVENT_OBJECT: () => NOTIFICATION_EVENT_OBJECT,
|
|
34
|
+
NotificationDelivery: () => NotificationDelivery,
|
|
35
|
+
NotificationDispatcher: () => NotificationDispatcher,
|
|
36
|
+
NotificationPreference: () => NotificationPreference,
|
|
37
|
+
NotificationReceipt: () => NotificationReceipt,
|
|
38
|
+
NotificationRetention: () => NotificationRetention,
|
|
39
|
+
NotificationSubscription: () => NotificationSubscription,
|
|
40
|
+
NotificationTemplate: () => NotificationTemplate,
|
|
41
|
+
NotificationTemplateStore: () => NotificationTemplateStore,
|
|
42
|
+
PREFERENCE_OBJECT: () => PREFERENCE_OBJECT,
|
|
43
|
+
PreferenceResolver: () => PreferenceResolver,
|
|
44
|
+
RECEIPT_OBJECT: () => RECEIPT_OBJECT,
|
|
45
|
+
RecipientResolver: () => RecipientResolver,
|
|
46
|
+
SqlNotificationOutbox: () => SqlNotificationOutbox,
|
|
47
|
+
TEAM_MEMBER_OBJECT: () => TEAM_MEMBER_OBJECT,
|
|
48
|
+
TEMPLATE_OBJECT: () => TEMPLATE_OBJECT,
|
|
49
|
+
USER_OBJECT: () => USER_OBJECT,
|
|
50
|
+
classifyDeliveryAttempt: () => classifyDeliveryAttempt,
|
|
51
|
+
createEmailChannel: () => createEmailChannel,
|
|
52
|
+
createInboxChannel: () => createInboxChannel,
|
|
53
|
+
hashPartition: () => hashPartition,
|
|
54
|
+
interpolate: () => interpolate,
|
|
55
|
+
nextRetryDelayMs: () => nextRetryDelayMs,
|
|
56
|
+
quietHoursDeferral: () => quietHoursDeferral,
|
|
57
|
+
renderNotification: () => renderNotification
|
|
58
|
+
});
|
|
59
|
+
module.exports = __toCommonJS(index_exports);
|
|
60
|
+
|
|
61
|
+
// src/messaging-service-plugin.ts
|
|
62
|
+
var import_node_crypto2 = require("crypto");
|
|
63
|
+
|
|
64
|
+
// src/recipient-resolver.ts
|
|
65
|
+
function looksLikeEmail(s) {
|
|
66
|
+
if (!s || /\s/.test(s)) return false;
|
|
67
|
+
const at = s.indexOf("@");
|
|
68
|
+
if (at <= 0 || at !== s.lastIndexOf("@") || at === s.length - 1) return false;
|
|
69
|
+
const domain = s.slice(at + 1);
|
|
70
|
+
const dot = domain.indexOf(".");
|
|
71
|
+
return dot > 0 && dot < domain.length - 1;
|
|
72
|
+
}
|
|
73
|
+
var USER_OBJECT = "sys_user";
|
|
74
|
+
var MEMBER_OBJECT = "sys_member";
|
|
75
|
+
var TEAM_MEMBER_OBJECT = "sys_team_member";
|
|
76
|
+
var DEFAULT_OWNER_FIELDS = ["owner_id", "assigned_to", "assignee_id", "owner", "assignee"];
|
|
77
|
+
var RecipientResolver = class {
|
|
78
|
+
constructor(opts) {
|
|
79
|
+
this.opts = opts;
|
|
80
|
+
this.userObject = opts.userObject ?? USER_OBJECT;
|
|
81
|
+
this.memberObject = opts.memberObject ?? MEMBER_OBJECT;
|
|
82
|
+
this.teamMemberObject = opts.teamMemberObject ?? TEAM_MEMBER_OBJECT;
|
|
83
|
+
this.ownerFields = opts.ownerFields ?? DEFAULT_OWNER_FIELDS;
|
|
84
|
+
}
|
|
85
|
+
/** Expand an audience to a de-duplicated list of recipient user ids. */
|
|
86
|
+
async resolve(audience, ctx = {}) {
|
|
87
|
+
const specs = Array.isArray(audience) ? audience : [audience];
|
|
88
|
+
const data = this.opts.getData();
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const spec of specs) {
|
|
91
|
+
for (const id of await this.resolveOne(spec, data, ctx)) {
|
|
92
|
+
if (id) out.push(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...new Set(out)];
|
|
96
|
+
}
|
|
97
|
+
async resolveOne(spec, data, ctx) {
|
|
98
|
+
if (typeof spec !== "string") {
|
|
99
|
+
if (spec && typeof spec === "object" && "ownerOf" in spec) {
|
|
100
|
+
return this.resolveOwnerOf(spec.ownerOf.object, spec.ownerOf.id, data);
|
|
101
|
+
}
|
|
102
|
+
this.opts.logger.warn(`[recipients] unrecognized audience spec ${JSON.stringify(spec)}; skipped`);
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const value = spec.trim();
|
|
106
|
+
if (!value) return [];
|
|
107
|
+
if (value.startsWith("user:")) return [value.slice(5)].filter(Boolean);
|
|
108
|
+
if (value.startsWith("role:")) return this.resolveRole(value.slice(5), data, ctx);
|
|
109
|
+
if (value.startsWith("team:")) return this.resolveTeam(value.slice(5), data);
|
|
110
|
+
if (value.startsWith("owner_of:")) {
|
|
111
|
+
const rest = value.slice("owner_of:".length);
|
|
112
|
+
const sep = rest.indexOf(":");
|
|
113
|
+
if (sep > 0) return this.resolveOwnerOf(rest.slice(0, sep), rest.slice(sep + 1), data);
|
|
114
|
+
this.opts.logger.warn(`[recipients] malformed owner_of spec '${value}'; skipped`);
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
if (looksLikeEmail(value)) return [await this.resolveEmail(value, data)];
|
|
118
|
+
return [value];
|
|
119
|
+
}
|
|
120
|
+
/** `role:` → `sys_member` rows with that role in the tenant. */
|
|
121
|
+
async resolveRole(role, data, ctx) {
|
|
122
|
+
if (!role || !data) return [];
|
|
123
|
+
const where = { role };
|
|
124
|
+
if (ctx.organizationId) where.organization_id = ctx.organizationId;
|
|
125
|
+
try {
|
|
126
|
+
const rows = await data.find(this.memberObject, { where, fields: ["user_id"], limit: 1e4 });
|
|
127
|
+
return userIds(rows);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.opts.logger.warn(`[recipients] role '${role}' lookup failed (${msg(err)}); 0 recipients`);
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** `team:` → `sys_team_member` rows for that team. */
|
|
134
|
+
async resolveTeam(teamId, data) {
|
|
135
|
+
if (!teamId || !data) return [];
|
|
136
|
+
try {
|
|
137
|
+
const rows = await data.find(this.teamMemberObject, {
|
|
138
|
+
where: { team_id: teamId },
|
|
139
|
+
fields: ["user_id"],
|
|
140
|
+
limit: 1e4
|
|
141
|
+
});
|
|
142
|
+
return userIds(rows);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.opts.logger.warn(`[recipients] team '${teamId}' lookup failed (${msg(err)}); 0 recipients`);
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** `owner_of:` → the owner/assignee field of the referenced record. */
|
|
149
|
+
async resolveOwnerOf(object, id, data) {
|
|
150
|
+
if (!object || !id || !data) return [];
|
|
151
|
+
try {
|
|
152
|
+
const rec = await data.findOne(object, { where: { id }, fields: ["id", ...this.ownerFields] });
|
|
153
|
+
if (!rec) return [];
|
|
154
|
+
for (const f of this.ownerFields) {
|
|
155
|
+
const v = rec[f];
|
|
156
|
+
if (typeof v === "string" && v.length > 0) return [v];
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.opts.logger.warn(`[recipients] owner_of '${object}:${id}' lookup failed (${msg(err)}); 0 recipients`);
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve an email-shaped recipient to its user id. Falls back to the email
|
|
166
|
+
* verbatim on no match or lookup error (a downstream channel may still key
|
|
167
|
+
* a row by it — never lose the recipient on a directory miss).
|
|
168
|
+
*/
|
|
169
|
+
async resolveEmail(email, data) {
|
|
170
|
+
if (!data) return email;
|
|
171
|
+
try {
|
|
172
|
+
const user = await data.findOne(this.userObject, { where: { email }, fields: ["id"] });
|
|
173
|
+
const id = user?.id;
|
|
174
|
+
if (id != null && String(id).length > 0) return String(id);
|
|
175
|
+
this.opts.logger.warn(`[recipients] no '${this.userObject}' matched email '${email}'; keeping verbatim`);
|
|
176
|
+
return email;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.opts.logger.warn(`[recipients] email '${email}' lookup failed (${msg(err)}); keeping verbatim`);
|
|
179
|
+
return email;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
function userIds(rows) {
|
|
184
|
+
if (!Array.isArray(rows)) return [];
|
|
185
|
+
return [...new Set(rows.map((r) => String(r?.user_id ?? "")).filter(Boolean))];
|
|
186
|
+
}
|
|
187
|
+
function msg(err) {
|
|
188
|
+
return err?.message ?? String(err);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/preference-resolver.ts
|
|
192
|
+
var PREFERENCE_OBJECT = "sys_notification_preference";
|
|
193
|
+
var WILDCARD = "*";
|
|
194
|
+
var PreferenceResolver = class {
|
|
195
|
+
constructor(opts) {
|
|
196
|
+
this.opts = opts;
|
|
197
|
+
this.objectName = opts.objectName ?? PREFERENCE_OBJECT;
|
|
198
|
+
this.mandatory = opts.mandatoryTopics ?? [];
|
|
199
|
+
}
|
|
200
|
+
/** Whether a topic bypasses preferences (exact or `prefix.` match). */
|
|
201
|
+
isMandatory(topic) {
|
|
202
|
+
return this.mandatory.some(
|
|
203
|
+
(m) => m.endsWith(".") ? topic.startsWith(m) : topic === m
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Filter `(recipient × channel)` by preference. Recipients left with no
|
|
208
|
+
* accepted channel are dropped from the result.
|
|
209
|
+
*/
|
|
210
|
+
async filter(recipients, channels, ctx) {
|
|
211
|
+
const all = () => recipients.map((r) => ({ recipient: r, channels: [...channels] }));
|
|
212
|
+
if (recipients.length === 0 || channels.length === 0) return [];
|
|
213
|
+
if (this.isMandatory(ctx.topic)) return all();
|
|
214
|
+
const data = this.opts.getData();
|
|
215
|
+
if (!data) return all();
|
|
216
|
+
let rows;
|
|
217
|
+
try {
|
|
218
|
+
rows = await this.loadRows(data, ctx);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
this.opts.logger.warn(
|
|
221
|
+
`[preferences] lookup for topic '${ctx.topic}' failed (${msg2(err)}); delivering all (fail-open)`
|
|
222
|
+
);
|
|
223
|
+
return all();
|
|
224
|
+
}
|
|
225
|
+
const recipientSet = new Set(recipients);
|
|
226
|
+
const index = /* @__PURE__ */ new Map();
|
|
227
|
+
for (const r of rows) {
|
|
228
|
+
const user = String(r.user_id ?? "");
|
|
229
|
+
if (user !== WILDCARD && !recipientSet.has(user)) continue;
|
|
230
|
+
const topic = String(r.topic ?? WILDCARD);
|
|
231
|
+
const channel = String(r.channel ?? WILDCARD);
|
|
232
|
+
index.set(`${user}|${topic}|${channel}`, {
|
|
233
|
+
enabled: asBool(r.enabled),
|
|
234
|
+
quietHours: parseQuietHours(r.quiet_hours)
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const nowMs = ctx.now ?? Date.now();
|
|
238
|
+
const critical = ctx.severity === "critical";
|
|
239
|
+
const targets = [];
|
|
240
|
+
for (const recipient of recipients) {
|
|
241
|
+
const accepted = channels.filter(
|
|
242
|
+
(channel) => this.resolveRow(index, recipient, ctx.topic, channel)?.enabled ?? true
|
|
243
|
+
);
|
|
244
|
+
if (accepted.length === 0) continue;
|
|
245
|
+
let notBefore;
|
|
246
|
+
if (!critical) {
|
|
247
|
+
const qh = this.resolveQuietHours(index, recipient, ctx.topic);
|
|
248
|
+
notBefore = qh ? quietHoursDeferral(qh, nowMs) : void 0;
|
|
249
|
+
}
|
|
250
|
+
targets.push(notBefore != null ? { recipient, channels: accepted, notBefore } : { recipient, channels: accepted });
|
|
251
|
+
}
|
|
252
|
+
return targets;
|
|
253
|
+
}
|
|
254
|
+
/** Load the candidate rows (topic-specific + wildcard-topic), org-scoped. */
|
|
255
|
+
async loadRows(data, ctx) {
|
|
256
|
+
const base = {};
|
|
257
|
+
if (ctx.organizationId) base.organization_id = ctx.organizationId;
|
|
258
|
+
const [specific, wildcard] = await Promise.all([
|
|
259
|
+
data.find(this.objectName, { where: { ...base, topic: ctx.topic }, limit: 1e4 }),
|
|
260
|
+
data.find(this.objectName, { where: { ...base, topic: WILDCARD }, limit: 1e4 })
|
|
261
|
+
]);
|
|
262
|
+
return [...specific ?? [], ...wildcard ?? []];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Most-specific-wins lookup for (user, topic, channel). User-specific beats
|
|
266
|
+
* the `*` user; topic/channel specific beats their wildcards.
|
|
267
|
+
*/
|
|
268
|
+
resolveRow(index, user, topic, channel) {
|
|
269
|
+
for (const u of [user, WILDCARD]) {
|
|
270
|
+
for (const t of [topic, WILDCARD]) {
|
|
271
|
+
for (const c of [channel, WILDCARD]) {
|
|
272
|
+
const hit = index.get(`${u}|${t}|${c}`);
|
|
273
|
+
if (hit !== void 0) return hit;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return void 0;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve a recipient's quiet-hours window. Declared on a channel-wildcard
|
|
281
|
+
* row (`(user, *, *)` or `(user, topic, *)`) — quiet hours are a per-person,
|
|
282
|
+
* channel-agnostic setting. Most-specific user/topic wins.
|
|
283
|
+
*/
|
|
284
|
+
resolveQuietHours(index, user, topic) {
|
|
285
|
+
for (const u of [user, WILDCARD]) {
|
|
286
|
+
for (const t of [topic, WILDCARD]) {
|
|
287
|
+
const hit = index.get(`${u}|${t}|${WILDCARD}`);
|
|
288
|
+
if (hit?.quietHours) return hit.quietHours;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
function asBool(v) {
|
|
295
|
+
return v === true || v === 1 || v === "1" || v === "true";
|
|
296
|
+
}
|
|
297
|
+
function parseQuietHours(v) {
|
|
298
|
+
let o = v;
|
|
299
|
+
if (typeof o === "string") {
|
|
300
|
+
try {
|
|
301
|
+
o = JSON.parse(o);
|
|
302
|
+
} catch {
|
|
303
|
+
return void 0;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!o || typeof o !== "object") return void 0;
|
|
307
|
+
if (o.start == null || o.end == null) return void 0;
|
|
308
|
+
return { tz: o.tz, start: String(o.start), end: String(o.end) };
|
|
309
|
+
}
|
|
310
|
+
function quietHoursDeferral(quietHours, nowMs) {
|
|
311
|
+
const start = parseHHMM(quietHours.start);
|
|
312
|
+
const end = parseHHMM(quietHours.end);
|
|
313
|
+
if (start == null || end == null || start === end) return void 0;
|
|
314
|
+
const cur = minutesOfDayInTz(nowMs, quietHours.tz ?? "UTC");
|
|
315
|
+
let untilEnd;
|
|
316
|
+
if (start < end) {
|
|
317
|
+
if (cur >= start && cur < end) untilEnd = end - cur;
|
|
318
|
+
} else {
|
|
319
|
+
if (cur >= start) untilEnd = 1440 - cur + end;
|
|
320
|
+
else if (cur < end) untilEnd = end - cur;
|
|
321
|
+
}
|
|
322
|
+
return untilEnd == null ? void 0 : nowMs + untilEnd * 6e4;
|
|
323
|
+
}
|
|
324
|
+
function parseHHMM(s) {
|
|
325
|
+
if (!s) return void 0;
|
|
326
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
|
|
327
|
+
if (!m) return void 0;
|
|
328
|
+
const h = Number(m[1]);
|
|
329
|
+
const min = Number(m[2]);
|
|
330
|
+
if (h > 23 || min > 59) return void 0;
|
|
331
|
+
return h * 60 + min;
|
|
332
|
+
}
|
|
333
|
+
function minutesOfDayInTz(nowMs, tz) {
|
|
334
|
+
try {
|
|
335
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
336
|
+
hour12: false,
|
|
337
|
+
hour: "2-digit",
|
|
338
|
+
minute: "2-digit",
|
|
339
|
+
timeZone: tz
|
|
340
|
+
}).formatToParts(new Date(nowMs));
|
|
341
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0") % 24;
|
|
342
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
|
343
|
+
return hour * 60 + minute;
|
|
344
|
+
} catch {
|
|
345
|
+
const d = new Date(nowMs);
|
|
346
|
+
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function msg2(err) {
|
|
350
|
+
return err?.message ?? String(err);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/messaging-service.ts
|
|
354
|
+
var NOTIFICATION_EVENT_OBJECT = "sys_notification";
|
|
355
|
+
var MessagingService = class {
|
|
356
|
+
constructor(ctx) {
|
|
357
|
+
this.ctx = ctx;
|
|
358
|
+
this.channels = /* @__PURE__ */ new Map();
|
|
359
|
+
this.now = ctx.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
360
|
+
this.resolver = ctx.recipientResolver ?? new RecipientResolver({ getData: () => ctx.getData?.(), logger: ctx.logger });
|
|
361
|
+
this.preferences = ctx.preferenceResolver ?? new PreferenceResolver({
|
|
362
|
+
getData: () => ctx.getData?.(),
|
|
363
|
+
logger: ctx.logger,
|
|
364
|
+
mandatoryTopics: ctx.mandatoryTopics
|
|
365
|
+
});
|
|
366
|
+
this.outbox = ctx.outbox;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Attach the durable delivery outbox after construction. The plugin wires
|
|
370
|
+
* this once the data engine is resolvable (kernel:ready), switching `emit()`
|
|
371
|
+
* from inline fan-out to the reliable enqueue → dispatcher path.
|
|
372
|
+
*/
|
|
373
|
+
setOutbox(outbox) {
|
|
374
|
+
this.outbox = outbox;
|
|
375
|
+
}
|
|
376
|
+
/** Register a channel implementation. A duplicate id warns and replaces. */
|
|
377
|
+
registerChannel(channel) {
|
|
378
|
+
if (this.channels.has(channel.id)) {
|
|
379
|
+
this.ctx.logger.warn(`[messaging] channel '${channel.id}' already registered; replacing`);
|
|
380
|
+
}
|
|
381
|
+
this.channels.set(channel.id, channel);
|
|
382
|
+
this.ctx.logger.info(`[messaging] channel registered: ${channel.id}`);
|
|
383
|
+
}
|
|
384
|
+
/** Remove a channel. No-op when absent. */
|
|
385
|
+
unregisterChannel(id) {
|
|
386
|
+
this.channels.delete(id);
|
|
387
|
+
}
|
|
388
|
+
/** Look up a channel by id. */
|
|
389
|
+
getChannel(id) {
|
|
390
|
+
return this.channels.get(id);
|
|
391
|
+
}
|
|
392
|
+
/** All registered channel ids. */
|
|
393
|
+
getRegisteredChannels() {
|
|
394
|
+
return [...this.channels.keys()];
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The single notification ingress. Writes the L2 event, resolves the
|
|
398
|
+
* audience, and fans the result out to its channels. An unregistered
|
|
399
|
+
* channel, or a channel that throws, is reported as a failed delivery — it
|
|
400
|
+
* never aborts the rest of the fan-out. A `dedupKey` that matches an
|
|
401
|
+
* existing event short-circuits: the event id is returned and no new
|
|
402
|
+
* deliveries are produced.
|
|
403
|
+
*/
|
|
404
|
+
async emit(input) {
|
|
405
|
+
const data = this.ctx.getData?.();
|
|
406
|
+
if (input.dedupKey && data) {
|
|
407
|
+
const existing = await this.findEventByDedupKey(data, input.dedupKey);
|
|
408
|
+
if (existing) {
|
|
409
|
+
this.ctx.logger.info(
|
|
410
|
+
`[messaging] emit: dedupKey '${input.dedupKey}' already emitted (${existing}); skipping`
|
|
411
|
+
);
|
|
412
|
+
return { notificationId: existing, deduped: true, deliveries: [], delivered: 0, failed: 0 };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
let notificationId;
|
|
416
|
+
try {
|
|
417
|
+
notificationId = await this.writeEvent(data, input);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (input.dedupKey && data) {
|
|
420
|
+
const winner = await this.findEventByDedupKey(data, input.dedupKey);
|
|
421
|
+
if (winner) {
|
|
422
|
+
this.ctx.logger.info(
|
|
423
|
+
`[messaging] emit: dedupKey '${input.dedupKey}' raced; converged to ${winner}`
|
|
424
|
+
);
|
|
425
|
+
return { notificationId: winner, deduped: true, deliveries: [], delivered: 0, failed: 0 };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
const recipients = await this.resolver.resolve(input.audience, {
|
|
431
|
+
organizationId: input.organizationId
|
|
432
|
+
});
|
|
433
|
+
if (recipients.length === 0) {
|
|
434
|
+
this.ctx.logger.warn(`[messaging] emit: topic '${input.topic}' resolved to 0 recipients`);
|
|
435
|
+
return { notificationId, deduped: false, deliveries: [], delivered: 0, failed: 0 };
|
|
436
|
+
}
|
|
437
|
+
const payload = input.payload ?? {};
|
|
438
|
+
const channels = input.channels?.length ? input.channels : ["inbox"];
|
|
439
|
+
const targets = await this.preferences.filter(recipients, channels, {
|
|
440
|
+
topic: input.topic,
|
|
441
|
+
organizationId: input.organizationId,
|
|
442
|
+
severity: input.severity
|
|
443
|
+
});
|
|
444
|
+
if (targets.length === 0) {
|
|
445
|
+
this.ctx.logger.info(`[messaging] emit: topic '${input.topic}' suppressed for all recipients by preference`);
|
|
446
|
+
return { notificationId, deduped: false, deliveries: [], delivered: 0, failed: 0 };
|
|
447
|
+
}
|
|
448
|
+
if (this.outbox) {
|
|
449
|
+
const deliveries2 = await this.enqueueDeliveries(this.outbox, notificationId, targets, input, payload);
|
|
450
|
+
const delivered2 = deliveries2.filter((d) => d.ok).length;
|
|
451
|
+
return { notificationId, deduped: false, deliveries: deliveries2, delivered: delivered2, failed: deliveries2.length - delivered2 };
|
|
452
|
+
}
|
|
453
|
+
const notification = {
|
|
454
|
+
notificationId,
|
|
455
|
+
organizationId: input.organizationId,
|
|
456
|
+
topic: input.topic,
|
|
457
|
+
title: str(payload.title) ?? input.topic,
|
|
458
|
+
body: str(payload.body) ?? "",
|
|
459
|
+
severity: input.severity ?? "info",
|
|
460
|
+
recipients,
|
|
461
|
+
channels: input.channels,
|
|
462
|
+
actionUrl: actionUrlFor(input, payload),
|
|
463
|
+
payload: input.payload
|
|
464
|
+
};
|
|
465
|
+
const { deliveries, delivered, failed } = await this.fanOut(notification, targets);
|
|
466
|
+
return { notificationId, deduped: false, deliveries, delivered, failed };
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Enqueue one `pending` delivery row per `(channel × recipient)`. The
|
|
470
|
+
* dispatcher does the actual send + retry; here `ok` means "accepted for
|
|
471
|
+
* delivery" (enqueued), not yet delivered — progress is observable on the
|
|
472
|
+
* `sys_notification_delivery` row.
|
|
473
|
+
*/
|
|
474
|
+
async enqueueDeliveries(outbox, notificationId, targets, input, payload) {
|
|
475
|
+
const deliveryPayload = {
|
|
476
|
+
...payload,
|
|
477
|
+
title: str(payload.title) ?? input.topic,
|
|
478
|
+
body: str(payload.body) ?? "",
|
|
479
|
+
severity: input.severity ?? "info",
|
|
480
|
+
actionUrl: actionUrlFor(input, payload)
|
|
481
|
+
};
|
|
482
|
+
const deliveries = [];
|
|
483
|
+
for (const { recipient, channels, notBefore } of targets) {
|
|
484
|
+
for (const channel of channels) {
|
|
485
|
+
try {
|
|
486
|
+
const id = await outbox.enqueue({
|
|
487
|
+
notificationId,
|
|
488
|
+
recipientId: recipient,
|
|
489
|
+
channel,
|
|
490
|
+
topic: input.topic,
|
|
491
|
+
payload: deliveryPayload,
|
|
492
|
+
organizationId: input.organizationId,
|
|
493
|
+
// Quiet-hours deferral (P3b): the dispatcher won't claim
|
|
494
|
+
// this row until `notBefore`. Absent ⇒ immediate.
|
|
495
|
+
notBefore
|
|
496
|
+
});
|
|
497
|
+
deliveries.push({ channel, recipient, ok: true, externalId: id });
|
|
498
|
+
} catch (err) {
|
|
499
|
+
deliveries.push({ channel, recipient, ok: false, error: err?.message ?? String(err) });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return deliveries;
|
|
504
|
+
}
|
|
505
|
+
/** Find an existing event id by its dedup key, tolerating lookup failure. */
|
|
506
|
+
async findEventByDedupKey(data, dedupKey) {
|
|
507
|
+
try {
|
|
508
|
+
const row = await data.findOne(NOTIFICATION_EVENT_OBJECT, {
|
|
509
|
+
where: { dedup_key: dedupKey },
|
|
510
|
+
fields: ["id"]
|
|
511
|
+
});
|
|
512
|
+
const id = row?.id;
|
|
513
|
+
return id != null && String(id).length > 0 ? String(id) : void 0;
|
|
514
|
+
} catch (err) {
|
|
515
|
+
this.ctx.logger.warn(`[messaging] dedup lookup failed (${err.message}); proceeding`);
|
|
516
|
+
return void 0;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Persist the L2 event and return its id. With no data layer (minimal/test
|
|
521
|
+
* stacks) we warn and synthesize an id so fan-out can still be exercised.
|
|
522
|
+
*/
|
|
523
|
+
async writeEvent(data, input) {
|
|
524
|
+
if (!data) {
|
|
525
|
+
this.ctx.logger.warn("[messaging] no data engine registered; event not persisted");
|
|
526
|
+
return `evt_${Math.random().toString(36).slice(2)}`;
|
|
527
|
+
}
|
|
528
|
+
const row = {
|
|
529
|
+
topic: input.topic,
|
|
530
|
+
payload: input.payload ?? null,
|
|
531
|
+
severity: input.severity ?? "info",
|
|
532
|
+
dedup_key: input.dedupKey ?? null,
|
|
533
|
+
// Normalize empty strings to null so the (source_object, source_id)
|
|
534
|
+
// index keys on real ids, never '' (producers may pass a bare object
|
|
535
|
+
// with no id — e.g. a comment thread_id with no record part).
|
|
536
|
+
source_object: str(input.source?.object) ?? null,
|
|
537
|
+
source_id: str(input.source?.id) ?? null,
|
|
538
|
+
actor_id: input.actorId ?? null,
|
|
539
|
+
organization_id: input.organizationId ?? null,
|
|
540
|
+
created_at: this.now()
|
|
541
|
+
};
|
|
542
|
+
const created = await data.insert(NOTIFICATION_EVENT_OBJECT, row);
|
|
543
|
+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
|
|
544
|
+
return id != null ? String(id) : `evt_${Math.random().toString(36).slice(2)}`;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Fan a notification out to each recipient's accepted channels. Each
|
|
548
|
+
* `(recipient, channel)` pair becomes one `send()` call. An unregistered
|
|
549
|
+
* channel, or a channel that throws, is reported as a failed delivery — it
|
|
550
|
+
* never aborts the rest of the fan-out.
|
|
551
|
+
*/
|
|
552
|
+
async fanOut(notification, targets) {
|
|
553
|
+
const deliveries = [];
|
|
554
|
+
for (const { recipient, channels } of targets) {
|
|
555
|
+
for (const channelId of channels) {
|
|
556
|
+
const channel = this.channels.get(channelId);
|
|
557
|
+
if (!channel) {
|
|
558
|
+
deliveries.push({
|
|
559
|
+
channel: channelId,
|
|
560
|
+
recipient,
|
|
561
|
+
ok: false,
|
|
562
|
+
error: `channel '${channelId}' not registered`
|
|
563
|
+
});
|
|
564
|
+
this.ctx.logger.warn(`[messaging] emit: channel '${channelId}' not registered`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const result = await channel.send(this.ctx, { notification, channel: channelId, recipient });
|
|
569
|
+
deliveries.push({
|
|
570
|
+
channel: channelId,
|
|
571
|
+
recipient,
|
|
572
|
+
ok: result.ok,
|
|
573
|
+
externalId: result.externalId,
|
|
574
|
+
error: result.error
|
|
575
|
+
});
|
|
576
|
+
} catch (err) {
|
|
577
|
+
deliveries.push({
|
|
578
|
+
channel: channelId,
|
|
579
|
+
recipient,
|
|
580
|
+
ok: false,
|
|
581
|
+
error: err?.message ?? String(err)
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const delivered = deliveries.filter((d) => d.ok).length;
|
|
587
|
+
return { deliveries, delivered, failed: deliveries.length - delivered };
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
function str(v) {
|
|
591
|
+
if (v == null) return void 0;
|
|
592
|
+
const s = String(v);
|
|
593
|
+
return s.length > 0 ? s : void 0;
|
|
594
|
+
}
|
|
595
|
+
function actionUrlFor(input, payload) {
|
|
596
|
+
const explicit = str(payload.url) ?? str(payload.actionUrl);
|
|
597
|
+
if (explicit) return explicit;
|
|
598
|
+
const obj = str(input.source?.object);
|
|
599
|
+
const id = str(input.source?.id);
|
|
600
|
+
return obj && id ? `/${obj}/${id}` : void 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/inbox-channel.ts
|
|
604
|
+
var INBOX_OBJECT = "sys_inbox_message";
|
|
605
|
+
var RECEIPT_OBJECT = "sys_notification_receipt";
|
|
606
|
+
function createInboxChannel(opts) {
|
|
607
|
+
const objectName = opts.objectName ?? INBOX_OBJECT;
|
|
608
|
+
const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT;
|
|
609
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
610
|
+
async function writeDeliveredReceipt(ctx, data, r) {
|
|
611
|
+
if (!r.notificationId) return;
|
|
612
|
+
try {
|
|
613
|
+
await data.insert(receiptObject, {
|
|
614
|
+
notification_id: r.notificationId,
|
|
615
|
+
delivery_id: null,
|
|
616
|
+
user_id: r.userId,
|
|
617
|
+
channel: "inbox",
|
|
618
|
+
state: "delivered",
|
|
619
|
+
at: r.at,
|
|
620
|
+
organization_id: r.organizationId ?? null,
|
|
621
|
+
created_at: r.at
|
|
622
|
+
});
|
|
623
|
+
} catch (err) {
|
|
624
|
+
ctx.logger.warn(
|
|
625
|
+
`[inbox] delivered receipt write failed for '${r.userId}' (${err.message}); inbox row stands`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
id: "inbox",
|
|
631
|
+
async send(ctx, delivery) {
|
|
632
|
+
const data = opts.getData();
|
|
633
|
+
const n = delivery.notification;
|
|
634
|
+
if (!data) {
|
|
635
|
+
ctx.logger.warn(
|
|
636
|
+
`[inbox] no data engine registered; inbox row for '${delivery.recipient}' not persisted`
|
|
637
|
+
);
|
|
638
|
+
return { ok: true };
|
|
639
|
+
}
|
|
640
|
+
const userId = delivery.recipient;
|
|
641
|
+
const at = now();
|
|
642
|
+
const row = {
|
|
643
|
+
user_id: userId,
|
|
644
|
+
notification_id: n.notificationId ?? null,
|
|
645
|
+
topic: n.topic,
|
|
646
|
+
title: n.title,
|
|
647
|
+
body_md: n.body,
|
|
648
|
+
severity: n.severity ?? "info",
|
|
649
|
+
action_url: n.actionUrl,
|
|
650
|
+
organization_id: n.organizationId ?? null,
|
|
651
|
+
created_at: at
|
|
652
|
+
};
|
|
653
|
+
let inboxId;
|
|
654
|
+
try {
|
|
655
|
+
const created = await data.insert(objectName, row);
|
|
656
|
+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
|
|
657
|
+
inboxId = id != null ? String(id) : void 0;
|
|
658
|
+
} catch (err) {
|
|
659
|
+
return { ok: false, error: `inbox insert failed: ${err.message}` };
|
|
660
|
+
}
|
|
661
|
+
await writeDeliveredReceipt(ctx, data, {
|
|
662
|
+
notificationId: n.notificationId,
|
|
663
|
+
userId,
|
|
664
|
+
organizationId: n.organizationId,
|
|
665
|
+
at
|
|
666
|
+
});
|
|
667
|
+
return { ok: true, externalId: inboxId };
|
|
668
|
+
},
|
|
669
|
+
classifyError(_err) {
|
|
670
|
+
return "retryable";
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/sql-outbox.ts
|
|
676
|
+
var import_node_crypto = require("crypto");
|
|
677
|
+
|
|
678
|
+
// src/backoff.ts
|
|
679
|
+
function hashPartition(key, count) {
|
|
680
|
+
if (count <= 0) throw new Error("partition count must be > 0");
|
|
681
|
+
let h = 2166136261;
|
|
682
|
+
for (let i = 0; i < key.length; i++) {
|
|
683
|
+
h ^= key.charCodeAt(i);
|
|
684
|
+
h = Math.imul(h, 16777619);
|
|
685
|
+
}
|
|
686
|
+
return Math.abs(h | 0) % count;
|
|
687
|
+
}
|
|
688
|
+
function nextRetryDelayMs(attemptsSoFar, rng = Math.random) {
|
|
689
|
+
const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5];
|
|
690
|
+
if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
|
|
691
|
+
const base = SCHEDULE[attemptsSoFar - 1];
|
|
692
|
+
const jitter = 0.8 + rng() * 0.4;
|
|
693
|
+
return Math.floor(base * jitter);
|
|
694
|
+
}
|
|
695
|
+
function classifyDeliveryAttempt(result, errorClass, attemptsSoFar, now = Date.now(), rng) {
|
|
696
|
+
if (result.ok) return { success: true };
|
|
697
|
+
if (errorClass === "invalid_recipient") {
|
|
698
|
+
return { success: false, error: result.error, suppressed: true };
|
|
699
|
+
}
|
|
700
|
+
if (errorClass === "permanent") {
|
|
701
|
+
return { success: false, error: result.error, dead: true };
|
|
702
|
+
}
|
|
703
|
+
const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
|
|
704
|
+
if (delay === null) {
|
|
705
|
+
return { success: false, error: result.error, dead: true };
|
|
706
|
+
}
|
|
707
|
+
return { success: false, error: result.error, nextAttemptAt: now + delay };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/sql-outbox.ts
|
|
711
|
+
var DELIVERY_OBJECT = "sys_notification_delivery";
|
|
712
|
+
var SqlNotificationOutbox = class {
|
|
713
|
+
constructor(engine, opts) {
|
|
714
|
+
this.engine = engine;
|
|
715
|
+
if (opts.partitionCount <= 0) throw new Error("SqlNotificationOutbox: partitionCount must be > 0");
|
|
716
|
+
this.objectName = opts.objectName ?? DELIVERY_OBJECT;
|
|
717
|
+
this.partitionCount = opts.partitionCount;
|
|
718
|
+
}
|
|
719
|
+
async enqueue(input) {
|
|
720
|
+
const dedup = {
|
|
721
|
+
notification_id: input.notificationId,
|
|
722
|
+
recipient_id: input.recipientId,
|
|
723
|
+
channel: input.channel
|
|
724
|
+
};
|
|
725
|
+
const existing = await this.engine.findOne(this.objectName, { where: dedup, fields: ["id"] });
|
|
726
|
+
if (existing?.id) return String(existing.id);
|
|
727
|
+
const id = (0, import_node_crypto.randomUUID)();
|
|
728
|
+
const now = Date.now();
|
|
729
|
+
const row = {
|
|
730
|
+
id,
|
|
731
|
+
notification_id: input.notificationId,
|
|
732
|
+
recipient_id: input.recipientId,
|
|
733
|
+
channel: input.channel,
|
|
734
|
+
topic: input.topic ?? null,
|
|
735
|
+
payload: input.payload ?? {},
|
|
736
|
+
organization_id: input.organizationId ?? null,
|
|
737
|
+
partition_key: hashPartition(input.notificationId, this.partitionCount),
|
|
738
|
+
status: "pending",
|
|
739
|
+
attempts: 0,
|
|
740
|
+
// Deferred dispatch (quiet-hours, P3): claim() skips pending rows
|
|
741
|
+
// whose next_attempt_at is in the future.
|
|
742
|
+
next_attempt_at: input.notBefore ?? null,
|
|
743
|
+
created_at: now,
|
|
744
|
+
updated_at: now
|
|
745
|
+
};
|
|
746
|
+
try {
|
|
747
|
+
await this.engine.insert(this.objectName, row);
|
|
748
|
+
return id;
|
|
749
|
+
} catch (err) {
|
|
750
|
+
const winner = await this.engine.findOne(this.objectName, { where: dedup, fields: ["id"] });
|
|
751
|
+
if (winner?.id) return String(winner.id);
|
|
752
|
+
throw err;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async claim(opts) {
|
|
756
|
+
const now = opts.now ?? Date.now();
|
|
757
|
+
await this.engine.update(
|
|
758
|
+
this.objectName,
|
|
759
|
+
{ status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
|
|
760
|
+
{ where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
|
|
761
|
+
);
|
|
762
|
+
const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
|
|
763
|
+
const candidates = await this.engine.find(this.objectName, {
|
|
764
|
+
where: {
|
|
765
|
+
status: "pending",
|
|
766
|
+
...partitionFilter,
|
|
767
|
+
$or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
|
|
768
|
+
},
|
|
769
|
+
fields: ["id"],
|
|
770
|
+
limit: opts.limit
|
|
771
|
+
});
|
|
772
|
+
if (!candidates.length) return [];
|
|
773
|
+
const ids = candidates.map((c) => c.id);
|
|
774
|
+
await this.engine.update(
|
|
775
|
+
this.objectName,
|
|
776
|
+
{ status: "in_flight", claimed_by: opts.nodeId, claimed_at: now, updated_at: now },
|
|
777
|
+
{ where: { id: { $in: ids }, status: "pending" }, multi: true }
|
|
778
|
+
);
|
|
779
|
+
const claimed = await this.engine.find(this.objectName, {
|
|
780
|
+
where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
|
|
781
|
+
});
|
|
782
|
+
return claimed.map((r) => this.toRecord(r));
|
|
783
|
+
}
|
|
784
|
+
async ack(id, result) {
|
|
785
|
+
const current = await this.engine.findOne(this.objectName, {
|
|
786
|
+
where: { id },
|
|
787
|
+
fields: ["attempts"]
|
|
788
|
+
});
|
|
789
|
+
if (!current) return;
|
|
790
|
+
const now = Date.now();
|
|
791
|
+
let status;
|
|
792
|
+
let nextAttemptAt = null;
|
|
793
|
+
let error = null;
|
|
794
|
+
if (result.success) {
|
|
795
|
+
status = "success";
|
|
796
|
+
} else if (result.suppressed) {
|
|
797
|
+
status = "suppressed";
|
|
798
|
+
error = result.error ?? null;
|
|
799
|
+
} else if (result.dead) {
|
|
800
|
+
status = "dead";
|
|
801
|
+
error = result.error ?? null;
|
|
802
|
+
} else {
|
|
803
|
+
status = "pending";
|
|
804
|
+
nextAttemptAt = result.nextAttemptAt ?? null;
|
|
805
|
+
error = result.error ?? null;
|
|
806
|
+
}
|
|
807
|
+
await this.engine.update(
|
|
808
|
+
this.objectName,
|
|
809
|
+
{
|
|
810
|
+
status,
|
|
811
|
+
attempts: (current.attempts ?? 0) + 1,
|
|
812
|
+
last_attempted_at: now,
|
|
813
|
+
claimed_by: null,
|
|
814
|
+
claimed_at: null,
|
|
815
|
+
next_attempt_at: nextAttemptAt,
|
|
816
|
+
error,
|
|
817
|
+
updated_at: now
|
|
818
|
+
},
|
|
819
|
+
{ where: { id }, multi: false }
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
async list(filter) {
|
|
823
|
+
const where = {};
|
|
824
|
+
if (filter?.status) where.status = filter.status;
|
|
825
|
+
if (filter?.notificationId) where.notification_id = filter.notificationId;
|
|
826
|
+
const rows = await this.engine.find(this.objectName, { where });
|
|
827
|
+
return rows.map((r) => this.toRecord(r));
|
|
828
|
+
}
|
|
829
|
+
toRecord(r) {
|
|
830
|
+
let payload = r.payload ?? {};
|
|
831
|
+
if (typeof payload === "string") {
|
|
832
|
+
try {
|
|
833
|
+
payload = JSON.parse(payload);
|
|
834
|
+
} catch {
|
|
835
|
+
payload = {};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
id: r.id,
|
|
840
|
+
notificationId: r.notification_id,
|
|
841
|
+
recipientId: r.recipient_id,
|
|
842
|
+
channel: r.channel,
|
|
843
|
+
topic: r.topic ?? void 0,
|
|
844
|
+
payload,
|
|
845
|
+
organizationId: r.organization_id ?? void 0,
|
|
846
|
+
partitionKey: r.partition_key,
|
|
847
|
+
status: r.status,
|
|
848
|
+
attempts: r.attempts,
|
|
849
|
+
claimedBy: r.claimed_by ?? void 0,
|
|
850
|
+
claimedAt: r.claimed_at ?? void 0,
|
|
851
|
+
nextAttemptAt: r.next_attempt_at ?? void 0,
|
|
852
|
+
lastAttemptedAt: r.last_attempted_at ?? void 0,
|
|
853
|
+
error: r.error ?? void 0,
|
|
854
|
+
createdAt: r.created_at,
|
|
855
|
+
updatedAt: r.updated_at
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// src/dispatcher.ts
|
|
861
|
+
var SINGLE_NODE_CLUSTER = {
|
|
862
|
+
lock: {
|
|
863
|
+
async acquire() {
|
|
864
|
+
return { release() {
|
|
865
|
+
}, isHeld: () => true, renew() {
|
|
866
|
+
} };
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
var NotificationDispatcher = class {
|
|
871
|
+
constructor(options) {
|
|
872
|
+
this.running = false;
|
|
873
|
+
const intervalMs = options.intervalMs ?? 500;
|
|
874
|
+
const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
|
|
875
|
+
this.opts = {
|
|
876
|
+
nodeId: options.nodeId,
|
|
877
|
+
outbox: options.outbox,
|
|
878
|
+
channels: options.channels,
|
|
879
|
+
channelContext: options.channelContext,
|
|
880
|
+
cluster: options.cluster ?? SINGLE_NODE_CLUSTER,
|
|
881
|
+
partitionCount: options.partitionCount ?? 8,
|
|
882
|
+
batchSize: options.batchSize ?? 32,
|
|
883
|
+
intervalMs,
|
|
884
|
+
lockTtlMs,
|
|
885
|
+
claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
|
|
886
|
+
rng: options.rng,
|
|
887
|
+
now: options.now,
|
|
888
|
+
logger: options.logger,
|
|
889
|
+
onAttempt: options.onAttempt
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
/** Begin the periodic loop. Idempotent. */
|
|
893
|
+
start() {
|
|
894
|
+
if (this.running) return;
|
|
895
|
+
this.running = true;
|
|
896
|
+
this.scheduleTick();
|
|
897
|
+
this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
|
|
898
|
+
this.timer?.unref?.();
|
|
899
|
+
}
|
|
900
|
+
/** Stop the loop and drain the in-flight tick. */
|
|
901
|
+
async stop() {
|
|
902
|
+
if (!this.running) return;
|
|
903
|
+
this.running = false;
|
|
904
|
+
if (this.timer) {
|
|
905
|
+
clearInterval(this.timer);
|
|
906
|
+
this.timer = void 0;
|
|
907
|
+
}
|
|
908
|
+
if (this.inflightTick) {
|
|
909
|
+
try {
|
|
910
|
+
await this.inflightTick;
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Run one full tick (all partitions). Exposed for deterministic tests. */
|
|
916
|
+
async tick() {
|
|
917
|
+
await this.runTick();
|
|
918
|
+
}
|
|
919
|
+
scheduleTick() {
|
|
920
|
+
if (this.inflightTick) return;
|
|
921
|
+
this.inflightTick = this.runTick().catch((err) => {
|
|
922
|
+
this.opts.logger?.warn?.("notification-dispatcher: tick failed", {
|
|
923
|
+
nodeId: this.opts.nodeId,
|
|
924
|
+
error: err?.message ?? String(err)
|
|
925
|
+
});
|
|
926
|
+
}).finally(() => {
|
|
927
|
+
this.inflightTick = void 0;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async runTick() {
|
|
931
|
+
const count = this.opts.partitionCount;
|
|
932
|
+
const offset = stableNodeOffset(this.opts.nodeId, count);
|
|
933
|
+
for (let step = 0; step < count; step++) {
|
|
934
|
+
await this.runPartition((offset + step) % count);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
async runPartition(index) {
|
|
938
|
+
const handle = await this.opts.cluster.lock.acquire(`notify.dispatcher.partition.${index}`, {
|
|
939
|
+
ttlMs: this.opts.lockTtlMs,
|
|
940
|
+
waitMs: 0
|
|
941
|
+
});
|
|
942
|
+
if (!handle) return;
|
|
943
|
+
try {
|
|
944
|
+
const claimed = await this.opts.outbox.claim({
|
|
945
|
+
nodeId: this.opts.nodeId,
|
|
946
|
+
limit: this.opts.batchSize,
|
|
947
|
+
partition: { index, count: this.opts.partitionCount },
|
|
948
|
+
claimTtlMs: this.opts.claimTtlMs
|
|
949
|
+
});
|
|
950
|
+
if (claimed.length === 0) return;
|
|
951
|
+
await handle.renew?.(this.opts.lockTtlMs);
|
|
952
|
+
for (const row of claimed) {
|
|
953
|
+
if (handle.isHeld && !handle.isHeld()) break;
|
|
954
|
+
await this.processRow(row);
|
|
955
|
+
}
|
|
956
|
+
} finally {
|
|
957
|
+
await handle.release();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async processRow(row) {
|
|
961
|
+
const channel = this.opts.channels.getChannel(row.channel);
|
|
962
|
+
if (!channel) {
|
|
963
|
+
await this.opts.outbox.ack(row.id, {
|
|
964
|
+
success: false,
|
|
965
|
+
error: `channel '${row.channel}' not registered`,
|
|
966
|
+
dead: true
|
|
967
|
+
});
|
|
968
|
+
this.opts.onAttempt?.(row, false);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const p = row.payload ?? {};
|
|
972
|
+
const notification = {
|
|
973
|
+
notificationId: row.notificationId,
|
|
974
|
+
organizationId: row.organizationId,
|
|
975
|
+
topic: row.topic,
|
|
976
|
+
title: typeof p.title === "string" ? p.title : row.topic ?? "",
|
|
977
|
+
body: typeof p.body === "string" ? p.body : "",
|
|
978
|
+
severity: p.severity ?? "info",
|
|
979
|
+
recipients: [row.recipientId],
|
|
980
|
+
channels: [row.channel],
|
|
981
|
+
actionUrl: typeof p.actionUrl === "string" ? p.actionUrl : void 0,
|
|
982
|
+
payload: p
|
|
983
|
+
};
|
|
984
|
+
let result;
|
|
985
|
+
try {
|
|
986
|
+
result = await channel.send(this.opts.channelContext, {
|
|
987
|
+
notification,
|
|
988
|
+
channel: row.channel,
|
|
989
|
+
recipient: row.recipientId
|
|
990
|
+
});
|
|
991
|
+
} catch (err) {
|
|
992
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
993
|
+
}
|
|
994
|
+
const errorClass = !result.ok && channel.classifyError ? channel.classifyError(result.error) : void 0;
|
|
995
|
+
const now = this.opts.now?.() ?? Date.now();
|
|
996
|
+
const ack = classifyDeliveryAttempt(result, errorClass, row.attempts, now, this.opts.rng);
|
|
997
|
+
await this.opts.outbox.ack(row.id, ack);
|
|
998
|
+
this.opts.onAttempt?.(row, result.ok);
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
function stableNodeOffset(nodeId, partitionCount) {
|
|
1002
|
+
let h = 0;
|
|
1003
|
+
for (let i = 0; i < nodeId.length; i++) h = h * 31 + nodeId.charCodeAt(i) | 0;
|
|
1004
|
+
return Math.abs(h) % partitionCount;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/retention.ts
|
|
1008
|
+
var DEFAULT_RETENTION_TARGETS = [
|
|
1009
|
+
{ object: RECEIPT_OBJECT, tsField: "created_at", format: "iso" },
|
|
1010
|
+
{ object: INBOX_OBJECT, tsField: "created_at", format: "iso" },
|
|
1011
|
+
{ object: DELIVERY_OBJECT, tsField: "created_at", format: "epoch" },
|
|
1012
|
+
{ object: NOTIFICATION_EVENT_OBJECT, tsField: "created_at", format: "iso" }
|
|
1013
|
+
];
|
|
1014
|
+
var NotificationRetention = class {
|
|
1015
|
+
constructor(opts) {
|
|
1016
|
+
this.opts = opts;
|
|
1017
|
+
this.now = opts.now ?? (() => Date.now());
|
|
1018
|
+
this.targets = opts.targets ?? DEFAULT_RETENTION_TARGETS;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Delete pipeline rows older than `retentionDays`. Returns one outcome per
|
|
1022
|
+
* swept object. No-op (empty result) when no data engine is available or
|
|
1023
|
+
* `retentionDays` is not a positive number.
|
|
1024
|
+
*/
|
|
1025
|
+
async prune(retentionDays) {
|
|
1026
|
+
const data = this.opts.getData();
|
|
1027
|
+
if (!data) {
|
|
1028
|
+
this.opts.logger.warn("[messaging] retention: no data engine; prune skipped");
|
|
1029
|
+
return [];
|
|
1030
|
+
}
|
|
1031
|
+
if (!(retentionDays > 0)) {
|
|
1032
|
+
this.opts.logger.warn(`[messaging] retention: invalid retentionDays=${retentionDays}; prune skipped`);
|
|
1033
|
+
return [];
|
|
1034
|
+
}
|
|
1035
|
+
const cutoffMs = this.now() - retentionDays * 864e5;
|
|
1036
|
+
const cutoffIso = new Date(cutoffMs).toISOString();
|
|
1037
|
+
const outcomes = [];
|
|
1038
|
+
for (const t of this.targets) {
|
|
1039
|
+
const cutoff = t.format === "epoch" ? cutoffMs : cutoffIso;
|
|
1040
|
+
try {
|
|
1041
|
+
const res = await data.delete(t.object, {
|
|
1042
|
+
where: { [t.tsField]: { $lt: cutoff } },
|
|
1043
|
+
multi: true,
|
|
1044
|
+
// System context: retention is an operator policy that spans
|
|
1045
|
+
// tenants, so it must not be scoped by the caller's RLS.
|
|
1046
|
+
context: { isSystem: true }
|
|
1047
|
+
});
|
|
1048
|
+
const deleted = countDeleted(res);
|
|
1049
|
+
outcomes.push({ object: t.object, deleted });
|
|
1050
|
+
if (deleted === void 0 || deleted > 0) {
|
|
1051
|
+
this.opts.logger.info(
|
|
1052
|
+
`[messaging] retention: pruned ${deleted ?? "?"} ${t.object} rows older than ${cutoffIso}`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
const msg3 = err?.message ?? String(err);
|
|
1057
|
+
this.opts.logger.warn(`[messaging] retention: prune of ${t.object} failed (${msg3}); continuing`);
|
|
1058
|
+
outcomes.push({ object: t.object, error: msg3 });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return outcomes;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
function countDeleted(res) {
|
|
1065
|
+
if (typeof res === "number") return res;
|
|
1066
|
+
if (Array.isArray(res)) return res.length;
|
|
1067
|
+
if (res && typeof res === "object") {
|
|
1068
|
+
const r = res;
|
|
1069
|
+
for (const k of ["deletedCount", "deleted", "count", "affected", "affectedRows"]) {
|
|
1070
|
+
if (typeof r[k] === "number") return r[k];
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return void 0;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// src/template-renderer.ts
|
|
1077
|
+
var TEMPLATE_OBJECT = "sys_notification_template";
|
|
1078
|
+
var DEFAULT_LOCALE = "en";
|
|
1079
|
+
var TOKEN = /\{\{\s*([\w.$]+)\s*\}\}/g;
|
|
1080
|
+
function interpolate(template, context) {
|
|
1081
|
+
if (!template) return "";
|
|
1082
|
+
return template.replace(TOKEN, (_m, path) => {
|
|
1083
|
+
const v = lookup(context, path);
|
|
1084
|
+
return v == null ? "" : String(v);
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function lookup(ctx, path) {
|
|
1088
|
+
let cur = ctx;
|
|
1089
|
+
for (const key of path.split(".")) {
|
|
1090
|
+
if (cur == null || typeof cur !== "object") return void 0;
|
|
1091
|
+
cur = cur[key];
|
|
1092
|
+
}
|
|
1093
|
+
return cur;
|
|
1094
|
+
}
|
|
1095
|
+
function renderNotification(template, input) {
|
|
1096
|
+
const ctx = {
|
|
1097
|
+
...input.payload,
|
|
1098
|
+
payload: input.payload,
|
|
1099
|
+
topic: input.topic,
|
|
1100
|
+
title: input.title ?? input.payload.title,
|
|
1101
|
+
body: input.body ?? input.payload.body
|
|
1102
|
+
};
|
|
1103
|
+
if (template && (template.subject || template.body)) {
|
|
1104
|
+
const subject = interpolate(String(template.subject ?? ""), ctx) || String(ctx.title ?? input.topic);
|
|
1105
|
+
const renderedBody = interpolate(String(template.body ?? ""), ctx);
|
|
1106
|
+
const isHtml = template.format === "html" || template.format === "mjml";
|
|
1107
|
+
return isHtml ? { subject, html: renderedBody } : { subject, text: renderedBody };
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
subject: String(ctx.title ?? input.topic),
|
|
1111
|
+
text: String(ctx.body ?? "")
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
var NotificationTemplateStore = class {
|
|
1115
|
+
constructor(opts) {
|
|
1116
|
+
this.opts = opts;
|
|
1117
|
+
this.objectName = opts.objectName ?? TEMPLATE_OBJECT;
|
|
1118
|
+
}
|
|
1119
|
+
async load(topic, channel, locale) {
|
|
1120
|
+
const data = this.opts.getData();
|
|
1121
|
+
if (!data) return null;
|
|
1122
|
+
const candidates = localeCandidates(locale);
|
|
1123
|
+
for (const loc of candidates) {
|
|
1124
|
+
try {
|
|
1125
|
+
const row = await data.findOne(this.objectName, {
|
|
1126
|
+
where: { topic, channel, locale: loc, is_active: true },
|
|
1127
|
+
fields: ["subject", "body", "format"]
|
|
1128
|
+
});
|
|
1129
|
+
if (row) return row;
|
|
1130
|
+
} catch {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
function localeCandidates(locale) {
|
|
1138
|
+
const out = [];
|
|
1139
|
+
const push = (l) => {
|
|
1140
|
+
if (l && !out.includes(l)) out.push(l);
|
|
1141
|
+
};
|
|
1142
|
+
push(locale);
|
|
1143
|
+
if (locale && locale.includes("-")) push(locale.split("-")[0]);
|
|
1144
|
+
push(DEFAULT_LOCALE);
|
|
1145
|
+
return out;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/email-channel.ts
|
|
1149
|
+
var USER_OBJECT2 = "sys_user";
|
|
1150
|
+
var EMAIL_SHAPE = (s) => {
|
|
1151
|
+
if (!s || /\s/.test(s)) return false;
|
|
1152
|
+
const at = s.indexOf("@");
|
|
1153
|
+
if (at <= 0 || at !== s.lastIndexOf("@") || at === s.length - 1) return false;
|
|
1154
|
+
const dot = s.slice(at + 1).indexOf(".");
|
|
1155
|
+
return dot > 0 && dot < s.length - at - 2;
|
|
1156
|
+
};
|
|
1157
|
+
function createEmailChannel(opts) {
|
|
1158
|
+
const userObject = opts.userObject ?? USER_OBJECT2;
|
|
1159
|
+
const defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE;
|
|
1160
|
+
async function resolveAddress(ctx, data, recipient) {
|
|
1161
|
+
if (EMAIL_SHAPE(recipient)) return recipient;
|
|
1162
|
+
if (!data) return void 0;
|
|
1163
|
+
try {
|
|
1164
|
+
const user = await data.findOne(userObject, { where: { id: recipient }, fields: ["email"] });
|
|
1165
|
+
const email = user?.email;
|
|
1166
|
+
return typeof email === "string" && EMAIL_SHAPE(email) ? email : void 0;
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
ctx.logger.warn(`[email] address lookup for '${recipient}' failed (${err.message})`);
|
|
1169
|
+
return void 0;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
id: "email",
|
|
1174
|
+
async send(ctx, delivery) {
|
|
1175
|
+
const email = opts.getEmail();
|
|
1176
|
+
if (!email) {
|
|
1177
|
+
ctx.logger.warn(`[email] no email service registered; '${delivery.recipient}' not emailed`);
|
|
1178
|
+
return { ok: true };
|
|
1179
|
+
}
|
|
1180
|
+
const n = delivery.notification;
|
|
1181
|
+
const address = await resolveAddress(ctx, opts.getData(), delivery.recipient);
|
|
1182
|
+
if (!address) {
|
|
1183
|
+
return { ok: false, error: `no email address for recipient '${delivery.recipient}'` };
|
|
1184
|
+
}
|
|
1185
|
+
const payload = n.payload ?? {};
|
|
1186
|
+
const locale = typeof payload.locale === "string" ? payload.locale : defaultLocale;
|
|
1187
|
+
const template = await opts.store.load(n.topic ?? "", "email", locale);
|
|
1188
|
+
const rendered = renderNotification(template, {
|
|
1189
|
+
topic: n.topic ?? "",
|
|
1190
|
+
payload,
|
|
1191
|
+
title: n.title,
|
|
1192
|
+
body: n.body
|
|
1193
|
+
});
|
|
1194
|
+
try {
|
|
1195
|
+
const result = await email.send({
|
|
1196
|
+
to: address,
|
|
1197
|
+
subject: rendered.subject,
|
|
1198
|
+
...rendered.html !== void 0 ? { html: rendered.html } : {},
|
|
1199
|
+
...rendered.text !== void 0 ? { text: rendered.text } : {}
|
|
1200
|
+
});
|
|
1201
|
+
const id = result?.id;
|
|
1202
|
+
return { ok: true, externalId: id != null ? String(id) : void 0 };
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
return { ok: false, error: `email send failed: ${err.message}` };
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
classifyError(_err) {
|
|
1208
|
+
return "retryable";
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/objects/inbox-message.object.ts
|
|
1214
|
+
var import_data = require("@objectstack/spec/data");
|
|
1215
|
+
var InboxMessage = import_data.ObjectSchema.create({
|
|
1216
|
+
name: "sys_inbox_message",
|
|
1217
|
+
label: "Inbox Message",
|
|
1218
|
+
pluralLabel: "Inbox Messages",
|
|
1219
|
+
icon: "inbox",
|
|
1220
|
+
description: "User-facing in-app notification rows materialized by the inbox messaging channel.",
|
|
1221
|
+
titleFormat: "{title}",
|
|
1222
|
+
compactLayout: ["title", "user_id", "severity", "created_at"],
|
|
1223
|
+
listViews: {
|
|
1224
|
+
mine: {
|
|
1225
|
+
type: "grid",
|
|
1226
|
+
name: "mine",
|
|
1227
|
+
label: "Notifications",
|
|
1228
|
+
data: { provider: "object", object: "sys_inbox_message" },
|
|
1229
|
+
columns: ["title", "topic", "severity", "created_at"],
|
|
1230
|
+
filter: [{ field: "user_id", operator: "equals", value: "{current_user_id}" }],
|
|
1231
|
+
sort: [{ field: "created_at", order: "desc" }],
|
|
1232
|
+
pagination: { pageSize: 50 },
|
|
1233
|
+
emptyState: { title: "Inbox zero", message: "No notifications." }
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
fields: {
|
|
1237
|
+
id: import_data.Field.text({
|
|
1238
|
+
label: "Inbox Message ID",
|
|
1239
|
+
required: true,
|
|
1240
|
+
readonly: true
|
|
1241
|
+
}),
|
|
1242
|
+
user_id: import_data.Field.text({
|
|
1243
|
+
label: "Recipient User",
|
|
1244
|
+
required: true,
|
|
1245
|
+
searchable: true
|
|
1246
|
+
}),
|
|
1247
|
+
notification_id: import_data.Field.text({
|
|
1248
|
+
label: "Notification Event",
|
|
1249
|
+
searchable: true,
|
|
1250
|
+
description: "FK \u2192 sys_notification (the L2 event this row materializes)"
|
|
1251
|
+
}),
|
|
1252
|
+
delivery_id: import_data.Field.text({
|
|
1253
|
+
label: "Delivery",
|
|
1254
|
+
description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
|
|
1255
|
+
}),
|
|
1256
|
+
topic: import_data.Field.text({
|
|
1257
|
+
label: "Topic",
|
|
1258
|
+
searchable: true
|
|
1259
|
+
}),
|
|
1260
|
+
title: import_data.Field.text({
|
|
1261
|
+
label: "Title",
|
|
1262
|
+
required: true
|
|
1263
|
+
}),
|
|
1264
|
+
body_md: import_data.Field.markdown({
|
|
1265
|
+
label: "Body"
|
|
1266
|
+
}),
|
|
1267
|
+
severity: import_data.Field.select({
|
|
1268
|
+
label: "Severity",
|
|
1269
|
+
options: [
|
|
1270
|
+
{ label: "Info", value: "info" },
|
|
1271
|
+
{ label: "Warning", value: "warning" },
|
|
1272
|
+
{ label: "Critical", value: "critical" }
|
|
1273
|
+
]
|
|
1274
|
+
}),
|
|
1275
|
+
action_url: import_data.Field.text({
|
|
1276
|
+
label: "Action URL"
|
|
1277
|
+
}),
|
|
1278
|
+
created_at: import_data.Field.datetime({
|
|
1279
|
+
label: "Created At",
|
|
1280
|
+
readonly: true
|
|
1281
|
+
})
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
// src/objects/notification-receipt.object.ts
|
|
1286
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
1287
|
+
var NotificationReceipt = import_data2.ObjectSchema.create({
|
|
1288
|
+
name: "sys_notification_receipt",
|
|
1289
|
+
label: "Notification Receipt",
|
|
1290
|
+
pluralLabel: "Notification Receipts",
|
|
1291
|
+
icon: "check-check",
|
|
1292
|
+
isSystem: true,
|
|
1293
|
+
managedBy: "system",
|
|
1294
|
+
description: "Per-recipient \xD7 channel receipt; the source of truth for notification read-state.",
|
|
1295
|
+
titleFormat: "{state}",
|
|
1296
|
+
compactLayout: ["notification_id", "user_id", "channel", "state", "at"],
|
|
1297
|
+
fields: {
|
|
1298
|
+
id: import_data2.Field.text({
|
|
1299
|
+
label: "Receipt ID",
|
|
1300
|
+
required: true,
|
|
1301
|
+
readonly: true
|
|
1302
|
+
}),
|
|
1303
|
+
notification_id: import_data2.Field.text({
|
|
1304
|
+
label: "Notification Event",
|
|
1305
|
+
required: true,
|
|
1306
|
+
searchable: true,
|
|
1307
|
+
description: "FK \u2192 sys_notification (L2 event)"
|
|
1308
|
+
}),
|
|
1309
|
+
delivery_id: import_data2.Field.text({
|
|
1310
|
+
label: "Delivery",
|
|
1311
|
+
required: false,
|
|
1312
|
+
description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
|
|
1313
|
+
}),
|
|
1314
|
+
user_id: import_data2.Field.text({
|
|
1315
|
+
label: "Recipient User",
|
|
1316
|
+
required: true,
|
|
1317
|
+
searchable: true
|
|
1318
|
+
}),
|
|
1319
|
+
channel: import_data2.Field.text({
|
|
1320
|
+
label: "Channel",
|
|
1321
|
+
required: true,
|
|
1322
|
+
description: "Channel id this receipt is for (inbox / email / push / \u2026)"
|
|
1323
|
+
}),
|
|
1324
|
+
state: import_data2.Field.select(["delivered", "read", "clicked", "dismissed"], {
|
|
1325
|
+
label: "State",
|
|
1326
|
+
required: true,
|
|
1327
|
+
defaultValue: "delivered"
|
|
1328
|
+
}),
|
|
1329
|
+
at: import_data2.Field.datetime({
|
|
1330
|
+
label: "At",
|
|
1331
|
+
required: false,
|
|
1332
|
+
description: "When the receipt reached its current state"
|
|
1333
|
+
}),
|
|
1334
|
+
created_at: import_data2.Field.datetime({
|
|
1335
|
+
label: "Created At",
|
|
1336
|
+
readonly: true
|
|
1337
|
+
})
|
|
1338
|
+
},
|
|
1339
|
+
indexes: [
|
|
1340
|
+
{ fields: ["notification_id", "user_id", "channel"], unique: true },
|
|
1341
|
+
{ fields: ["user_id", "state"] }
|
|
1342
|
+
]
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// src/objects/notification-delivery.object.ts
|
|
1346
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1347
|
+
var NotificationDelivery = import_data3.ObjectSchema.create({
|
|
1348
|
+
name: "sys_notification_delivery",
|
|
1349
|
+
label: "Notification Delivery",
|
|
1350
|
+
pluralLabel: "Notification Deliveries",
|
|
1351
|
+
icon: "send",
|
|
1352
|
+
isSystem: true,
|
|
1353
|
+
managedBy: "system",
|
|
1354
|
+
description: "Durable per-recipient \xD7 channel delivery outbox (ADR-0030 Layer 4).",
|
|
1355
|
+
titleFormat: "{channel} \u2192 {recipient_id}",
|
|
1356
|
+
compactLayout: ["notification_id", "recipient_id", "channel", "status", "attempts"],
|
|
1357
|
+
fields: {
|
|
1358
|
+
id: import_data3.Field.text({ label: "Delivery ID", required: true, readonly: true }),
|
|
1359
|
+
notification_id: import_data3.Field.text({
|
|
1360
|
+
label: "Notification Event",
|
|
1361
|
+
required: true,
|
|
1362
|
+
searchable: true,
|
|
1363
|
+
description: "FK \u2192 sys_notification (L2 event)"
|
|
1364
|
+
}),
|
|
1365
|
+
recipient_id: import_data3.Field.text({ label: "Recipient User", required: true, searchable: true }),
|
|
1366
|
+
channel: import_data3.Field.text({ label: "Channel", required: true }),
|
|
1367
|
+
topic: import_data3.Field.text({ label: "Topic", searchable: true }),
|
|
1368
|
+
payload: import_data3.Field.json({
|
|
1369
|
+
label: "Payload",
|
|
1370
|
+
description: "Snapshot of the rendered notification content for dispatch."
|
|
1371
|
+
}),
|
|
1372
|
+
status: import_data3.Field.select(["pending", "in_flight", "success", "failed", "dead", "suppressed"], {
|
|
1373
|
+
label: "Status",
|
|
1374
|
+
required: true,
|
|
1375
|
+
defaultValue: "pending"
|
|
1376
|
+
}),
|
|
1377
|
+
attempts: import_data3.Field.number({ label: "Attempts", defaultValue: 0 }),
|
|
1378
|
+
partition_key: import_data3.Field.number({ label: "Partition Key", defaultValue: 0 }),
|
|
1379
|
+
claimed_by: import_data3.Field.text({ label: "Claimed By", description: "Node id while in_flight" }),
|
|
1380
|
+
claimed_at: import_data3.Field.number({ label: "Claimed At (ms)" }),
|
|
1381
|
+
next_attempt_at: import_data3.Field.number({ label: "Next Attempt At (ms)" }),
|
|
1382
|
+
last_attempted_at: import_data3.Field.number({ label: "Last Attempted At (ms)" }),
|
|
1383
|
+
error: import_data3.Field.textarea({ label: "Error" }),
|
|
1384
|
+
created_at: import_data3.Field.number({ label: "Created At (ms)", readonly: true }),
|
|
1385
|
+
updated_at: import_data3.Field.number({ label: "Updated At (ms)" })
|
|
1386
|
+
},
|
|
1387
|
+
indexes: [
|
|
1388
|
+
// Dedup: one delivery per (event, recipient, channel).
|
|
1389
|
+
{ fields: ["notification_id", "recipient_id", "channel"], unique: true },
|
|
1390
|
+
// The hot claim query.
|
|
1391
|
+
{ fields: ["status", "partition_key", "next_attempt_at"] },
|
|
1392
|
+
// Stale-in_flight reaper.
|
|
1393
|
+
{ fields: ["status", "claimed_at"] },
|
|
1394
|
+
{ fields: ["notification_id"] }
|
|
1395
|
+
]
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
// src/objects/notification-preference.object.ts
|
|
1399
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
1400
|
+
var NotificationPreference = import_data4.ObjectSchema.create({
|
|
1401
|
+
name: "sys_notification_preference",
|
|
1402
|
+
label: "Notification Preference",
|
|
1403
|
+
pluralLabel: "Notification Preferences",
|
|
1404
|
+
icon: "bell-ring",
|
|
1405
|
+
isSystem: true,
|
|
1406
|
+
managedBy: "system",
|
|
1407
|
+
description: "Per-user \xD7 topic \xD7 channel notification toggle (mute/allow), with admin-global defaults.",
|
|
1408
|
+
titleFormat: "{user_id} \xB7 {topic} \xB7 {channel}",
|
|
1409
|
+
compactLayout: ["user_id", "topic", "channel", "enabled", "digest"],
|
|
1410
|
+
fields: {
|
|
1411
|
+
id: import_data4.Field.text({ label: "Preference ID", required: true, readonly: true }),
|
|
1412
|
+
user_id: import_data4.Field.text({
|
|
1413
|
+
label: "User",
|
|
1414
|
+
required: true,
|
|
1415
|
+
searchable: true,
|
|
1416
|
+
description: "Recipient user id, or '*' for the admin-global default."
|
|
1417
|
+
}),
|
|
1418
|
+
topic: import_data4.Field.text({
|
|
1419
|
+
label: "Topic",
|
|
1420
|
+
required: true,
|
|
1421
|
+
searchable: true,
|
|
1422
|
+
defaultValue: "*",
|
|
1423
|
+
description: "Notification topic, or '*' for all topics."
|
|
1424
|
+
}),
|
|
1425
|
+
channel: import_data4.Field.text({
|
|
1426
|
+
label: "Channel",
|
|
1427
|
+
required: true,
|
|
1428
|
+
defaultValue: "*",
|
|
1429
|
+
description: "Channel id (inbox/email/push/\u2026), or '*' for all channels."
|
|
1430
|
+
}),
|
|
1431
|
+
enabled: import_data4.Field.boolean({
|
|
1432
|
+
label: "Enabled",
|
|
1433
|
+
defaultValue: true,
|
|
1434
|
+
description: "When false, this (user, topic, channel) is muted."
|
|
1435
|
+
}),
|
|
1436
|
+
digest: import_data4.Field.select(["none", "daily", "weekly"], {
|
|
1437
|
+
label: "Digest",
|
|
1438
|
+
required: false,
|
|
1439
|
+
defaultValue: "none",
|
|
1440
|
+
description: "Batch cadence (P3 digest middleware)."
|
|
1441
|
+
}),
|
|
1442
|
+
quiet_hours: import_data4.Field.json({
|
|
1443
|
+
label: "Quiet Hours",
|
|
1444
|
+
required: false,
|
|
1445
|
+
description: "Optional { tz, start, end } window (P3 quiet-hours middleware)."
|
|
1446
|
+
}),
|
|
1447
|
+
created_at: import_data4.Field.datetime({ label: "Created At", readonly: true }),
|
|
1448
|
+
updated_at: import_data4.Field.datetime({ label: "Updated At", required: false })
|
|
1449
|
+
},
|
|
1450
|
+
indexes: [
|
|
1451
|
+
{ fields: ["user_id", "topic", "channel"], unique: true },
|
|
1452
|
+
{ fields: ["topic"] }
|
|
1453
|
+
]
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// src/objects/notification-subscription.object.ts
|
|
1457
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
1458
|
+
var NotificationSubscription = import_data5.ObjectSchema.create({
|
|
1459
|
+
name: "sys_notification_subscription",
|
|
1460
|
+
label: "Notification Subscription",
|
|
1461
|
+
pluralLabel: "Notification Subscriptions",
|
|
1462
|
+
icon: "rss",
|
|
1463
|
+
isSystem: true,
|
|
1464
|
+
managedBy: "system",
|
|
1465
|
+
description: "Standing subscription of a principal (role/team/user) to a notification topic.",
|
|
1466
|
+
titleFormat: "{principal} \xB7 {topic}",
|
|
1467
|
+
compactLayout: ["topic", "principal", "enabled", "created_at"],
|
|
1468
|
+
fields: {
|
|
1469
|
+
id: import_data5.Field.text({ label: "Subscription ID", required: true, readonly: true }),
|
|
1470
|
+
topic: import_data5.Field.text({
|
|
1471
|
+
label: "Topic",
|
|
1472
|
+
required: true,
|
|
1473
|
+
searchable: true,
|
|
1474
|
+
description: "Notification topic this principal subscribes to."
|
|
1475
|
+
}),
|
|
1476
|
+
principal: import_data5.Field.text({
|
|
1477
|
+
label: "Principal",
|
|
1478
|
+
required: true,
|
|
1479
|
+
searchable: true,
|
|
1480
|
+
description: "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | bare user id."
|
|
1481
|
+
}),
|
|
1482
|
+
enabled: import_data5.Field.boolean({
|
|
1483
|
+
label: "Enabled",
|
|
1484
|
+
defaultValue: true,
|
|
1485
|
+
description: "When false, the subscription is inactive."
|
|
1486
|
+
}),
|
|
1487
|
+
created_at: import_data5.Field.datetime({ label: "Created At", readonly: true })
|
|
1488
|
+
},
|
|
1489
|
+
indexes: [
|
|
1490
|
+
{ fields: ["topic", "principal"], unique: true },
|
|
1491
|
+
{ fields: ["topic"] }
|
|
1492
|
+
]
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// src/objects/notification-template.object.ts
|
|
1496
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
1497
|
+
var NotificationTemplate = import_data6.ObjectSchema.create({
|
|
1498
|
+
name: "sys_notification_template",
|
|
1499
|
+
label: "Notification Template",
|
|
1500
|
+
pluralLabel: "Notification Templates",
|
|
1501
|
+
icon: "file-text",
|
|
1502
|
+
isSystem: true,
|
|
1503
|
+
managedBy: "system",
|
|
1504
|
+
description: "Per (topic \xD7 channel \xD7 locale) render template for notifications.",
|
|
1505
|
+
titleFormat: "{topic} \xB7 {channel} \xB7 {locale}",
|
|
1506
|
+
compactLayout: ["topic", "channel", "locale", "is_active"],
|
|
1507
|
+
fields: {
|
|
1508
|
+
id: import_data6.Field.text({ label: "Template ID", required: true, readonly: true }),
|
|
1509
|
+
topic: import_data6.Field.text({ label: "Topic", required: true, searchable: true }),
|
|
1510
|
+
channel: import_data6.Field.text({
|
|
1511
|
+
label: "Channel",
|
|
1512
|
+
required: true,
|
|
1513
|
+
defaultValue: "email",
|
|
1514
|
+
description: "Channel id this template renders for (email/inbox/push/\u2026)."
|
|
1515
|
+
}),
|
|
1516
|
+
locale: import_data6.Field.text({
|
|
1517
|
+
label: "Locale",
|
|
1518
|
+
required: true,
|
|
1519
|
+
defaultValue: "en",
|
|
1520
|
+
description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'."
|
|
1521
|
+
}),
|
|
1522
|
+
version: import_data6.Field.number({
|
|
1523
|
+
label: "Version",
|
|
1524
|
+
required: false,
|
|
1525
|
+
defaultValue: 1
|
|
1526
|
+
}),
|
|
1527
|
+
subject: import_data6.Field.text({
|
|
1528
|
+
label: "Subject / Title",
|
|
1529
|
+
required: false,
|
|
1530
|
+
description: "Rendered into the email subject / inbox title. Supports {{ payload.x }}."
|
|
1531
|
+
}),
|
|
1532
|
+
body: import_data6.Field.markdown({
|
|
1533
|
+
label: "Body",
|
|
1534
|
+
required: false,
|
|
1535
|
+
description: "Template body. Supports {{ payload.x }}. Interpreted per `format`."
|
|
1536
|
+
}),
|
|
1537
|
+
format: import_data6.Field.select(["markdown", "html", "text", "mjml"], {
|
|
1538
|
+
label: "Body Format",
|
|
1539
|
+
required: false,
|
|
1540
|
+
defaultValue: "markdown"
|
|
1541
|
+
}),
|
|
1542
|
+
is_active: import_data6.Field.boolean({
|
|
1543
|
+
label: "Active",
|
|
1544
|
+
defaultValue: true,
|
|
1545
|
+
description: "Only active templates are selected at render time."
|
|
1546
|
+
}),
|
|
1547
|
+
created_at: import_data6.Field.datetime({ label: "Created At", readonly: true }),
|
|
1548
|
+
updated_at: import_data6.Field.datetime({ label: "Updated At", required: false })
|
|
1549
|
+
},
|
|
1550
|
+
indexes: [
|
|
1551
|
+
{ fields: ["topic", "channel", "locale"] },
|
|
1552
|
+
{ fields: ["topic"] }
|
|
1553
|
+
]
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// src/messaging-service-plugin.ts
|
|
1557
|
+
var MessagingServicePlugin = class {
|
|
1558
|
+
constructor(options = {}) {
|
|
1559
|
+
this.name = "com.objectstack.service.messaging";
|
|
1560
|
+
this.version = "1.0.0";
|
|
1561
|
+
this.type = "standard";
|
|
1562
|
+
this.dependencies = ["com.objectstack.engine.objectql"];
|
|
1563
|
+
this.options = {
|
|
1564
|
+
registerInbox: true,
|
|
1565
|
+
reliableDelivery: true,
|
|
1566
|
+
partitionCount: 8,
|
|
1567
|
+
dispatchIntervalMs: 500,
|
|
1568
|
+
mandatoryTopics: [],
|
|
1569
|
+
retentionDays: 0,
|
|
1570
|
+
retentionSweepMs: 36e5,
|
|
1571
|
+
...options
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
async init(ctx) {
|
|
1575
|
+
const getData = () => {
|
|
1576
|
+
try {
|
|
1577
|
+
return ctx.getService("data") ?? ctx.getService("objectql");
|
|
1578
|
+
} catch {
|
|
1579
|
+
return void 0;
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
const service = new MessagingService({
|
|
1583
|
+
logger: ctx.logger,
|
|
1584
|
+
getData,
|
|
1585
|
+
mandatoryTopics: this.options.mandatoryTopics
|
|
1586
|
+
});
|
|
1587
|
+
if (this.options.registerInbox) {
|
|
1588
|
+
service.registerChannel(createInboxChannel({ getData }));
|
|
1589
|
+
}
|
|
1590
|
+
ctx.registerService("messaging", service);
|
|
1591
|
+
ctx.getService("manifest").register({
|
|
1592
|
+
id: "com.objectstack.service.messaging",
|
|
1593
|
+
name: "Messaging Service",
|
|
1594
|
+
version: "1.0.0",
|
|
1595
|
+
type: "plugin",
|
|
1596
|
+
scope: "system",
|
|
1597
|
+
objects: [
|
|
1598
|
+
InboxMessage,
|
|
1599
|
+
NotificationReceipt,
|
|
1600
|
+
NotificationDelivery,
|
|
1601
|
+
NotificationPreference,
|
|
1602
|
+
NotificationSubscription,
|
|
1603
|
+
NotificationTemplate
|
|
1604
|
+
],
|
|
1605
|
+
navigationContributions: [
|
|
1606
|
+
{
|
|
1607
|
+
app: "setup",
|
|
1608
|
+
group: "group_configuration",
|
|
1609
|
+
priority: 120,
|
|
1610
|
+
items: [
|
|
1611
|
+
{ id: "nav_notification_preferences", type: "object", label: "Notification Preferences", objectName: "sys_notification_preference", icon: "bell-ring", requiresObject: "sys_notification_preference" },
|
|
1612
|
+
{ id: "nav_notification_subscriptions", type: "object", label: "Notification Subscriptions", objectName: "sys_notification_subscription", icon: "rss", requiresObject: "sys_notification_subscription" },
|
|
1613
|
+
{ id: "nav_notification_templates", type: "object", label: "Notification Templates", objectName: "sys_notification_template", icon: "file-text", requiresObject: "sys_notification_template" }
|
|
1614
|
+
]
|
|
1615
|
+
}
|
|
1616
|
+
]
|
|
1617
|
+
});
|
|
1618
|
+
if (typeof ctx.hook === "function") {
|
|
1619
|
+
const templateStore = new NotificationTemplateStore({ getData });
|
|
1620
|
+
const getEmail = () => {
|
|
1621
|
+
try {
|
|
1622
|
+
return ctx.getService("email");
|
|
1623
|
+
} catch {
|
|
1624
|
+
return void 0;
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
ctx.hook("kernel:ready", async () => {
|
|
1628
|
+
if (getEmail()) {
|
|
1629
|
+
service.registerChannel(createEmailChannel({ getEmail, getData, store: templateStore }));
|
|
1630
|
+
ctx.logger.info("[messaging] email channel registered (renders sys_notification_template)");
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
if (this.options.reliableDelivery && typeof ctx.hook === "function") {
|
|
1635
|
+
ctx.hook("kernel:ready", async () => {
|
|
1636
|
+
const engine = getData();
|
|
1637
|
+
if (!engine) {
|
|
1638
|
+
ctx.logger.warn("[messaging] no data engine at kernel:ready \u2014 reliable delivery disabled (inline fan-out)");
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const outbox = new SqlNotificationOutbox(engine, { partitionCount: this.options.partitionCount });
|
|
1642
|
+
service.setOutbox(outbox);
|
|
1643
|
+
let cluster;
|
|
1644
|
+
try {
|
|
1645
|
+
cluster = ctx.getService("cluster");
|
|
1646
|
+
} catch {
|
|
1647
|
+
cluster = void 0;
|
|
1648
|
+
}
|
|
1649
|
+
this.dispatcher = new NotificationDispatcher({
|
|
1650
|
+
nodeId: `notify-${process.pid}-${(0, import_node_crypto2.randomUUID)().slice(0, 8)}`,
|
|
1651
|
+
outbox,
|
|
1652
|
+
channels: service,
|
|
1653
|
+
channelContext: { logger: ctx.logger },
|
|
1654
|
+
cluster,
|
|
1655
|
+
partitionCount: this.options.partitionCount,
|
|
1656
|
+
intervalMs: this.options.dispatchIntervalMs,
|
|
1657
|
+
logger: ctx.logger
|
|
1658
|
+
});
|
|
1659
|
+
this.dispatcher.start();
|
|
1660
|
+
ctx.logger.info(
|
|
1661
|
+
`[messaging] reliable delivery on (outbox + dispatcher, ${this.options.partitionCount} partitions${cluster ? ", clustered" : ", single-node"})`
|
|
1662
|
+
);
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
if (this.options.retentionDays > 0 && typeof ctx.hook === "function") {
|
|
1666
|
+
ctx.hook("kernel:ready", async () => {
|
|
1667
|
+
const retention = new NotificationRetention({ getData, logger: ctx.logger });
|
|
1668
|
+
const days = this.options.retentionDays;
|
|
1669
|
+
const sweep = () => {
|
|
1670
|
+
void retention.prune(days).catch(
|
|
1671
|
+
(err) => ctx.logger.warn(`[messaging] retention sweep failed: ${err?.message ?? err}`)
|
|
1672
|
+
);
|
|
1673
|
+
};
|
|
1674
|
+
sweep();
|
|
1675
|
+
this.retentionTimer = setInterval(sweep, this.options.retentionSweepMs);
|
|
1676
|
+
this.retentionTimer.unref?.();
|
|
1677
|
+
ctx.logger.info(
|
|
1678
|
+
`[messaging] retention on (prune > ${days}d every ${Math.round(this.options.retentionSweepMs / 1e3)}s)`
|
|
1679
|
+
);
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
ctx.logger.info(
|
|
1683
|
+
`[messaging] service registered with channels: ${service.getRegisteredChannels().join(", ") || "(none)"}`
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
/** Stop the dispatcher loop + retention sweep on shutdown. */
|
|
1687
|
+
async stop() {
|
|
1688
|
+
await this.dispatcher?.stop();
|
|
1689
|
+
this.dispatcher = void 0;
|
|
1690
|
+
if (this.retentionTimer) {
|
|
1691
|
+
clearInterval(this.retentionTimer);
|
|
1692
|
+
this.retentionTimer = void 0;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
// src/memory-outbox.ts
|
|
1698
|
+
var import_node_crypto3 = require("crypto");
|
|
1699
|
+
var MemoryNotificationOutbox = class {
|
|
1700
|
+
constructor(partitionCount = 8, clock = () => Date.now()) {
|
|
1701
|
+
this.partitionCount = partitionCount;
|
|
1702
|
+
this.clock = clock;
|
|
1703
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
1704
|
+
}
|
|
1705
|
+
async enqueue(input) {
|
|
1706
|
+
for (const r of this.rows.values()) {
|
|
1707
|
+
if (r.notificationId === input.notificationId && r.recipientId === input.recipientId && r.channel === input.channel) {
|
|
1708
|
+
return r.id;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const id = (0, import_node_crypto3.randomUUID)();
|
|
1712
|
+
const now = this.clock();
|
|
1713
|
+
this.rows.set(id, {
|
|
1714
|
+
id,
|
|
1715
|
+
notificationId: input.notificationId,
|
|
1716
|
+
recipientId: input.recipientId,
|
|
1717
|
+
channel: input.channel,
|
|
1718
|
+
topic: input.topic,
|
|
1719
|
+
payload: input.payload ?? {},
|
|
1720
|
+
organizationId: input.organizationId,
|
|
1721
|
+
partitionKey: hashPartition(input.notificationId, this.partitionCount),
|
|
1722
|
+
status: "pending",
|
|
1723
|
+
attempts: 0,
|
|
1724
|
+
// Deferred dispatch (quiet-hours, P3): claim() skips pending rows
|
|
1725
|
+
// whose nextAttemptAt is still in the future.
|
|
1726
|
+
nextAttemptAt: input.notBefore,
|
|
1727
|
+
createdAt: now,
|
|
1728
|
+
updatedAt: now
|
|
1729
|
+
});
|
|
1730
|
+
return id;
|
|
1731
|
+
}
|
|
1732
|
+
async claim(opts) {
|
|
1733
|
+
const now = opts.now ?? this.clock();
|
|
1734
|
+
for (const r of this.rows.values()) {
|
|
1735
|
+
if (r.status === "in_flight" && (r.claimedAt ?? 0) < now - opts.claimTtlMs) {
|
|
1736
|
+
r.status = "pending";
|
|
1737
|
+
r.claimedBy = void 0;
|
|
1738
|
+
r.claimedAt = void 0;
|
|
1739
|
+
r.updatedAt = now;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
const out = [];
|
|
1743
|
+
for (const r of this.rows.values()) {
|
|
1744
|
+
if (out.length >= opts.limit) break;
|
|
1745
|
+
if (r.status !== "pending") continue;
|
|
1746
|
+
if (opts.partition && r.partitionKey !== opts.partition.index) continue;
|
|
1747
|
+
if (r.nextAttemptAt != null && r.nextAttemptAt > now) continue;
|
|
1748
|
+
r.status = "in_flight";
|
|
1749
|
+
r.claimedBy = opts.nodeId;
|
|
1750
|
+
r.claimedAt = now;
|
|
1751
|
+
r.updatedAt = now;
|
|
1752
|
+
out.push({ ...r });
|
|
1753
|
+
}
|
|
1754
|
+
return out;
|
|
1755
|
+
}
|
|
1756
|
+
async ack(id, result) {
|
|
1757
|
+
const r = this.rows.get(id);
|
|
1758
|
+
if (!r) return;
|
|
1759
|
+
const now = this.clock();
|
|
1760
|
+
r.attempts += 1;
|
|
1761
|
+
r.lastAttemptedAt = now;
|
|
1762
|
+
r.claimedBy = void 0;
|
|
1763
|
+
r.claimedAt = void 0;
|
|
1764
|
+
r.updatedAt = now;
|
|
1765
|
+
if (result.success) {
|
|
1766
|
+
r.status = "success";
|
|
1767
|
+
r.nextAttemptAt = void 0;
|
|
1768
|
+
r.error = void 0;
|
|
1769
|
+
} else if (result.suppressed) {
|
|
1770
|
+
r.status = "suppressed";
|
|
1771
|
+
r.error = result.error;
|
|
1772
|
+
} else if (result.dead) {
|
|
1773
|
+
r.status = "dead";
|
|
1774
|
+
r.error = result.error;
|
|
1775
|
+
} else {
|
|
1776
|
+
r.status = "pending";
|
|
1777
|
+
r.nextAttemptAt = result.nextAttemptAt;
|
|
1778
|
+
r.error = result.error;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
async list(filter) {
|
|
1782
|
+
let rows = [...this.rows.values()];
|
|
1783
|
+
if (filter?.status) rows = rows.filter((r) => r.status === filter.status);
|
|
1784
|
+
if (filter?.notificationId) rows = rows.filter((r) => r.notificationId === filter.notificationId);
|
|
1785
|
+
return rows.map((r) => ({ ...r }));
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1789
|
+
0 && (module.exports = {
|
|
1790
|
+
DEFAULT_LOCALE,
|
|
1791
|
+
DEFAULT_RETENTION_TARGETS,
|
|
1792
|
+
DELIVERY_OBJECT,
|
|
1793
|
+
EMAIL_USER_OBJECT,
|
|
1794
|
+
INBOX_OBJECT,
|
|
1795
|
+
InboxMessage,
|
|
1796
|
+
MEMBER_OBJECT,
|
|
1797
|
+
MemoryNotificationOutbox,
|
|
1798
|
+
MessagingService,
|
|
1799
|
+
MessagingServicePlugin,
|
|
1800
|
+
NOTIFICATION_EVENT_OBJECT,
|
|
1801
|
+
NotificationDelivery,
|
|
1802
|
+
NotificationDispatcher,
|
|
1803
|
+
NotificationPreference,
|
|
1804
|
+
NotificationReceipt,
|
|
1805
|
+
NotificationRetention,
|
|
1806
|
+
NotificationSubscription,
|
|
1807
|
+
NotificationTemplate,
|
|
1808
|
+
NotificationTemplateStore,
|
|
1809
|
+
PREFERENCE_OBJECT,
|
|
1810
|
+
PreferenceResolver,
|
|
1811
|
+
RECEIPT_OBJECT,
|
|
1812
|
+
RecipientResolver,
|
|
1813
|
+
SqlNotificationOutbox,
|
|
1814
|
+
TEAM_MEMBER_OBJECT,
|
|
1815
|
+
TEMPLATE_OBJECT,
|
|
1816
|
+
USER_OBJECT,
|
|
1817
|
+
classifyDeliveryAttempt,
|
|
1818
|
+
createEmailChannel,
|
|
1819
|
+
createInboxChannel,
|
|
1820
|
+
hashPartition,
|
|
1821
|
+
interpolate,
|
|
1822
|
+
nextRetryDelayMs,
|
|
1823
|
+
quietHoursDeferral,
|
|
1824
|
+
renderNotification
|
|
1825
|
+
});
|
|
1826
|
+
//# sourceMappingURL=index.cjs.map
|