@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.
Files changed (6) hide show
  1. package/README.md +230 -57
  2. package/index.d.mts +696 -111
  3. package/index.d.ts +696 -111
  4. package/index.js +1382 -686
  5. package/index.mjs +1363 -676
  6. 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 import_mongoose2 = __toESM(require("mongoose"));
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 normalizeStoredUserId(user?._id ?? null);
304
+ return normalizeId(user?._id);
284
305
  }
285
306
  function normalizeStoredUserId(value) {
286
- if (value === null || value === void 0) return null;
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 String(value);
318
+ return value instanceof import_mongoose2.Types.ObjectId ? value.toHexString() : null;
292
319
  }
293
320
 
294
- // src/schemas/message.ts
295
- function resolveDocumentModel(document, modelName, role) {
296
- const model = document.constructor;
297
- const connection = model.db;
298
- if (!connection) {
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
- try {
304
- return connection.model(modelName);
305
- } catch (error) {
306
- const connectionName = connection.name || "<unnamed>";
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
- function createArchiveMethod(ctx) {
315
- return function archive(actionCd, archivedBy, registry) {
316
- if (!includesAction(this.templateCd, actionCd, registry) || !archivedBy) {
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
- function assertModelRegistered(connection, name, role) {
388
- if (!connection.modelNames().includes(name)) {
389
- const connectionName = "name" in connection && connection.name ? connection.name : "global mongoose";
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
- function resolveConfig(config) {
396
- return {
397
- emailNotifier: config?.emailNotifier ?? null,
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
- const schema = new import_mongoose2.default.Schema(BaseMessageFields, {
411
- timestamps: true
412
- });
413
- schema.index({ fromUser: 1, createdAt: -1, _id: -1 });
414
- schema.index({ toUser: 1, createdAt: -1, _id: -1 });
415
- schema.index({ toRoles: 1, createdAt: -1, _id: -1 });
416
- schema.index({ actionState: 1, actionLeaseExpiresAt: 1 });
417
- schema.index(
418
- { actionAttemptId: 1 },
419
- {
420
- unique: true,
421
- partialFilterExpression: { actionAttemptId: { $type: "string" } }
422
- }
423
- );
424
- schema.index(
425
- { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
426
- {
427
- partialFilterExpression: {
428
- clientRequestId: { $type: "string" },
429
- clientRequestOwnerId: { $type: "string" },
430
- templateCd: { $type: "string" }
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
- schema.index(
435
- { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
436
- {
437
- unique: true,
438
- partialFilterExpression: {
439
- clientRequestId: { $type: "string" },
440
- clientRequestOwnerId: { $type: "string" },
441
- templateCd: { $type: "string" },
442
- clientRequestItemIndex: { $type: "number" }
443
- }
444
- }
445
- );
446
- schema.methods.isSender = isSender;
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${reason ? `: ${reason}` : ""}`);
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 = options.clientRequestLeaseMs ?? CLIENT_REQUEST_LEASE_MS;
847
- this.clientRequestWaitMs = options.clientRequestWaitMs ?? CLIENT_REQUEST_WAIT_MS;
848
- this.clientRequestPollMs = options.clientRequestPollMs ?? CLIENT_REQUEST_POLL_MS;
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
- for (let index = 0; index < items.length; index++) {
972
- docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
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
- async getActions(messageId, usertype, options = {}) {
1041
- const message = options.message ?? await this.findMessage(messageId, { populate: options.populate });
1042
- if (!message) return null;
1043
- const user = this.requireUserId(options.user);
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
- if (data.message.actionNotificationState === "pending" || data.message.actionNotificationState === "failed") {
1074
- throw new ActionNotificationPendingError(
1075
- String(data.message._id),
1076
- data.message.actionAttemptId ?? "",
1077
- void 0
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 action.runHandler(ctx);
1130
+ result = await authorizedAction.runHandler(ctx);
1102
1131
  } catch (error) {
1103
- await this.markActionRetryable(data.message._id, claim.actionAttemptId, error, claim.message);
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(claim.message, action, authorizedUser, claim.actionAttemptId);
1107
- if (action.senderNotification) {
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(action, ctx, claim.message);
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(data.message._id, claim.actionAttemptId, "failed", error, claim.message);
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 import_mongoose5.default.model(modelName);
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 import_mongoose5.default.model(name);
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 = interpolateTemplate(template, m.templateData || {}, "receiver");
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
- const Message = this.resolveModel("active");
1239
- const MessageRequest = this.resolveModel("request", void 0, Message);
1240
- const operation = async (session2) => {
1241
- const created = docs.length > 0 ? await Message.create(docs, session2 ? { session: session2, ordered: true } : void 0) : [];
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
- session2 ? { session: session2 } : void 0
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
- let result = [];
1271
- await session.withTransaction(async () => {
1272
- result = await operation(session);
1316
+ return await runMessageTransaction(Message, operation, {
1317
+ operation: "createBatch",
1318
+ onTransactionCleanupFailure: this.onTransactionCleanupFailure
1273
1319
  });
1274
- return result;
1275
1320
  } catch (error) {
1276
- const wrappedError = this.isTransactionSupportError(error) ? new MessageTransactionRequiredError(error) : error;
1277
- await this.compensatePaymentSessions(docs, wrappedError, scope);
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
- await this.compensatePaymentSession(sessionId, originalError, scope);
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
- { $set: { ...claimBase, actionState: "processing", actionAttemptId: firstAttemptId } },
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 (firstClaim?.actionAttemptId) {
1343
- return { message: firstClaim, actionAttemptId: firstClaim.actionAttemptId };
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 retryClaim = await Message.findOneAndUpdate(
1502
+ const Message = this.resolveModel("active", message);
1503
+ const updated = await Message.updateOne(
1504
+ { _id: message._id, actionState: "processing", actionAttemptId, actionOwnerToken },
1346
1505
  {
1347
- _id: messageId,
1348
- actionCd,
1349
- actionAttemptId: { $type: "string" },
1350
- $or: [{ actionState: "retryable" }, { actionState: "processing", actionLeaseExpiresAt: { $lte: now } }]
1351
- },
1352
- { $set: { ...claimBase, actionState: "processing" } },
1353
- { returnDocument: "after" }
1354
- );
1355
- if (retryClaim?.actionAttemptId) {
1356
- return { message: retryClaim, actionAttemptId: retryClaim.actionAttemptId };
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
- throw new MessageArchivedError(String(messageId));
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
- const operation = async (session2) => {
1394
- await MessageArchive.create(
1395
- [
1396
- {
1397
- ...data,
1398
- actionCd: action.actionCd,
1399
- archivedBy: user._id,
1400
- archivedAt: /* @__PURE__ */ new Date(),
1401
- actionAttemptId,
1402
- actionNotificationState: notificationState,
1403
- actionNotificationError: null,
1404
- actionNotificationAttemptedAt: null
1405
- }
1406
- ],
1407
- session2 ? { session: session2, ordered: true } : void 0
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
- session2 ? { session: session2 } : void 0
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 session.withTransaction(async () => {
1425
- await operation(session);
1593
+ await runMessageTransaction(Message, operation, {
1594
+ operation: "actionArchive",
1595
+ onTransactionCleanupFailure: this.onTransactionCleanupFailure
1426
1596
  });
1427
1597
  } catch (error) {
1428
- if (this.isTransactionSupportError(error)) {
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 content;
1620
+ let content2;
1454
1621
  if (typeof action.senderNotification === "function") {
1455
- content = await action.senderNotification(ctx);
1622
+ content2 = await action.senderNotification(ctx);
1456
1623
  } else {
1457
- content = action.senderNotification;
1624
+ content2 = action.senderNotification;
1458
1625
  }
1459
1626
  const senderTitle = message.senderContent?.title ?? "";
1460
- const senderNotificationContent = (0, import_utils.isString)(content) ? { title: senderTitle, long: content, short: content } : {
1461
- title: content.title || senderTitle,
1462
- long: content.long,
1463
- short: content.short || content.long
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)(content) ? content.documents || [] : [];
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
- if (!user || user._id === void 0 || user._id === null) {
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 findCompletedClientRequestReplay(scope) {
1551
- const reservation = await this.findClientRequestReservation(scope);
1552
- const docs = await this.findByClientRequestScope(scope);
1553
- if (!reservation) {
1554
- if (docs.length > 0) {
1555
- throw new ClientRequestInconsistentStateError(scope.clientRequestId, "messages exist without a reservation");
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
- if (reservation.state !== "completed") {
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
- if (docs.length > 0) {
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 indexes = new Set(docs.map((doc) => doc.clientRequestItemIndex));
1581
- const hasExpectedIndexes = docs.length === reservation.itemCount && indexes.size === reservation.itemCount && Array.from({ length: reservation.itemCount }, (_, index) => indexes.has(index)).every(Boolean);
1582
- if (!hasExpectedIndexes) {
1583
- throw new ClientRequestInconsistentStateError(
1584
- scope.clientRequestId,
1585
- `completed reservation expects item indexes 0..${reservation.itemCount - 1} but found ${JSON.stringify(
1586
- Array.from(indexes)
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
- return docs;
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 = Date.now() + this.clientRequestWaitMs;
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 (Date.now() >= deadline) {
1832
+ if (this.clientRequestNow() >= deadline) {
1604
1833
  throw new ClientRequestPendingError(scope.clientRequestId);
1605
1834
  }
1606
- await this.clientRequestDelay(Math.min(this.clientRequestPollMs, Math.max(deadline - Date.now(), 0)));
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
- async tryAcquireClientRequestLease(scope) {
1610
- const created = await this.tryCreateClientRequestReservation(scope);
1611
- if (created) return created;
1612
- return this.tryTakeOverStaleClientRequestReservation(scope);
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 MessageRequest.create({
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
- if (this.isDuplicateKeyError(error)) {
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
- async tryTakeOverStaleClientRequestReservation(scope) {
1634
- const MessageRequest = this.resolveModel("request");
1635
- const ownerId = (0, import_node_crypto.randomUUID)();
1636
- const reservation = await MessageRequest.findOneAndUpdate(
1637
- { ...scope, state: "pending", leaseExpiresAt: { $lte: /* @__PURE__ */ new Date() } },
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
- async findClientRequestReservation(scope) {
1649
- const MessageRequest = this.resolveModel("request");
1650
- return await MessageRequest.findOne(scope);
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
- async failClientRequestReservation(scope, lease, error) {
1653
- const MessageRequest = this.resolveModel("request");
1654
- await MessageRequest.updateOne(
1655
- { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1656
- {
1657
- $set: {
1658
- state: "failed",
1659
- failedAt: /* @__PURE__ */ new Date(),
1660
- failureMessage: error instanceof Error ? error.message : String(error),
1661
- leaseExpiresAt: null
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
- isDuplicateKeyError(error) {
1667
- return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
1668
- }
1669
- isTransactionSupportError(error) {
1670
- if (!(error instanceof Error)) return false;
1671
- return error.message.includes("Transaction numbers are only allowed") || error.message.includes("Transaction is not supported") || error.message.includes("transactions are not supported");
1672
- }
1673
- delay(ms) {
1674
- return new Promise((resolve) => setTimeout(resolve, ms));
1675
- }
1676
- isArchivedMessage(message) {
1677
- return "archivedAt" in message;
1678
- }
1679
- applyPopulate(query, populate) {
1680
- if (!populate) {
1681
- return query;
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
- const items = Array.isArray(populate) ? populate : [populate];
1684
- let current = query;
1685
- for (const item of items) {
1686
- current = current.populate(item);
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
- return current;
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
- async findByIdWithOptions(model, id, options) {
1691
- const baseQuery = model.findById(id);
1692
- const selectedQuery = options.select === void 0 ? baseQuery : baseQuery.select(options.select);
1693
- const query = this.applyPopulate(selectedQuery, options.populate);
1694
- return query;
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
- if (!user || user._id === void 0 || user._id === null || String(user._id).trim().length === 0) {
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 || error instanceof ClientRequestFailedError) {
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
- const service = new MessageService({ getModel, paymentProvider, onPaymentCompensationFailure, adminRoles, registry });
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
- return await service.createMessage({
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 = !!permissions[adminPermissionKey];
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
  });