@web-ts-toolkit/message-service 0.6.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 +201 -0
- package/README.md +198 -0
- package/index.d.mts +607 -0
- package/index.d.ts +607 -0
- package/index.js +802 -0
- package/index.mjs +742 -0
- package/package.json +51 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// src/schemas/base.ts
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
var MESSAGE_MODEL_NAME = "Message";
|
|
4
|
+
var MESSAGE_ARCHIVE_MODEL_NAME = "MessageArchive";
|
|
5
|
+
var MessageContentSchema = new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
title: { type: String, default: "" },
|
|
8
|
+
long: { type: String, default: "" },
|
|
9
|
+
short: { type: String, default: "" }
|
|
10
|
+
},
|
|
11
|
+
{ _id: false }
|
|
12
|
+
);
|
|
13
|
+
function defaultMessageContent() {
|
|
14
|
+
return { title: "", long: "", short: "" };
|
|
15
|
+
}
|
|
16
|
+
var BaseMessageFields = {
|
|
17
|
+
templateCd: { type: String, default: "" },
|
|
18
|
+
type: { type: String, default: "notification" },
|
|
19
|
+
fromUser: { type: mongoose.Schema.Types.ObjectId, default: null },
|
|
20
|
+
toUser: { type: mongoose.Schema.Types.ObjectId, default: null },
|
|
21
|
+
toRoles: { type: [String], default: [] },
|
|
22
|
+
senderContent: { type: MessageContentSchema, default: defaultMessageContent },
|
|
23
|
+
receiverContent: { type: MessageContentSchema, default: defaultMessageContent },
|
|
24
|
+
documents: { type: [mongoose.Schema.Types.ObjectId], default: [] },
|
|
25
|
+
paymentSession: { type: String, default: null },
|
|
26
|
+
paymentCd: { type: String, default: "" },
|
|
27
|
+
payload: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
28
|
+
display: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
29
|
+
clientRequestId: { type: String, default: null }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/schemas/message.ts
|
|
33
|
+
import mongoose2 from "mongoose";
|
|
34
|
+
|
|
35
|
+
// src/template-registry.ts
|
|
36
|
+
var TemplateRegistry = class {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.templates = /* @__PURE__ */ new Map();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register a template. Overwrites if templateCd already exists.
|
|
42
|
+
*/
|
|
43
|
+
register(template) {
|
|
44
|
+
this.templates.set(template.templateCd, template);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Register multiple templates at once.
|
|
48
|
+
*/
|
|
49
|
+
registerAll(templates) {
|
|
50
|
+
for (const t of templates) {
|
|
51
|
+
this.register(t);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Find a template by its templateCd.
|
|
56
|
+
*/
|
|
57
|
+
find(templateCd) {
|
|
58
|
+
return this.templates.get(templateCd);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check if a template exists.
|
|
62
|
+
*/
|
|
63
|
+
has(templateCd) {
|
|
64
|
+
return this.templates.has(templateCd);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get all registered template codes.
|
|
68
|
+
*/
|
|
69
|
+
getTemplateCodes() {
|
|
70
|
+
return Array.from(this.templates.keys());
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get all registered templates.
|
|
74
|
+
*/
|
|
75
|
+
getAll() {
|
|
76
|
+
return Array.from(this.templates.values());
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove a template by templateCd.
|
|
80
|
+
*/
|
|
81
|
+
unregister(templateCd) {
|
|
82
|
+
return this.templates.delete(templateCd);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Clear all templates.
|
|
86
|
+
*/
|
|
87
|
+
clear() {
|
|
88
|
+
this.templates.clear();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var defaultRegistry = new TemplateRegistry();
|
|
92
|
+
function includesAction(templateCd, actionCd, registry) {
|
|
93
|
+
const template = registry.find(templateCd);
|
|
94
|
+
if (!template) return false;
|
|
95
|
+
return template.actions.some((a) => a.actionCd === actionCd);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/schemas/methods.ts
|
|
99
|
+
function isSender(user) {
|
|
100
|
+
return String(this.fromUser) === String(user.id || user._id);
|
|
101
|
+
}
|
|
102
|
+
function isReceiver(user) {
|
|
103
|
+
const id = String(user.id || user._id);
|
|
104
|
+
return String(this.toUser) === id || this.toRoles.some((r) => user.roles?.includes(r));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/schemas/message.ts
|
|
108
|
+
function createArchiveMethod(ctx) {
|
|
109
|
+
return function archive(actionCd, archivedBy, registry) {
|
|
110
|
+
if (!includesAction(this.templateCd, actionCd, registry) || !archivedBy) {
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
}
|
|
113
|
+
const MessageArchive = mongoose2.model(ctx.archiveModelName);
|
|
114
|
+
const data = this.toObject();
|
|
115
|
+
return MessageArchive.create({
|
|
116
|
+
...data,
|
|
117
|
+
actionCd,
|
|
118
|
+
archivedBy
|
|
119
|
+
}).then(async () => {
|
|
120
|
+
await this.deleteOne();
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function createPreSaveHook(ctx) {
|
|
125
|
+
return async function sendNotificationEmail() {
|
|
126
|
+
if (!this.toUser || !this.receiverContent?.title) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const title = this.receiverContent.title.trim();
|
|
130
|
+
if (ctx.emailNotificationExclusions.includes(title.toLowerCase())) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const User = mongoose2.model(ctx.userModelName);
|
|
135
|
+
const user = await User.findById(this.toUser).select("email").lean();
|
|
136
|
+
if (!user?.email) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const long = this.receiverContent.long || "";
|
|
140
|
+
const short = this.receiverContent.short || "";
|
|
141
|
+
const body = long.length > short.length ? long : short;
|
|
142
|
+
await ctx.emailNotifier(user.email, title, body);
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function assertModelRegistered(name, role) {
|
|
148
|
+
if (!mongoose2.modelNames().includes(name)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered with Mongoose. Call mongoose.model("${name}", ...) before buildMessageSchema().`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function resolveConfig(config) {
|
|
155
|
+
return {
|
|
156
|
+
emailNotifier: config?.emailNotifier ?? null,
|
|
157
|
+
emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map((e) => e.toLowerCase()),
|
|
158
|
+
userModelName: config?.userModelName ?? "User",
|
|
159
|
+
archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function buildMessageSchema(config) {
|
|
163
|
+
const resolved = resolveConfig(config);
|
|
164
|
+
if (resolved.emailNotifier) {
|
|
165
|
+
assertModelRegistered(resolved.userModelName, "user");
|
|
166
|
+
}
|
|
167
|
+
const schema = new mongoose2.Schema(BaseMessageFields, {
|
|
168
|
+
timestamps: true
|
|
169
|
+
});
|
|
170
|
+
schema.index({ createdAt: 1 });
|
|
171
|
+
schema.index({ clientRequestId: 1 }, { sparse: true });
|
|
172
|
+
schema.methods.isSender = isSender;
|
|
173
|
+
schema.methods.isReceiver = isReceiver;
|
|
174
|
+
schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
|
|
175
|
+
if (resolved.emailNotifier) {
|
|
176
|
+
schema.pre(
|
|
177
|
+
"save",
|
|
178
|
+
createPreSaveHook({
|
|
179
|
+
emailNotifier: resolved.emailNotifier,
|
|
180
|
+
emailNotificationExclusions: resolved.emailNotificationExclusions,
|
|
181
|
+
userModelName: resolved.userModelName
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return schema;
|
|
186
|
+
}
|
|
187
|
+
var MessageSchema = buildMessageSchema();
|
|
188
|
+
|
|
189
|
+
// src/schemas/message-archive.ts
|
|
190
|
+
import mongoose3 from "mongoose";
|
|
191
|
+
function buildMessageArchiveSchema() {
|
|
192
|
+
const schema = new mongoose3.Schema(
|
|
193
|
+
{
|
|
194
|
+
...BaseMessageFields,
|
|
195
|
+
actionCd: { type: String, default: "" },
|
|
196
|
+
archivedBy: { type: mongoose3.Schema.Types.ObjectId, default: null, index: true },
|
|
197
|
+
archivedAt: { type: Date, default: Date.now }
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
timestamps: true
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
schema.index({ createdAt: 1 });
|
|
204
|
+
schema.methods.isSender = isSender;
|
|
205
|
+
schema.methods.isReceiver = isReceiver;
|
|
206
|
+
return schema;
|
|
207
|
+
}
|
|
208
|
+
var MessageArchiveSchema = buildMessageArchiveSchema();
|
|
209
|
+
|
|
210
|
+
// src/template-engine.ts
|
|
211
|
+
import Handlebars from "handlebars";
|
|
212
|
+
var compiledTemplates = /* @__PURE__ */ new Map();
|
|
213
|
+
function compile(template, data) {
|
|
214
|
+
let compiled = compiledTemplates.get(template);
|
|
215
|
+
if (!compiled) {
|
|
216
|
+
compiled = Handlebars.compile(template, { noEscape: true });
|
|
217
|
+
compiledTemplates.set(template, compiled);
|
|
218
|
+
}
|
|
219
|
+
return compiled(data);
|
|
220
|
+
}
|
|
221
|
+
function filterActions(actions, usertype, options = {}) {
|
|
222
|
+
const { permissions = {}, message = {}, data = {} } = options;
|
|
223
|
+
return actions.filter((action) => {
|
|
224
|
+
if (!action[usertype]) return false;
|
|
225
|
+
if (action.permission && !permissions[action.permission]) return false;
|
|
226
|
+
if (action.condition && !action.condition(message)) return false;
|
|
227
|
+
return true;
|
|
228
|
+
}).map((action) => {
|
|
229
|
+
const { confirmation } = action;
|
|
230
|
+
return {
|
|
231
|
+
actionCd: action.actionCd,
|
|
232
|
+
isDefault: action.isDefault,
|
|
233
|
+
name: compile(action.name, data),
|
|
234
|
+
variant: action.variant,
|
|
235
|
+
confirmation: confirmation ? {
|
|
236
|
+
title: compile(confirmation.title, data),
|
|
237
|
+
message: compile(confirmation.message, data),
|
|
238
|
+
...confirmation.notesLabel ? { notesLabel: compile(confirmation.notesLabel, data) } : {},
|
|
239
|
+
...confirmation.requireNotes !== void 0 ? { requireNotes: confirmation.requireNotes } : {},
|
|
240
|
+
...confirmation.documents !== void 0 ? { documents: confirmation.documents } : {}
|
|
241
|
+
} : void 0,
|
|
242
|
+
payload: action.payload
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function isActionAllowed(action, user, message, options = {}) {
|
|
247
|
+
const isReceiver2 = action.receiver && message.isReceiver(user);
|
|
248
|
+
const isSender2 = action.sender && message.isSender(user);
|
|
249
|
+
if (!isReceiver2 && !isSender2) return false;
|
|
250
|
+
if (action.permission && !(options.permissions || {})[action.permission]) return false;
|
|
251
|
+
if (action.condition && !action.condition(message)) return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
function interpolateTemplate(template, data, usertype, options = {}) {
|
|
255
|
+
const { permissions = {}, message = {} } = options;
|
|
256
|
+
const { senderContent, receiverContent, actions, uiTemplate } = template;
|
|
257
|
+
return {
|
|
258
|
+
senderContent: {
|
|
259
|
+
title: compile(senderContent?.title || "", data),
|
|
260
|
+
long: compile(senderContent?.long || "", data),
|
|
261
|
+
short: compile(senderContent?.short || "", data)
|
|
262
|
+
},
|
|
263
|
+
receiverContent: {
|
|
264
|
+
title: compile(receiverContent?.title || "", data),
|
|
265
|
+
long: compile(receiverContent?.long || "", data),
|
|
266
|
+
short: compile(receiverContent?.short || "", data)
|
|
267
|
+
},
|
|
268
|
+
uiTemplate: resolveUiTemplate(uiTemplate, usertype),
|
|
269
|
+
actions: filterActions(actions, usertype, { permissions, message, data })
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function resolveUiTemplate(uiTemplate, usertype) {
|
|
273
|
+
if (typeof uiTemplate === "string") return uiTemplate;
|
|
274
|
+
return uiTemplate[usertype] || uiTemplate.sender || uiTemplate.receiver || "";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/providers/email.ts
|
|
278
|
+
var NoopEmailProvider = class {
|
|
279
|
+
async sendNotification(_to, _title, _body) {
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/providers/payment.ts
|
|
284
|
+
var NoopPaymentProvider = class {
|
|
285
|
+
async createSession(_user, _code, _priceArgs) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
async expireSession(_sessionId) {
|
|
289
|
+
}
|
|
290
|
+
async refundPayment(_sessionId) {
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/message-service.ts
|
|
295
|
+
import { isString } from "@web-ts-toolkit/utils";
|
|
296
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
297
|
+
var MAX_LIST_LIMIT = 100;
|
|
298
|
+
var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
|
|
299
|
+
var MessageNotFoundError = class extends Error {
|
|
300
|
+
constructor(messageId) {
|
|
301
|
+
super(`message "${messageId}" not found`);
|
|
302
|
+
this.name = "MessageNotFoundError";
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
var MessageArchivedError = class extends Error {
|
|
306
|
+
constructor(messageId) {
|
|
307
|
+
super(`message "${messageId}" is archived`);
|
|
308
|
+
this.name = "MessageArchivedError";
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
var TemplateNotFoundError = class extends Error {
|
|
312
|
+
constructor(templateCd) {
|
|
313
|
+
super(`template "${templateCd}" not found`);
|
|
314
|
+
this.name = "TemplateNotFoundError";
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var ActionNotFoundError = class extends Error {
|
|
318
|
+
constructor(templateCd, actionCd) {
|
|
319
|
+
super(`action "${actionCd}" not found in template "${templateCd}"`);
|
|
320
|
+
this.name = "ActionNotFoundError";
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
var ActionNotAllowedError = class extends Error {
|
|
324
|
+
constructor() {
|
|
325
|
+
super("not allowed");
|
|
326
|
+
this.name = "ActionNotAllowedError";
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
var MessageService = class {
|
|
330
|
+
constructor(options) {
|
|
331
|
+
this.getModel = options.getModel;
|
|
332
|
+
this.paymentProvider = options.paymentProvider ?? null;
|
|
333
|
+
this.expirePaymentSession = this.paymentProvider?.expireSession.bind(this.paymentProvider);
|
|
334
|
+
this.refundPaymentSession = this.paymentProvider?.refundPayment.bind(this.paymentProvider);
|
|
335
|
+
this.adminRoles = options.adminRoles ?? [];
|
|
336
|
+
this.registry = options.registry ?? defaultRegistry;
|
|
337
|
+
this.defaultListLimit = options.defaultListLimit ?? DEFAULT_LIST_LIMIT;
|
|
338
|
+
this.maxListLimit = options.maxListLimit ?? MAX_LIST_LIMIT;
|
|
339
|
+
}
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
// Message lookup
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
/**
|
|
344
|
+
* Find a message by id, falling back to the archive. Returns null if
|
|
345
|
+
* the message does not exist in either collection.
|
|
346
|
+
*/
|
|
347
|
+
async findMessage(messageId, options = {}) {
|
|
348
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
349
|
+
const MessageArchive = this.getModel(MESSAGE_ARCHIVE_MODEL_NAME);
|
|
350
|
+
const message = await this.findByIdWithOptions(Message, messageId, options);
|
|
351
|
+
if (message) return message;
|
|
352
|
+
return await this.findByIdWithOptions(
|
|
353
|
+
MessageArchive,
|
|
354
|
+
messageId,
|
|
355
|
+
options
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
async findMessageOrThrow(messageId, options = {}) {
|
|
359
|
+
const message = await this.findMessage(messageId, options);
|
|
360
|
+
if (!message) {
|
|
361
|
+
throw new MessageNotFoundError(messageId);
|
|
362
|
+
}
|
|
363
|
+
return message;
|
|
364
|
+
}
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
// Create message from template
|
|
367
|
+
// -------------------------------------------------------------------------
|
|
368
|
+
async createMessage(params) {
|
|
369
|
+
const {
|
|
370
|
+
templateCd,
|
|
371
|
+
user,
|
|
372
|
+
roles = [],
|
|
373
|
+
identity = {},
|
|
374
|
+
permissions = {},
|
|
375
|
+
payload = {},
|
|
376
|
+
payerUser,
|
|
377
|
+
req,
|
|
378
|
+
clientRequestId
|
|
379
|
+
} = params;
|
|
380
|
+
if (clientRequestId) {
|
|
381
|
+
const existing = await this.findByClientRequestId(clientRequestId);
|
|
382
|
+
if (existing) return existing;
|
|
383
|
+
}
|
|
384
|
+
const template = this.registry.find(templateCd);
|
|
385
|
+
if (!template) throw new TemplateNotFoundError(templateCd);
|
|
386
|
+
const messageData = await template.prepareMessage({
|
|
387
|
+
user,
|
|
388
|
+
roles,
|
|
389
|
+
identity,
|
|
390
|
+
permissions,
|
|
391
|
+
payload,
|
|
392
|
+
getModel: this.getModel,
|
|
393
|
+
req
|
|
394
|
+
});
|
|
395
|
+
if (!messageData) return [];
|
|
396
|
+
const ctx = { user, roles, identity, permissions, payload, payerUser, req };
|
|
397
|
+
const items = Array.isArray(messageData) ? messageData : [messageData];
|
|
398
|
+
const results = [];
|
|
399
|
+
for (const m of items) {
|
|
400
|
+
results.push(await this.persistItem(template, m, ctx, clientRequestId));
|
|
401
|
+
}
|
|
402
|
+
return results;
|
|
403
|
+
}
|
|
404
|
+
// -------------------------------------------------------------------------
|
|
405
|
+
// Create generic notification (no template, no actions)
|
|
406
|
+
// -------------------------------------------------------------------------
|
|
407
|
+
async createNotification(params) {
|
|
408
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
409
|
+
return Message.create({
|
|
410
|
+
type: "notification",
|
|
411
|
+
templateCd: GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
412
|
+
fromUser: params.fromUser ?? null,
|
|
413
|
+
toUser: params.toUser ?? null,
|
|
414
|
+
toRoles: params.toRoles,
|
|
415
|
+
senderContent: params.senderContent,
|
|
416
|
+
receiverContent: params.receiverContent,
|
|
417
|
+
documents: params.documents || []
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// -------------------------------------------------------------------------
|
|
421
|
+
// List messages
|
|
422
|
+
// -------------------------------------------------------------------------
|
|
423
|
+
/**
|
|
424
|
+
* Build a Mongoose filter for messages visible to the given user.
|
|
425
|
+
* Exposed so callers can use the same visibility rules for custom
|
|
426
|
+
* queries (e.g. with `populate`).
|
|
427
|
+
*/
|
|
428
|
+
buildVisibilityFilter(user) {
|
|
429
|
+
const userId = user.id || String(user._id);
|
|
430
|
+
return {
|
|
431
|
+
$or: [{ fromUser: userId }, { toUser: userId }, { toRoles: { $in: user.roles ?? [] } }]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* List active (non-archived) messages visible to a user.
|
|
436
|
+
* Returns messages where the user is the sender, the receiver,
|
|
437
|
+
* or matches one of the recipient's roles.
|
|
438
|
+
*/
|
|
439
|
+
async listMessages(params) {
|
|
440
|
+
const { user, limit: rawLimit, skip: rawSkip, populate } = params;
|
|
441
|
+
const limit = Math.min(Math.max(rawLimit ?? this.defaultListLimit, 1), this.maxListLimit);
|
|
442
|
+
const skip = Math.max(rawSkip ?? 0, 0);
|
|
443
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
444
|
+
const query = this.applyPopulate(
|
|
445
|
+
Message.find(this.buildVisibilityFilter(user)).sort({ createdAt: -1 }).skip(skip).limit(limit),
|
|
446
|
+
populate
|
|
447
|
+
);
|
|
448
|
+
return query;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Count active (non-archived) messages visible to a user.
|
|
452
|
+
* Useful for badge indicators ("3 new messages").
|
|
453
|
+
*/
|
|
454
|
+
async countMessages(user) {
|
|
455
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
456
|
+
return Message.countDocuments(this.buildVisibilityFilter(user));
|
|
457
|
+
}
|
|
458
|
+
// -------------------------------------------------------------------------
|
|
459
|
+
// Get actions for a message
|
|
460
|
+
// -------------------------------------------------------------------------
|
|
461
|
+
async getActions(messageId, usertype, options = {}) {
|
|
462
|
+
const message = options.message ?? await this.findMessage(messageId, { populate: options.populate });
|
|
463
|
+
if (!message) return null;
|
|
464
|
+
if (!options.isAdmin && options.user) {
|
|
465
|
+
const isAllowedUsertype = usertype === "sender" ? message.isSender(options.user) : message.isReceiver(options.user);
|
|
466
|
+
if (!isAllowedUsertype) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (message.templateCd === GENERIC_NOTIFICATION_TEMPLATE_CD) {
|
|
471
|
+
return { uiTemplate: "notification", actions: [] };
|
|
472
|
+
}
|
|
473
|
+
const template = this.registry.find(message.templateCd);
|
|
474
|
+
if (!template) return null;
|
|
475
|
+
const data = message.payload ?? {};
|
|
476
|
+
const interpolated = interpolateTemplate(template, data, usertype, {
|
|
477
|
+
permissions: options.permissions,
|
|
478
|
+
message
|
|
479
|
+
});
|
|
480
|
+
if (options.isAdmin) {
|
|
481
|
+
return { uiTemplate: interpolated.uiTemplate, actions: [] };
|
|
482
|
+
}
|
|
483
|
+
return { uiTemplate: interpolated.uiTemplate, actions: interpolated.actions };
|
|
484
|
+
}
|
|
485
|
+
// -------------------------------------------------------------------------
|
|
486
|
+
// Handle an action on a message
|
|
487
|
+
// -------------------------------------------------------------------------
|
|
488
|
+
async handleAction(templateCd, actionCd, data) {
|
|
489
|
+
if (this.isArchivedMessage(data.message)) {
|
|
490
|
+
throw new MessageArchivedError(String(data.message._id));
|
|
491
|
+
}
|
|
492
|
+
const template = this.registry.find(templateCd);
|
|
493
|
+
if (!template) throw new TemplateNotFoundError(templateCd);
|
|
494
|
+
const action = template.actions.find((a) => a.actionCd === actionCd);
|
|
495
|
+
if (!action) throw new ActionNotFoundError(templateCd, actionCd);
|
|
496
|
+
if (!isActionAllowed(action, data.user, data.message, { permissions: data.permissions })) {
|
|
497
|
+
throw new ActionNotAllowedError();
|
|
498
|
+
}
|
|
499
|
+
const ctx = this.buildActionContext(data);
|
|
500
|
+
const result = await action.runHandler(ctx);
|
|
501
|
+
await data.message.archive(actionCd, data.user._id, this.registry);
|
|
502
|
+
await this.runSenderNotification(action, ctx, data.message);
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
// -------------------------------------------------------------------------
|
|
506
|
+
// Private helpers
|
|
507
|
+
// -------------------------------------------------------------------------
|
|
508
|
+
async persistItem(template, m, ctx, clientRequestId) {
|
|
509
|
+
const toUser = m.toUser ?? null;
|
|
510
|
+
const toRoles = (m.toRoles && m.toRoles.length > 0 ? m.toRoles : this.adminRoles).slice();
|
|
511
|
+
const type = m.type || template.type || "notification";
|
|
512
|
+
const paymentCd = m.paymentCd || template.paymentCd || "";
|
|
513
|
+
let paymentSession = null;
|
|
514
|
+
if (paymentCd && this.paymentProvider && (toUser || toRoles.length > 0)) {
|
|
515
|
+
paymentSession = await this.paymentProvider.createSession(
|
|
516
|
+
(ctx.payerUser || ctx.user)._id,
|
|
517
|
+
paymentCd,
|
|
518
|
+
m.priceArgs
|
|
519
|
+
);
|
|
520
|
+
if (!paymentSession) throw new Error("payment session creation failed");
|
|
521
|
+
}
|
|
522
|
+
const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
|
|
523
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
524
|
+
return Message.create({
|
|
525
|
+
type,
|
|
526
|
+
templateCd: template.templateCd,
|
|
527
|
+
fromUser: m.fromUser || ctx.user._id,
|
|
528
|
+
toUser,
|
|
529
|
+
toRoles,
|
|
530
|
+
senderContent: interpolated.senderContent,
|
|
531
|
+
receiverContent: interpolated.receiverContent,
|
|
532
|
+
documents: ctx.payload.documents || [],
|
|
533
|
+
paymentSession,
|
|
534
|
+
paymentCd,
|
|
535
|
+
payload: m.payload || ctx.payload,
|
|
536
|
+
display: m.display,
|
|
537
|
+
clientRequestId: clientRequestId ?? null
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
buildActionContext(data) {
|
|
541
|
+
return {
|
|
542
|
+
message: data.message,
|
|
543
|
+
user: data.user,
|
|
544
|
+
getModel: this.getModel,
|
|
545
|
+
expireSession: this.expirePaymentSession,
|
|
546
|
+
refundPayment: this.refundPaymentSession,
|
|
547
|
+
req: data.req
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
async runSenderNotification(action, ctx, message) {
|
|
551
|
+
if (!action.senderNotification) return;
|
|
552
|
+
let content;
|
|
553
|
+
if (typeof action.senderNotification === "function") {
|
|
554
|
+
content = await action.senderNotification(ctx);
|
|
555
|
+
} else {
|
|
556
|
+
content = action.senderNotification;
|
|
557
|
+
}
|
|
558
|
+
const senderTitle = message.senderContent?.title ?? "";
|
|
559
|
+
const senderNotificationContent = isString(content) ? { title: senderTitle, long: content, short: content } : {
|
|
560
|
+
title: content.title || senderTitle,
|
|
561
|
+
long: content.long,
|
|
562
|
+
short: content.short || content.long
|
|
563
|
+
};
|
|
564
|
+
const documents = !isString(content) ? content.documents || [] : [];
|
|
565
|
+
if (message.fromUser) {
|
|
566
|
+
await this.createNotification({
|
|
567
|
+
toUser: message.fromUser,
|
|
568
|
+
receiverContent: senderNotificationContent,
|
|
569
|
+
documents
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async findByClientRequestId(clientRequestId) {
|
|
574
|
+
const Message = this.getModel(MESSAGE_MODEL_NAME);
|
|
575
|
+
const docs = await Message.find({ clientRequestId }).sort({ createdAt: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
|
|
576
|
+
return docs.length > 0 ? docs : null;
|
|
577
|
+
}
|
|
578
|
+
isArchivedMessage(message) {
|
|
579
|
+
return "archivedAt" in message;
|
|
580
|
+
}
|
|
581
|
+
applyPopulate(query, populate) {
|
|
582
|
+
if (!populate) {
|
|
583
|
+
return query;
|
|
584
|
+
}
|
|
585
|
+
const items = Array.isArray(populate) ? populate : [populate];
|
|
586
|
+
let current = query;
|
|
587
|
+
for (const item of items) {
|
|
588
|
+
current = current.populate(item);
|
|
589
|
+
}
|
|
590
|
+
return current;
|
|
591
|
+
}
|
|
592
|
+
async findByIdWithOptions(model, id, options) {
|
|
593
|
+
const baseQuery = model.findById(id);
|
|
594
|
+
const selectedQuery = options.select === void 0 ? baseQuery : baseQuery.select(options.select);
|
|
595
|
+
const query = this.applyPopulate(selectedQuery, options.populate);
|
|
596
|
+
return query;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/route-factory.ts
|
|
601
|
+
import JsonRouter from "@web-ts-toolkit/express-json-router";
|
|
602
|
+
var ACTION_CD_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
603
|
+
function assertValidActionCd(actionCd) {
|
|
604
|
+
if (typeof actionCd !== "string" || !ACTION_CD_PATTERN.test(actionCd)) {
|
|
605
|
+
throw new JsonRouter.clientErrors.BadRequestError(
|
|
606
|
+
"actionCd must be a string of letters, digits, underscores, and hyphens"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function assertValidUsertype(usertype) {
|
|
611
|
+
if (usertype !== "sender" && usertype !== "receiver") {
|
|
612
|
+
throw new JsonRouter.clientErrors.BadRequestError('usertype must be "sender" or "receiver"');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function mapServiceError(error) {
|
|
616
|
+
if (error instanceof MessageNotFoundError) {
|
|
617
|
+
throw new JsonRouter.clientErrors.NotFoundError("message not found");
|
|
618
|
+
}
|
|
619
|
+
if (error instanceof TemplateNotFoundError) {
|
|
620
|
+
throw new JsonRouter.clientErrors.NotFoundError(error.message);
|
|
621
|
+
}
|
|
622
|
+
if (error instanceof ActionNotFoundError) {
|
|
623
|
+
throw new JsonRouter.clientErrors.NotFoundError(error.message);
|
|
624
|
+
}
|
|
625
|
+
if (error instanceof ActionNotAllowedError) {
|
|
626
|
+
throw new JsonRouter.clientErrors.ForbiddenError(error.message);
|
|
627
|
+
}
|
|
628
|
+
if (error instanceof MessageArchivedError) {
|
|
629
|
+
throw new JsonRouter.clientErrors.GoneError(error.message);
|
|
630
|
+
}
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
function createMessageRoutes(options) {
|
|
634
|
+
const {
|
|
635
|
+
getModel,
|
|
636
|
+
paymentProvider = null,
|
|
637
|
+
adminRoles,
|
|
638
|
+
registry,
|
|
639
|
+
authMiddleware = [],
|
|
640
|
+
getUser = defaultGetUser,
|
|
641
|
+
getPermissions = defaultGetPermissions,
|
|
642
|
+
getIdentity = defaultGetIdentity,
|
|
643
|
+
adminPermissionKey = "is.admin"
|
|
644
|
+
} = options;
|
|
645
|
+
const service = new MessageService({ getModel, paymentProvider, adminRoles, registry });
|
|
646
|
+
const router = new JsonRouter("", authMiddleware);
|
|
647
|
+
router.post("/new/:templateCd", async (req) => {
|
|
648
|
+
const templateCd = req.params.templateCd;
|
|
649
|
+
const user = getUser(req) || { _id: "" };
|
|
650
|
+
const roles = user.roles || [];
|
|
651
|
+
const identity = getIdentity(req);
|
|
652
|
+
const permissions = getPermissions(req);
|
|
653
|
+
const body = req.body || {};
|
|
654
|
+
const { clientRequestId, ...payload } = body;
|
|
655
|
+
const clientRequestIdStr = typeof clientRequestId === "string" ? clientRequestId : void 0;
|
|
656
|
+
try {
|
|
657
|
+
return await service.createMessage({
|
|
658
|
+
templateCd,
|
|
659
|
+
user,
|
|
660
|
+
roles,
|
|
661
|
+
identity,
|
|
662
|
+
permissions,
|
|
663
|
+
payload,
|
|
664
|
+
payerUser: user,
|
|
665
|
+
req,
|
|
666
|
+
clientRequestId: clientRequestIdStr
|
|
667
|
+
});
|
|
668
|
+
} catch (error) {
|
|
669
|
+
mapServiceError(error);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
router.get("/:id/actions/:usertype", async (req) => {
|
|
673
|
+
const id = req.params.id;
|
|
674
|
+
const usertype = req.params.usertype;
|
|
675
|
+
assertValidUsertype(usertype);
|
|
676
|
+
const permissions = getPermissions(req);
|
|
677
|
+
const isAdmin = !!permissions[adminPermissionKey];
|
|
678
|
+
const user = getUser(req) || { _id: "" };
|
|
679
|
+
const result = await service.getActions(id, usertype, { permissions, user, isAdmin });
|
|
680
|
+
if (!result) {
|
|
681
|
+
throw new JsonRouter.clientErrors.NotFoundError("message not found");
|
|
682
|
+
}
|
|
683
|
+
return result;
|
|
684
|
+
});
|
|
685
|
+
async function handleAction(req) {
|
|
686
|
+
const id = req.params.id;
|
|
687
|
+
const actionCd = req.params.actionCd;
|
|
688
|
+
assertValidActionCd(actionCd);
|
|
689
|
+
let message;
|
|
690
|
+
try {
|
|
691
|
+
message = await service.findMessageOrThrow(id);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
mapServiceError(error);
|
|
694
|
+
}
|
|
695
|
+
const user = getUser(req) || { _id: "" };
|
|
696
|
+
const permissions = getPermissions(req);
|
|
697
|
+
try {
|
|
698
|
+
return await service.handleAction(message.templateCd, actionCd, { message, user, permissions, req });
|
|
699
|
+
} catch (error) {
|
|
700
|
+
mapServiceError(error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
router.get("/:id/action/:actionCd", handleAction);
|
|
704
|
+
router.post("/:id/action/:actionCd", handleAction);
|
|
705
|
+
return { router, service };
|
|
706
|
+
}
|
|
707
|
+
function defaultGetUser(req) {
|
|
708
|
+
const raw = req._user || req.user;
|
|
709
|
+
return raw;
|
|
710
|
+
}
|
|
711
|
+
function defaultGetPermissions(req) {
|
|
712
|
+
return req._permissions || {};
|
|
713
|
+
}
|
|
714
|
+
function defaultGetIdentity(req) {
|
|
715
|
+
return req._identity || {};
|
|
716
|
+
}
|
|
717
|
+
export {
|
|
718
|
+
ActionNotAllowedError,
|
|
719
|
+
ActionNotFoundError,
|
|
720
|
+
BaseMessageFields,
|
|
721
|
+
GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
722
|
+
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
723
|
+
MESSAGE_MODEL_NAME,
|
|
724
|
+
MessageArchiveSchema,
|
|
725
|
+
MessageArchivedError,
|
|
726
|
+
MessageContentSchema,
|
|
727
|
+
MessageNotFoundError,
|
|
728
|
+
MessageSchema,
|
|
729
|
+
MessageService,
|
|
730
|
+
NoopEmailProvider,
|
|
731
|
+
NoopPaymentProvider,
|
|
732
|
+
TemplateNotFoundError,
|
|
733
|
+
TemplateRegistry,
|
|
734
|
+
buildMessageArchiveSchema,
|
|
735
|
+
buildMessageSchema,
|
|
736
|
+
createMessageRoutes,
|
|
737
|
+
defaultRegistry,
|
|
738
|
+
filterActions,
|
|
739
|
+
includesAction,
|
|
740
|
+
interpolateTemplate,
|
|
741
|
+
isActionAllowed
|
|
742
|
+
};
|