@web-ts-toolkit/message-service 0.43.0 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -57
- package/index.d.mts +696 -111
- package/index.d.ts +696 -111
- package/index.js +1382 -686
- package/index.mjs +1363 -676
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -42,8 +42,10 @@ __export(index_exports, {
|
|
|
42
42
|
ClientRequestPendingError: () => ClientRequestPendingError,
|
|
43
43
|
GENERIC_NOTIFICATION_TEMPLATE_CD: () => GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
44
44
|
InvalidClientRequestIdError: () => InvalidClientRequestIdError,
|
|
45
|
+
InvalidMessageServiceOptionError: () => InvalidMessageServiceOptionError,
|
|
45
46
|
InvalidMessageUserError: () => InvalidMessageUserError,
|
|
46
47
|
InvalidPaginationValueError: () => InvalidPaginationValueError,
|
|
48
|
+
MAX_MESSAGE_SERVICE_TIMEOUT_MS: () => MAX_MESSAGE_SERVICE_TIMEOUT_MS,
|
|
47
49
|
MESSAGE_ARCHIVE_MODEL_NAME: () => MESSAGE_ARCHIVE_MODEL_NAME,
|
|
48
50
|
MESSAGE_MODEL_NAME: () => MESSAGE_MODEL_NAME,
|
|
49
51
|
MESSAGE_REQUEST_MODEL_NAME: () => MESSAGE_REQUEST_MODEL_NAME,
|
|
@@ -58,6 +60,7 @@ __export(index_exports, {
|
|
|
58
60
|
MessageTransactionRequiredError: () => MessageTransactionRequiredError,
|
|
59
61
|
NoopEmailProvider: () => NoopEmailProvider,
|
|
60
62
|
NoopPaymentProvider: () => NoopPaymentProvider,
|
|
63
|
+
PaymentSessionCompensationAggregateError: () => PaymentSessionCompensationAggregateError,
|
|
61
64
|
PaymentSessionCompensationError: () => PaymentSessionCompensationError,
|
|
62
65
|
TemplateNotFoundError: () => TemplateNotFoundError,
|
|
63
66
|
TemplateRegistry: () => TemplateRegistry,
|
|
@@ -68,9 +71,15 @@ __export(index_exports, {
|
|
|
68
71
|
createMessageRoutes: () => createMessageRoutes,
|
|
69
72
|
defaultRegistry: () => defaultRegistry,
|
|
70
73
|
filterActions: () => filterActions,
|
|
74
|
+
hasExplicitPermissionGrant: () => hasExplicitPermissionGrant,
|
|
71
75
|
includesAction: () => includesAction,
|
|
76
|
+
interpolateMessageContent: () => interpolateMessageContent,
|
|
72
77
|
interpolateTemplate: () => interpolateTemplate,
|
|
73
|
-
isActionAllowed: () => isActionAllowed
|
|
78
|
+
isActionAllowed: () => isActionAllowed,
|
|
79
|
+
isValidMessageUserId: () => isValidMessageUserId,
|
|
80
|
+
requireMessageUserId: () => requireMessageUserId,
|
|
81
|
+
resolveUiTemplate: () => resolveUiTemplate,
|
|
82
|
+
serializePublicMessage: () => serializePublicMessage
|
|
74
83
|
});
|
|
75
84
|
module.exports = __toCommonJS(index_exports);
|
|
76
85
|
|
|
@@ -109,6 +118,7 @@ var BaseMessageFields = {
|
|
|
109
118
|
actionState: { type: String, enum: ["active", "processing", "retryable"], default: "active" },
|
|
110
119
|
actionCd: { type: String, default: null },
|
|
111
120
|
actionAttemptId: { type: String, default: null },
|
|
121
|
+
actionOwnerToken: { type: String, default: null },
|
|
112
122
|
actionClaimedBy: { type: String, default: null },
|
|
113
123
|
actionClaimedAt: { type: Date, default: null },
|
|
114
124
|
actionLeaseExpiresAt: { type: Date, default: null },
|
|
@@ -116,7 +126,7 @@ var BaseMessageFields = {
|
|
|
116
126
|
};
|
|
117
127
|
|
|
118
128
|
// src/schemas/message.ts
|
|
119
|
-
var
|
|
129
|
+
var import_mongoose4 = __toESM(require("mongoose"));
|
|
120
130
|
|
|
121
131
|
// src/runtime-contract.ts
|
|
122
132
|
var ERROR_BRAND_PREFIX = "@web-ts-toolkit/message-service/error/";
|
|
@@ -209,6 +219,9 @@ var TemplateRegistry = class {
|
|
|
209
219
|
}
|
|
210
220
|
/**
|
|
211
221
|
* Register a template. Overwrites if templateCd already exists.
|
|
222
|
+
*
|
|
223
|
+
* Takes an author-friendly mutable `MessageTemplate`; the registry stores a
|
|
224
|
+
* frozen shallow snapshot (see `RegisteredMessageTemplate`).
|
|
212
225
|
*/
|
|
213
226
|
register(template) {
|
|
214
227
|
validateTemplate(template);
|
|
@@ -224,6 +237,11 @@ var TemplateRegistry = class {
|
|
|
224
237
|
}
|
|
225
238
|
/**
|
|
226
239
|
* Find a template by its templateCd.
|
|
240
|
+
*
|
|
241
|
+
* Returns a readonly `RegisteredMessageTemplate` view matching the registry
|
|
242
|
+
* freeze depth (top-level, content objects, object uiTemplates, the action
|
|
243
|
+
* array, and each action plus its confirmation/payload are frozen).
|
|
244
|
+
* Mutating the result throws at runtime; register a replacement instead.
|
|
227
245
|
*/
|
|
228
246
|
find(templateCd) {
|
|
229
247
|
return this.templates.get(templateCd);
|
|
@@ -242,6 +260,8 @@ var TemplateRegistry = class {
|
|
|
242
260
|
}
|
|
243
261
|
/**
|
|
244
262
|
* Get all registered templates.
|
|
263
|
+
*
|
|
264
|
+
* Returns readonly `RegisteredMessageTemplate` views; see `find()`.
|
|
245
265
|
*/
|
|
246
266
|
getAll() {
|
|
247
267
|
return Array.from(this.templates.values());
|
|
@@ -269,6 +289,7 @@ function includesAction(templateCd, actionCd, registry) {
|
|
|
269
289
|
}
|
|
270
290
|
|
|
271
291
|
// src/schemas/methods.ts
|
|
292
|
+
var import_mongoose2 = require("mongoose");
|
|
272
293
|
function isSender(user) {
|
|
273
294
|
const fromUser = normalizeStoredUserId(this.fromUser);
|
|
274
295
|
const userId = normalizeMessageUserId(user);
|
|
@@ -280,450 +301,131 @@ function isReceiver(user) {
|
|
|
280
301
|
return toUser !== null && userId !== null && toUser === userId || this.toRoles.some((r) => user.roles?.includes(r));
|
|
281
302
|
}
|
|
282
303
|
function normalizeMessageUserId(user) {
|
|
283
|
-
return
|
|
304
|
+
return normalizeId(user?._id);
|
|
284
305
|
}
|
|
285
306
|
function normalizeStoredUserId(value) {
|
|
286
|
-
if (value
|
|
307
|
+
if (value instanceof import_mongoose2.Types.ObjectId) return value.toHexString();
|
|
308
|
+
if (value !== null && typeof value === "object") {
|
|
309
|
+
return normalizeId(value._id);
|
|
310
|
+
}
|
|
311
|
+
return normalizeId(value);
|
|
312
|
+
}
|
|
313
|
+
function normalizeId(value) {
|
|
287
314
|
if (typeof value === "string") {
|
|
288
315
|
const trimmed = value.trim();
|
|
289
316
|
return trimmed.length > 0 ? trimmed : null;
|
|
290
317
|
}
|
|
291
|
-
return
|
|
318
|
+
return value instanceof import_mongoose2.Types.ObjectId ? value.toHexString() : null;
|
|
292
319
|
}
|
|
293
320
|
|
|
294
|
-
// src/
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
throw new Error(
|
|
300
|
-
`message-service: cannot resolve ${role} model "${modelName}" because the hydrated document has no owning connection`
|
|
301
|
-
);
|
|
321
|
+
// src/errors.ts
|
|
322
|
+
var TRANSACTION_REQUIRED_MESSAGE = "message-service requires MongoDB replica set or sharded-cluster transactions for idempotent batch creation";
|
|
323
|
+
var MessageNotFoundError = class extends Error {
|
|
324
|
+
static [Symbol.hasInstance](value) {
|
|
325
|
+
return isRuntimeError(value, "MessageNotFoundError");
|
|
302
326
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const resolutionError = new Error(
|
|
308
|
-
`message-service: ${role} model "${modelName}" is not registered on Mongoose connection "${connectionName}"`
|
|
309
|
-
);
|
|
310
|
-
resolutionError.cause = error;
|
|
311
|
-
throw resolutionError;
|
|
327
|
+
constructor(messageId) {
|
|
328
|
+
super(`message "${messageId}" not found`);
|
|
329
|
+
this.name = "MessageNotFoundError";
|
|
330
|
+
markRuntimeError(this, this.name);
|
|
312
331
|
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return Promise.resolve();
|
|
318
|
-
}
|
|
319
|
-
const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
|
|
320
|
-
const data = this.toObject();
|
|
321
|
-
return MessageArchive.create({
|
|
322
|
-
...data,
|
|
323
|
-
actionCd,
|
|
324
|
-
archivedBy
|
|
325
|
-
}).then(async () => {
|
|
326
|
-
await this.deleteOne();
|
|
327
|
-
});
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
var EMAIL_WAS_NEW_LOCAL = "messageServiceEmailWasNew";
|
|
331
|
-
function normalizeEmailTitle(value) {
|
|
332
|
-
return value.trim().toLowerCase();
|
|
333
|
-
}
|
|
334
|
-
function createEmailStateCaptureHook() {
|
|
335
|
-
return function captureEmailState() {
|
|
336
|
-
this.$locals[EMAIL_WAS_NEW_LOCAL] = this.isNew;
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
function createPostSaveEmailHook(ctx) {
|
|
340
|
-
return async function sendNotificationEmail() {
|
|
341
|
-
if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
if (this.$session()) {
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (!this.toUser || !this.receiverContent?.title) {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const title = this.receiverContent.title.trim();
|
|
351
|
-
if (!title || ctx.emailNotificationExclusions.includes(normalizeEmailTitle(title))) {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
let user;
|
|
355
|
-
try {
|
|
356
|
-
const User = resolveDocumentModel(this, ctx.userModelName, "user");
|
|
357
|
-
user = await User.findById(this.toUser).select("email").lean();
|
|
358
|
-
} catch (error) {
|
|
359
|
-
await reportEmailFailure(ctx, "recipientLookup", error, this, title);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (!user?.email) {
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const long = this.receiverContent.long || "";
|
|
366
|
-
const short = this.receiverContent.short || "";
|
|
367
|
-
const body = long.length > short.length ? long : short;
|
|
368
|
-
try {
|
|
369
|
-
await ctx.emailNotifier(user.email, title, body);
|
|
370
|
-
} catch (error) {
|
|
371
|
-
await reportEmailFailure(ctx, "notifier", error, this, title);
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
async function reportEmailFailure(ctx, stage, error, message, title) {
|
|
376
|
-
try {
|
|
377
|
-
await ctx.onEmailDeliveryFailure?.({
|
|
378
|
-
stage,
|
|
379
|
-
error,
|
|
380
|
-
messageId: message._id,
|
|
381
|
-
recipientId: message.toUser,
|
|
382
|
-
title
|
|
383
|
-
});
|
|
384
|
-
} catch {
|
|
332
|
+
};
|
|
333
|
+
var MessageArchivedError = class extends Error {
|
|
334
|
+
static [Symbol.hasInstance](value) {
|
|
335
|
+
return isRuntimeError(value, "MessageArchivedError");
|
|
385
336
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
throw new Error(
|
|
391
|
-
`message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered on Mongoose connection "${connectionName}". Register the model on that connection before buildMessageSchema().`
|
|
392
|
-
);
|
|
337
|
+
constructor(messageId) {
|
|
338
|
+
super(`message "${messageId}" is archived`);
|
|
339
|
+
this.name = "MessageArchivedError";
|
|
340
|
+
markRuntimeError(this, this.name);
|
|
393
341
|
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
|
|
399
|
-
emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
|
|
400
|
-
userModelName: config?.userModelName ?? "User",
|
|
401
|
-
archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
|
|
402
|
-
connection: config?.connection ?? import_mongoose2.default
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
function buildMessageSchema(config) {
|
|
406
|
-
const resolved = resolveConfig(config);
|
|
407
|
-
if (resolved.emailNotifier) {
|
|
408
|
-
assertModelRegistered(resolved.connection, resolved.userModelName, "user");
|
|
342
|
+
};
|
|
343
|
+
var TemplateNotFoundError = class extends Error {
|
|
344
|
+
static [Symbol.hasInstance](value) {
|
|
345
|
+
return isRuntimeError(value, "TemplateNotFoundError");
|
|
409
346
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
347
|
+
constructor(templateCd) {
|
|
348
|
+
super(`template "${templateCd}" not found`);
|
|
349
|
+
this.name = "TemplateNotFoundError";
|
|
350
|
+
markRuntimeError(this, this.name);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
var ActionNotFoundError = class extends Error {
|
|
354
|
+
static [Symbol.hasInstance](value) {
|
|
355
|
+
return isRuntimeError(value, "ActionNotFoundError");
|
|
356
|
+
}
|
|
357
|
+
constructor(templateCd, actionCd) {
|
|
358
|
+
super(`action "${actionCd}" not found in template "${templateCd}"`);
|
|
359
|
+
this.name = "ActionNotFoundError";
|
|
360
|
+
markRuntimeError(this, this.name);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
var ActionNotAllowedError = class extends Error {
|
|
364
|
+
static [Symbol.hasInstance](value) {
|
|
365
|
+
return isRuntimeError(value, "ActionNotAllowedError");
|
|
366
|
+
}
|
|
367
|
+
constructor() {
|
|
368
|
+
super("not allowed");
|
|
369
|
+
this.name = "ActionNotAllowedError";
|
|
370
|
+
markRuntimeError(this, this.name);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
var InvalidMessageUserError = class extends Error {
|
|
374
|
+
static [Symbol.hasInstance](value) {
|
|
375
|
+
return isRuntimeError(value, "InvalidMessageUserError");
|
|
376
|
+
}
|
|
377
|
+
constructor(message = "user._id must be a non-empty string or ObjectId") {
|
|
378
|
+
super(message);
|
|
379
|
+
this.name = "InvalidMessageUserError";
|
|
380
|
+
markRuntimeError(this, this.name);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
var ActionTemplateMismatchError = class extends Error {
|
|
384
|
+
static [Symbol.hasInstance](value) {
|
|
385
|
+
return isRuntimeError(value, "ActionTemplateMismatchError");
|
|
386
|
+
}
|
|
387
|
+
constructor(expectedTemplateCd, receivedTemplateCd) {
|
|
388
|
+
super(`message template "${expectedTemplateCd}" does not match requested template "${receivedTemplateCd}"`);
|
|
389
|
+
this.name = "ActionTemplateMismatchError";
|
|
390
|
+
markRuntimeError(this, this.name);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
var ActionConflictError = class extends Error {
|
|
394
|
+
static [Symbol.hasInstance](value) {
|
|
395
|
+
return isRuntimeError(value, "ActionConflictError");
|
|
396
|
+
}
|
|
397
|
+
constructor(messageId) {
|
|
398
|
+
super(`message "${messageId}" already has an action in progress`);
|
|
399
|
+
this.name = "ActionConflictError";
|
|
400
|
+
markRuntimeError(this, this.name);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var ActionRetryableError = class extends Error {
|
|
404
|
+
static [Symbol.hasInstance](value) {
|
|
405
|
+
return isRuntimeError(value, "ActionRetryableError");
|
|
406
|
+
}
|
|
407
|
+
constructor(messageId, actionAttemptId, cause) {
|
|
408
|
+
super(`message "${messageId}" action attempt "${actionAttemptId}" failed before commit and may be retried`);
|
|
409
|
+
this.name = "ActionRetryableError";
|
|
410
|
+
markRuntimeError(this, this.name);
|
|
411
|
+
this.actionAttemptId = actionAttemptId;
|
|
412
|
+
if (cause) {
|
|
413
|
+
this.cause = cause;
|
|
432
414
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
schema.methods.isReceiver = isReceiver;
|
|
448
|
-
schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
|
|
449
|
-
if (resolved.emailNotifier) {
|
|
450
|
-
schema.pre("save", createEmailStateCaptureHook());
|
|
451
|
-
schema.post(
|
|
452
|
-
"save",
|
|
453
|
-
createPostSaveEmailHook({
|
|
454
|
-
emailNotifier: resolved.emailNotifier,
|
|
455
|
-
onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
|
|
456
|
-
emailNotificationExclusions: resolved.emailNotificationExclusions,
|
|
457
|
-
userModelName: resolved.userModelName
|
|
458
|
-
})
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
return schema;
|
|
462
|
-
}
|
|
463
|
-
var MessageSchema = buildMessageSchema();
|
|
464
|
-
|
|
465
|
-
// src/schemas/message-archive.ts
|
|
466
|
-
var import_mongoose3 = __toESM(require("mongoose"));
|
|
467
|
-
function buildMessageArchiveSchema() {
|
|
468
|
-
const schema = new import_mongoose3.default.Schema(
|
|
469
|
-
{
|
|
470
|
-
...BaseMessageFields,
|
|
471
|
-
actionCd: { type: String, default: "" },
|
|
472
|
-
archivedBy: { type: import_mongoose3.default.Schema.Types.ObjectId, default: null, index: true },
|
|
473
|
-
archivedAt: { type: Date, default: Date.now },
|
|
474
|
-
actionAttemptId: { type: String, default: null, index: true },
|
|
475
|
-
actionNotificationState: { type: String, enum: ["none", "pending", "sent", "failed"], default: "none" },
|
|
476
|
-
actionNotificationError: { type: String, default: null },
|
|
477
|
-
actionNotificationAttemptedAt: { type: Date, default: null }
|
|
478
|
-
},
|
|
479
|
-
{
|
|
480
|
-
timestamps: true
|
|
481
|
-
}
|
|
482
|
-
);
|
|
483
|
-
schema.index({ createdAt: 1 });
|
|
484
|
-
schema.methods.isSender = isSender;
|
|
485
|
-
schema.methods.isReceiver = isReceiver;
|
|
486
|
-
return schema;
|
|
487
|
-
}
|
|
488
|
-
var MessageArchiveSchema = buildMessageArchiveSchema();
|
|
489
|
-
|
|
490
|
-
// src/schemas/message-request.ts
|
|
491
|
-
var import_mongoose4 = __toESM(require("mongoose"));
|
|
492
|
-
function buildMessageRequestSchema() {
|
|
493
|
-
const schema = new import_mongoose4.default.Schema(
|
|
494
|
-
{
|
|
495
|
-
clientRequestId: { type: String, required: true },
|
|
496
|
-
clientRequestOwnerId: { type: String, required: true },
|
|
497
|
-
templateCd: { type: String, required: true },
|
|
498
|
-
state: { type: String, enum: ["pending", "completed", "failed"], default: "pending" },
|
|
499
|
-
itemCount: { type: Number, default: null },
|
|
500
|
-
leaseOwnerId: { type: String, default: null },
|
|
501
|
-
leaseExpiresAt: { type: Date, default: null },
|
|
502
|
-
completedAt: { type: Date, default: null },
|
|
503
|
-
failedAt: { type: Date, default: null },
|
|
504
|
-
failureMessage: { type: String, default: null }
|
|
505
|
-
},
|
|
506
|
-
{
|
|
507
|
-
timestamps: true
|
|
508
|
-
}
|
|
509
|
-
);
|
|
510
|
-
schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
|
|
511
|
-
schema.index({ updatedAt: 1 });
|
|
512
|
-
schema.index({ state: 1, leaseExpiresAt: 1 });
|
|
513
|
-
return schema;
|
|
514
|
-
}
|
|
515
|
-
var MessageRequestSchema = buildMessageRequestSchema();
|
|
516
|
-
|
|
517
|
-
// src/template-engine.ts
|
|
518
|
-
var import_handlebars = __toESM(require("handlebars"));
|
|
519
|
-
var compiledTemplates = /* @__PURE__ */ new Map();
|
|
520
|
-
function compile(template, data) {
|
|
521
|
-
let compiled = compiledTemplates.get(template);
|
|
522
|
-
if (!compiled) {
|
|
523
|
-
compiled = import_handlebars.default.compile(template, { noEscape: true });
|
|
524
|
-
compiledTemplates.set(template, compiled);
|
|
525
|
-
}
|
|
526
|
-
return compiled(data);
|
|
527
|
-
}
|
|
528
|
-
function filterActions(actions, usertype, options = {}) {
|
|
529
|
-
const { permissions = {}, message = {}, data = {} } = options;
|
|
530
|
-
return actions.filter((action) => {
|
|
531
|
-
if (!action[usertype]) return false;
|
|
532
|
-
if (action.permission && !permissions[action.permission]) return false;
|
|
533
|
-
if (action.condition && !action.condition(message)) return false;
|
|
534
|
-
return true;
|
|
535
|
-
}).map((action) => {
|
|
536
|
-
const { confirmation } = action;
|
|
537
|
-
return {
|
|
538
|
-
actionCd: action.actionCd,
|
|
539
|
-
isDefault: action.isDefault,
|
|
540
|
-
name: compile(action.name, data),
|
|
541
|
-
variant: action.variant,
|
|
542
|
-
confirmation: confirmation ? {
|
|
543
|
-
title: compile(confirmation.title, data),
|
|
544
|
-
message: compile(confirmation.message, data),
|
|
545
|
-
...confirmation.notesLabel ? { notesLabel: compile(confirmation.notesLabel, data) } : {},
|
|
546
|
-
...confirmation.requireNotes !== void 0 ? { requireNotes: confirmation.requireNotes } : {},
|
|
547
|
-
...confirmation.documents !== void 0 ? { documents: confirmation.documents } : {}
|
|
548
|
-
} : void 0,
|
|
549
|
-
payload: action.payload
|
|
550
|
-
};
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
function isActionAllowed(action, user, message, options = {}) {
|
|
554
|
-
const isReceiver2 = action.receiver && message.isReceiver(user);
|
|
555
|
-
const isSender2 = action.sender && message.isSender(user);
|
|
556
|
-
if (!isReceiver2 && !isSender2) return false;
|
|
557
|
-
if (action.permission && !(options.permissions || {})[action.permission]) return false;
|
|
558
|
-
if (action.condition && !action.condition(message)) return false;
|
|
559
|
-
return true;
|
|
560
|
-
}
|
|
561
|
-
function interpolateTemplate(template, data, usertype, options = {}) {
|
|
562
|
-
const { permissions = {}, message = {} } = options;
|
|
563
|
-
const { senderContent, receiverContent, actions, uiTemplate } = template;
|
|
564
|
-
return {
|
|
565
|
-
senderContent: {
|
|
566
|
-
title: compile(senderContent?.title || "", data),
|
|
567
|
-
long: compile(senderContent?.long || "", data),
|
|
568
|
-
short: compile(senderContent?.short || "", data)
|
|
569
|
-
},
|
|
570
|
-
receiverContent: {
|
|
571
|
-
title: compile(receiverContent?.title || "", data),
|
|
572
|
-
long: compile(receiverContent?.long || "", data),
|
|
573
|
-
short: compile(receiverContent?.short || "", data)
|
|
574
|
-
},
|
|
575
|
-
uiTemplate: resolveUiTemplate(uiTemplate, usertype),
|
|
576
|
-
actions: filterActions(actions, usertype, { permissions, message, data })
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
function resolveUiTemplate(uiTemplate, usertype) {
|
|
580
|
-
if (typeof uiTemplate === "string") return uiTemplate;
|
|
581
|
-
return uiTemplate[usertype] || uiTemplate.sender || uiTemplate.receiver || "";
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// src/providers/email.ts
|
|
585
|
-
var NoopEmailProvider = class {
|
|
586
|
-
async sendNotification(_to, _title, _body) {
|
|
587
|
-
}
|
|
588
|
-
};
|
|
589
|
-
|
|
590
|
-
// src/providers/payment.ts
|
|
591
|
-
var NoopPaymentProvider = class {
|
|
592
|
-
async createSession(_user, _code, _priceArgs) {
|
|
593
|
-
return null;
|
|
594
|
-
}
|
|
595
|
-
async expireSession(_sessionId) {
|
|
596
|
-
}
|
|
597
|
-
async refundPayment(_sessionId) {
|
|
598
|
-
}
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
// src/message-service.ts
|
|
602
|
-
var import_utils = require("@web-ts-toolkit/utils");
|
|
603
|
-
var import_node_crypto = require("crypto");
|
|
604
|
-
var import_mongoose5 = __toESM(require("mongoose"));
|
|
605
|
-
var DEFAULT_LIST_LIMIT = 50;
|
|
606
|
-
var MAX_LIST_LIMIT = 100;
|
|
607
|
-
var DUPLICATE_KEY_ERROR_CODE = 11e3;
|
|
608
|
-
var CLIENT_REQUEST_LEASE_MS = 3e4;
|
|
609
|
-
var CLIENT_REQUEST_WAIT_MS = 5e3;
|
|
610
|
-
var CLIENT_REQUEST_POLL_MS = 200;
|
|
611
|
-
var ACTION_LEASE_MS = 3e4;
|
|
612
|
-
var MAX_CLIENT_REQUEST_ID_LENGTH = 128;
|
|
613
|
-
var TRANSACTION_REQUIRED_MESSAGE = "message-service requires MongoDB replica set or sharded-cluster transactions for idempotent batch creation";
|
|
614
|
-
var DEFAULT_MODEL_NAMES = {
|
|
615
|
-
active: MESSAGE_MODEL_NAME,
|
|
616
|
-
archive: MESSAGE_ARCHIVE_MODEL_NAME,
|
|
617
|
-
request: MESSAGE_REQUEST_MODEL_NAME,
|
|
618
|
-
user: "User"
|
|
619
|
-
};
|
|
620
|
-
var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
|
|
621
|
-
var MessageNotFoundError = class extends Error {
|
|
622
|
-
static [Symbol.hasInstance](value) {
|
|
623
|
-
return isRuntimeError(value, "MessageNotFoundError");
|
|
624
|
-
}
|
|
625
|
-
constructor(messageId) {
|
|
626
|
-
super(`message "${messageId}" not found`);
|
|
627
|
-
this.name = "MessageNotFoundError";
|
|
628
|
-
markRuntimeError(this, this.name);
|
|
629
|
-
}
|
|
630
|
-
};
|
|
631
|
-
var MessageArchivedError = class extends Error {
|
|
632
|
-
static [Symbol.hasInstance](value) {
|
|
633
|
-
return isRuntimeError(value, "MessageArchivedError");
|
|
634
|
-
}
|
|
635
|
-
constructor(messageId) {
|
|
636
|
-
super(`message "${messageId}" is archived`);
|
|
637
|
-
this.name = "MessageArchivedError";
|
|
638
|
-
markRuntimeError(this, this.name);
|
|
639
|
-
}
|
|
640
|
-
};
|
|
641
|
-
var TemplateNotFoundError = class extends Error {
|
|
642
|
-
static [Symbol.hasInstance](value) {
|
|
643
|
-
return isRuntimeError(value, "TemplateNotFoundError");
|
|
644
|
-
}
|
|
645
|
-
constructor(templateCd) {
|
|
646
|
-
super(`template "${templateCd}" not found`);
|
|
647
|
-
this.name = "TemplateNotFoundError";
|
|
648
|
-
markRuntimeError(this, this.name);
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
var ActionNotFoundError = class extends Error {
|
|
652
|
-
static [Symbol.hasInstance](value) {
|
|
653
|
-
return isRuntimeError(value, "ActionNotFoundError");
|
|
654
|
-
}
|
|
655
|
-
constructor(templateCd, actionCd) {
|
|
656
|
-
super(`action "${actionCd}" not found in template "${templateCd}"`);
|
|
657
|
-
this.name = "ActionNotFoundError";
|
|
658
|
-
markRuntimeError(this, this.name);
|
|
659
|
-
}
|
|
660
|
-
};
|
|
661
|
-
var ActionNotAllowedError = class extends Error {
|
|
662
|
-
static [Symbol.hasInstance](value) {
|
|
663
|
-
return isRuntimeError(value, "ActionNotAllowedError");
|
|
664
|
-
}
|
|
665
|
-
constructor() {
|
|
666
|
-
super("not allowed");
|
|
667
|
-
this.name = "ActionNotAllowedError";
|
|
668
|
-
markRuntimeError(this, this.name);
|
|
669
|
-
}
|
|
670
|
-
};
|
|
671
|
-
var InvalidMessageUserError = class extends Error {
|
|
672
|
-
static [Symbol.hasInstance](value) {
|
|
673
|
-
return isRuntimeError(value, "InvalidMessageUserError");
|
|
674
|
-
}
|
|
675
|
-
constructor(message = "user._id must be a non-empty string or ObjectId") {
|
|
676
|
-
super(message);
|
|
677
|
-
this.name = "InvalidMessageUserError";
|
|
678
|
-
markRuntimeError(this, this.name);
|
|
679
|
-
}
|
|
680
|
-
};
|
|
681
|
-
var ActionTemplateMismatchError = class extends Error {
|
|
682
|
-
static [Symbol.hasInstance](value) {
|
|
683
|
-
return isRuntimeError(value, "ActionTemplateMismatchError");
|
|
684
|
-
}
|
|
685
|
-
constructor(expectedTemplateCd, receivedTemplateCd) {
|
|
686
|
-
super(`message template "${expectedTemplateCd}" does not match requested template "${receivedTemplateCd}"`);
|
|
687
|
-
this.name = "ActionTemplateMismatchError";
|
|
688
|
-
markRuntimeError(this, this.name);
|
|
689
|
-
}
|
|
690
|
-
};
|
|
691
|
-
var ActionConflictError = class extends Error {
|
|
692
|
-
static [Symbol.hasInstance](value) {
|
|
693
|
-
return isRuntimeError(value, "ActionConflictError");
|
|
694
|
-
}
|
|
695
|
-
constructor(messageId) {
|
|
696
|
-
super(`message "${messageId}" already has an action in progress`);
|
|
697
|
-
this.name = "ActionConflictError";
|
|
698
|
-
markRuntimeError(this, this.name);
|
|
699
|
-
}
|
|
700
|
-
};
|
|
701
|
-
var ActionRetryableError = class extends Error {
|
|
702
|
-
static [Symbol.hasInstance](value) {
|
|
703
|
-
return isRuntimeError(value, "ActionRetryableError");
|
|
704
|
-
}
|
|
705
|
-
constructor(messageId, actionAttemptId, cause) {
|
|
706
|
-
super(`message "${messageId}" action attempt "${actionAttemptId}" failed before commit and may be retried`);
|
|
707
|
-
this.name = "ActionRetryableError";
|
|
708
|
-
markRuntimeError(this, this.name);
|
|
709
|
-
this.actionAttemptId = actionAttemptId;
|
|
710
|
-
if (cause) {
|
|
711
|
-
this.cause = cause;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
var ActionNotificationPendingError = class extends Error {
|
|
716
|
-
static [Symbol.hasInstance](value) {
|
|
717
|
-
return isRuntimeError(value, "ActionNotificationPendingError");
|
|
718
|
-
}
|
|
719
|
-
constructor(messageId, actionAttemptId, result, cause) {
|
|
720
|
-
super(`message "${messageId}" action committed but sender notification is pending`);
|
|
721
|
-
this.name = "ActionNotificationPendingError";
|
|
722
|
-
markRuntimeError(this, this.name);
|
|
723
|
-
this.actionAttemptId = actionAttemptId;
|
|
724
|
-
this.result = result;
|
|
725
|
-
if (cause) {
|
|
726
|
-
this.cause = cause;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
var ActionNotificationPendingError = class extends Error {
|
|
418
|
+
static [Symbol.hasInstance](value) {
|
|
419
|
+
return isRuntimeError(value, "ActionNotificationPendingError");
|
|
420
|
+
}
|
|
421
|
+
constructor(messageId, actionAttemptId, result, cause) {
|
|
422
|
+
super(`message "${messageId}" action committed but sender notification is pending`);
|
|
423
|
+
this.name = "ActionNotificationPendingError";
|
|
424
|
+
markRuntimeError(this, this.name);
|
|
425
|
+
this.actionAttemptId = actionAttemptId;
|
|
426
|
+
this.result = result;
|
|
427
|
+
if (cause) {
|
|
428
|
+
this.cause = cause;
|
|
727
429
|
}
|
|
728
430
|
}
|
|
729
431
|
};
|
|
@@ -747,6 +449,16 @@ var InvalidPaginationValueError = class extends Error {
|
|
|
747
449
|
markRuntimeError(this, this.name);
|
|
748
450
|
}
|
|
749
451
|
};
|
|
452
|
+
var InvalidMessageServiceOptionError = class extends Error {
|
|
453
|
+
static [Symbol.hasInstance](value) {
|
|
454
|
+
return isRuntimeError(value, "InvalidMessageServiceOptionError");
|
|
455
|
+
}
|
|
456
|
+
constructor(message) {
|
|
457
|
+
super(message);
|
|
458
|
+
this.name = "InvalidMessageServiceOptionError";
|
|
459
|
+
markRuntimeError(this, this.name);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
750
462
|
var ClientRequestPendingError = class extends Error {
|
|
751
463
|
static [Symbol.hasInstance](value) {
|
|
752
464
|
return isRuntimeError(value, "ClientRequestPendingError");
|
|
@@ -764,9 +476,14 @@ var ClientRequestFailedError = class extends Error {
|
|
|
764
476
|
return isRuntimeError(value, "ClientRequestFailedError");
|
|
765
477
|
}
|
|
766
478
|
constructor(clientRequestId, reason) {
|
|
767
|
-
super(`clientRequestId "${clientRequestId}" previously failed
|
|
479
|
+
super(`clientRequestId "${clientRequestId}" previously failed; retry with a new clientRequestId`);
|
|
768
480
|
this.name = "ClientRequestFailedError";
|
|
769
481
|
markRuntimeError(this, this.name);
|
|
482
|
+
this.clientRequestId = clientRequestId;
|
|
483
|
+
this.failureReason = reason ?? null;
|
|
484
|
+
if (reason != null) {
|
|
485
|
+
this.cause = reason;
|
|
486
|
+
}
|
|
770
487
|
}
|
|
771
488
|
};
|
|
772
489
|
var ClientRequestInconsistentStateError = class extends Error {
|
|
@@ -810,7 +527,7 @@ var MessageModelResolutionError = class extends Error {
|
|
|
810
527
|
};
|
|
811
528
|
var PaymentSessionCompensationError = class extends Error {
|
|
812
529
|
static [Symbol.hasInstance](value) {
|
|
813
|
-
return isRuntimeError(value, "PaymentSessionCompensationError");
|
|
530
|
+
return isRuntimeError(value, "PaymentSessionCompensationError") || isRuntimeError(value, "PaymentSessionCompensationAggregateError");
|
|
814
531
|
}
|
|
815
532
|
constructor(sessionId, operation, compensationError, originalError, hookError) {
|
|
816
533
|
super(`payment session compensation failed for session "${sessionId}" during ${operation}`);
|
|
@@ -824,6 +541,202 @@ var PaymentSessionCompensationError = class extends Error {
|
|
|
824
541
|
this.cause = compensationError;
|
|
825
542
|
}
|
|
826
543
|
};
|
|
544
|
+
var PaymentSessionCompensationAggregateError = class extends PaymentSessionCompensationError {
|
|
545
|
+
static [Symbol.hasInstance](value) {
|
|
546
|
+
return isRuntimeError(value, "PaymentSessionCompensationAggregateError");
|
|
547
|
+
}
|
|
548
|
+
constructor(failures, originalError) {
|
|
549
|
+
const first = failures[0];
|
|
550
|
+
super(first.sessionId, "expire", first.compensationError, originalError, first.hookError);
|
|
551
|
+
this.name = "PaymentSessionCompensationAggregateError";
|
|
552
|
+
markRuntimeError(this, this.name);
|
|
553
|
+
this.failures = failures;
|
|
554
|
+
if (failures.length > 1) {
|
|
555
|
+
this.message = `payment session compensation failed for ${failures.length} sessions (${failures.map((f) => `"${f.sessionId}"`).join(", ")}) during expire`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/persistence.ts
|
|
561
|
+
function isDuplicateKeyError(error) {
|
|
562
|
+
return error instanceof Error && "code" in error && error.code === 11e3;
|
|
563
|
+
}
|
|
564
|
+
function isTransactionSupportError(error) {
|
|
565
|
+
if (!(error instanceof Error)) return false;
|
|
566
|
+
return error.message.includes("Transaction numbers are only allowed") || error.message.includes("Transaction is not supported") || error.message.includes("transactions are not supported");
|
|
567
|
+
}
|
|
568
|
+
async function runMessageTransaction(activeModel, operation, options) {
|
|
569
|
+
const startSession = activeModel.db?.startSession;
|
|
570
|
+
if (!options.session && typeof startSession !== "function") {
|
|
571
|
+
throw new MessageTransactionRequiredError(
|
|
572
|
+
new Error("message-service: atomic lifecycle write requires a Mongoose model with db.startSession support")
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
let session;
|
|
576
|
+
try {
|
|
577
|
+
session = options.session ?? await startSession.call(activeModel.db);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw isTransactionSupportError(error) ? new MessageTransactionRequiredError(error) : error;
|
|
580
|
+
}
|
|
581
|
+
let committed = false;
|
|
582
|
+
let originalError;
|
|
583
|
+
let operationFailure;
|
|
584
|
+
try {
|
|
585
|
+
let result;
|
|
586
|
+
await session.withTransaction(async () => {
|
|
587
|
+
operationFailure = void 0;
|
|
588
|
+
try {
|
|
589
|
+
result = await operation(session);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
operationFailure = { error };
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
committed = true;
|
|
596
|
+
return result;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
const primary = operationFailure ? operationFailure.error : error;
|
|
599
|
+
originalError = isTransactionSupportError(primary) ? new MessageTransactionRequiredError(primary) : primary;
|
|
600
|
+
throw originalError;
|
|
601
|
+
} finally {
|
|
602
|
+
if (!options.session) {
|
|
603
|
+
try {
|
|
604
|
+
await session.endSession();
|
|
605
|
+
} catch (error) {
|
|
606
|
+
const event = {
|
|
607
|
+
operation: options.operation,
|
|
608
|
+
stage: "endSession",
|
|
609
|
+
error,
|
|
610
|
+
...committed ? { outcome: "committed" } : { outcome: "failed", originalError }
|
|
611
|
+
};
|
|
612
|
+
try {
|
|
613
|
+
await options.onTransactionCleanupFailure?.(event);
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/message-service.ts
|
|
622
|
+
var import_utils = require("@web-ts-toolkit/utils");
|
|
623
|
+
var import_node_crypto = require("crypto");
|
|
624
|
+
var import_mongoose3 = __toESM(require("mongoose"));
|
|
625
|
+
|
|
626
|
+
// src/template-engine.ts
|
|
627
|
+
var import_handlebars = __toESM(require("handlebars"));
|
|
628
|
+
var compiledTemplates = /* @__PURE__ */ new Map();
|
|
629
|
+
function compile(template, data) {
|
|
630
|
+
let compiled = compiledTemplates.get(template);
|
|
631
|
+
if (!compiled) {
|
|
632
|
+
compiled = import_handlebars.default.compile(template, { noEscape: true });
|
|
633
|
+
compiledTemplates.set(template, compiled);
|
|
634
|
+
}
|
|
635
|
+
return compiled(data);
|
|
636
|
+
}
|
|
637
|
+
function hasExplicitPermissionGrant(permissions, key) {
|
|
638
|
+
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return false;
|
|
639
|
+
if (typeof key !== "string" || key.length === 0) return false;
|
|
640
|
+
if (!Object.prototype.hasOwnProperty.call(permissions, key)) return false;
|
|
641
|
+
return permissions[key] === true;
|
|
642
|
+
}
|
|
643
|
+
function interpolateMessageContent(template, data) {
|
|
644
|
+
const { senderContent, receiverContent } = template;
|
|
645
|
+
return {
|
|
646
|
+
senderContent: {
|
|
647
|
+
title: compile(senderContent?.title || "", data),
|
|
648
|
+
long: compile(senderContent?.long || "", data),
|
|
649
|
+
short: compile(senderContent?.short || "", data)
|
|
650
|
+
},
|
|
651
|
+
receiverContent: {
|
|
652
|
+
title: compile(receiverContent?.title || "", data),
|
|
653
|
+
long: compile(receiverContent?.long || "", data),
|
|
654
|
+
short: compile(receiverContent?.short || "", data)
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function resolveUiTemplate(uiTemplate, usertype) {
|
|
659
|
+
if (typeof uiTemplate === "string") return uiTemplate;
|
|
660
|
+
return uiTemplate[usertype] || uiTemplate.sender || uiTemplate.receiver || "";
|
|
661
|
+
}
|
|
662
|
+
function filterActions(actions, usertype, options = {}) {
|
|
663
|
+
const { permissions = {}, message = {}, data = {} } = options;
|
|
664
|
+
return actions.filter((action) => {
|
|
665
|
+
if (!action[usertype]) return false;
|
|
666
|
+
if (action.permission && !hasExplicitPermissionGrant(permissions, action.permission)) return false;
|
|
667
|
+
if (action.condition && !action.condition(message)) return false;
|
|
668
|
+
return true;
|
|
669
|
+
}).map((action) => {
|
|
670
|
+
const { confirmation } = action;
|
|
671
|
+
return {
|
|
672
|
+
actionCd: action.actionCd,
|
|
673
|
+
isDefault: action.isDefault,
|
|
674
|
+
name: compile(action.name, data),
|
|
675
|
+
variant: action.variant,
|
|
676
|
+
confirmation: confirmation ? {
|
|
677
|
+
title: compile(confirmation.title, data),
|
|
678
|
+
message: compile(confirmation.message, data),
|
|
679
|
+
...confirmation.notesLabel ? { notesLabel: compile(confirmation.notesLabel, data) } : {},
|
|
680
|
+
...confirmation.requireNotes !== void 0 ? { requireNotes: confirmation.requireNotes } : {},
|
|
681
|
+
...confirmation.documents !== void 0 ? { documents: confirmation.documents } : {}
|
|
682
|
+
} : void 0,
|
|
683
|
+
payload: action.payload
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function isActionAllowed(action, user, message, options = {}) {
|
|
688
|
+
const isReceiver2 = action.receiver && message.isReceiver(user);
|
|
689
|
+
const isSender2 = action.sender && message.isSender(user);
|
|
690
|
+
if (!isReceiver2 && !isSender2) return false;
|
|
691
|
+
if (action.permission && !hasExplicitPermissionGrant(options.permissions, action.permission)) return false;
|
|
692
|
+
if (action.condition && !action.condition(message)) return false;
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
function interpolateTemplate(template, data, usertype, options = {}) {
|
|
696
|
+
const { permissions = {}, message = {} } = options;
|
|
697
|
+
const { actions, uiTemplate } = template;
|
|
698
|
+
return {
|
|
699
|
+
...interpolateMessageContent(template, data),
|
|
700
|
+
uiTemplate: resolveUiTemplate(uiTemplate, usertype),
|
|
701
|
+
actions: filterActions(actions, usertype, { permissions, message, data })
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/message-service.ts
|
|
706
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
707
|
+
var MAX_LIST_LIMIT = 100;
|
|
708
|
+
var CLIENT_REQUEST_LEASE_MS = 3e4;
|
|
709
|
+
var CLIENT_REQUEST_WAIT_MS = 5e3;
|
|
710
|
+
var CLIENT_REQUEST_POLL_MS = 200;
|
|
711
|
+
var MAX_MESSAGE_SERVICE_TIMEOUT_MS = 2147483647;
|
|
712
|
+
var ACTION_LEASE_MS = 3e4;
|
|
713
|
+
var MAX_CLIENT_REQUEST_ID_LENGTH = 128;
|
|
714
|
+
var DEFAULT_MODEL_NAMES = {
|
|
715
|
+
active: MESSAGE_MODEL_NAME,
|
|
716
|
+
archive: MESSAGE_ARCHIVE_MODEL_NAME,
|
|
717
|
+
request: MESSAGE_REQUEST_MODEL_NAME,
|
|
718
|
+
user: "User"
|
|
719
|
+
};
|
|
720
|
+
var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
|
|
721
|
+
function isValidMessageUserId(id) {
|
|
722
|
+
if (typeof id === "string") {
|
|
723
|
+
return id.trim().length > 0;
|
|
724
|
+
}
|
|
725
|
+
if (id instanceof import_mongoose3.default.Types.ObjectId) {
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
function requireMessageUserId(user) {
|
|
731
|
+
if (!user || typeof user !== "object" || Array.isArray(user)) {
|
|
732
|
+
throw new InvalidMessageUserError();
|
|
733
|
+
}
|
|
734
|
+
const id = user._id;
|
|
735
|
+
if (!isValidMessageUserId(id)) {
|
|
736
|
+
throw new InvalidMessageUserError();
|
|
737
|
+
}
|
|
738
|
+
return typeof id === "string" ? id.trim() : String(id);
|
|
739
|
+
}
|
|
827
740
|
var MessageService = class {
|
|
828
741
|
constructor(options) {
|
|
829
742
|
this.getModel = options.getModel;
|
|
@@ -833,6 +746,7 @@ var MessageService = class {
|
|
|
833
746
|
this.expirePaymentSession = this.paymentProvider?.expireSession.bind(this.paymentProvider);
|
|
834
747
|
this.refundPaymentSession = this.paymentProvider?.refundPayment.bind(this.paymentProvider);
|
|
835
748
|
this.onPaymentCompensationFailure = options.onPaymentCompensationFailure;
|
|
749
|
+
this.onTransactionCleanupFailure = options.onTransactionCleanupFailure;
|
|
836
750
|
this.adminRoles = options.adminRoles ?? [];
|
|
837
751
|
this.registry = options.registry ?? defaultRegistry;
|
|
838
752
|
this.maxListLimit = this.validatePositiveIntegerOption("maxListLimit", options.maxListLimit ?? MAX_LIST_LIMIT);
|
|
@@ -843,10 +757,23 @@ var MessageService = class {
|
|
|
843
757
|
if (this.defaultListLimit > this.maxListLimit) {
|
|
844
758
|
throw new InvalidPaginationValueError("defaultListLimit must be less than or equal to maxListLimit");
|
|
845
759
|
}
|
|
846
|
-
this.clientRequestLeaseMs =
|
|
847
|
-
|
|
848
|
-
|
|
760
|
+
this.clientRequestLeaseMs = this.validateDurationOption(
|
|
761
|
+
"clientRequestLeaseMs",
|
|
762
|
+
options.clientRequestLeaseMs ?? CLIENT_REQUEST_LEASE_MS,
|
|
763
|
+
1
|
|
764
|
+
);
|
|
765
|
+
this.clientRequestWaitMs = this.validateDurationOption(
|
|
766
|
+
"clientRequestWaitMs",
|
|
767
|
+
options.clientRequestWaitMs ?? CLIENT_REQUEST_WAIT_MS,
|
|
768
|
+
0
|
|
769
|
+
);
|
|
770
|
+
this.clientRequestPollMs = this.validateDurationOption(
|
|
771
|
+
"clientRequestPollMs",
|
|
772
|
+
options.clientRequestPollMs ?? CLIENT_REQUEST_POLL_MS,
|
|
773
|
+
1
|
|
774
|
+
);
|
|
849
775
|
this.clientRequestDelay = options.clientRequestDelay ?? this.delay;
|
|
776
|
+
this.clientRequestNow = options.clientRequestNow ?? (() => Date.now());
|
|
850
777
|
this.actionLeaseMs = ACTION_LEASE_MS;
|
|
851
778
|
}
|
|
852
779
|
// -------------------------------------------------------------------------
|
|
@@ -855,6 +782,10 @@ var MessageService = class {
|
|
|
855
782
|
/**
|
|
856
783
|
* Find a message by id, falling back to the archive. Returns null if
|
|
857
784
|
* the message does not exist in either collection.
|
|
785
|
+
*
|
|
786
|
+
* Trusted host-level operation: takes no principal and performs no
|
|
787
|
+
* user-identity validation. Hosts must authorize the returned document
|
|
788
|
+
* before exposing it to a user (see `getActions`/`handleAction`).
|
|
858
789
|
*/
|
|
859
790
|
async findMessage(messageId, options = {}) {
|
|
860
791
|
const Message = this.resolveModel("active");
|
|
@@ -867,6 +798,13 @@ var MessageService = class {
|
|
|
867
798
|
options
|
|
868
799
|
);
|
|
869
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Same as `findMessage`, but throws `MessageNotFoundError` when neither the
|
|
803
|
+
* active nor the archive collection holds the id.
|
|
804
|
+
*
|
|
805
|
+
* Trusted host-level operation: no principal validation. Hosts must
|
|
806
|
+
* authorize before exposing the result to a user.
|
|
807
|
+
*/
|
|
870
808
|
async findMessageOrThrow(messageId, options = {}) {
|
|
871
809
|
const message = await this.findMessage(messageId, options);
|
|
872
810
|
if (!message) {
|
|
@@ -877,6 +815,25 @@ var MessageService = class {
|
|
|
877
815
|
// -------------------------------------------------------------------------
|
|
878
816
|
// Create message from template
|
|
879
817
|
// -------------------------------------------------------------------------
|
|
818
|
+
/**
|
|
819
|
+
* Create messages from a trusted template as an authenticated user.
|
|
820
|
+
*
|
|
821
|
+
* User-facing entry point: `user` (and `payerUser` when provided) must
|
|
822
|
+
* carry a valid principal id (non-empty string or ObjectId; numbers,
|
|
823
|
+
* arrays, and plain objects are rejected with `InvalidMessageUserError`)
|
|
824
|
+
* before any template, provider, or model effect runs. Both the
|
|
825
|
+
* idempotent (`clientRequestId`) and direct branches enforce this.
|
|
826
|
+
*
|
|
827
|
+
* Idempotent replay contract (MSGF-06): a completed same-scope replay
|
|
828
|
+
* returns the current active/archive records merged from both collections
|
|
829
|
+
* (`Array<IMessage | IMessageArchive>`), sorted by exact
|
|
830
|
+
* `clientRequestItemIndex`, without rerunning preparation/payment. Fresh
|
|
831
|
+
* creates resolve active documents only. Archive entries are returned as
|
|
832
|
+
* persisted `IMessageArchive` — never cast to `IMessage` and never given
|
|
833
|
+
* fabricated active-only methods. Narrow with `'archivedAt' in doc`.
|
|
834
|
+
* Archive retention bounds the replay window: deleted archive documents
|
|
835
|
+
* surface as `ClientRequestInconsistentStateError`, not partial replays.
|
|
836
|
+
*/
|
|
880
837
|
async createMessage(params) {
|
|
881
838
|
const {
|
|
882
839
|
templateCd,
|
|
@@ -889,6 +846,10 @@ var MessageService = class {
|
|
|
889
846
|
req,
|
|
890
847
|
clientRequestId
|
|
891
848
|
} = params;
|
|
849
|
+
this.requireUserId(user);
|
|
850
|
+
if (payerUser !== void 0) {
|
|
851
|
+
this.requireUserId(payerUser);
|
|
852
|
+
}
|
|
892
853
|
const normalizedClientRequestId = this.normalizeClientRequestId(clientRequestId);
|
|
893
854
|
if (normalizedClientRequestId) {
|
|
894
855
|
return this.createMessageWithReservation({
|
|
@@ -968,8 +929,13 @@ var MessageService = class {
|
|
|
968
929
|
const items = Array.isArray(messageData) ? messageData : [messageData];
|
|
969
930
|
if (clientRequestScope && clientRequestLease) {
|
|
970
931
|
const docs = [];
|
|
971
|
-
|
|
972
|
-
|
|
932
|
+
try {
|
|
933
|
+
for (let index = 0; index < items.length; index++) {
|
|
934
|
+
docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
|
|
935
|
+
}
|
|
936
|
+
} catch (error) {
|
|
937
|
+
await this.compensatePaymentSessions(docs, error, clientRequestScope);
|
|
938
|
+
throw error;
|
|
973
939
|
}
|
|
974
940
|
return this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, docs);
|
|
975
941
|
}
|
|
@@ -983,6 +949,13 @@ var MessageService = class {
|
|
|
983
949
|
// -------------------------------------------------------------------------
|
|
984
950
|
// Create generic notification (no template, no actions)
|
|
985
951
|
// -------------------------------------------------------------------------
|
|
952
|
+
/**
|
|
953
|
+
* Create a generic notification without template preparation or actions.
|
|
954
|
+
*
|
|
955
|
+
* Trusted host-level operation: takes raw `UserId` values (string or
|
|
956
|
+
* ObjectId, including null) and performs no principal validation. Hosts
|
|
957
|
+
* own authentication/authorization for notification creation.
|
|
958
|
+
*/
|
|
986
959
|
async createNotification(params, sourceDocument) {
|
|
987
960
|
const Message = this.resolveModel("active", sourceDocument);
|
|
988
961
|
return Message.create({
|
|
@@ -1003,6 +976,8 @@ var MessageService = class {
|
|
|
1003
976
|
* Build a Mongoose filter for messages visible to the given user.
|
|
1004
977
|
* Exposed so callers can use the same visibility rules for custom
|
|
1005
978
|
* queries (e.g. with `populate`).
|
|
979
|
+
*
|
|
980
|
+
* User-facing helper: validates `user` before building the filter.
|
|
1006
981
|
*/
|
|
1007
982
|
buildVisibilityFilter(user) {
|
|
1008
983
|
const userId = this.requireUserId(user);
|
|
@@ -1014,6 +989,9 @@ var MessageService = class {
|
|
|
1014
989
|
* List active (non-archived) messages visible to a user.
|
|
1015
990
|
* Returns messages where the user is the sender, the receiver,
|
|
1016
991
|
* or matches one of the recipient's roles.
|
|
992
|
+
*
|
|
993
|
+
* User-facing operation: validates `user` via the shared principal
|
|
994
|
+
* contract. Returns `IMessage[]` ordered by `{ createdAt: -1, _id: -1 }`.
|
|
1017
995
|
*/
|
|
1018
996
|
async listMessages(params) {
|
|
1019
997
|
const { user, limit: rawLimit, skip: rawSkip, populate } = params;
|
|
@@ -1029,6 +1007,8 @@ var MessageService = class {
|
|
|
1029
1007
|
/**
|
|
1030
1008
|
* Count active (non-archived) messages visible to a user.
|
|
1031
1009
|
* Useful for badge indicators ("3 new messages").
|
|
1010
|
+
*
|
|
1011
|
+
* User-facing operation: validates `user` via the shared principal contract.
|
|
1032
1012
|
*/
|
|
1033
1013
|
async countMessages(user) {
|
|
1034
1014
|
const Message = this.resolveModel("active");
|
|
@@ -1037,11 +1017,32 @@ var MessageService = class {
|
|
|
1037
1017
|
// -------------------------------------------------------------------------
|
|
1038
1018
|
// Get actions for a message
|
|
1039
1019
|
// -------------------------------------------------------------------------
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* Get available actions for a message as an authenticated user.
|
|
1022
|
+
*
|
|
1023
|
+
* User-facing operation: `options.user` is required and must carry a valid
|
|
1024
|
+
* principal id (non-empty string or ObjectId). It is validated before any
|
|
1025
|
+
* model lookup or template operation; invalid principals throw
|
|
1026
|
+
* `InvalidMessageUserError`.
|
|
1027
|
+
*
|
|
1028
|
+
* Returns `{ uiTemplate, actions }` for visible messages, or `null` when the
|
|
1029
|
+
* message is missing, the user has no sender/receiver relationship (unless
|
|
1030
|
+
* `isAdmin`), or the template is unknown. Admin (`isAdmin`) and archived
|
|
1031
|
+
* views return the resolved `uiTemplate` with an empty action list without
|
|
1032
|
+
* evaluating action `condition` predicates. All listings
|
|
1033
|
+
* read the requested id from the configured store. Relationships are
|
|
1034
|
+
* checked on canonical stored IDs before optional presentation population.
|
|
1035
|
+
* Conditions see that read snapshot with `populate` applied (mutations still
|
|
1036
|
+
* check unpopulated atomic claims); this does not reserve an action or prevent
|
|
1037
|
+
* subsequent host writes. Action labels and
|
|
1038
|
+
* confirmations render from persisted `message.payload` (missing values
|
|
1039
|
+
* render empty), distinct from creation-time `templateData`.
|
|
1040
|
+
*/
|
|
1041
|
+
async getActions(messageId, usertype, options) {
|
|
1042
|
+
const user = requireMessageUserId(options?.user);
|
|
1044
1043
|
const authorizedUser = { ...options.user, _id: user };
|
|
1044
|
+
const message = await this.findMessage(messageId);
|
|
1045
|
+
if (!message) return null;
|
|
1045
1046
|
if (!options.isAdmin) {
|
|
1046
1047
|
const isAllowedUsertype = usertype === "sender" ? message.isSender(authorizedUser) : message.isReceiver(authorizedUser);
|
|
1047
1048
|
if (!isAllowedUsertype) {
|
|
@@ -1053,31 +1054,44 @@ var MessageService = class {
|
|
|
1053
1054
|
}
|
|
1054
1055
|
const template = this.registry.find(message.templateCd);
|
|
1055
1056
|
if (!template) return null;
|
|
1057
|
+
if (options.isAdmin || this.isArchivedMessage(message)) {
|
|
1058
|
+
return { uiTemplate: resolveUiTemplate(template.uiTemplate, usertype), actions: [] };
|
|
1059
|
+
}
|
|
1060
|
+
if (options.populate) {
|
|
1061
|
+
await message.populate(options.populate);
|
|
1062
|
+
}
|
|
1056
1063
|
const data = message.payload ?? {};
|
|
1057
1064
|
const interpolated = interpolateTemplate(template, data, usertype, {
|
|
1058
1065
|
permissions: options.permissions,
|
|
1059
1066
|
message
|
|
1060
1067
|
});
|
|
1061
|
-
if (options.isAdmin || this.isArchivedMessage(message)) {
|
|
1062
|
-
return { uiTemplate: interpolated.uiTemplate, actions: [] };
|
|
1063
|
-
}
|
|
1064
1068
|
return { uiTemplate: interpolated.uiTemplate, actions: interpolated.actions };
|
|
1065
1069
|
}
|
|
1066
1070
|
// -------------------------------------------------------------------------
|
|
1067
1071
|
// Handle an action on a message
|
|
1068
1072
|
// -------------------------------------------------------------------------
|
|
1073
|
+
/**
|
|
1074
|
+
* Execute an action with a durable claim, stable handler attempt key,
|
|
1075
|
+
* transactional archive, and post-commit sender notification status.
|
|
1076
|
+
*
|
|
1077
|
+
* User-facing operation: `data.user` is validated before any template or
|
|
1078
|
+
* model effect. Authorization and handler selection bind to the persisted
|
|
1079
|
+
* claim, not the caller-supplied `data.message` copy. Archived messages
|
|
1080
|
+
* are re-read by id on their owning connection and apply a stored
|
|
1081
|
+
* sender/receiver relationship gate before disclosing current attempt IDs
|
|
1082
|
+
* or notification state. A missing archive throws `MessageNotFoundError`.
|
|
1083
|
+
* Returns the handler's value (`unknown`; templates
|
|
1084
|
+
* document their own shape).
|
|
1085
|
+
*/
|
|
1069
1086
|
async handleAction(templateCd, actionCd, data) {
|
|
1070
1087
|
const userId = this.requireUserId(data.user);
|
|
1071
1088
|
const authorizedUser = { ...data.user, _id: userId };
|
|
1072
1089
|
if (this.isArchivedMessage(data.message)) {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
throw new MessageArchivedError(String(data.message._id));
|
|
1090
|
+
const archived = await this.resolveModel("archive", data.message).findById(
|
|
1091
|
+
data.message._id
|
|
1092
|
+
);
|
|
1093
|
+
if (!archived) throw new MessageNotFoundError(String(data.message._id));
|
|
1094
|
+
this.throwArchivedOutcome(archived, authorizedUser);
|
|
1081
1095
|
}
|
|
1082
1096
|
if (templateCd !== data.message.templateCd) {
|
|
1083
1097
|
throw new ActionTemplateMismatchError(data.message.templateCd, templateCd);
|
|
@@ -1090,6 +1104,21 @@ var MessageService = class {
|
|
|
1090
1104
|
throw new ActionNotAllowedError();
|
|
1091
1105
|
}
|
|
1092
1106
|
const claim = await this.claimAction(data.message, actionCd, authorizedUser);
|
|
1107
|
+
let authorizedAction;
|
|
1108
|
+
try {
|
|
1109
|
+
authorizedAction = this.authorizeClaimedAction(templateCd, actionCd, claim.message, authorizedUser, {
|
|
1110
|
+
permissions: data.permissions
|
|
1111
|
+
});
|
|
1112
|
+
} catch (authError) {
|
|
1113
|
+
try {
|
|
1114
|
+
await this.releaseDeniedActionClaim(claim, authError);
|
|
1115
|
+
} catch (releaseError) {
|
|
1116
|
+
if (!(releaseError instanceof ActionConflictError)) {
|
|
1117
|
+
throw releaseError;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
throw authError;
|
|
1121
|
+
}
|
|
1093
1122
|
const ctx = this.buildActionContext({
|
|
1094
1123
|
...data,
|
|
1095
1124
|
user: authorizedUser,
|
|
@@ -1098,24 +1127,44 @@ var MessageService = class {
|
|
|
1098
1127
|
});
|
|
1099
1128
|
let result;
|
|
1100
1129
|
try {
|
|
1101
|
-
result = await
|
|
1130
|
+
result = await authorizedAction.runHandler(ctx);
|
|
1102
1131
|
} catch (error) {
|
|
1103
|
-
await this.markActionRetryable(
|
|
1132
|
+
await this.markActionRetryable(
|
|
1133
|
+
data.message._id,
|
|
1134
|
+
claim.actionAttemptId,
|
|
1135
|
+
claim.actionOwnerToken,
|
|
1136
|
+
error,
|
|
1137
|
+
claim.message
|
|
1138
|
+
);
|
|
1104
1139
|
throw new ActionRetryableError(String(data.message._id), claim.actionAttemptId, error);
|
|
1105
1140
|
}
|
|
1106
|
-
await this.archiveClaimedMessage(
|
|
1107
|
-
|
|
1141
|
+
await this.archiveClaimedMessage(
|
|
1142
|
+
claim.message,
|
|
1143
|
+
authorizedAction,
|
|
1144
|
+
authorizedUser,
|
|
1145
|
+
claim.actionAttemptId,
|
|
1146
|
+
claim.actionOwnerToken
|
|
1147
|
+
);
|
|
1148
|
+
if (authorizedAction.senderNotification) {
|
|
1108
1149
|
try {
|
|
1109
|
-
await this.runSenderNotification(
|
|
1150
|
+
await this.runSenderNotification(authorizedAction, ctx, claim.message);
|
|
1110
1151
|
await this.markActionNotificationState(
|
|
1111
1152
|
data.message._id,
|
|
1112
1153
|
claim.actionAttemptId,
|
|
1154
|
+
claim.actionOwnerToken,
|
|
1113
1155
|
"sent",
|
|
1114
1156
|
void 0,
|
|
1115
1157
|
claim.message
|
|
1116
1158
|
);
|
|
1117
1159
|
} catch (error) {
|
|
1118
|
-
await this.markActionNotificationState(
|
|
1160
|
+
await this.markActionNotificationState(
|
|
1161
|
+
data.message._id,
|
|
1162
|
+
claim.actionAttemptId,
|
|
1163
|
+
claim.actionOwnerToken,
|
|
1164
|
+
"failed",
|
|
1165
|
+
error,
|
|
1166
|
+
claim.message
|
|
1167
|
+
);
|
|
1119
1168
|
throw new ActionNotificationPendingError(String(data.message._id), claim.actionAttemptId, result, error);
|
|
1120
1169
|
}
|
|
1121
1170
|
}
|
|
@@ -1147,7 +1196,7 @@ var MessageService = class {
|
|
|
1147
1196
|
if (this.connection) {
|
|
1148
1197
|
return this.connection.model(modelName);
|
|
1149
1198
|
}
|
|
1150
|
-
return
|
|
1199
|
+
return import_mongoose3.default.model(modelName);
|
|
1151
1200
|
} catch (error) {
|
|
1152
1201
|
throw new MessageModelResolutionError(role, modelName, this.describeModelSource(sourceConnection), error);
|
|
1153
1202
|
}
|
|
@@ -1159,7 +1208,7 @@ var MessageService = class {
|
|
|
1159
1208
|
if (sourceConnection) return sourceConnection.model(name);
|
|
1160
1209
|
if (this.getModel) return this.getModel(name);
|
|
1161
1210
|
if (this.connection) return this.connection.model(name);
|
|
1162
|
-
return
|
|
1211
|
+
return import_mongoose3.default.model(name);
|
|
1163
1212
|
} catch (error) {
|
|
1164
1213
|
throw new MessageModelResolutionError("user", name, this.describeModelSource(sourceConnection), error);
|
|
1165
1214
|
}
|
|
@@ -1209,7 +1258,7 @@ var MessageService = class {
|
|
|
1209
1258
|
);
|
|
1210
1259
|
if (!paymentSession) throw new Error("payment session creation failed");
|
|
1211
1260
|
}
|
|
1212
|
-
const interpolated =
|
|
1261
|
+
const interpolated = interpolateMessageContent(template, m.templateData || {});
|
|
1213
1262
|
return {
|
|
1214
1263
|
type,
|
|
1215
1264
|
templateCd: template.templateCd,
|
|
@@ -1235,10 +1284,17 @@ var MessageService = class {
|
|
|
1235
1284
|
}
|
|
1236
1285
|
}
|
|
1237
1286
|
async persistPreparedBatchTransaction(scope, lease, docs) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1287
|
+
let Message;
|
|
1288
|
+
let MessageRequest;
|
|
1289
|
+
try {
|
|
1290
|
+
Message = this.resolveModel("active");
|
|
1291
|
+
MessageRequest = this.resolveModel("request", void 0, Message);
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
await this.compensatePaymentSessions(docs, error, scope);
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
const operation = async (session) => {
|
|
1297
|
+
const created = docs.length > 0 ? await Message.create(docs, { session, ordered: true }) : [];
|
|
1242
1298
|
const result = await MessageRequest.updateOne(
|
|
1243
1299
|
{ ...scope, state: "pending", leaseOwnerId: lease.ownerId },
|
|
1244
1300
|
{
|
|
@@ -1249,45 +1305,50 @@ var MessageService = class {
|
|
|
1249
1305
|
leaseExpiresAt: null
|
|
1250
1306
|
}
|
|
1251
1307
|
},
|
|
1252
|
-
|
|
1308
|
+
{ session }
|
|
1253
1309
|
);
|
|
1254
1310
|
if ((result.matchedCount ?? result.n ?? 0) !== 1) {
|
|
1255
1311
|
throw new ClientRequestPendingError(scope.clientRequestId);
|
|
1256
1312
|
}
|
|
1257
1313
|
return created;
|
|
1258
1314
|
};
|
|
1259
|
-
const startSession = Message.db?.startSession;
|
|
1260
|
-
if (!startSession) {
|
|
1261
|
-
try {
|
|
1262
|
-
return await operation();
|
|
1263
|
-
} catch (error) {
|
|
1264
|
-
await this.compensatePaymentSessions(docs, error, scope);
|
|
1265
|
-
throw error;
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
const session = await startSession.call(Message.db);
|
|
1269
1315
|
try {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1316
|
+
return await runMessageTransaction(Message, operation, {
|
|
1317
|
+
operation: "createBatch",
|
|
1318
|
+
onTransactionCleanupFailure: this.onTransactionCleanupFailure
|
|
1273
1319
|
});
|
|
1274
|
-
return result;
|
|
1275
1320
|
} catch (error) {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (this.isTransactionSupportError(error)) {
|
|
1279
|
-
throw wrappedError;
|
|
1280
|
-
}
|
|
1281
|
-
throw wrappedError;
|
|
1282
|
-
} finally {
|
|
1283
|
-
await session.endSession();
|
|
1321
|
+
await this.compensatePaymentSessions(docs, error, scope);
|
|
1322
|
+
throw error;
|
|
1284
1323
|
}
|
|
1285
1324
|
}
|
|
1286
1325
|
async compensatePaymentSessions(docs, originalError, scope) {
|
|
1287
1326
|
const sessionIds = docs.map((doc) => doc.paymentSession).filter((sessionId) => typeof sessionId === "string" && sessionId.length > 0);
|
|
1327
|
+
if (sessionIds.length === 0) return;
|
|
1328
|
+
if (!this.expirePaymentSession) return;
|
|
1329
|
+
const failures = [];
|
|
1330
|
+
let firstSingleError;
|
|
1288
1331
|
for (const sessionId of sessionIds) {
|
|
1289
|
-
|
|
1332
|
+
try {
|
|
1333
|
+
await this.compensatePaymentSession(sessionId, originalError, scope);
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
if (error instanceof PaymentSessionCompensationError) {
|
|
1336
|
+
if (!firstSingleError) firstSingleError = error;
|
|
1337
|
+
failures.push({
|
|
1338
|
+
sessionId: error.sessionId,
|
|
1339
|
+
compensationError: error.compensationError,
|
|
1340
|
+
hookError: error.hookError
|
|
1341
|
+
});
|
|
1342
|
+
} else {
|
|
1343
|
+
failures.push({ sessionId, compensationError: error });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1290
1346
|
}
|
|
1347
|
+
if (failures.length === 0) return;
|
|
1348
|
+
if (sessionIds.length === 1 && firstSingleError) {
|
|
1349
|
+
throw firstSingleError;
|
|
1350
|
+
}
|
|
1351
|
+
throw new PaymentSessionCompensationAggregateError(failures, originalError);
|
|
1291
1352
|
}
|
|
1292
1353
|
async compensatePaymentSession(sessionId, originalError, scope) {
|
|
1293
1354
|
if (!this.expirePaymentSession) return;
|
|
@@ -1334,42 +1395,134 @@ var MessageService = class {
|
|
|
1334
1395
|
actionFailureMessage: null
|
|
1335
1396
|
};
|
|
1336
1397
|
const firstAttemptId = (0, import_node_crypto.randomUUID)();
|
|
1398
|
+
const firstOwnerToken = (0, import_node_crypto.randomUUID)();
|
|
1337
1399
|
const firstClaim = await Message.findOneAndUpdate(
|
|
1338
1400
|
{ _id: messageId, $or: [{ actionState: "active" }, { actionState: null }, { actionState: { $exists: false } }] },
|
|
1339
|
-
{
|
|
1401
|
+
{
|
|
1402
|
+
$set: {
|
|
1403
|
+
...claimBase,
|
|
1404
|
+
actionState: "processing",
|
|
1405
|
+
actionAttemptId: firstAttemptId,
|
|
1406
|
+
actionOwnerToken: firstOwnerToken
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
{ returnDocument: "after" }
|
|
1410
|
+
);
|
|
1411
|
+
if (firstClaim?.actionAttemptId && firstClaim?.actionOwnerToken) {
|
|
1412
|
+
return {
|
|
1413
|
+
message: firstClaim,
|
|
1414
|
+
actionAttemptId: firstClaim.actionAttemptId,
|
|
1415
|
+
actionOwnerToken: firstClaim.actionOwnerToken,
|
|
1416
|
+
fresh: true
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
const nextOwnerToken = (0, import_node_crypto.randomUUID)();
|
|
1420
|
+
const retryClaim = await Message.findOneAndUpdate(
|
|
1421
|
+
{
|
|
1422
|
+
_id: messageId,
|
|
1423
|
+
actionCd,
|
|
1424
|
+
actionAttemptId: { $type: "string" },
|
|
1425
|
+
$or: [{ actionState: "retryable" }, { actionState: "processing", actionLeaseExpiresAt: { $lte: now } }]
|
|
1426
|
+
},
|
|
1427
|
+
{ $set: { ...claimBase, actionState: "processing", actionOwnerToken: nextOwnerToken } },
|
|
1340
1428
|
{ returnDocument: "after" }
|
|
1341
1429
|
);
|
|
1342
|
-
if (
|
|
1343
|
-
return {
|
|
1430
|
+
if (retryClaim?.actionAttemptId && retryClaim?.actionOwnerToken) {
|
|
1431
|
+
return {
|
|
1432
|
+
message: retryClaim,
|
|
1433
|
+
actionAttemptId: retryClaim.actionAttemptId,
|
|
1434
|
+
actionOwnerToken: retryClaim.actionOwnerToken,
|
|
1435
|
+
fresh: false
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const archived = await this.resolveModel("archive", message, Message).findById(
|
|
1439
|
+
messageId
|
|
1440
|
+
);
|
|
1441
|
+
if (archived) {
|
|
1442
|
+
this.throwArchivedOutcome(archived, user);
|
|
1443
|
+
}
|
|
1444
|
+
throw new ActionConflictError(String(messageId));
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Relationship/permission policy for archived outcomes.
|
|
1448
|
+
*
|
|
1449
|
+
* Only the recorded sender or receiver may observe whether an archived
|
|
1450
|
+
* message is pending notification or terminally archived. This keeps
|
|
1451
|
+
* authorized retries (including pending-notification retries) available even
|
|
1452
|
+
* when the template has been unregistered, while unrelated callers receive a
|
|
1453
|
+
* stable `ActionNotAllowedError` without attempt IDs or notification state.
|
|
1454
|
+
*/
|
|
1455
|
+
authorizeArchivedOutcome(message, user) {
|
|
1456
|
+
if (!message.isSender(user) && !message.isReceiver(user)) {
|
|
1457
|
+
throw new ActionNotAllowedError();
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/** Call only with an authoritative, unpopulated archive read. */
|
|
1461
|
+
throwArchivedOutcome(message, user) {
|
|
1462
|
+
this.authorizeArchivedOutcome(message, user);
|
|
1463
|
+
if (message.actionNotificationState === "pending" || message.actionNotificationState === "failed") {
|
|
1464
|
+
throw new ActionNotificationPendingError(String(message._id), message.actionAttemptId ?? "", void 0);
|
|
1465
|
+
}
|
|
1466
|
+
throw new MessageArchivedError(String(message._id));
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Authoritative post-claim authorization bound to the persisted document
|
|
1470
|
+
* returned by the atomic claim. Re-validates the requested template/action
|
|
1471
|
+
* selection and the sender/receiver, permission, and condition checks
|
|
1472
|
+
* against persisted `templateCd`, parties, roles, and payload data.
|
|
1473
|
+
*
|
|
1474
|
+
* Supported update protocol: the guarantee covers the document state as of
|
|
1475
|
+
* the atomic claim (`findOneAndUpdate` returning the persisted document).
|
|
1476
|
+
* Host writes that commit before the claim are observed; host writes that
|
|
1477
|
+
* commit after the claim (during handler execution) are not prevented. Hosts
|
|
1478
|
+
* must not mutate action-relevant fields (`templateCd`, `toUser`/`toRoles`/
|
|
1479
|
+
* `fromUser`, condition payload) concurrently with in-flight actions outside
|
|
1480
|
+
* a coordinated protocol. A fresh-document read alone is not a substitute
|
|
1481
|
+
* for this claim-bound check.
|
|
1482
|
+
*/
|
|
1483
|
+
authorizeClaimedAction(templateCd, actionCd, claimed, user, options = {}) {
|
|
1484
|
+
if (templateCd !== claimed.templateCd) {
|
|
1485
|
+
throw new ActionTemplateMismatchError(claimed.templateCd, templateCd);
|
|
1486
|
+
}
|
|
1487
|
+
const template = this.registry.find(claimed.templateCd);
|
|
1488
|
+
if (!template) throw new TemplateNotFoundError(claimed.templateCd);
|
|
1489
|
+
const action = template.actions.find((a) => a.actionCd === actionCd);
|
|
1490
|
+
if (!action) throw new ActionNotFoundError(claimed.templateCd, actionCd);
|
|
1491
|
+
if (!isActionAllowed(action, user, claimed, { permissions: options.permissions })) {
|
|
1492
|
+
throw new ActionNotAllowedError();
|
|
1493
|
+
}
|
|
1494
|
+
return action;
|
|
1495
|
+
}
|
|
1496
|
+
async releaseDeniedActionClaim(claim, error) {
|
|
1497
|
+
const { message, actionAttemptId, actionOwnerToken } = claim;
|
|
1498
|
+
if (!claim.fresh) {
|
|
1499
|
+
await this.markActionRetryable(message._id, actionAttemptId, actionOwnerToken, error, message);
|
|
1500
|
+
return;
|
|
1344
1501
|
}
|
|
1345
|
-
const
|
|
1502
|
+
const Message = this.resolveModel("active", message);
|
|
1503
|
+
const updated = await Message.updateOne(
|
|
1504
|
+
{ _id: message._id, actionState: "processing", actionAttemptId, actionOwnerToken },
|
|
1346
1505
|
{
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1358
|
-
const archived = await this.resolveModel("archive", message, Message).findById(
|
|
1359
|
-
messageId
|
|
1360
|
-
);
|
|
1361
|
-
if (archived) {
|
|
1362
|
-
if (archived.actionNotificationState === "pending" || archived.actionNotificationState === "failed") {
|
|
1363
|
-
throw new ActionNotificationPendingError(String(messageId), archived.actionAttemptId ?? "", void 0);
|
|
1506
|
+
$set: {
|
|
1507
|
+
actionState: "active",
|
|
1508
|
+
actionCd: null,
|
|
1509
|
+
actionAttemptId: null,
|
|
1510
|
+
actionOwnerToken: null,
|
|
1511
|
+
actionClaimedBy: null,
|
|
1512
|
+
actionClaimedAt: null,
|
|
1513
|
+
actionLeaseExpiresAt: null,
|
|
1514
|
+
actionFailureMessage: null
|
|
1515
|
+
}
|
|
1364
1516
|
}
|
|
1365
|
-
|
|
1517
|
+
);
|
|
1518
|
+
if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
|
|
1519
|
+
throw new ActionConflictError(String(message._id));
|
|
1366
1520
|
}
|
|
1367
|
-
throw new ActionConflictError(String(messageId));
|
|
1368
1521
|
}
|
|
1369
|
-
async markActionRetryable(messageId, actionAttemptId, error, sourceDocument) {
|
|
1522
|
+
async markActionRetryable(messageId, actionAttemptId, actionOwnerToken, error, sourceDocument) {
|
|
1370
1523
|
const Message = this.resolveModel("active", sourceDocument);
|
|
1371
|
-
await Message.updateOne(
|
|
1372
|
-
{ _id: messageId, actionState: "processing", actionAttemptId },
|
|
1524
|
+
const updated = await Message.updateOne(
|
|
1525
|
+
{ _id: messageId, actionState: "processing", actionAttemptId, actionOwnerToken },
|
|
1373
1526
|
{
|
|
1374
1527
|
$set: {
|
|
1375
1528
|
actionState: "retryable",
|
|
@@ -1378,8 +1531,11 @@ var MessageService = class {
|
|
|
1378
1531
|
}
|
|
1379
1532
|
}
|
|
1380
1533
|
);
|
|
1534
|
+
if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
|
|
1535
|
+
throw new ActionConflictError(String(messageId));
|
|
1536
|
+
}
|
|
1381
1537
|
}
|
|
1382
|
-
async archiveClaimedMessage(message, action, user, actionAttemptId) {
|
|
1538
|
+
async archiveClaimedMessage(message, action, user, actionAttemptId, actionOwnerToken) {
|
|
1383
1539
|
const Message = this.resolveModel("active", message);
|
|
1384
1540
|
const MessageArchive = this.resolveModel("archive", message, Message);
|
|
1385
1541
|
const messageId = message._id;
|
|
@@ -1390,55 +1546,63 @@ var MessageService = class {
|
|
|
1390
1546
|
delete data.actionClaimedAt;
|
|
1391
1547
|
delete data.actionLeaseExpiresAt;
|
|
1392
1548
|
delete data.actionFailureMessage;
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1549
|
+
delete data.actionOwnerToken;
|
|
1550
|
+
const ownershipFilter = {
|
|
1551
|
+
_id: messageId,
|
|
1552
|
+
actionState: "processing",
|
|
1553
|
+
actionAttemptId,
|
|
1554
|
+
actionOwnerToken
|
|
1555
|
+
};
|
|
1556
|
+
const ownership = await Message.findOne(ownershipFilter);
|
|
1557
|
+
if (!ownership) {
|
|
1558
|
+
throw new ActionConflictError(String(messageId));
|
|
1559
|
+
}
|
|
1560
|
+
const operation = async (session) => {
|
|
1561
|
+
try {
|
|
1562
|
+
await MessageArchive.create(
|
|
1563
|
+
[
|
|
1564
|
+
{
|
|
1565
|
+
...data,
|
|
1566
|
+
actionCd: action.actionCd,
|
|
1567
|
+
archivedBy: user._id,
|
|
1568
|
+
archivedAt: /* @__PURE__ */ new Date(),
|
|
1569
|
+
actionAttemptId,
|
|
1570
|
+
actionOwnerToken,
|
|
1571
|
+
actionNotificationState: notificationState,
|
|
1572
|
+
actionNotificationError: null,
|
|
1573
|
+
actionNotificationAttemptedAt: null
|
|
1574
|
+
}
|
|
1575
|
+
],
|
|
1576
|
+
{ session, ordered: true }
|
|
1577
|
+
);
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
if (isDuplicateKeyError(error)) {
|
|
1580
|
+
throw new ActionConflictError(String(messageId));
|
|
1581
|
+
}
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
1409
1584
|
const deleted = await Message.deleteOne(
|
|
1410
|
-
{ _id: messageId, actionState: "processing", actionAttemptId },
|
|
1411
|
-
|
|
1585
|
+
{ _id: messageId, actionState: "processing", actionAttemptId, actionOwnerToken },
|
|
1586
|
+
{ session }
|
|
1412
1587
|
);
|
|
1413
1588
|
if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
|
|
1414
1589
|
throw new ActionConflictError(String(messageId));
|
|
1415
1590
|
}
|
|
1416
1591
|
};
|
|
1417
|
-
const startSession = Message.db?.startSession;
|
|
1418
|
-
if (!startSession) {
|
|
1419
|
-
await operation();
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
const session = await startSession.call(Message.db);
|
|
1423
1592
|
try {
|
|
1424
|
-
await
|
|
1425
|
-
|
|
1593
|
+
await runMessageTransaction(Message, operation, {
|
|
1594
|
+
operation: "actionArchive",
|
|
1595
|
+
onTransactionCleanupFailure: this.onTransactionCleanupFailure
|
|
1426
1596
|
});
|
|
1427
1597
|
} catch (error) {
|
|
1428
|
-
|
|
1429
|
-
await this.markActionRetryable(messageId, actionAttemptId, error, message);
|
|
1430
|
-
throw new MessageTransactionRequiredError(error);
|
|
1431
|
-
}
|
|
1432
|
-
await this.markActionRetryable(messageId, actionAttemptId, error, message);
|
|
1598
|
+
await this.markActionRetryable(messageId, actionAttemptId, actionOwnerToken, error, message);
|
|
1433
1599
|
throw error;
|
|
1434
|
-
} finally {
|
|
1435
|
-
await session.endSession();
|
|
1436
1600
|
}
|
|
1437
1601
|
}
|
|
1438
|
-
async markActionNotificationState(messageId, actionAttemptId, state, error, sourceDocument) {
|
|
1602
|
+
async markActionNotificationState(messageId, actionAttemptId, actionOwnerToken, state, error, sourceDocument) {
|
|
1439
1603
|
const MessageArchive = this.resolveModel("archive", sourceDocument);
|
|
1440
|
-
await MessageArchive.updateOne(
|
|
1441
|
-
{ _id: messageId, actionAttemptId },
|
|
1604
|
+
const updated = await MessageArchive.updateOne(
|
|
1605
|
+
{ _id: messageId, actionAttemptId, actionOwnerToken },
|
|
1442
1606
|
{
|
|
1443
1607
|
$set: {
|
|
1444
1608
|
actionNotificationState: state,
|
|
@@ -1447,22 +1611,25 @@ var MessageService = class {
|
|
|
1447
1611
|
}
|
|
1448
1612
|
}
|
|
1449
1613
|
);
|
|
1614
|
+
if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
|
|
1615
|
+
throw new ActionConflictError(String(messageId));
|
|
1616
|
+
}
|
|
1450
1617
|
}
|
|
1451
1618
|
async runSenderNotification(action, ctx, message) {
|
|
1452
1619
|
if (!action.senderNotification) return;
|
|
1453
|
-
let
|
|
1620
|
+
let content2;
|
|
1454
1621
|
if (typeof action.senderNotification === "function") {
|
|
1455
|
-
|
|
1622
|
+
content2 = await action.senderNotification(ctx);
|
|
1456
1623
|
} else {
|
|
1457
|
-
|
|
1624
|
+
content2 = action.senderNotification;
|
|
1458
1625
|
}
|
|
1459
1626
|
const senderTitle = message.senderContent?.title ?? "";
|
|
1460
|
-
const senderNotificationContent = (0, import_utils.isString)(
|
|
1461
|
-
title:
|
|
1462
|
-
long:
|
|
1463
|
-
short:
|
|
1627
|
+
const senderNotificationContent = (0, import_utils.isString)(content2) ? { title: senderTitle, long: content2, short: content2 } : {
|
|
1628
|
+
title: content2.title || senderTitle,
|
|
1629
|
+
long: content2.long,
|
|
1630
|
+
short: content2.short || content2.long
|
|
1464
1631
|
};
|
|
1465
|
-
const documents = !(0, import_utils.isString)(
|
|
1632
|
+
const documents = !(0, import_utils.isString)(content2) ? content2.documents || [] : [];
|
|
1466
1633
|
if (message.fromUser) {
|
|
1467
1634
|
await this.createNotification(
|
|
1468
1635
|
{
|
|
@@ -1501,6 +1668,19 @@ var MessageService = class {
|
|
|
1501
1668
|
}
|
|
1502
1669
|
return value;
|
|
1503
1670
|
}
|
|
1671
|
+
validateDurationOption(name, value, min) {
|
|
1672
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
|
|
1673
|
+
throw new InvalidMessageServiceOptionError(
|
|
1674
|
+
`${name} must be a finite safe integer in [${min}, ${MAX_MESSAGE_SERVICE_TIMEOUT_MS}] ms`
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
if (value < min || value > MAX_MESSAGE_SERVICE_TIMEOUT_MS) {
|
|
1678
|
+
throw new InvalidMessageServiceOptionError(
|
|
1679
|
+
`${name} must be a finite safe integer in [${min}, ${MAX_MESSAGE_SERVICE_TIMEOUT_MS}] ms`
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
return value;
|
|
1683
|
+
}
|
|
1504
1684
|
normalizeListLimit(value) {
|
|
1505
1685
|
if (value === void 0) {
|
|
1506
1686
|
return this.defaultListLimit;
|
|
@@ -1527,41 +1707,53 @@ var MessageService = class {
|
|
|
1527
1707
|
};
|
|
1528
1708
|
}
|
|
1529
1709
|
requireUserId(user) {
|
|
1530
|
-
|
|
1531
|
-
throw new InvalidMessageUserError();
|
|
1532
|
-
}
|
|
1533
|
-
if (typeof user._id === "string") {
|
|
1534
|
-
const id = user._id.trim();
|
|
1535
|
-
if (id.length === 0) {
|
|
1536
|
-
throw new InvalidMessageUserError();
|
|
1537
|
-
}
|
|
1538
|
-
return id;
|
|
1539
|
-
}
|
|
1540
|
-
if (user._id instanceof import_mongoose5.default.Types.ObjectId) {
|
|
1541
|
-
return String(user._id);
|
|
1542
|
-
}
|
|
1543
|
-
throw new InvalidMessageUserError();
|
|
1710
|
+
return requireMessageUserId(user);
|
|
1544
1711
|
}
|
|
1545
1712
|
async findByClientRequestScope(scope) {
|
|
1546
1713
|
const Message = this.resolveModel("active");
|
|
1547
1714
|
const docs = await Message.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
|
|
1548
1715
|
return docs;
|
|
1549
1716
|
}
|
|
1550
|
-
async
|
|
1551
|
-
const
|
|
1552
|
-
const docs = await
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1717
|
+
async findArchivedByClientRequestScope(scope) {
|
|
1718
|
+
const MessageArchive = this.resolveModel("archive");
|
|
1719
|
+
const docs = await MessageArchive.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
|
|
1720
|
+
return docs;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Merge one active snapshot and one archive snapshot for a completed scope.
|
|
1724
|
+
*
|
|
1725
|
+
* Archival moves a document with create-then-delete ordering, so with an
|
|
1726
|
+
* active-then-archive read order a moving item is always visible in at least
|
|
1727
|
+
* one snapshot (no miss). A commit landing between the two reads surfaces
|
|
1728
|
+
* the same `_id` in both snapshots; deduplicating by `_id` string keeps the
|
|
1729
|
+
* active copy and prevents double-counting. Returns the merged batch sorted
|
|
1730
|
+
* by exact `clientRequestItemIndex`, or `null` when the merged set does not
|
|
1731
|
+
* satisfy the exact distinct-index contract.
|
|
1732
|
+
*/
|
|
1733
|
+
mergeReplayBatch(active, archived, itemCount) {
|
|
1734
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1735
|
+
for (const doc of [...active, ...archived]) {
|
|
1736
|
+
const key = String(doc._id);
|
|
1737
|
+
if (!byId.has(key)) {
|
|
1738
|
+
byId.set(key, doc);
|
|
1556
1739
|
}
|
|
1557
|
-
return null;
|
|
1558
|
-
}
|
|
1559
|
-
if (reservation.state === "failed") {
|
|
1560
|
-
throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
|
|
1561
1740
|
}
|
|
1562
|
-
|
|
1741
|
+
const docs = [...byId.values()];
|
|
1742
|
+
const indexes = new Set(docs.map((doc) => doc.clientRequestItemIndex));
|
|
1743
|
+
const hasExpectedIndexes = docs.length === itemCount && indexes.size === itemCount && Array.from({ length: itemCount }, (_, index) => indexes.has(index)).every(Boolean);
|
|
1744
|
+
if (!hasExpectedIndexes) {
|
|
1563
1745
|
return null;
|
|
1564
1746
|
}
|
|
1747
|
+
return docs.sort((a, b) => {
|
|
1748
|
+
const indexA = a.clientRequestItemIndex ?? 0;
|
|
1749
|
+
const indexB = b.clientRequestItemIndex ?? 0;
|
|
1750
|
+
if (indexA !== indexB) return indexA - indexB;
|
|
1751
|
+
return String(a._id).localeCompare(
|
|
1752
|
+
String(b._id)
|
|
1753
|
+
);
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
async buildCompletedReplay(reservation, scope, cachedActive, cachedArchived) {
|
|
1565
1757
|
if (reservation.itemCount === null || reservation.itemCount < 0 || !Number.isInteger(reservation.itemCount)) {
|
|
1566
1758
|
throw new ClientRequestInconsistentStateError(
|
|
1567
1759
|
scope.clientRequestId,
|
|
@@ -1569,7 +1761,9 @@ var MessageService = class {
|
|
|
1569
1761
|
);
|
|
1570
1762
|
}
|
|
1571
1763
|
if (reservation.itemCount === 0) {
|
|
1572
|
-
|
|
1764
|
+
const active2 = cachedActive ?? await this.findByClientRequestScope(scope);
|
|
1765
|
+
const archived2 = cachedArchived ?? await this.findArchivedByClientRequestScope(scope);
|
|
1766
|
+
if (active2.length > 0 || archived2.length > 0) {
|
|
1573
1767
|
throw new ClientRequestInconsistentStateError(
|
|
1574
1768
|
scope.clientRequestId,
|
|
1575
1769
|
"completed zero-item reservation has messages"
|
|
@@ -1577,20 +1771,55 @@ var MessageService = class {
|
|
|
1577
1771
|
}
|
|
1578
1772
|
return [];
|
|
1579
1773
|
}
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1774
|
+
const itemCount = reservation.itemCount;
|
|
1775
|
+
let active = cachedActive ?? await this.findByClientRequestScope(scope);
|
|
1776
|
+
let archived = cachedArchived ?? await this.findArchivedByClientRequestScope(scope);
|
|
1777
|
+
const merged = this.mergeReplayBatch(active, archived, itemCount);
|
|
1778
|
+
if (merged) {
|
|
1779
|
+
return merged;
|
|
1780
|
+
}
|
|
1781
|
+
active = await this.findByClientRequestScope(scope);
|
|
1782
|
+
archived = await this.findArchivedByClientRequestScope(scope);
|
|
1783
|
+
const reconciled = this.mergeReplayBatch(active, archived, itemCount);
|
|
1784
|
+
if (reconciled) {
|
|
1785
|
+
return reconciled;
|
|
1786
|
+
}
|
|
1787
|
+
const indexes = new Set([...active, ...archived].map((doc) => doc.clientRequestItemIndex));
|
|
1788
|
+
throw new ClientRequestInconsistentStateError(
|
|
1789
|
+
scope.clientRequestId,
|
|
1790
|
+
`completed reservation expects item indexes 0..${itemCount - 1} but found ${JSON.stringify(Array.from(indexes))}`
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
async findCompletedClientRequestReplay(scope) {
|
|
1794
|
+
const reservation = await this.findClientRequestReservation(scope);
|
|
1795
|
+
if (!reservation) {
|
|
1796
|
+
const active = await this.findByClientRequestScope(scope);
|
|
1797
|
+
const archived = await this.findArchivedByClientRequestScope(scope);
|
|
1798
|
+
if (active.length === 0 && archived.length === 0) {
|
|
1799
|
+
return null;
|
|
1800
|
+
}
|
|
1801
|
+
const reread = await this.findClientRequestReservation(scope);
|
|
1802
|
+
if (!reread) {
|
|
1803
|
+
throw new ClientRequestInconsistentStateError(scope.clientRequestId, "messages exist without a reservation");
|
|
1804
|
+
}
|
|
1805
|
+
if (reread.state === "failed") {
|
|
1806
|
+
throw new ClientRequestFailedError(scope.clientRequestId, reread.failureMessage);
|
|
1807
|
+
}
|
|
1808
|
+
if (reread.state !== "completed") {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
return this.buildCompletedReplay(reread, scope, active, archived);
|
|
1589
1812
|
}
|
|
1590
|
-
|
|
1813
|
+
if (reservation.state === "failed") {
|
|
1814
|
+
throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
|
|
1815
|
+
}
|
|
1816
|
+
if (reservation.state !== "completed") {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
return this.buildCompletedReplay(reservation, scope);
|
|
1591
1820
|
}
|
|
1592
1821
|
async acquireClientRequestStart(scope) {
|
|
1593
|
-
const deadline =
|
|
1822
|
+
const deadline = this.clientRequestNow() + this.clientRequestWaitMs;
|
|
1594
1823
|
while (true) {
|
|
1595
1824
|
const lease = await this.tryAcquireClientRequestLease(scope);
|
|
1596
1825
|
if (lease) {
|
|
@@ -1600,101 +1829,509 @@ var MessageService = class {
|
|
|
1600
1829
|
if (replay !== null) {
|
|
1601
1830
|
return { kind: "replay", replay };
|
|
1602
1831
|
}
|
|
1603
|
-
if (
|
|
1832
|
+
if (this.clientRequestNow() >= deadline) {
|
|
1604
1833
|
throw new ClientRequestPendingError(scope.clientRequestId);
|
|
1605
1834
|
}
|
|
1606
|
-
await this.clientRequestDelay(
|
|
1835
|
+
await this.clientRequestDelay(
|
|
1836
|
+
Math.min(this.clientRequestPollMs, Math.max(deadline - this.clientRequestNow(), 0))
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
async tryAcquireClientRequestLease(scope) {
|
|
1841
|
+
const created = await this.tryCreateClientRequestReservation(scope);
|
|
1842
|
+
if (created) return created;
|
|
1843
|
+
return this.tryTakeOverStaleClientRequestReservation(scope);
|
|
1844
|
+
}
|
|
1845
|
+
async tryCreateClientRequestReservation(scope) {
|
|
1846
|
+
const MessageRequest = this.resolveModel("request");
|
|
1847
|
+
const ownerId = (0, import_node_crypto.randomUUID)();
|
|
1848
|
+
try {
|
|
1849
|
+
await MessageRequest.create({
|
|
1850
|
+
...scope,
|
|
1851
|
+
state: "pending",
|
|
1852
|
+
itemCount: null,
|
|
1853
|
+
leaseOwnerId: ownerId,
|
|
1854
|
+
leaseExpiresAt: new Date(this.clientRequestNow() + this.clientRequestLeaseMs)
|
|
1855
|
+
});
|
|
1856
|
+
return { ownerId };
|
|
1857
|
+
} catch (error) {
|
|
1858
|
+
if (isDuplicateKeyError(error)) {
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
throw error;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
async tryTakeOverStaleClientRequestReservation(scope) {
|
|
1865
|
+
const MessageRequest = this.resolveModel("request");
|
|
1866
|
+
const ownerId = (0, import_node_crypto.randomUUID)();
|
|
1867
|
+
const reservation = await MessageRequest.findOneAndUpdate(
|
|
1868
|
+
{ ...scope, state: "pending", leaseExpiresAt: { $lte: new Date(this.clientRequestNow()) } },
|
|
1869
|
+
{
|
|
1870
|
+
$set: {
|
|
1871
|
+
leaseOwnerId: ownerId,
|
|
1872
|
+
leaseExpiresAt: new Date(this.clientRequestNow() + this.clientRequestLeaseMs)
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
{ returnDocument: "after" }
|
|
1876
|
+
);
|
|
1877
|
+
return reservation ? { ownerId } : null;
|
|
1878
|
+
}
|
|
1879
|
+
async findClientRequestReservation(scope) {
|
|
1880
|
+
const MessageRequest = this.resolveModel("request");
|
|
1881
|
+
return await MessageRequest.findOne(scope);
|
|
1882
|
+
}
|
|
1883
|
+
async failClientRequestReservation(scope, lease, error) {
|
|
1884
|
+
const MessageRequest = this.resolveModel("request");
|
|
1885
|
+
await MessageRequest.updateOne(
|
|
1886
|
+
{ ...scope, state: "pending", leaseOwnerId: lease.ownerId },
|
|
1887
|
+
{
|
|
1888
|
+
$set: {
|
|
1889
|
+
state: "failed",
|
|
1890
|
+
failedAt: /* @__PURE__ */ new Date(),
|
|
1891
|
+
failureMessage: error instanceof Error ? error.message : String(error),
|
|
1892
|
+
leaseExpiresAt: null
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
delay(ms) {
|
|
1898
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1899
|
+
}
|
|
1900
|
+
isArchivedMessage(message) {
|
|
1901
|
+
return "archivedAt" in message;
|
|
1902
|
+
}
|
|
1903
|
+
applyPopulate(query, populate) {
|
|
1904
|
+
if (!populate) {
|
|
1905
|
+
return query;
|
|
1906
|
+
}
|
|
1907
|
+
const items = Array.isArray(populate) ? populate : [populate];
|
|
1908
|
+
let current = query;
|
|
1909
|
+
for (const item of items) {
|
|
1910
|
+
current = current.populate(item);
|
|
1911
|
+
}
|
|
1912
|
+
return current;
|
|
1913
|
+
}
|
|
1914
|
+
async findByIdWithOptions(model, id, options) {
|
|
1915
|
+
const baseQuery = model.findById(id);
|
|
1916
|
+
const selectedQuery = options.select === void 0 ? baseQuery : baseQuery.select(options.select);
|
|
1917
|
+
const query = this.applyPopulate(selectedQuery, options.populate);
|
|
1918
|
+
return query;
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
// src/schemas/message.ts
|
|
1923
|
+
function resolveDocumentModel(document, modelName, role) {
|
|
1924
|
+
const model = document.constructor;
|
|
1925
|
+
const connection = model.db;
|
|
1926
|
+
if (!connection) {
|
|
1927
|
+
throw new Error(
|
|
1928
|
+
`message-service: cannot resolve ${role} model "${modelName}" because the hydrated document has no owning connection`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
try {
|
|
1932
|
+
return connection.model(modelName);
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
const connectionName = connection.name || "<unnamed>";
|
|
1935
|
+
const resolutionError = new Error(
|
|
1936
|
+
`message-service: ${role} model "${modelName}" is not registered on Mongoose connection "${connectionName}"`
|
|
1937
|
+
);
|
|
1938
|
+
resolutionError.cause = error;
|
|
1939
|
+
throw resolutionError;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
function createArchiveMethod(ctx) {
|
|
1943
|
+
return async function archive(actionCd, archivedBy, registry) {
|
|
1944
|
+
if (typeof actionCd !== "string" || actionCd.length === 0) {
|
|
1945
|
+
throw new ActionNotFoundError(
|
|
1946
|
+
String(this.templateCd ?? ""),
|
|
1947
|
+
String(actionCd)
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
if (!isValidMessageUserId(archivedBy)) {
|
|
1951
|
+
throw new InvalidMessageUserError();
|
|
1952
|
+
}
|
|
1953
|
+
if (!registry || typeof registry.find !== "function") {
|
|
1954
|
+
throw new TemplateNotFoundError(String(this.templateCd ?? ""));
|
|
1955
|
+
}
|
|
1956
|
+
const templateCd = this.templateCd;
|
|
1957
|
+
const template = registry.find(templateCd);
|
|
1958
|
+
if (!template) {
|
|
1959
|
+
throw new TemplateNotFoundError(templateCd);
|
|
1960
|
+
}
|
|
1961
|
+
if (!includesAction(templateCd, actionCd, registry)) {
|
|
1962
|
+
throw new ActionNotFoundError(templateCd, actionCd);
|
|
1963
|
+
}
|
|
1964
|
+
const activeModel = this.constructor;
|
|
1965
|
+
const connection = activeModel.db;
|
|
1966
|
+
if (!connection || typeof connection.startSession !== "function") {
|
|
1967
|
+
throw new MessageTransactionRequiredError(
|
|
1968
|
+
new Error("message-service: direct archive() requires a Mongoose connection with session/transaction support")
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
|
|
1972
|
+
const ActiveTyped = activeModel;
|
|
1973
|
+
const messageId = this._id;
|
|
1974
|
+
const existingSession = typeof this.$session === "function" ? this.$session() : null;
|
|
1975
|
+
const run = async (session) => {
|
|
1976
|
+
const now = /* @__PURE__ */ new Date();
|
|
1977
|
+
const fresh = await ActiveTyped.findOne({ _id: messageId }).session(session).lean();
|
|
1978
|
+
if (!fresh) {
|
|
1979
|
+
const archivedMatch = await MessageArchive.findOne({ _id: messageId }).session(session).select("_id").lean().catch(() => null);
|
|
1980
|
+
if (archivedMatch) {
|
|
1981
|
+
throw new MessageArchivedError(String(messageId));
|
|
1982
|
+
}
|
|
1983
|
+
throw new MessageNotFoundError(String(messageId));
|
|
1984
|
+
}
|
|
1985
|
+
const state = fresh.actionState;
|
|
1986
|
+
const leaseExpiresAt = fresh.actionLeaseExpiresAt;
|
|
1987
|
+
const liveLease = state === "processing" && leaseExpiresAt instanceof Date && leaseExpiresAt.getTime() > now.getTime();
|
|
1988
|
+
if (state === "processing" && !liveLease) {
|
|
1989
|
+
const expired = leaseExpiresAt instanceof Date && leaseExpiresAt.getTime() <= now.getTime();
|
|
1990
|
+
if (!expired) {
|
|
1991
|
+
throw new ActionConflictError(String(messageId));
|
|
1992
|
+
}
|
|
1993
|
+
} else if (liveLease) {
|
|
1994
|
+
throw new ActionConflictError(String(messageId));
|
|
1995
|
+
}
|
|
1996
|
+
const source = { ...fresh };
|
|
1997
|
+
delete source.actionState;
|
|
1998
|
+
delete source.actionClaimedBy;
|
|
1999
|
+
delete source.actionClaimedAt;
|
|
2000
|
+
delete source.actionLeaseExpiresAt;
|
|
2001
|
+
delete source.actionFailureMessage;
|
|
2002
|
+
const archiveDoc = {
|
|
2003
|
+
...source,
|
|
2004
|
+
actionCd,
|
|
2005
|
+
archivedBy,
|
|
2006
|
+
archivedAt: /* @__PURE__ */ new Date(),
|
|
2007
|
+
actionNotificationState: "none",
|
|
2008
|
+
actionNotificationError: null,
|
|
2009
|
+
actionNotificationAttemptedAt: null
|
|
2010
|
+
};
|
|
2011
|
+
try {
|
|
2012
|
+
await MessageArchive.create([archiveDoc], { session, ordered: true });
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
if (isDuplicateKeyError(error)) {
|
|
2015
|
+
throw new MessageArchivedError(String(messageId));
|
|
2016
|
+
}
|
|
2017
|
+
throw error;
|
|
2018
|
+
}
|
|
2019
|
+
const deleted = await ActiveTyped.deleteOne(
|
|
2020
|
+
{
|
|
2021
|
+
_id: messageId,
|
|
2022
|
+
$or: [
|
|
2023
|
+
{ actionState: "active" },
|
|
2024
|
+
{ actionState: "retryable" },
|
|
2025
|
+
{ actionState: "processing", actionLeaseExpiresAt: { $lte: now } },
|
|
2026
|
+
{ actionState: null },
|
|
2027
|
+
{ actionState: { $exists: false } }
|
|
2028
|
+
]
|
|
2029
|
+
},
|
|
2030
|
+
{ session }
|
|
2031
|
+
);
|
|
2032
|
+
if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
|
|
2033
|
+
throw new ActionConflictError(String(messageId));
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
try {
|
|
2037
|
+
await runMessageTransaction(activeModel, run, {
|
|
2038
|
+
operation: "directArchive",
|
|
2039
|
+
session: existingSession ?? void 0,
|
|
2040
|
+
onTransactionCleanupFailure: ctx.onTransactionCleanupFailure
|
|
2041
|
+
});
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
if (isDuplicateKeyError(error)) {
|
|
2044
|
+
throw new MessageArchivedError(String(messageId));
|
|
2045
|
+
}
|
|
2046
|
+
throw error;
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
var EMAIL_WAS_NEW_LOCAL = "messageServiceEmailWasNew";
|
|
2051
|
+
function normalizeEmailTitle(value) {
|
|
2052
|
+
return value.trim().toLowerCase();
|
|
2053
|
+
}
|
|
2054
|
+
function createEmailStateCaptureHook() {
|
|
2055
|
+
return function captureEmailState() {
|
|
2056
|
+
this.$locals[EMAIL_WAS_NEW_LOCAL] = this.isNew;
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
function createPostSaveEmailHook(ctx) {
|
|
2060
|
+
return async function sendNotificationEmail() {
|
|
2061
|
+
if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
if (this.$session()) {
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (!this.toUser || !this.receiverContent?.title) {
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
const title = this.receiverContent.title.trim();
|
|
2071
|
+
if (!title || ctx.emailNotificationExclusions.includes(normalizeEmailTitle(title))) {
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
let user;
|
|
2075
|
+
try {
|
|
2076
|
+
const User = resolveDocumentModel(this, ctx.userModelName, "user");
|
|
2077
|
+
user = await User.findById(this.toUser).select("email").lean();
|
|
2078
|
+
} catch (error) {
|
|
2079
|
+
await reportEmailFailure(ctx, "recipientLookup", error, this, title);
|
|
2080
|
+
return;
|
|
1607
2081
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
async tryCreateClientRequestReservation(scope) {
|
|
1615
|
-
const MessageRequest = this.resolveModel("request");
|
|
1616
|
-
const ownerId = (0, import_node_crypto.randomUUID)();
|
|
2082
|
+
if (!user?.email) {
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const long = this.receiverContent.long || "";
|
|
2086
|
+
const short = this.receiverContent.short || "";
|
|
2087
|
+
const body = long.length > short.length ? long : short;
|
|
1617
2088
|
try {
|
|
1618
|
-
await
|
|
1619
|
-
...scope,
|
|
1620
|
-
state: "pending",
|
|
1621
|
-
itemCount: null,
|
|
1622
|
-
leaseOwnerId: ownerId,
|
|
1623
|
-
leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
|
|
1624
|
-
});
|
|
1625
|
-
return { ownerId };
|
|
2089
|
+
await ctx.emailNotifier(user.email, title, body);
|
|
1626
2090
|
} catch (error) {
|
|
1627
|
-
|
|
1628
|
-
return null;
|
|
1629
|
-
}
|
|
1630
|
-
throw error;
|
|
2091
|
+
await reportEmailFailure(ctx, "notifier", error, this, title);
|
|
1631
2092
|
}
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
async function reportEmailFailure(ctx, stage, error, message, title) {
|
|
2096
|
+
try {
|
|
2097
|
+
await ctx.onEmailDeliveryFailure?.({
|
|
2098
|
+
stage,
|
|
2099
|
+
error,
|
|
2100
|
+
messageId: message._id,
|
|
2101
|
+
recipientId: message.toUser,
|
|
2102
|
+
title
|
|
2103
|
+
});
|
|
2104
|
+
} catch {
|
|
1632
2105
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
{
|
|
1639
|
-
$set: {
|
|
1640
|
-
leaseOwnerId: ownerId,
|
|
1641
|
-
leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
|
|
1642
|
-
}
|
|
1643
|
-
},
|
|
1644
|
-
{ returnDocument: "after" }
|
|
2106
|
+
}
|
|
2107
|
+
function assertModelRegistered(connection, name, role) {
|
|
2108
|
+
if (!connection.modelNames().includes(name)) {
|
|
2109
|
+
const connectionName = "name" in connection && connection.name ? connection.name : "global mongoose";
|
|
2110
|
+
throw new Error(
|
|
2111
|
+
`message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered on Mongoose connection "${connectionName}". Register the model on that connection before buildMessageSchema().`
|
|
1645
2112
|
);
|
|
1646
|
-
return reservation ? { ownerId } : null;
|
|
1647
2113
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
2114
|
+
}
|
|
2115
|
+
function resolveConfig(config) {
|
|
2116
|
+
return {
|
|
2117
|
+
onTransactionCleanupFailure: config?.onTransactionCleanupFailure,
|
|
2118
|
+
emailNotifier: config?.emailNotifier ?? null,
|
|
2119
|
+
onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
|
|
2120
|
+
emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
|
|
2121
|
+
userModelName: config?.userModelName ?? "User",
|
|
2122
|
+
archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
|
|
2123
|
+
connection: config?.connection ?? import_mongoose4.default
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
function buildMessageSchema(config) {
|
|
2127
|
+
const resolved = resolveConfig(config);
|
|
2128
|
+
if (resolved.emailNotifier) {
|
|
2129
|
+
assertModelRegistered(resolved.connection, resolved.userModelName, "user");
|
|
1651
2130
|
}
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
2131
|
+
const schema = new import_mongoose4.default.Schema(BaseMessageFields, {
|
|
2132
|
+
timestamps: true
|
|
2133
|
+
});
|
|
2134
|
+
schema.index({ fromUser: 1, createdAt: -1, _id: -1 });
|
|
2135
|
+
schema.index({ toUser: 1, createdAt: -1, _id: -1 });
|
|
2136
|
+
schema.index({ toRoles: 1, createdAt: -1, _id: -1 });
|
|
2137
|
+
schema.index({ actionState: 1, actionLeaseExpiresAt: 1 });
|
|
2138
|
+
schema.index(
|
|
2139
|
+
{ actionAttemptId: 1 },
|
|
2140
|
+
{
|
|
2141
|
+
unique: true,
|
|
2142
|
+
partialFilterExpression: { actionAttemptId: { $type: "string" } }
|
|
2143
|
+
}
|
|
2144
|
+
);
|
|
2145
|
+
schema.index(
|
|
2146
|
+
{ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
|
|
2147
|
+
{
|
|
2148
|
+
partialFilterExpression: {
|
|
2149
|
+
clientRequestId: { $type: "string" },
|
|
2150
|
+
clientRequestOwnerId: { $type: "string" },
|
|
2151
|
+
templateCd: { $type: "string" }
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
);
|
|
2155
|
+
schema.index(
|
|
2156
|
+
{ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
|
|
2157
|
+
{
|
|
2158
|
+
unique: true,
|
|
2159
|
+
partialFilterExpression: {
|
|
2160
|
+
clientRequestId: { $type: "string" },
|
|
2161
|
+
clientRequestOwnerId: { $type: "string" },
|
|
2162
|
+
templateCd: { $type: "string" },
|
|
2163
|
+
clientRequestItemIndex: { $type: "number" }
|
|
1663
2164
|
}
|
|
2165
|
+
}
|
|
2166
|
+
);
|
|
2167
|
+
schema.methods.isSender = isSender;
|
|
2168
|
+
schema.methods.isReceiver = isReceiver;
|
|
2169
|
+
schema.methods.archive = createArchiveMethod({
|
|
2170
|
+
archiveModelName: resolved.archiveModelName,
|
|
2171
|
+
onTransactionCleanupFailure: resolved.onTransactionCleanupFailure
|
|
2172
|
+
});
|
|
2173
|
+
if (resolved.emailNotifier) {
|
|
2174
|
+
schema.pre("save", createEmailStateCaptureHook());
|
|
2175
|
+
schema.post(
|
|
2176
|
+
"save",
|
|
2177
|
+
createPostSaveEmailHook({
|
|
2178
|
+
emailNotifier: resolved.emailNotifier,
|
|
2179
|
+
onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
|
|
2180
|
+
emailNotificationExclusions: resolved.emailNotificationExclusions,
|
|
2181
|
+
userModelName: resolved.userModelName
|
|
2182
|
+
})
|
|
1664
2183
|
);
|
|
1665
2184
|
}
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
2185
|
+
return schema;
|
|
2186
|
+
}
|
|
2187
|
+
var MessageSchema = buildMessageSchema();
|
|
2188
|
+
|
|
2189
|
+
// src/schemas/message-archive.ts
|
|
2190
|
+
var import_mongoose5 = __toESM(require("mongoose"));
|
|
2191
|
+
function buildMessageArchiveSchema() {
|
|
2192
|
+
const schema = new import_mongoose5.default.Schema(
|
|
2193
|
+
{
|
|
2194
|
+
...BaseMessageFields,
|
|
2195
|
+
actionCd: { type: String, default: "" },
|
|
2196
|
+
archivedBy: { type: import_mongoose5.default.Schema.Types.ObjectId, default: null, index: true },
|
|
2197
|
+
archivedAt: { type: Date, default: Date.now },
|
|
2198
|
+
actionAttemptId: { type: String, default: null, index: true },
|
|
2199
|
+
actionNotificationState: { type: String, enum: ["none", "pending", "sent", "failed"], default: "none" },
|
|
2200
|
+
actionNotificationError: { type: String, default: null },
|
|
2201
|
+
actionNotificationAttemptedAt: { type: Date, default: null }
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
timestamps: true
|
|
1682
2205
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
2206
|
+
);
|
|
2207
|
+
schema.index({ createdAt: 1 });
|
|
2208
|
+
schema.index(
|
|
2209
|
+
{ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
|
|
2210
|
+
{
|
|
2211
|
+
partialFilterExpression: {
|
|
2212
|
+
clientRequestId: { $type: "string" },
|
|
2213
|
+
clientRequestOwnerId: { $type: "string" },
|
|
2214
|
+
templateCd: { $type: "string" }
|
|
2215
|
+
}
|
|
1687
2216
|
}
|
|
1688
|
-
|
|
2217
|
+
);
|
|
2218
|
+
schema.index(
|
|
2219
|
+
{ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
|
|
2220
|
+
{
|
|
2221
|
+
unique: true,
|
|
2222
|
+
partialFilterExpression: {
|
|
2223
|
+
clientRequestId: { $type: "string" },
|
|
2224
|
+
clientRequestOwnerId: { $type: "string" },
|
|
2225
|
+
templateCd: { $type: "string" },
|
|
2226
|
+
clientRequestItemIndex: { $type: "number" }
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
);
|
|
2230
|
+
schema.methods.isSender = isSender;
|
|
2231
|
+
schema.methods.isReceiver = isReceiver;
|
|
2232
|
+
return schema;
|
|
2233
|
+
}
|
|
2234
|
+
var MessageArchiveSchema = buildMessageArchiveSchema();
|
|
2235
|
+
|
|
2236
|
+
// src/schemas/message-request.ts
|
|
2237
|
+
var import_mongoose6 = __toESM(require("mongoose"));
|
|
2238
|
+
function buildMessageRequestSchema() {
|
|
2239
|
+
const schema = new import_mongoose6.default.Schema(
|
|
2240
|
+
{
|
|
2241
|
+
clientRequestId: { type: String, required: true },
|
|
2242
|
+
clientRequestOwnerId: { type: String, required: true },
|
|
2243
|
+
templateCd: { type: String, required: true },
|
|
2244
|
+
state: { type: String, enum: ["pending", "completed", "failed"], default: "pending" },
|
|
2245
|
+
itemCount: { type: Number, default: null },
|
|
2246
|
+
leaseOwnerId: { type: String, default: null },
|
|
2247
|
+
leaseExpiresAt: { type: Date, default: null },
|
|
2248
|
+
completedAt: { type: Date, default: null },
|
|
2249
|
+
failedAt: { type: Date, default: null },
|
|
2250
|
+
failureMessage: { type: String, default: null }
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
timestamps: true
|
|
2254
|
+
}
|
|
2255
|
+
);
|
|
2256
|
+
schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
|
|
2257
|
+
schema.index({ updatedAt: 1 });
|
|
2258
|
+
schema.index({ state: 1, leaseExpiresAt: 1 });
|
|
2259
|
+
return schema;
|
|
2260
|
+
}
|
|
2261
|
+
var MessageRequestSchema = buildMessageRequestSchema();
|
|
2262
|
+
|
|
2263
|
+
// src/providers/email.ts
|
|
2264
|
+
var NoopEmailProvider = class {
|
|
2265
|
+
async sendNotification(_to, _title, _body) {
|
|
1689
2266
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
// src/providers/payment.ts
|
|
2270
|
+
var NoopPaymentProvider = class {
|
|
2271
|
+
async createSession(_user, _code, _priceArgs) {
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
async expireSession(_sessionId) {
|
|
2275
|
+
}
|
|
2276
|
+
async refundPayment(_sessionId) {
|
|
1695
2277
|
}
|
|
1696
2278
|
};
|
|
1697
2279
|
|
|
2280
|
+
// src/public-message.ts
|
|
2281
|
+
var import_mongoose7 = require("mongoose");
|
|
2282
|
+
function referenceId(value) {
|
|
2283
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
2284
|
+
if (value instanceof import_mongoose7.Types.ObjectId) return value.toHexString();
|
|
2285
|
+
return null;
|
|
2286
|
+
}
|
|
2287
|
+
function populatedId(value) {
|
|
2288
|
+
return referenceId(value) ?? (value && typeof value === "object" && "_id" in value ? referenceId(value._id) : null);
|
|
2289
|
+
}
|
|
2290
|
+
function party(value) {
|
|
2291
|
+
const id = referenceId(value);
|
|
2292
|
+
if (id !== null) return id;
|
|
2293
|
+
if (!value || typeof value !== "object" || !("_id" in value)) return null;
|
|
2294
|
+
const populated = referenceId(value._id);
|
|
2295
|
+
if (populated === null) return null;
|
|
2296
|
+
const result = { _id: populated };
|
|
2297
|
+
if ("displayName" in value && typeof value.displayName === "string") result.displayName = value.displayName;
|
|
2298
|
+
if ("email" in value && typeof value.email === "string") result.email = value.email;
|
|
2299
|
+
return result;
|
|
2300
|
+
}
|
|
2301
|
+
function content(value) {
|
|
2302
|
+
return { title: value.title, long: value.long, short: value.short };
|
|
2303
|
+
}
|
|
2304
|
+
function serializePublicMessage(message) {
|
|
2305
|
+
const id = referenceId(message._id);
|
|
2306
|
+
if (id === null) throw new TypeError("Message must have a string or ObjectId _id");
|
|
2307
|
+
const result = {
|
|
2308
|
+
_id: id,
|
|
2309
|
+
templateCd: message.templateCd,
|
|
2310
|
+
type: message.type,
|
|
2311
|
+
fromUser: party(message.fromUser),
|
|
2312
|
+
toUser: party(message.toUser),
|
|
2313
|
+
toRoles: [...message.toRoles],
|
|
2314
|
+
senderContent: content(message.senderContent),
|
|
2315
|
+
receiverContent: content(message.receiverContent),
|
|
2316
|
+
documents: message.documents.map(populatedId).filter((value) => value !== null),
|
|
2317
|
+
paymentSession: message.paymentSession,
|
|
2318
|
+
paymentCd: message.paymentCd,
|
|
2319
|
+
payload: message.payload,
|
|
2320
|
+
display: message.display,
|
|
2321
|
+
actionState: message.actionState,
|
|
2322
|
+
actionCd: message.actionCd,
|
|
2323
|
+
actionAttemptId: message.actionAttemptId,
|
|
2324
|
+
createdAt: message.createdAt.toISOString(),
|
|
2325
|
+
updatedAt: message.updatedAt.toISOString()
|
|
2326
|
+
};
|
|
2327
|
+
if (message.archivedAt != null) {
|
|
2328
|
+
result.archivedAt = message.archivedAt.toISOString();
|
|
2329
|
+
result.archivedBy = populatedId(message.archivedBy);
|
|
2330
|
+
result.actionNotificationState = message.actionNotificationState ?? "none";
|
|
2331
|
+
}
|
|
2332
|
+
return result;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
1698
2335
|
// src/route-factory.ts
|
|
1699
2336
|
var import_express_json_router = __toESM(require("@web-ts-toolkit/express-json-router"));
|
|
1700
2337
|
var ACTION_CD_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -1754,7 +2391,9 @@ function assertValidBody(body) {
|
|
|
1754
2391
|
}
|
|
1755
2392
|
}
|
|
1756
2393
|
function requireUser(user) {
|
|
1757
|
-
|
|
2394
|
+
try {
|
|
2395
|
+
requireMessageUserId(user);
|
|
2396
|
+
} catch {
|
|
1758
2397
|
throw new import_express_json_router.default.clientErrors.UnauthorizedError("authentication required");
|
|
1759
2398
|
}
|
|
1760
2399
|
return user;
|
|
@@ -1790,28 +2429,75 @@ function mapServiceError(error) {
|
|
|
1790
2429
|
if (error instanceof InvalidMessageUserError) {
|
|
1791
2430
|
throw new import_express_json_router.default.clientErrors.UnauthorizedError("authentication required");
|
|
1792
2431
|
}
|
|
1793
|
-
if (error instanceof ClientRequestPendingError
|
|
2432
|
+
if (error instanceof ClientRequestPendingError) {
|
|
1794
2433
|
throw new import_express_json_router.default.clientErrors.ConflictError(error.message);
|
|
1795
2434
|
}
|
|
2435
|
+
if (error instanceof ClientRequestFailedError) {
|
|
2436
|
+
throw new import_express_json_router.default.clientErrors.ConflictError(
|
|
2437
|
+
`clientRequestId "${error.clientRequestId}" previously failed; retry with a new clientRequestId`
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
1796
2440
|
if (isMongooseCastError(error)) {
|
|
1797
2441
|
throw new import_express_json_router.default.clientErrors.BadRequestError("id must be a valid ObjectId");
|
|
1798
2442
|
}
|
|
1799
2443
|
throw error;
|
|
1800
2444
|
}
|
|
2445
|
+
var SERVICE_CONSTRUCTION_KEYS = [
|
|
2446
|
+
"getModel",
|
|
2447
|
+
"connection",
|
|
2448
|
+
"modelNames",
|
|
2449
|
+
"paymentProvider",
|
|
2450
|
+
"onPaymentCompensationFailure",
|
|
2451
|
+
"onTransactionCleanupFailure",
|
|
2452
|
+
"adminRoles",
|
|
2453
|
+
"registry",
|
|
2454
|
+
"defaultListLimit",
|
|
2455
|
+
"maxListLimit",
|
|
2456
|
+
"clientRequestLeaseMs",
|
|
2457
|
+
"clientRequestWaitMs",
|
|
2458
|
+
"clientRequestPollMs",
|
|
2459
|
+
"clientRequestDelay",
|
|
2460
|
+
"clientRequestNow"
|
|
2461
|
+
];
|
|
1801
2462
|
function createMessageRoutes(options) {
|
|
1802
2463
|
const {
|
|
1803
|
-
getModel,
|
|
1804
|
-
paymentProvider = null,
|
|
1805
|
-
onPaymentCompensationFailure,
|
|
1806
|
-
adminRoles,
|
|
1807
|
-
registry,
|
|
1808
2464
|
authMiddleware = [],
|
|
1809
2465
|
getUser = defaultGetUser,
|
|
1810
2466
|
getPermissions = defaultGetPermissions,
|
|
1811
2467
|
getIdentity = defaultGetIdentity,
|
|
1812
2468
|
adminPermissionKey = "is.admin"
|
|
1813
2469
|
} = options;
|
|
1814
|
-
|
|
2470
|
+
let service;
|
|
2471
|
+
if (options.service !== void 0) {
|
|
2472
|
+
const conflicts = SERVICE_CONSTRUCTION_KEYS.filter(
|
|
2473
|
+
(key) => options[key] !== void 0
|
|
2474
|
+
);
|
|
2475
|
+
if (conflicts.length > 0) {
|
|
2476
|
+
throw new InvalidMessageServiceOptionError(
|
|
2477
|
+
`createMessageRoutes: "service" cannot be combined with service construction options: ${conflicts.join(", ")}. Pass route-only options (authMiddleware, getUser, getPermissions, getIdentity, adminPermissionKey) alongside "service", or construct without "service".`
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
service = options.service;
|
|
2481
|
+
} else {
|
|
2482
|
+
const construction = options;
|
|
2483
|
+
service = new MessageService({
|
|
2484
|
+
getModel: construction.getModel,
|
|
2485
|
+
connection: construction.connection,
|
|
2486
|
+
modelNames: construction.modelNames,
|
|
2487
|
+
paymentProvider: construction.paymentProvider,
|
|
2488
|
+
onPaymentCompensationFailure: construction.onPaymentCompensationFailure,
|
|
2489
|
+
onTransactionCleanupFailure: construction.onTransactionCleanupFailure,
|
|
2490
|
+
adminRoles: construction.adminRoles,
|
|
2491
|
+
registry: construction.registry,
|
|
2492
|
+
defaultListLimit: construction.defaultListLimit,
|
|
2493
|
+
maxListLimit: construction.maxListLimit,
|
|
2494
|
+
clientRequestLeaseMs: construction.clientRequestLeaseMs,
|
|
2495
|
+
clientRequestWaitMs: construction.clientRequestWaitMs,
|
|
2496
|
+
clientRequestPollMs: construction.clientRequestPollMs,
|
|
2497
|
+
clientRequestDelay: construction.clientRequestDelay,
|
|
2498
|
+
clientRequestNow: construction.clientRequestNow
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
1815
2501
|
const router = new import_express_json_router.default("", authMiddleware);
|
|
1816
2502
|
router.post("/new/:templateCd", async (req) => {
|
|
1817
2503
|
const templateCd = req.params.templateCd;
|
|
@@ -1826,7 +2512,7 @@ function createMessageRoutes(options) {
|
|
|
1826
2512
|
const hasClientRequestId = Object.prototype.hasOwnProperty.call(body, "clientRequestId");
|
|
1827
2513
|
assertValidClientRequestId(hasClientRequestId ? clientRequestId : void 0);
|
|
1828
2514
|
try {
|
|
1829
|
-
|
|
2515
|
+
const messages = await service.createMessage({
|
|
1830
2516
|
templateCd,
|
|
1831
2517
|
user,
|
|
1832
2518
|
roles,
|
|
@@ -1837,6 +2523,7 @@ function createMessageRoutes(options) {
|
|
|
1837
2523
|
req,
|
|
1838
2524
|
clientRequestId: hasClientRequestId ? clientRequestId : void 0
|
|
1839
2525
|
});
|
|
2526
|
+
return messages.map(serializePublicMessage);
|
|
1840
2527
|
} catch (error) {
|
|
1841
2528
|
mapServiceError(error);
|
|
1842
2529
|
}
|
|
@@ -1848,7 +2535,7 @@ function createMessageRoutes(options) {
|
|
|
1848
2535
|
assertValidMessageId(id);
|
|
1849
2536
|
assertValidUsertype(usertype);
|
|
1850
2537
|
const permissions = getPermissions(req);
|
|
1851
|
-
const isAdmin =
|
|
2538
|
+
const isAdmin = hasExplicitPermissionGrant(permissions, adminPermissionKey);
|
|
1852
2539
|
let result;
|
|
1853
2540
|
try {
|
|
1854
2541
|
result = await service.getActions(id, usertype, { permissions, user, isAdmin });
|
|
@@ -1909,8 +2596,10 @@ function defaultGetIdentity(req) {
|
|
|
1909
2596
|
ClientRequestPendingError,
|
|
1910
2597
|
GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
1911
2598
|
InvalidClientRequestIdError,
|
|
2599
|
+
InvalidMessageServiceOptionError,
|
|
1912
2600
|
InvalidMessageUserError,
|
|
1913
2601
|
InvalidPaginationValueError,
|
|
2602
|
+
MAX_MESSAGE_SERVICE_TIMEOUT_MS,
|
|
1914
2603
|
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
1915
2604
|
MESSAGE_MODEL_NAME,
|
|
1916
2605
|
MESSAGE_REQUEST_MODEL_NAME,
|
|
@@ -1925,6 +2614,7 @@ function defaultGetIdentity(req) {
|
|
|
1925
2614
|
MessageTransactionRequiredError,
|
|
1926
2615
|
NoopEmailProvider,
|
|
1927
2616
|
NoopPaymentProvider,
|
|
2617
|
+
PaymentSessionCompensationAggregateError,
|
|
1928
2618
|
PaymentSessionCompensationError,
|
|
1929
2619
|
TemplateNotFoundError,
|
|
1930
2620
|
TemplateRegistry,
|
|
@@ -1935,7 +2625,13 @@ function defaultGetIdentity(req) {
|
|
|
1935
2625
|
createMessageRoutes,
|
|
1936
2626
|
defaultRegistry,
|
|
1937
2627
|
filterActions,
|
|
2628
|
+
hasExplicitPermissionGrant,
|
|
1938
2629
|
includesAction,
|
|
2630
|
+
interpolateMessageContent,
|
|
1939
2631
|
interpolateTemplate,
|
|
1940
|
-
isActionAllowed
|
|
2632
|
+
isActionAllowed,
|
|
2633
|
+
isValidMessageUserId,
|
|
2634
|
+
requireMessageUserId,
|
|
2635
|
+
resolveUiTemplate,
|
|
2636
|
+
serializePublicMessage
|
|
1941
2637
|
});
|