@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.mjs CHANGED
@@ -33,6 +33,7 @@ var BaseMessageFields = {
33
33
  actionState: { type: String, enum: ["active", "processing", "retryable"], default: "active" },
34
34
  actionCd: { type: String, default: null },
35
35
  actionAttemptId: { type: String, default: null },
36
+ actionOwnerToken: { type: String, default: null },
36
37
  actionClaimedBy: { type: String, default: null },
37
38
  actionClaimedAt: { type: Date, default: null },
38
39
  actionLeaseExpiresAt: { type: Date, default: null },
@@ -40,7 +41,7 @@ var BaseMessageFields = {
40
41
  };
41
42
 
42
43
  // src/schemas/message.ts
43
- import mongoose2 from "mongoose";
44
+ import mongoose3 from "mongoose";
44
45
 
45
46
  // src/runtime-contract.ts
46
47
  var ERROR_BRAND_PREFIX = "@web-ts-toolkit/message-service/error/";
@@ -133,6 +134,9 @@ var TemplateRegistry = class {
133
134
  }
134
135
  /**
135
136
  * Register a template. Overwrites if templateCd already exists.
137
+ *
138
+ * Takes an author-friendly mutable `MessageTemplate`; the registry stores a
139
+ * frozen shallow snapshot (see `RegisteredMessageTemplate`).
136
140
  */
137
141
  register(template) {
138
142
  validateTemplate(template);
@@ -148,6 +152,11 @@ var TemplateRegistry = class {
148
152
  }
149
153
  /**
150
154
  * Find a template by its templateCd.
155
+ *
156
+ * Returns a readonly `RegisteredMessageTemplate` view matching the registry
157
+ * freeze depth (top-level, content objects, object uiTemplates, the action
158
+ * array, and each action plus its confirmation/payload are frozen).
159
+ * Mutating the result throws at runtime; register a replacement instead.
151
160
  */
152
161
  find(templateCd) {
153
162
  return this.templates.get(templateCd);
@@ -166,6 +175,8 @@ var TemplateRegistry = class {
166
175
  }
167
176
  /**
168
177
  * Get all registered templates.
178
+ *
179
+ * Returns readonly `RegisteredMessageTemplate` views; see `find()`.
169
180
  */
170
181
  getAll() {
171
182
  return Array.from(this.templates.values());
@@ -193,6 +204,7 @@ function includesAction(templateCd, actionCd, registry) {
193
204
  }
194
205
 
195
206
  // src/schemas/methods.ts
207
+ import { Types } from "mongoose";
196
208
  function isSender(user) {
197
209
  const fromUser = normalizeStoredUserId(this.fromUser);
198
210
  const userId = normalizeMessageUserId(user);
@@ -204,450 +216,131 @@ function isReceiver(user) {
204
216
  return toUser !== null && userId !== null && toUser === userId || this.toRoles.some((r) => user.roles?.includes(r));
205
217
  }
206
218
  function normalizeMessageUserId(user) {
207
- return normalizeStoredUserId(user?._id ?? null);
219
+ return normalizeId(user?._id);
208
220
  }
209
221
  function normalizeStoredUserId(value) {
210
- if (value === null || value === void 0) return null;
222
+ if (value instanceof Types.ObjectId) return value.toHexString();
223
+ if (value !== null && typeof value === "object") {
224
+ return normalizeId(value._id);
225
+ }
226
+ return normalizeId(value);
227
+ }
228
+ function normalizeId(value) {
211
229
  if (typeof value === "string") {
212
230
  const trimmed = value.trim();
213
231
  return trimmed.length > 0 ? trimmed : null;
214
232
  }
215
- return String(value);
233
+ return value instanceof Types.ObjectId ? value.toHexString() : null;
216
234
  }
217
235
 
218
- // src/schemas/message.ts
219
- function resolveDocumentModel(document, modelName, role) {
220
- const model = document.constructor;
221
- const connection = model.db;
222
- if (!connection) {
223
- throw new Error(
224
- `message-service: cannot resolve ${role} model "${modelName}" because the hydrated document has no owning connection`
225
- );
236
+ // src/errors.ts
237
+ var TRANSACTION_REQUIRED_MESSAGE = "message-service requires MongoDB replica set or sharded-cluster transactions for idempotent batch creation";
238
+ var MessageNotFoundError = class extends Error {
239
+ static [Symbol.hasInstance](value) {
240
+ return isRuntimeError(value, "MessageNotFoundError");
226
241
  }
227
- try {
228
- return connection.model(modelName);
229
- } catch (error) {
230
- const connectionName = connection.name || "<unnamed>";
231
- const resolutionError = new Error(
232
- `message-service: ${role} model "${modelName}" is not registered on Mongoose connection "${connectionName}"`
233
- );
234
- resolutionError.cause = error;
235
- throw resolutionError;
242
+ constructor(messageId) {
243
+ super(`message "${messageId}" not found`);
244
+ this.name = "MessageNotFoundError";
245
+ markRuntimeError(this, this.name);
236
246
  }
237
- }
238
- function createArchiveMethod(ctx) {
239
- return function archive(actionCd, archivedBy, registry) {
240
- if (!includesAction(this.templateCd, actionCd, registry) || !archivedBy) {
241
- return Promise.resolve();
242
- }
243
- const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
244
- const data = this.toObject();
245
- return MessageArchive.create({
246
- ...data,
247
- actionCd,
248
- archivedBy
249
- }).then(async () => {
250
- await this.deleteOne();
251
- });
252
- };
253
- }
254
- var EMAIL_WAS_NEW_LOCAL = "messageServiceEmailWasNew";
255
- function normalizeEmailTitle(value) {
256
- return value.trim().toLowerCase();
257
- }
258
- function createEmailStateCaptureHook() {
259
- return function captureEmailState() {
260
- this.$locals[EMAIL_WAS_NEW_LOCAL] = this.isNew;
261
- };
262
- }
263
- function createPostSaveEmailHook(ctx) {
264
- return async function sendNotificationEmail() {
265
- if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
266
- return;
267
- }
268
- if (this.$session()) {
269
- return;
270
- }
271
- if (!this.toUser || !this.receiverContent?.title) {
272
- return;
273
- }
274
- const title = this.receiverContent.title.trim();
275
- if (!title || ctx.emailNotificationExclusions.includes(normalizeEmailTitle(title))) {
276
- return;
277
- }
278
- let user;
279
- try {
280
- const User = resolveDocumentModel(this, ctx.userModelName, "user");
281
- user = await User.findById(this.toUser).select("email").lean();
282
- } catch (error) {
283
- await reportEmailFailure(ctx, "recipientLookup", error, this, title);
284
- return;
285
- }
286
- if (!user?.email) {
287
- return;
288
- }
289
- const long = this.receiverContent.long || "";
290
- const short = this.receiverContent.short || "";
291
- const body = long.length > short.length ? long : short;
292
- try {
293
- await ctx.emailNotifier(user.email, title, body);
294
- } catch (error) {
295
- await reportEmailFailure(ctx, "notifier", error, this, title);
296
- }
297
- };
298
- }
299
- async function reportEmailFailure(ctx, stage, error, message, title) {
300
- try {
301
- await ctx.onEmailDeliveryFailure?.({
302
- stage,
303
- error,
304
- messageId: message._id,
305
- recipientId: message.toUser,
306
- title
307
- });
308
- } catch {
247
+ };
248
+ var MessageArchivedError = class extends Error {
249
+ static [Symbol.hasInstance](value) {
250
+ return isRuntimeError(value, "MessageArchivedError");
309
251
  }
310
- }
311
- function assertModelRegistered(connection, name, role) {
312
- if (!connection.modelNames().includes(name)) {
313
- const connectionName = "name" in connection && connection.name ? connection.name : "global mongoose";
314
- throw new Error(
315
- `message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered on Mongoose connection "${connectionName}". Register the model on that connection before buildMessageSchema().`
316
- );
252
+ constructor(messageId) {
253
+ super(`message "${messageId}" is archived`);
254
+ this.name = "MessageArchivedError";
255
+ markRuntimeError(this, this.name);
317
256
  }
318
- }
319
- function resolveConfig(config) {
320
- return {
321
- emailNotifier: config?.emailNotifier ?? null,
322
- onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
323
- emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
324
- userModelName: config?.userModelName ?? "User",
325
- archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
326
- connection: config?.connection ?? mongoose2
327
- };
328
- }
329
- function buildMessageSchema(config) {
330
- const resolved = resolveConfig(config);
331
- if (resolved.emailNotifier) {
332
- assertModelRegistered(resolved.connection, resolved.userModelName, "user");
257
+ };
258
+ var TemplateNotFoundError = class extends Error {
259
+ static [Symbol.hasInstance](value) {
260
+ return isRuntimeError(value, "TemplateNotFoundError");
333
261
  }
334
- const schema = new mongoose2.Schema(BaseMessageFields, {
335
- timestamps: true
336
- });
337
- schema.index({ fromUser: 1, createdAt: -1, _id: -1 });
338
- schema.index({ toUser: 1, createdAt: -1, _id: -1 });
339
- schema.index({ toRoles: 1, createdAt: -1, _id: -1 });
340
- schema.index({ actionState: 1, actionLeaseExpiresAt: 1 });
341
- schema.index(
342
- { actionAttemptId: 1 },
343
- {
344
- unique: true,
345
- partialFilterExpression: { actionAttemptId: { $type: "string" } }
346
- }
347
- );
348
- schema.index(
349
- { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
350
- {
351
- partialFilterExpression: {
352
- clientRequestId: { $type: "string" },
353
- clientRequestOwnerId: { $type: "string" },
354
- templateCd: { $type: "string" }
355
- }
262
+ constructor(templateCd) {
263
+ super(`template "${templateCd}" not found`);
264
+ this.name = "TemplateNotFoundError";
265
+ markRuntimeError(this, this.name);
266
+ }
267
+ };
268
+ var ActionNotFoundError = class extends Error {
269
+ static [Symbol.hasInstance](value) {
270
+ return isRuntimeError(value, "ActionNotFoundError");
271
+ }
272
+ constructor(templateCd, actionCd) {
273
+ super(`action "${actionCd}" not found in template "${templateCd}"`);
274
+ this.name = "ActionNotFoundError";
275
+ markRuntimeError(this, this.name);
276
+ }
277
+ };
278
+ var ActionNotAllowedError = class extends Error {
279
+ static [Symbol.hasInstance](value) {
280
+ return isRuntimeError(value, "ActionNotAllowedError");
281
+ }
282
+ constructor() {
283
+ super("not allowed");
284
+ this.name = "ActionNotAllowedError";
285
+ markRuntimeError(this, this.name);
286
+ }
287
+ };
288
+ var InvalidMessageUserError = class extends Error {
289
+ static [Symbol.hasInstance](value) {
290
+ return isRuntimeError(value, "InvalidMessageUserError");
291
+ }
292
+ constructor(message = "user._id must be a non-empty string or ObjectId") {
293
+ super(message);
294
+ this.name = "InvalidMessageUserError";
295
+ markRuntimeError(this, this.name);
296
+ }
297
+ };
298
+ var ActionTemplateMismatchError = class extends Error {
299
+ static [Symbol.hasInstance](value) {
300
+ return isRuntimeError(value, "ActionTemplateMismatchError");
301
+ }
302
+ constructor(expectedTemplateCd, receivedTemplateCd) {
303
+ super(`message template "${expectedTemplateCd}" does not match requested template "${receivedTemplateCd}"`);
304
+ this.name = "ActionTemplateMismatchError";
305
+ markRuntimeError(this, this.name);
306
+ }
307
+ };
308
+ var ActionConflictError = class extends Error {
309
+ static [Symbol.hasInstance](value) {
310
+ return isRuntimeError(value, "ActionConflictError");
311
+ }
312
+ constructor(messageId) {
313
+ super(`message "${messageId}" already has an action in progress`);
314
+ this.name = "ActionConflictError";
315
+ markRuntimeError(this, this.name);
316
+ }
317
+ };
318
+ var ActionRetryableError = class extends Error {
319
+ static [Symbol.hasInstance](value) {
320
+ return isRuntimeError(value, "ActionRetryableError");
321
+ }
322
+ constructor(messageId, actionAttemptId, cause) {
323
+ super(`message "${messageId}" action attempt "${actionAttemptId}" failed before commit and may be retried`);
324
+ this.name = "ActionRetryableError";
325
+ markRuntimeError(this, this.name);
326
+ this.actionAttemptId = actionAttemptId;
327
+ if (cause) {
328
+ this.cause = cause;
356
329
  }
357
- );
358
- schema.index(
359
- { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
360
- {
361
- unique: true,
362
- partialFilterExpression: {
363
- clientRequestId: { $type: "string" },
364
- clientRequestOwnerId: { $type: "string" },
365
- templateCd: { $type: "string" },
366
- clientRequestItemIndex: { $type: "number" }
367
- }
368
- }
369
- );
370
- schema.methods.isSender = isSender;
371
- schema.methods.isReceiver = isReceiver;
372
- schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
373
- if (resolved.emailNotifier) {
374
- schema.pre("save", createEmailStateCaptureHook());
375
- schema.post(
376
- "save",
377
- createPostSaveEmailHook({
378
- emailNotifier: resolved.emailNotifier,
379
- onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
380
- emailNotificationExclusions: resolved.emailNotificationExclusions,
381
- userModelName: resolved.userModelName
382
- })
383
- );
384
- }
385
- return schema;
386
- }
387
- var MessageSchema = buildMessageSchema();
388
-
389
- // src/schemas/message-archive.ts
390
- import mongoose3 from "mongoose";
391
- function buildMessageArchiveSchema() {
392
- const schema = new mongoose3.Schema(
393
- {
394
- ...BaseMessageFields,
395
- actionCd: { type: String, default: "" },
396
- archivedBy: { type: mongoose3.Schema.Types.ObjectId, default: null, index: true },
397
- archivedAt: { type: Date, default: Date.now },
398
- actionAttemptId: { type: String, default: null, index: true },
399
- actionNotificationState: { type: String, enum: ["none", "pending", "sent", "failed"], default: "none" },
400
- actionNotificationError: { type: String, default: null },
401
- actionNotificationAttemptedAt: { type: Date, default: null }
402
- },
403
- {
404
- timestamps: true
405
- }
406
- );
407
- schema.index({ createdAt: 1 });
408
- schema.methods.isSender = isSender;
409
- schema.methods.isReceiver = isReceiver;
410
- return schema;
411
- }
412
- var MessageArchiveSchema = buildMessageArchiveSchema();
413
-
414
- // src/schemas/message-request.ts
415
- import mongoose4 from "mongoose";
416
- function buildMessageRequestSchema() {
417
- const schema = new mongoose4.Schema(
418
- {
419
- clientRequestId: { type: String, required: true },
420
- clientRequestOwnerId: { type: String, required: true },
421
- templateCd: { type: String, required: true },
422
- state: { type: String, enum: ["pending", "completed", "failed"], default: "pending" },
423
- itemCount: { type: Number, default: null },
424
- leaseOwnerId: { type: String, default: null },
425
- leaseExpiresAt: { type: Date, default: null },
426
- completedAt: { type: Date, default: null },
427
- failedAt: { type: Date, default: null },
428
- failureMessage: { type: String, default: null }
429
- },
430
- {
431
- timestamps: true
432
- }
433
- );
434
- schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
435
- schema.index({ updatedAt: 1 });
436
- schema.index({ state: 1, leaseExpiresAt: 1 });
437
- return schema;
438
- }
439
- var MessageRequestSchema = buildMessageRequestSchema();
440
-
441
- // src/template-engine.ts
442
- import Handlebars from "handlebars";
443
- var compiledTemplates = /* @__PURE__ */ new Map();
444
- function compile(template, data) {
445
- let compiled = compiledTemplates.get(template);
446
- if (!compiled) {
447
- compiled = Handlebars.compile(template, { noEscape: true });
448
- compiledTemplates.set(template, compiled);
449
- }
450
- return compiled(data);
451
- }
452
- function filterActions(actions, usertype, options = {}) {
453
- const { permissions = {}, message = {}, data = {} } = options;
454
- return actions.filter((action) => {
455
- if (!action[usertype]) return false;
456
- if (action.permission && !permissions[action.permission]) return false;
457
- if (action.condition && !action.condition(message)) return false;
458
- return true;
459
- }).map((action) => {
460
- const { confirmation } = action;
461
- return {
462
- actionCd: action.actionCd,
463
- isDefault: action.isDefault,
464
- name: compile(action.name, data),
465
- variant: action.variant,
466
- confirmation: confirmation ? {
467
- title: compile(confirmation.title, data),
468
- message: compile(confirmation.message, data),
469
- ...confirmation.notesLabel ? { notesLabel: compile(confirmation.notesLabel, data) } : {},
470
- ...confirmation.requireNotes !== void 0 ? { requireNotes: confirmation.requireNotes } : {},
471
- ...confirmation.documents !== void 0 ? { documents: confirmation.documents } : {}
472
- } : void 0,
473
- payload: action.payload
474
- };
475
- });
476
- }
477
- function isActionAllowed(action, user, message, options = {}) {
478
- const isReceiver2 = action.receiver && message.isReceiver(user);
479
- const isSender2 = action.sender && message.isSender(user);
480
- if (!isReceiver2 && !isSender2) return false;
481
- if (action.permission && !(options.permissions || {})[action.permission]) return false;
482
- if (action.condition && !action.condition(message)) return false;
483
- return true;
484
- }
485
- function interpolateTemplate(template, data, usertype, options = {}) {
486
- const { permissions = {}, message = {} } = options;
487
- const { senderContent, receiverContent, actions, uiTemplate } = template;
488
- return {
489
- senderContent: {
490
- title: compile(senderContent?.title || "", data),
491
- long: compile(senderContent?.long || "", data),
492
- short: compile(senderContent?.short || "", data)
493
- },
494
- receiverContent: {
495
- title: compile(receiverContent?.title || "", data),
496
- long: compile(receiverContent?.long || "", data),
497
- short: compile(receiverContent?.short || "", data)
498
- },
499
- uiTemplate: resolveUiTemplate(uiTemplate, usertype),
500
- actions: filterActions(actions, usertype, { permissions, message, data })
501
- };
502
- }
503
- function resolveUiTemplate(uiTemplate, usertype) {
504
- if (typeof uiTemplate === "string") return uiTemplate;
505
- return uiTemplate[usertype] || uiTemplate.sender || uiTemplate.receiver || "";
506
- }
507
-
508
- // src/providers/email.ts
509
- var NoopEmailProvider = class {
510
- async sendNotification(_to, _title, _body) {
511
- }
512
- };
513
-
514
- // src/providers/payment.ts
515
- var NoopPaymentProvider = class {
516
- async createSession(_user, _code, _priceArgs) {
517
- return null;
518
- }
519
- async expireSession(_sessionId) {
520
- }
521
- async refundPayment(_sessionId) {
522
- }
523
- };
524
-
525
- // src/message-service.ts
526
- import { isString } from "@web-ts-toolkit/utils";
527
- import { randomUUID } from "crypto";
528
- import mongoose5 from "mongoose";
529
- var DEFAULT_LIST_LIMIT = 50;
530
- var MAX_LIST_LIMIT = 100;
531
- var DUPLICATE_KEY_ERROR_CODE = 11e3;
532
- var CLIENT_REQUEST_LEASE_MS = 3e4;
533
- var CLIENT_REQUEST_WAIT_MS = 5e3;
534
- var CLIENT_REQUEST_POLL_MS = 200;
535
- var ACTION_LEASE_MS = 3e4;
536
- var MAX_CLIENT_REQUEST_ID_LENGTH = 128;
537
- var TRANSACTION_REQUIRED_MESSAGE = "message-service requires MongoDB replica set or sharded-cluster transactions for idempotent batch creation";
538
- var DEFAULT_MODEL_NAMES = {
539
- active: MESSAGE_MODEL_NAME,
540
- archive: MESSAGE_ARCHIVE_MODEL_NAME,
541
- request: MESSAGE_REQUEST_MODEL_NAME,
542
- user: "User"
543
- };
544
- var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
545
- var MessageNotFoundError = class extends Error {
546
- static [Symbol.hasInstance](value) {
547
- return isRuntimeError(value, "MessageNotFoundError");
548
- }
549
- constructor(messageId) {
550
- super(`message "${messageId}" not found`);
551
- this.name = "MessageNotFoundError";
552
- markRuntimeError(this, this.name);
553
- }
554
- };
555
- var MessageArchivedError = class extends Error {
556
- static [Symbol.hasInstance](value) {
557
- return isRuntimeError(value, "MessageArchivedError");
558
- }
559
- constructor(messageId) {
560
- super(`message "${messageId}" is archived`);
561
- this.name = "MessageArchivedError";
562
- markRuntimeError(this, this.name);
563
- }
564
- };
565
- var TemplateNotFoundError = class extends Error {
566
- static [Symbol.hasInstance](value) {
567
- return isRuntimeError(value, "TemplateNotFoundError");
568
- }
569
- constructor(templateCd) {
570
- super(`template "${templateCd}" not found`);
571
- this.name = "TemplateNotFoundError";
572
- markRuntimeError(this, this.name);
573
- }
574
- };
575
- var ActionNotFoundError = class extends Error {
576
- static [Symbol.hasInstance](value) {
577
- return isRuntimeError(value, "ActionNotFoundError");
578
- }
579
- constructor(templateCd, actionCd) {
580
- super(`action "${actionCd}" not found in template "${templateCd}"`);
581
- this.name = "ActionNotFoundError";
582
- markRuntimeError(this, this.name);
583
- }
584
- };
585
- var ActionNotAllowedError = class extends Error {
586
- static [Symbol.hasInstance](value) {
587
- return isRuntimeError(value, "ActionNotAllowedError");
588
- }
589
- constructor() {
590
- super("not allowed");
591
- this.name = "ActionNotAllowedError";
592
- markRuntimeError(this, this.name);
593
- }
594
- };
595
- var InvalidMessageUserError = class extends Error {
596
- static [Symbol.hasInstance](value) {
597
- return isRuntimeError(value, "InvalidMessageUserError");
598
- }
599
- constructor(message = "user._id must be a non-empty string or ObjectId") {
600
- super(message);
601
- this.name = "InvalidMessageUserError";
602
- markRuntimeError(this, this.name);
603
- }
604
- };
605
- var ActionTemplateMismatchError = class extends Error {
606
- static [Symbol.hasInstance](value) {
607
- return isRuntimeError(value, "ActionTemplateMismatchError");
608
- }
609
- constructor(expectedTemplateCd, receivedTemplateCd) {
610
- super(`message template "${expectedTemplateCd}" does not match requested template "${receivedTemplateCd}"`);
611
- this.name = "ActionTemplateMismatchError";
612
- markRuntimeError(this, this.name);
613
- }
614
- };
615
- var ActionConflictError = class extends Error {
616
- static [Symbol.hasInstance](value) {
617
- return isRuntimeError(value, "ActionConflictError");
618
- }
619
- constructor(messageId) {
620
- super(`message "${messageId}" already has an action in progress`);
621
- this.name = "ActionConflictError";
622
- markRuntimeError(this, this.name);
623
- }
624
- };
625
- var ActionRetryableError = class extends Error {
626
- static [Symbol.hasInstance](value) {
627
- return isRuntimeError(value, "ActionRetryableError");
628
- }
629
- constructor(messageId, actionAttemptId, cause) {
630
- super(`message "${messageId}" action attempt "${actionAttemptId}" failed before commit and may be retried`);
631
- this.name = "ActionRetryableError";
632
- markRuntimeError(this, this.name);
633
- this.actionAttemptId = actionAttemptId;
634
- if (cause) {
635
- this.cause = cause;
636
- }
637
- }
638
- };
639
- var ActionNotificationPendingError = class extends Error {
640
- static [Symbol.hasInstance](value) {
641
- return isRuntimeError(value, "ActionNotificationPendingError");
642
- }
643
- constructor(messageId, actionAttemptId, result, cause) {
644
- super(`message "${messageId}" action committed but sender notification is pending`);
645
- this.name = "ActionNotificationPendingError";
646
- markRuntimeError(this, this.name);
647
- this.actionAttemptId = actionAttemptId;
648
- this.result = result;
649
- if (cause) {
650
- this.cause = cause;
330
+ }
331
+ };
332
+ var ActionNotificationPendingError = class extends Error {
333
+ static [Symbol.hasInstance](value) {
334
+ return isRuntimeError(value, "ActionNotificationPendingError");
335
+ }
336
+ constructor(messageId, actionAttemptId, result, cause) {
337
+ super(`message "${messageId}" action committed but sender notification is pending`);
338
+ this.name = "ActionNotificationPendingError";
339
+ markRuntimeError(this, this.name);
340
+ this.actionAttemptId = actionAttemptId;
341
+ this.result = result;
342
+ if (cause) {
343
+ this.cause = cause;
651
344
  }
652
345
  }
653
346
  };
@@ -671,6 +364,16 @@ var InvalidPaginationValueError = class extends Error {
671
364
  markRuntimeError(this, this.name);
672
365
  }
673
366
  };
367
+ var InvalidMessageServiceOptionError = class extends Error {
368
+ static [Symbol.hasInstance](value) {
369
+ return isRuntimeError(value, "InvalidMessageServiceOptionError");
370
+ }
371
+ constructor(message) {
372
+ super(message);
373
+ this.name = "InvalidMessageServiceOptionError";
374
+ markRuntimeError(this, this.name);
375
+ }
376
+ };
674
377
  var ClientRequestPendingError = class extends Error {
675
378
  static [Symbol.hasInstance](value) {
676
379
  return isRuntimeError(value, "ClientRequestPendingError");
@@ -688,9 +391,14 @@ var ClientRequestFailedError = class extends Error {
688
391
  return isRuntimeError(value, "ClientRequestFailedError");
689
392
  }
690
393
  constructor(clientRequestId, reason) {
691
- super(`clientRequestId "${clientRequestId}" previously failed${reason ? `: ${reason}` : ""}`);
394
+ super(`clientRequestId "${clientRequestId}" previously failed; retry with a new clientRequestId`);
692
395
  this.name = "ClientRequestFailedError";
693
396
  markRuntimeError(this, this.name);
397
+ this.clientRequestId = clientRequestId;
398
+ this.failureReason = reason ?? null;
399
+ if (reason != null) {
400
+ this.cause = reason;
401
+ }
694
402
  }
695
403
  };
696
404
  var ClientRequestInconsistentStateError = class extends Error {
@@ -734,7 +442,7 @@ var MessageModelResolutionError = class extends Error {
734
442
  };
735
443
  var PaymentSessionCompensationError = class extends Error {
736
444
  static [Symbol.hasInstance](value) {
737
- return isRuntimeError(value, "PaymentSessionCompensationError");
445
+ return isRuntimeError(value, "PaymentSessionCompensationError") || isRuntimeError(value, "PaymentSessionCompensationAggregateError");
738
446
  }
739
447
  constructor(sessionId, operation, compensationError, originalError, hookError) {
740
448
  super(`payment session compensation failed for session "${sessionId}" during ${operation}`);
@@ -748,6 +456,202 @@ var PaymentSessionCompensationError = class extends Error {
748
456
  this.cause = compensationError;
749
457
  }
750
458
  };
459
+ var PaymentSessionCompensationAggregateError = class extends PaymentSessionCompensationError {
460
+ static [Symbol.hasInstance](value) {
461
+ return isRuntimeError(value, "PaymentSessionCompensationAggregateError");
462
+ }
463
+ constructor(failures, originalError) {
464
+ const first = failures[0];
465
+ super(first.sessionId, "expire", first.compensationError, originalError, first.hookError);
466
+ this.name = "PaymentSessionCompensationAggregateError";
467
+ markRuntimeError(this, this.name);
468
+ this.failures = failures;
469
+ if (failures.length > 1) {
470
+ this.message = `payment session compensation failed for ${failures.length} sessions (${failures.map((f) => `"${f.sessionId}"`).join(", ")}) during expire`;
471
+ }
472
+ }
473
+ };
474
+
475
+ // src/persistence.ts
476
+ function isDuplicateKeyError(error) {
477
+ return error instanceof Error && "code" in error && error.code === 11e3;
478
+ }
479
+ function isTransactionSupportError(error) {
480
+ if (!(error instanceof Error)) return false;
481
+ return error.message.includes("Transaction numbers are only allowed") || error.message.includes("Transaction is not supported") || error.message.includes("transactions are not supported");
482
+ }
483
+ async function runMessageTransaction(activeModel, operation, options) {
484
+ const startSession = activeModel.db?.startSession;
485
+ if (!options.session && typeof startSession !== "function") {
486
+ throw new MessageTransactionRequiredError(
487
+ new Error("message-service: atomic lifecycle write requires a Mongoose model with db.startSession support")
488
+ );
489
+ }
490
+ let session;
491
+ try {
492
+ session = options.session ?? await startSession.call(activeModel.db);
493
+ } catch (error) {
494
+ throw isTransactionSupportError(error) ? new MessageTransactionRequiredError(error) : error;
495
+ }
496
+ let committed = false;
497
+ let originalError;
498
+ let operationFailure;
499
+ try {
500
+ let result;
501
+ await session.withTransaction(async () => {
502
+ operationFailure = void 0;
503
+ try {
504
+ result = await operation(session);
505
+ } catch (error) {
506
+ operationFailure = { error };
507
+ throw error;
508
+ }
509
+ });
510
+ committed = true;
511
+ return result;
512
+ } catch (error) {
513
+ const primary = operationFailure ? operationFailure.error : error;
514
+ originalError = isTransactionSupportError(primary) ? new MessageTransactionRequiredError(primary) : primary;
515
+ throw originalError;
516
+ } finally {
517
+ if (!options.session) {
518
+ try {
519
+ await session.endSession();
520
+ } catch (error) {
521
+ const event = {
522
+ operation: options.operation,
523
+ stage: "endSession",
524
+ error,
525
+ ...committed ? { outcome: "committed" } : { outcome: "failed", originalError }
526
+ };
527
+ try {
528
+ await options.onTransactionCleanupFailure?.(event);
529
+ } catch {
530
+ }
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ // src/message-service.ts
537
+ import { isString } from "@web-ts-toolkit/utils";
538
+ import { randomUUID } from "crypto";
539
+ import mongoose2 from "mongoose";
540
+
541
+ // src/template-engine.ts
542
+ import Handlebars from "handlebars";
543
+ var compiledTemplates = /* @__PURE__ */ new Map();
544
+ function compile(template, data) {
545
+ let compiled = compiledTemplates.get(template);
546
+ if (!compiled) {
547
+ compiled = Handlebars.compile(template, { noEscape: true });
548
+ compiledTemplates.set(template, compiled);
549
+ }
550
+ return compiled(data);
551
+ }
552
+ function hasExplicitPermissionGrant(permissions, key) {
553
+ if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return false;
554
+ if (typeof key !== "string" || key.length === 0) return false;
555
+ if (!Object.prototype.hasOwnProperty.call(permissions, key)) return false;
556
+ return permissions[key] === true;
557
+ }
558
+ function interpolateMessageContent(template, data) {
559
+ const { senderContent, receiverContent } = template;
560
+ return {
561
+ senderContent: {
562
+ title: compile(senderContent?.title || "", data),
563
+ long: compile(senderContent?.long || "", data),
564
+ short: compile(senderContent?.short || "", data)
565
+ },
566
+ receiverContent: {
567
+ title: compile(receiverContent?.title || "", data),
568
+ long: compile(receiverContent?.long || "", data),
569
+ short: compile(receiverContent?.short || "", data)
570
+ }
571
+ };
572
+ }
573
+ function resolveUiTemplate(uiTemplate, usertype) {
574
+ if (typeof uiTemplate === "string") return uiTemplate;
575
+ return uiTemplate[usertype] || uiTemplate.sender || uiTemplate.receiver || "";
576
+ }
577
+ function filterActions(actions, usertype, options = {}) {
578
+ const { permissions = {}, message = {}, data = {} } = options;
579
+ return actions.filter((action) => {
580
+ if (!action[usertype]) return false;
581
+ if (action.permission && !hasExplicitPermissionGrant(permissions, action.permission)) return false;
582
+ if (action.condition && !action.condition(message)) return false;
583
+ return true;
584
+ }).map((action) => {
585
+ const { confirmation } = action;
586
+ return {
587
+ actionCd: action.actionCd,
588
+ isDefault: action.isDefault,
589
+ name: compile(action.name, data),
590
+ variant: action.variant,
591
+ confirmation: confirmation ? {
592
+ title: compile(confirmation.title, data),
593
+ message: compile(confirmation.message, data),
594
+ ...confirmation.notesLabel ? { notesLabel: compile(confirmation.notesLabel, data) } : {},
595
+ ...confirmation.requireNotes !== void 0 ? { requireNotes: confirmation.requireNotes } : {},
596
+ ...confirmation.documents !== void 0 ? { documents: confirmation.documents } : {}
597
+ } : void 0,
598
+ payload: action.payload
599
+ };
600
+ });
601
+ }
602
+ function isActionAllowed(action, user, message, options = {}) {
603
+ const isReceiver2 = action.receiver && message.isReceiver(user);
604
+ const isSender2 = action.sender && message.isSender(user);
605
+ if (!isReceiver2 && !isSender2) return false;
606
+ if (action.permission && !hasExplicitPermissionGrant(options.permissions, action.permission)) return false;
607
+ if (action.condition && !action.condition(message)) return false;
608
+ return true;
609
+ }
610
+ function interpolateTemplate(template, data, usertype, options = {}) {
611
+ const { permissions = {}, message = {} } = options;
612
+ const { actions, uiTemplate } = template;
613
+ return {
614
+ ...interpolateMessageContent(template, data),
615
+ uiTemplate: resolveUiTemplate(uiTemplate, usertype),
616
+ actions: filterActions(actions, usertype, { permissions, message, data })
617
+ };
618
+ }
619
+
620
+ // src/message-service.ts
621
+ var DEFAULT_LIST_LIMIT = 50;
622
+ var MAX_LIST_LIMIT = 100;
623
+ var CLIENT_REQUEST_LEASE_MS = 3e4;
624
+ var CLIENT_REQUEST_WAIT_MS = 5e3;
625
+ var CLIENT_REQUEST_POLL_MS = 200;
626
+ var MAX_MESSAGE_SERVICE_TIMEOUT_MS = 2147483647;
627
+ var ACTION_LEASE_MS = 3e4;
628
+ var MAX_CLIENT_REQUEST_ID_LENGTH = 128;
629
+ var DEFAULT_MODEL_NAMES = {
630
+ active: MESSAGE_MODEL_NAME,
631
+ archive: MESSAGE_ARCHIVE_MODEL_NAME,
632
+ request: MESSAGE_REQUEST_MODEL_NAME,
633
+ user: "User"
634
+ };
635
+ var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
636
+ function isValidMessageUserId(id) {
637
+ if (typeof id === "string") {
638
+ return id.trim().length > 0;
639
+ }
640
+ if (id instanceof mongoose2.Types.ObjectId) {
641
+ return true;
642
+ }
643
+ return false;
644
+ }
645
+ function requireMessageUserId(user) {
646
+ if (!user || typeof user !== "object" || Array.isArray(user)) {
647
+ throw new InvalidMessageUserError();
648
+ }
649
+ const id = user._id;
650
+ if (!isValidMessageUserId(id)) {
651
+ throw new InvalidMessageUserError();
652
+ }
653
+ return typeof id === "string" ? id.trim() : String(id);
654
+ }
751
655
  var MessageService = class {
752
656
  constructor(options) {
753
657
  this.getModel = options.getModel;
@@ -757,6 +661,7 @@ var MessageService = class {
757
661
  this.expirePaymentSession = this.paymentProvider?.expireSession.bind(this.paymentProvider);
758
662
  this.refundPaymentSession = this.paymentProvider?.refundPayment.bind(this.paymentProvider);
759
663
  this.onPaymentCompensationFailure = options.onPaymentCompensationFailure;
664
+ this.onTransactionCleanupFailure = options.onTransactionCleanupFailure;
760
665
  this.adminRoles = options.adminRoles ?? [];
761
666
  this.registry = options.registry ?? defaultRegistry;
762
667
  this.maxListLimit = this.validatePositiveIntegerOption("maxListLimit", options.maxListLimit ?? MAX_LIST_LIMIT);
@@ -767,10 +672,23 @@ var MessageService = class {
767
672
  if (this.defaultListLimit > this.maxListLimit) {
768
673
  throw new InvalidPaginationValueError("defaultListLimit must be less than or equal to maxListLimit");
769
674
  }
770
- this.clientRequestLeaseMs = options.clientRequestLeaseMs ?? CLIENT_REQUEST_LEASE_MS;
771
- this.clientRequestWaitMs = options.clientRequestWaitMs ?? CLIENT_REQUEST_WAIT_MS;
772
- this.clientRequestPollMs = options.clientRequestPollMs ?? CLIENT_REQUEST_POLL_MS;
675
+ this.clientRequestLeaseMs = this.validateDurationOption(
676
+ "clientRequestLeaseMs",
677
+ options.clientRequestLeaseMs ?? CLIENT_REQUEST_LEASE_MS,
678
+ 1
679
+ );
680
+ this.clientRequestWaitMs = this.validateDurationOption(
681
+ "clientRequestWaitMs",
682
+ options.clientRequestWaitMs ?? CLIENT_REQUEST_WAIT_MS,
683
+ 0
684
+ );
685
+ this.clientRequestPollMs = this.validateDurationOption(
686
+ "clientRequestPollMs",
687
+ options.clientRequestPollMs ?? CLIENT_REQUEST_POLL_MS,
688
+ 1
689
+ );
773
690
  this.clientRequestDelay = options.clientRequestDelay ?? this.delay;
691
+ this.clientRequestNow = options.clientRequestNow ?? (() => Date.now());
774
692
  this.actionLeaseMs = ACTION_LEASE_MS;
775
693
  }
776
694
  // -------------------------------------------------------------------------
@@ -779,6 +697,10 @@ var MessageService = class {
779
697
  /**
780
698
  * Find a message by id, falling back to the archive. Returns null if
781
699
  * the message does not exist in either collection.
700
+ *
701
+ * Trusted host-level operation: takes no principal and performs no
702
+ * user-identity validation. Hosts must authorize the returned document
703
+ * before exposing it to a user (see `getActions`/`handleAction`).
782
704
  */
783
705
  async findMessage(messageId, options = {}) {
784
706
  const Message = this.resolveModel("active");
@@ -791,6 +713,13 @@ var MessageService = class {
791
713
  options
792
714
  );
793
715
  }
716
+ /**
717
+ * Same as `findMessage`, but throws `MessageNotFoundError` when neither the
718
+ * active nor the archive collection holds the id.
719
+ *
720
+ * Trusted host-level operation: no principal validation. Hosts must
721
+ * authorize before exposing the result to a user.
722
+ */
794
723
  async findMessageOrThrow(messageId, options = {}) {
795
724
  const message = await this.findMessage(messageId, options);
796
725
  if (!message) {
@@ -801,6 +730,25 @@ var MessageService = class {
801
730
  // -------------------------------------------------------------------------
802
731
  // Create message from template
803
732
  // -------------------------------------------------------------------------
733
+ /**
734
+ * Create messages from a trusted template as an authenticated user.
735
+ *
736
+ * User-facing entry point: `user` (and `payerUser` when provided) must
737
+ * carry a valid principal id (non-empty string or ObjectId; numbers,
738
+ * arrays, and plain objects are rejected with `InvalidMessageUserError`)
739
+ * before any template, provider, or model effect runs. Both the
740
+ * idempotent (`clientRequestId`) and direct branches enforce this.
741
+ *
742
+ * Idempotent replay contract (MSGF-06): a completed same-scope replay
743
+ * returns the current active/archive records merged from both collections
744
+ * (`Array<IMessage | IMessageArchive>`), sorted by exact
745
+ * `clientRequestItemIndex`, without rerunning preparation/payment. Fresh
746
+ * creates resolve active documents only. Archive entries are returned as
747
+ * persisted `IMessageArchive` — never cast to `IMessage` and never given
748
+ * fabricated active-only methods. Narrow with `'archivedAt' in doc`.
749
+ * Archive retention bounds the replay window: deleted archive documents
750
+ * surface as `ClientRequestInconsistentStateError`, not partial replays.
751
+ */
804
752
  async createMessage(params) {
805
753
  const {
806
754
  templateCd,
@@ -813,6 +761,10 @@ var MessageService = class {
813
761
  req,
814
762
  clientRequestId
815
763
  } = params;
764
+ this.requireUserId(user);
765
+ if (payerUser !== void 0) {
766
+ this.requireUserId(payerUser);
767
+ }
816
768
  const normalizedClientRequestId = this.normalizeClientRequestId(clientRequestId);
817
769
  if (normalizedClientRequestId) {
818
770
  return this.createMessageWithReservation({
@@ -892,8 +844,13 @@ var MessageService = class {
892
844
  const items = Array.isArray(messageData) ? messageData : [messageData];
893
845
  if (clientRequestScope && clientRequestLease) {
894
846
  const docs = [];
895
- for (let index = 0; index < items.length; index++) {
896
- docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
847
+ try {
848
+ for (let index = 0; index < items.length; index++) {
849
+ docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
850
+ }
851
+ } catch (error) {
852
+ await this.compensatePaymentSessions(docs, error, clientRequestScope);
853
+ throw error;
897
854
  }
898
855
  return this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, docs);
899
856
  }
@@ -907,6 +864,13 @@ var MessageService = class {
907
864
  // -------------------------------------------------------------------------
908
865
  // Create generic notification (no template, no actions)
909
866
  // -------------------------------------------------------------------------
867
+ /**
868
+ * Create a generic notification without template preparation or actions.
869
+ *
870
+ * Trusted host-level operation: takes raw `UserId` values (string or
871
+ * ObjectId, including null) and performs no principal validation. Hosts
872
+ * own authentication/authorization for notification creation.
873
+ */
910
874
  async createNotification(params, sourceDocument) {
911
875
  const Message = this.resolveModel("active", sourceDocument);
912
876
  return Message.create({
@@ -927,6 +891,8 @@ var MessageService = class {
927
891
  * Build a Mongoose filter for messages visible to the given user.
928
892
  * Exposed so callers can use the same visibility rules for custom
929
893
  * queries (e.g. with `populate`).
894
+ *
895
+ * User-facing helper: validates `user` before building the filter.
930
896
  */
931
897
  buildVisibilityFilter(user) {
932
898
  const userId = this.requireUserId(user);
@@ -938,6 +904,9 @@ var MessageService = class {
938
904
  * List active (non-archived) messages visible to a user.
939
905
  * Returns messages where the user is the sender, the receiver,
940
906
  * or matches one of the recipient's roles.
907
+ *
908
+ * User-facing operation: validates `user` via the shared principal
909
+ * contract. Returns `IMessage[]` ordered by `{ createdAt: -1, _id: -1 }`.
941
910
  */
942
911
  async listMessages(params) {
943
912
  const { user, limit: rawLimit, skip: rawSkip, populate } = params;
@@ -953,6 +922,8 @@ var MessageService = class {
953
922
  /**
954
923
  * Count active (non-archived) messages visible to a user.
955
924
  * Useful for badge indicators ("3 new messages").
925
+ *
926
+ * User-facing operation: validates `user` via the shared principal contract.
956
927
  */
957
928
  async countMessages(user) {
958
929
  const Message = this.resolveModel("active");
@@ -961,11 +932,32 @@ var MessageService = class {
961
932
  // -------------------------------------------------------------------------
962
933
  // Get actions for a message
963
934
  // -------------------------------------------------------------------------
964
- async getActions(messageId, usertype, options = {}) {
965
- const message = options.message ?? await this.findMessage(messageId, { populate: options.populate });
966
- if (!message) return null;
967
- const user = this.requireUserId(options.user);
935
+ /**
936
+ * Get available actions for a message as an authenticated user.
937
+ *
938
+ * User-facing operation: `options.user` is required and must carry a valid
939
+ * principal id (non-empty string or ObjectId). It is validated before any
940
+ * model lookup or template operation; invalid principals throw
941
+ * `InvalidMessageUserError`.
942
+ *
943
+ * Returns `{ uiTemplate, actions }` for visible messages, or `null` when the
944
+ * message is missing, the user has no sender/receiver relationship (unless
945
+ * `isAdmin`), or the template is unknown. Admin (`isAdmin`) and archived
946
+ * views return the resolved `uiTemplate` with an empty action list without
947
+ * evaluating action `condition` predicates. All listings
948
+ * read the requested id from the configured store. Relationships are
949
+ * checked on canonical stored IDs before optional presentation population.
950
+ * Conditions see that read snapshot with `populate` applied (mutations still
951
+ * check unpopulated atomic claims); this does not reserve an action or prevent
952
+ * subsequent host writes. Action labels and
953
+ * confirmations render from persisted `message.payload` (missing values
954
+ * render empty), distinct from creation-time `templateData`.
955
+ */
956
+ async getActions(messageId, usertype, options) {
957
+ const user = requireMessageUserId(options?.user);
968
958
  const authorizedUser = { ...options.user, _id: user };
959
+ const message = await this.findMessage(messageId);
960
+ if (!message) return null;
969
961
  if (!options.isAdmin) {
970
962
  const isAllowedUsertype = usertype === "sender" ? message.isSender(authorizedUser) : message.isReceiver(authorizedUser);
971
963
  if (!isAllowedUsertype) {
@@ -977,31 +969,44 @@ var MessageService = class {
977
969
  }
978
970
  const template = this.registry.find(message.templateCd);
979
971
  if (!template) return null;
972
+ if (options.isAdmin || this.isArchivedMessage(message)) {
973
+ return { uiTemplate: resolveUiTemplate(template.uiTemplate, usertype), actions: [] };
974
+ }
975
+ if (options.populate) {
976
+ await message.populate(options.populate);
977
+ }
980
978
  const data = message.payload ?? {};
981
979
  const interpolated = interpolateTemplate(template, data, usertype, {
982
980
  permissions: options.permissions,
983
981
  message
984
982
  });
985
- if (options.isAdmin || this.isArchivedMessage(message)) {
986
- return { uiTemplate: interpolated.uiTemplate, actions: [] };
987
- }
988
983
  return { uiTemplate: interpolated.uiTemplate, actions: interpolated.actions };
989
984
  }
990
985
  // -------------------------------------------------------------------------
991
986
  // Handle an action on a message
992
987
  // -------------------------------------------------------------------------
988
+ /**
989
+ * Execute an action with a durable claim, stable handler attempt key,
990
+ * transactional archive, and post-commit sender notification status.
991
+ *
992
+ * User-facing operation: `data.user` is validated before any template or
993
+ * model effect. Authorization and handler selection bind to the persisted
994
+ * claim, not the caller-supplied `data.message` copy. Archived messages
995
+ * are re-read by id on their owning connection and apply a stored
996
+ * sender/receiver relationship gate before disclosing current attempt IDs
997
+ * or notification state. A missing archive throws `MessageNotFoundError`.
998
+ * Returns the handler's value (`unknown`; templates
999
+ * document their own shape).
1000
+ */
993
1001
  async handleAction(templateCd, actionCd, data) {
994
1002
  const userId = this.requireUserId(data.user);
995
1003
  const authorizedUser = { ...data.user, _id: userId };
996
1004
  if (this.isArchivedMessage(data.message)) {
997
- if (data.message.actionNotificationState === "pending" || data.message.actionNotificationState === "failed") {
998
- throw new ActionNotificationPendingError(
999
- String(data.message._id),
1000
- data.message.actionAttemptId ?? "",
1001
- void 0
1002
- );
1003
- }
1004
- throw new MessageArchivedError(String(data.message._id));
1005
+ const archived = await this.resolveModel("archive", data.message).findById(
1006
+ data.message._id
1007
+ );
1008
+ if (!archived) throw new MessageNotFoundError(String(data.message._id));
1009
+ this.throwArchivedOutcome(archived, authorizedUser);
1005
1010
  }
1006
1011
  if (templateCd !== data.message.templateCd) {
1007
1012
  throw new ActionTemplateMismatchError(data.message.templateCd, templateCd);
@@ -1014,6 +1019,21 @@ var MessageService = class {
1014
1019
  throw new ActionNotAllowedError();
1015
1020
  }
1016
1021
  const claim = await this.claimAction(data.message, actionCd, authorizedUser);
1022
+ let authorizedAction;
1023
+ try {
1024
+ authorizedAction = this.authorizeClaimedAction(templateCd, actionCd, claim.message, authorizedUser, {
1025
+ permissions: data.permissions
1026
+ });
1027
+ } catch (authError) {
1028
+ try {
1029
+ await this.releaseDeniedActionClaim(claim, authError);
1030
+ } catch (releaseError) {
1031
+ if (!(releaseError instanceof ActionConflictError)) {
1032
+ throw releaseError;
1033
+ }
1034
+ }
1035
+ throw authError;
1036
+ }
1017
1037
  const ctx = this.buildActionContext({
1018
1038
  ...data,
1019
1039
  user: authorizedUser,
@@ -1022,24 +1042,44 @@ var MessageService = class {
1022
1042
  });
1023
1043
  let result;
1024
1044
  try {
1025
- result = await action.runHandler(ctx);
1045
+ result = await authorizedAction.runHandler(ctx);
1026
1046
  } catch (error) {
1027
- await this.markActionRetryable(data.message._id, claim.actionAttemptId, error, claim.message);
1047
+ await this.markActionRetryable(
1048
+ data.message._id,
1049
+ claim.actionAttemptId,
1050
+ claim.actionOwnerToken,
1051
+ error,
1052
+ claim.message
1053
+ );
1028
1054
  throw new ActionRetryableError(String(data.message._id), claim.actionAttemptId, error);
1029
1055
  }
1030
- await this.archiveClaimedMessage(claim.message, action, authorizedUser, claim.actionAttemptId);
1031
- if (action.senderNotification) {
1056
+ await this.archiveClaimedMessage(
1057
+ claim.message,
1058
+ authorizedAction,
1059
+ authorizedUser,
1060
+ claim.actionAttemptId,
1061
+ claim.actionOwnerToken
1062
+ );
1063
+ if (authorizedAction.senderNotification) {
1032
1064
  try {
1033
- await this.runSenderNotification(action, ctx, claim.message);
1065
+ await this.runSenderNotification(authorizedAction, ctx, claim.message);
1034
1066
  await this.markActionNotificationState(
1035
1067
  data.message._id,
1036
1068
  claim.actionAttemptId,
1069
+ claim.actionOwnerToken,
1037
1070
  "sent",
1038
1071
  void 0,
1039
1072
  claim.message
1040
1073
  );
1041
1074
  } catch (error) {
1042
- await this.markActionNotificationState(data.message._id, claim.actionAttemptId, "failed", error, claim.message);
1075
+ await this.markActionNotificationState(
1076
+ data.message._id,
1077
+ claim.actionAttemptId,
1078
+ claim.actionOwnerToken,
1079
+ "failed",
1080
+ error,
1081
+ claim.message
1082
+ );
1043
1083
  throw new ActionNotificationPendingError(String(data.message._id), claim.actionAttemptId, result, error);
1044
1084
  }
1045
1085
  }
@@ -1071,7 +1111,7 @@ var MessageService = class {
1071
1111
  if (this.connection) {
1072
1112
  return this.connection.model(modelName);
1073
1113
  }
1074
- return mongoose5.model(modelName);
1114
+ return mongoose2.model(modelName);
1075
1115
  } catch (error) {
1076
1116
  throw new MessageModelResolutionError(role, modelName, this.describeModelSource(sourceConnection), error);
1077
1117
  }
@@ -1083,7 +1123,7 @@ var MessageService = class {
1083
1123
  if (sourceConnection) return sourceConnection.model(name);
1084
1124
  if (this.getModel) return this.getModel(name);
1085
1125
  if (this.connection) return this.connection.model(name);
1086
- return mongoose5.model(name);
1126
+ return mongoose2.model(name);
1087
1127
  } catch (error) {
1088
1128
  throw new MessageModelResolutionError("user", name, this.describeModelSource(sourceConnection), error);
1089
1129
  }
@@ -1133,7 +1173,7 @@ var MessageService = class {
1133
1173
  );
1134
1174
  if (!paymentSession) throw new Error("payment session creation failed");
1135
1175
  }
1136
- const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
1176
+ const interpolated = interpolateMessageContent(template, m.templateData || {});
1137
1177
  return {
1138
1178
  type,
1139
1179
  templateCd: template.templateCd,
@@ -1159,10 +1199,17 @@ var MessageService = class {
1159
1199
  }
1160
1200
  }
1161
1201
  async persistPreparedBatchTransaction(scope, lease, docs) {
1162
- const Message = this.resolveModel("active");
1163
- const MessageRequest = this.resolveModel("request", void 0, Message);
1164
- const operation = async (session2) => {
1165
- const created = docs.length > 0 ? await Message.create(docs, session2 ? { session: session2, ordered: true } : void 0) : [];
1202
+ let Message;
1203
+ let MessageRequest;
1204
+ try {
1205
+ Message = this.resolveModel("active");
1206
+ MessageRequest = this.resolveModel("request", void 0, Message);
1207
+ } catch (error) {
1208
+ await this.compensatePaymentSessions(docs, error, scope);
1209
+ throw error;
1210
+ }
1211
+ const operation = async (session) => {
1212
+ const created = docs.length > 0 ? await Message.create(docs, { session, ordered: true }) : [];
1166
1213
  const result = await MessageRequest.updateOne(
1167
1214
  { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1168
1215
  {
@@ -1173,45 +1220,50 @@ var MessageService = class {
1173
1220
  leaseExpiresAt: null
1174
1221
  }
1175
1222
  },
1176
- session2 ? { session: session2 } : void 0
1223
+ { session }
1177
1224
  );
1178
1225
  if ((result.matchedCount ?? result.n ?? 0) !== 1) {
1179
1226
  throw new ClientRequestPendingError(scope.clientRequestId);
1180
1227
  }
1181
1228
  return created;
1182
1229
  };
1183
- const startSession = Message.db?.startSession;
1184
- if (!startSession) {
1185
- try {
1186
- return await operation();
1187
- } catch (error) {
1188
- await this.compensatePaymentSessions(docs, error, scope);
1189
- throw error;
1190
- }
1191
- }
1192
- const session = await startSession.call(Message.db);
1193
1230
  try {
1194
- let result = [];
1195
- await session.withTransaction(async () => {
1196
- result = await operation(session);
1231
+ return await runMessageTransaction(Message, operation, {
1232
+ operation: "createBatch",
1233
+ onTransactionCleanupFailure: this.onTransactionCleanupFailure
1197
1234
  });
1198
- return result;
1199
1235
  } catch (error) {
1200
- const wrappedError = this.isTransactionSupportError(error) ? new MessageTransactionRequiredError(error) : error;
1201
- await this.compensatePaymentSessions(docs, wrappedError, scope);
1202
- if (this.isTransactionSupportError(error)) {
1203
- throw wrappedError;
1204
- }
1205
- throw wrappedError;
1206
- } finally {
1207
- await session.endSession();
1236
+ await this.compensatePaymentSessions(docs, error, scope);
1237
+ throw error;
1208
1238
  }
1209
1239
  }
1210
1240
  async compensatePaymentSessions(docs, originalError, scope) {
1211
1241
  const sessionIds = docs.map((doc) => doc.paymentSession).filter((sessionId) => typeof sessionId === "string" && sessionId.length > 0);
1242
+ if (sessionIds.length === 0) return;
1243
+ if (!this.expirePaymentSession) return;
1244
+ const failures = [];
1245
+ let firstSingleError;
1212
1246
  for (const sessionId of sessionIds) {
1213
- await this.compensatePaymentSession(sessionId, originalError, scope);
1247
+ try {
1248
+ await this.compensatePaymentSession(sessionId, originalError, scope);
1249
+ } catch (error) {
1250
+ if (error instanceof PaymentSessionCompensationError) {
1251
+ if (!firstSingleError) firstSingleError = error;
1252
+ failures.push({
1253
+ sessionId: error.sessionId,
1254
+ compensationError: error.compensationError,
1255
+ hookError: error.hookError
1256
+ });
1257
+ } else {
1258
+ failures.push({ sessionId, compensationError: error });
1259
+ }
1260
+ }
1214
1261
  }
1262
+ if (failures.length === 0) return;
1263
+ if (sessionIds.length === 1 && firstSingleError) {
1264
+ throw firstSingleError;
1265
+ }
1266
+ throw new PaymentSessionCompensationAggregateError(failures, originalError);
1215
1267
  }
1216
1268
  async compensatePaymentSession(sessionId, originalError, scope) {
1217
1269
  if (!this.expirePaymentSession) return;
@@ -1258,14 +1310,28 @@ var MessageService = class {
1258
1310
  actionFailureMessage: null
1259
1311
  };
1260
1312
  const firstAttemptId = randomUUID();
1313
+ const firstOwnerToken = randomUUID();
1261
1314
  const firstClaim = await Message.findOneAndUpdate(
1262
1315
  { _id: messageId, $or: [{ actionState: "active" }, { actionState: null }, { actionState: { $exists: false } }] },
1263
- { $set: { ...claimBase, actionState: "processing", actionAttemptId: firstAttemptId } },
1316
+ {
1317
+ $set: {
1318
+ ...claimBase,
1319
+ actionState: "processing",
1320
+ actionAttemptId: firstAttemptId,
1321
+ actionOwnerToken: firstOwnerToken
1322
+ }
1323
+ },
1264
1324
  { returnDocument: "after" }
1265
1325
  );
1266
- if (firstClaim?.actionAttemptId) {
1267
- return { message: firstClaim, actionAttemptId: firstClaim.actionAttemptId };
1326
+ if (firstClaim?.actionAttemptId && firstClaim?.actionOwnerToken) {
1327
+ return {
1328
+ message: firstClaim,
1329
+ actionAttemptId: firstClaim.actionAttemptId,
1330
+ actionOwnerToken: firstClaim.actionOwnerToken,
1331
+ fresh: true
1332
+ };
1268
1333
  }
1334
+ const nextOwnerToken = randomUUID();
1269
1335
  const retryClaim = await Message.findOneAndUpdate(
1270
1336
  {
1271
1337
  _id: messageId,
@@ -1273,27 +1339,105 @@ var MessageService = class {
1273
1339
  actionAttemptId: { $type: "string" },
1274
1340
  $or: [{ actionState: "retryable" }, { actionState: "processing", actionLeaseExpiresAt: { $lte: now } }]
1275
1341
  },
1276
- { $set: { ...claimBase, actionState: "processing" } },
1342
+ { $set: { ...claimBase, actionState: "processing", actionOwnerToken: nextOwnerToken } },
1277
1343
  { returnDocument: "after" }
1278
1344
  );
1279
- if (retryClaim?.actionAttemptId) {
1280
- return { message: retryClaim, actionAttemptId: retryClaim.actionAttemptId };
1345
+ if (retryClaim?.actionAttemptId && retryClaim?.actionOwnerToken) {
1346
+ return {
1347
+ message: retryClaim,
1348
+ actionAttemptId: retryClaim.actionAttemptId,
1349
+ actionOwnerToken: retryClaim.actionOwnerToken,
1350
+ fresh: false
1351
+ };
1352
+ }
1353
+ const archived = await this.resolveModel("archive", message, Message).findById(
1354
+ messageId
1355
+ );
1356
+ if (archived) {
1357
+ this.throwArchivedOutcome(archived, user);
1358
+ }
1359
+ throw new ActionConflictError(String(messageId));
1360
+ }
1361
+ /**
1362
+ * Relationship/permission policy for archived outcomes.
1363
+ *
1364
+ * Only the recorded sender or receiver may observe whether an archived
1365
+ * message is pending notification or terminally archived. This keeps
1366
+ * authorized retries (including pending-notification retries) available even
1367
+ * when the template has been unregistered, while unrelated callers receive a
1368
+ * stable `ActionNotAllowedError` without attempt IDs or notification state.
1369
+ */
1370
+ authorizeArchivedOutcome(message, user) {
1371
+ if (!message.isSender(user) && !message.isReceiver(user)) {
1372
+ throw new ActionNotAllowedError();
1373
+ }
1374
+ }
1375
+ /** Call only with an authoritative, unpopulated archive read. */
1376
+ throwArchivedOutcome(message, user) {
1377
+ this.authorizeArchivedOutcome(message, user);
1378
+ if (message.actionNotificationState === "pending" || message.actionNotificationState === "failed") {
1379
+ throw new ActionNotificationPendingError(String(message._id), message.actionAttemptId ?? "", void 0);
1380
+ }
1381
+ throw new MessageArchivedError(String(message._id));
1382
+ }
1383
+ /**
1384
+ * Authoritative post-claim authorization bound to the persisted document
1385
+ * returned by the atomic claim. Re-validates the requested template/action
1386
+ * selection and the sender/receiver, permission, and condition checks
1387
+ * against persisted `templateCd`, parties, roles, and payload data.
1388
+ *
1389
+ * Supported update protocol: the guarantee covers the document state as of
1390
+ * the atomic claim (`findOneAndUpdate` returning the persisted document).
1391
+ * Host writes that commit before the claim are observed; host writes that
1392
+ * commit after the claim (during handler execution) are not prevented. Hosts
1393
+ * must not mutate action-relevant fields (`templateCd`, `toUser`/`toRoles`/
1394
+ * `fromUser`, condition payload) concurrently with in-flight actions outside
1395
+ * a coordinated protocol. A fresh-document read alone is not a substitute
1396
+ * for this claim-bound check.
1397
+ */
1398
+ authorizeClaimedAction(templateCd, actionCd, claimed, user, options = {}) {
1399
+ if (templateCd !== claimed.templateCd) {
1400
+ throw new ActionTemplateMismatchError(claimed.templateCd, templateCd);
1281
1401
  }
1282
- const archived = await this.resolveModel("archive", message, Message).findById(
1283
- messageId
1284
- );
1285
- if (archived) {
1286
- if (archived.actionNotificationState === "pending" || archived.actionNotificationState === "failed") {
1287
- throw new ActionNotificationPendingError(String(messageId), archived.actionAttemptId ?? "", void 0);
1402
+ const template = this.registry.find(claimed.templateCd);
1403
+ if (!template) throw new TemplateNotFoundError(claimed.templateCd);
1404
+ const action = template.actions.find((a) => a.actionCd === actionCd);
1405
+ if (!action) throw new ActionNotFoundError(claimed.templateCd, actionCd);
1406
+ if (!isActionAllowed(action, user, claimed, { permissions: options.permissions })) {
1407
+ throw new ActionNotAllowedError();
1408
+ }
1409
+ return action;
1410
+ }
1411
+ async releaseDeniedActionClaim(claim, error) {
1412
+ const { message, actionAttemptId, actionOwnerToken } = claim;
1413
+ if (!claim.fresh) {
1414
+ await this.markActionRetryable(message._id, actionAttemptId, actionOwnerToken, error, message);
1415
+ return;
1416
+ }
1417
+ const Message = this.resolveModel("active", message);
1418
+ const updated = await Message.updateOne(
1419
+ { _id: message._id, actionState: "processing", actionAttemptId, actionOwnerToken },
1420
+ {
1421
+ $set: {
1422
+ actionState: "active",
1423
+ actionCd: null,
1424
+ actionAttemptId: null,
1425
+ actionOwnerToken: null,
1426
+ actionClaimedBy: null,
1427
+ actionClaimedAt: null,
1428
+ actionLeaseExpiresAt: null,
1429
+ actionFailureMessage: null
1430
+ }
1288
1431
  }
1289
- throw new MessageArchivedError(String(messageId));
1432
+ );
1433
+ if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
1434
+ throw new ActionConflictError(String(message._id));
1290
1435
  }
1291
- throw new ActionConflictError(String(messageId));
1292
1436
  }
1293
- async markActionRetryable(messageId, actionAttemptId, error, sourceDocument) {
1437
+ async markActionRetryable(messageId, actionAttemptId, actionOwnerToken, error, sourceDocument) {
1294
1438
  const Message = this.resolveModel("active", sourceDocument);
1295
- await Message.updateOne(
1296
- { _id: messageId, actionState: "processing", actionAttemptId },
1439
+ const updated = await Message.updateOne(
1440
+ { _id: messageId, actionState: "processing", actionAttemptId, actionOwnerToken },
1297
1441
  {
1298
1442
  $set: {
1299
1443
  actionState: "retryable",
@@ -1302,8 +1446,11 @@ var MessageService = class {
1302
1446
  }
1303
1447
  }
1304
1448
  );
1449
+ if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
1450
+ throw new ActionConflictError(String(messageId));
1451
+ }
1305
1452
  }
1306
- async archiveClaimedMessage(message, action, user, actionAttemptId) {
1453
+ async archiveClaimedMessage(message, action, user, actionAttemptId, actionOwnerToken) {
1307
1454
  const Message = this.resolveModel("active", message);
1308
1455
  const MessageArchive = this.resolveModel("archive", message, Message);
1309
1456
  const messageId = message._id;
@@ -1314,55 +1461,63 @@ var MessageService = class {
1314
1461
  delete data.actionClaimedAt;
1315
1462
  delete data.actionLeaseExpiresAt;
1316
1463
  delete data.actionFailureMessage;
1317
- const operation = async (session2) => {
1318
- await MessageArchive.create(
1319
- [
1320
- {
1321
- ...data,
1322
- actionCd: action.actionCd,
1323
- archivedBy: user._id,
1324
- archivedAt: /* @__PURE__ */ new Date(),
1325
- actionAttemptId,
1326
- actionNotificationState: notificationState,
1327
- actionNotificationError: null,
1328
- actionNotificationAttemptedAt: null
1329
- }
1330
- ],
1331
- session2 ? { session: session2, ordered: true } : void 0
1332
- );
1464
+ delete data.actionOwnerToken;
1465
+ const ownershipFilter = {
1466
+ _id: messageId,
1467
+ actionState: "processing",
1468
+ actionAttemptId,
1469
+ actionOwnerToken
1470
+ };
1471
+ const ownership = await Message.findOne(ownershipFilter);
1472
+ if (!ownership) {
1473
+ throw new ActionConflictError(String(messageId));
1474
+ }
1475
+ const operation = async (session) => {
1476
+ try {
1477
+ await MessageArchive.create(
1478
+ [
1479
+ {
1480
+ ...data,
1481
+ actionCd: action.actionCd,
1482
+ archivedBy: user._id,
1483
+ archivedAt: /* @__PURE__ */ new Date(),
1484
+ actionAttemptId,
1485
+ actionOwnerToken,
1486
+ actionNotificationState: notificationState,
1487
+ actionNotificationError: null,
1488
+ actionNotificationAttemptedAt: null
1489
+ }
1490
+ ],
1491
+ { session, ordered: true }
1492
+ );
1493
+ } catch (error) {
1494
+ if (isDuplicateKeyError(error)) {
1495
+ throw new ActionConflictError(String(messageId));
1496
+ }
1497
+ throw error;
1498
+ }
1333
1499
  const deleted = await Message.deleteOne(
1334
- { _id: messageId, actionState: "processing", actionAttemptId },
1335
- session2 ? { session: session2 } : void 0
1500
+ { _id: messageId, actionState: "processing", actionAttemptId, actionOwnerToken },
1501
+ { session }
1336
1502
  );
1337
1503
  if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
1338
1504
  throw new ActionConflictError(String(messageId));
1339
1505
  }
1340
1506
  };
1341
- const startSession = Message.db?.startSession;
1342
- if (!startSession) {
1343
- await operation();
1344
- return;
1345
- }
1346
- const session = await startSession.call(Message.db);
1347
1507
  try {
1348
- await session.withTransaction(async () => {
1349
- await operation(session);
1508
+ await runMessageTransaction(Message, operation, {
1509
+ operation: "actionArchive",
1510
+ onTransactionCleanupFailure: this.onTransactionCleanupFailure
1350
1511
  });
1351
1512
  } catch (error) {
1352
- if (this.isTransactionSupportError(error)) {
1353
- await this.markActionRetryable(messageId, actionAttemptId, error, message);
1354
- throw new MessageTransactionRequiredError(error);
1355
- }
1356
- await this.markActionRetryable(messageId, actionAttemptId, error, message);
1513
+ await this.markActionRetryable(messageId, actionAttemptId, actionOwnerToken, error, message);
1357
1514
  throw error;
1358
- } finally {
1359
- await session.endSession();
1360
1515
  }
1361
1516
  }
1362
- async markActionNotificationState(messageId, actionAttemptId, state, error, sourceDocument) {
1517
+ async markActionNotificationState(messageId, actionAttemptId, actionOwnerToken, state, error, sourceDocument) {
1363
1518
  const MessageArchive = this.resolveModel("archive", sourceDocument);
1364
- await MessageArchive.updateOne(
1365
- { _id: messageId, actionAttemptId },
1519
+ const updated = await MessageArchive.updateOne(
1520
+ { _id: messageId, actionAttemptId, actionOwnerToken },
1366
1521
  {
1367
1522
  $set: {
1368
1523
  actionNotificationState: state,
@@ -1371,22 +1526,25 @@ var MessageService = class {
1371
1526
  }
1372
1527
  }
1373
1528
  );
1529
+ if ((updated.matchedCount ?? updated.n ?? 0) !== 1) {
1530
+ throw new ActionConflictError(String(messageId));
1531
+ }
1374
1532
  }
1375
1533
  async runSenderNotification(action, ctx, message) {
1376
1534
  if (!action.senderNotification) return;
1377
- let content;
1535
+ let content2;
1378
1536
  if (typeof action.senderNotification === "function") {
1379
- content = await action.senderNotification(ctx);
1537
+ content2 = await action.senderNotification(ctx);
1380
1538
  } else {
1381
- content = action.senderNotification;
1539
+ content2 = action.senderNotification;
1382
1540
  }
1383
1541
  const senderTitle = message.senderContent?.title ?? "";
1384
- const senderNotificationContent = isString(content) ? { title: senderTitle, long: content, short: content } : {
1385
- title: content.title || senderTitle,
1386
- long: content.long,
1387
- short: content.short || content.long
1542
+ const senderNotificationContent = isString(content2) ? { title: senderTitle, long: content2, short: content2 } : {
1543
+ title: content2.title || senderTitle,
1544
+ long: content2.long,
1545
+ short: content2.short || content2.long
1388
1546
  };
1389
- const documents = !isString(content) ? content.documents || [] : [];
1547
+ const documents = !isString(content2) ? content2.documents || [] : [];
1390
1548
  if (message.fromUser) {
1391
1549
  await this.createNotification(
1392
1550
  {
@@ -1425,6 +1583,19 @@ var MessageService = class {
1425
1583
  }
1426
1584
  return value;
1427
1585
  }
1586
+ validateDurationOption(name, value, min) {
1587
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
1588
+ throw new InvalidMessageServiceOptionError(
1589
+ `${name} must be a finite safe integer in [${min}, ${MAX_MESSAGE_SERVICE_TIMEOUT_MS}] ms`
1590
+ );
1591
+ }
1592
+ if (value < min || value > MAX_MESSAGE_SERVICE_TIMEOUT_MS) {
1593
+ throw new InvalidMessageServiceOptionError(
1594
+ `${name} must be a finite safe integer in [${min}, ${MAX_MESSAGE_SERVICE_TIMEOUT_MS}] ms`
1595
+ );
1596
+ }
1597
+ return value;
1598
+ }
1428
1599
  normalizeListLimit(value) {
1429
1600
  if (value === void 0) {
1430
1601
  return this.defaultListLimit;
@@ -1451,41 +1622,53 @@ var MessageService = class {
1451
1622
  };
1452
1623
  }
1453
1624
  requireUserId(user) {
1454
- if (!user || user._id === void 0 || user._id === null) {
1455
- throw new InvalidMessageUserError();
1456
- }
1457
- if (typeof user._id === "string") {
1458
- const id = user._id.trim();
1459
- if (id.length === 0) {
1460
- throw new InvalidMessageUserError();
1461
- }
1462
- return id;
1463
- }
1464
- if (user._id instanceof mongoose5.Types.ObjectId) {
1465
- return String(user._id);
1466
- }
1467
- throw new InvalidMessageUserError();
1625
+ return requireMessageUserId(user);
1468
1626
  }
1469
1627
  async findByClientRequestScope(scope) {
1470
1628
  const Message = this.resolveModel("active");
1471
1629
  const docs = await Message.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
1472
1630
  return docs;
1473
1631
  }
1474
- async findCompletedClientRequestReplay(scope) {
1475
- const reservation = await this.findClientRequestReservation(scope);
1476
- const docs = await this.findByClientRequestScope(scope);
1477
- if (!reservation) {
1478
- if (docs.length > 0) {
1479
- throw new ClientRequestInconsistentStateError(scope.clientRequestId, "messages exist without a reservation");
1632
+ async findArchivedByClientRequestScope(scope) {
1633
+ const MessageArchive = this.resolveModel("archive");
1634
+ const docs = await MessageArchive.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
1635
+ return docs;
1636
+ }
1637
+ /**
1638
+ * Merge one active snapshot and one archive snapshot for a completed scope.
1639
+ *
1640
+ * Archival moves a document with create-then-delete ordering, so with an
1641
+ * active-then-archive read order a moving item is always visible in at least
1642
+ * one snapshot (no miss). A commit landing between the two reads surfaces
1643
+ * the same `_id` in both snapshots; deduplicating by `_id` string keeps the
1644
+ * active copy and prevents double-counting. Returns the merged batch sorted
1645
+ * by exact `clientRequestItemIndex`, or `null` when the merged set does not
1646
+ * satisfy the exact distinct-index contract.
1647
+ */
1648
+ mergeReplayBatch(active, archived, itemCount) {
1649
+ const byId = /* @__PURE__ */ new Map();
1650
+ for (const doc of [...active, ...archived]) {
1651
+ const key = String(doc._id);
1652
+ if (!byId.has(key)) {
1653
+ byId.set(key, doc);
1480
1654
  }
1481
- return null;
1482
- }
1483
- if (reservation.state === "failed") {
1484
- throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
1485
1655
  }
1486
- if (reservation.state !== "completed") {
1656
+ const docs = [...byId.values()];
1657
+ const indexes = new Set(docs.map((doc) => doc.clientRequestItemIndex));
1658
+ const hasExpectedIndexes = docs.length === itemCount && indexes.size === itemCount && Array.from({ length: itemCount }, (_, index) => indexes.has(index)).every(Boolean);
1659
+ if (!hasExpectedIndexes) {
1487
1660
  return null;
1488
1661
  }
1662
+ return docs.sort((a, b) => {
1663
+ const indexA = a.clientRequestItemIndex ?? 0;
1664
+ const indexB = b.clientRequestItemIndex ?? 0;
1665
+ if (indexA !== indexB) return indexA - indexB;
1666
+ return String(a._id).localeCompare(
1667
+ String(b._id)
1668
+ );
1669
+ });
1670
+ }
1671
+ async buildCompletedReplay(reservation, scope, cachedActive, cachedArchived) {
1489
1672
  if (reservation.itemCount === null || reservation.itemCount < 0 || !Number.isInteger(reservation.itemCount)) {
1490
1673
  throw new ClientRequestInconsistentStateError(
1491
1674
  scope.clientRequestId,
@@ -1493,7 +1676,9 @@ var MessageService = class {
1493
1676
  );
1494
1677
  }
1495
1678
  if (reservation.itemCount === 0) {
1496
- if (docs.length > 0) {
1679
+ const active2 = cachedActive ?? await this.findByClientRequestScope(scope);
1680
+ const archived2 = cachedArchived ?? await this.findArchivedByClientRequestScope(scope);
1681
+ if (active2.length > 0 || archived2.length > 0) {
1497
1682
  throw new ClientRequestInconsistentStateError(
1498
1683
  scope.clientRequestId,
1499
1684
  "completed zero-item reservation has messages"
@@ -1501,20 +1686,55 @@ var MessageService = class {
1501
1686
  }
1502
1687
  return [];
1503
1688
  }
1504
- const indexes = new Set(docs.map((doc) => doc.clientRequestItemIndex));
1505
- const hasExpectedIndexes = docs.length === reservation.itemCount && indexes.size === reservation.itemCount && Array.from({ length: reservation.itemCount }, (_, index) => indexes.has(index)).every(Boolean);
1506
- if (!hasExpectedIndexes) {
1507
- throw new ClientRequestInconsistentStateError(
1508
- scope.clientRequestId,
1509
- `completed reservation expects item indexes 0..${reservation.itemCount - 1} but found ${JSON.stringify(
1510
- Array.from(indexes)
1511
- )}`
1512
- );
1689
+ const itemCount = reservation.itemCount;
1690
+ let active = cachedActive ?? await this.findByClientRequestScope(scope);
1691
+ let archived = cachedArchived ?? await this.findArchivedByClientRequestScope(scope);
1692
+ const merged = this.mergeReplayBatch(active, archived, itemCount);
1693
+ if (merged) {
1694
+ return merged;
1695
+ }
1696
+ active = await this.findByClientRequestScope(scope);
1697
+ archived = await this.findArchivedByClientRequestScope(scope);
1698
+ const reconciled = this.mergeReplayBatch(active, archived, itemCount);
1699
+ if (reconciled) {
1700
+ return reconciled;
1701
+ }
1702
+ const indexes = new Set([...active, ...archived].map((doc) => doc.clientRequestItemIndex));
1703
+ throw new ClientRequestInconsistentStateError(
1704
+ scope.clientRequestId,
1705
+ `completed reservation expects item indexes 0..${itemCount - 1} but found ${JSON.stringify(Array.from(indexes))}`
1706
+ );
1707
+ }
1708
+ async findCompletedClientRequestReplay(scope) {
1709
+ const reservation = await this.findClientRequestReservation(scope);
1710
+ if (!reservation) {
1711
+ const active = await this.findByClientRequestScope(scope);
1712
+ const archived = await this.findArchivedByClientRequestScope(scope);
1713
+ if (active.length === 0 && archived.length === 0) {
1714
+ return null;
1715
+ }
1716
+ const reread = await this.findClientRequestReservation(scope);
1717
+ if (!reread) {
1718
+ throw new ClientRequestInconsistentStateError(scope.clientRequestId, "messages exist without a reservation");
1719
+ }
1720
+ if (reread.state === "failed") {
1721
+ throw new ClientRequestFailedError(scope.clientRequestId, reread.failureMessage);
1722
+ }
1723
+ if (reread.state !== "completed") {
1724
+ return null;
1725
+ }
1726
+ return this.buildCompletedReplay(reread, scope, active, archived);
1513
1727
  }
1514
- return docs;
1728
+ if (reservation.state === "failed") {
1729
+ throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
1730
+ }
1731
+ if (reservation.state !== "completed") {
1732
+ return null;
1733
+ }
1734
+ return this.buildCompletedReplay(reservation, scope);
1515
1735
  }
1516
1736
  async acquireClientRequestStart(scope) {
1517
- const deadline = Date.now() + this.clientRequestWaitMs;
1737
+ const deadline = this.clientRequestNow() + this.clientRequestWaitMs;
1518
1738
  while (true) {
1519
1739
  const lease = await this.tryAcquireClientRequestLease(scope);
1520
1740
  if (lease) {
@@ -1524,101 +1744,509 @@ var MessageService = class {
1524
1744
  if (replay !== null) {
1525
1745
  return { kind: "replay", replay };
1526
1746
  }
1527
- if (Date.now() >= deadline) {
1747
+ if (this.clientRequestNow() >= deadline) {
1528
1748
  throw new ClientRequestPendingError(scope.clientRequestId);
1529
1749
  }
1530
- await this.clientRequestDelay(Math.min(this.clientRequestPollMs, Math.max(deadline - Date.now(), 0)));
1750
+ await this.clientRequestDelay(
1751
+ Math.min(this.clientRequestPollMs, Math.max(deadline - this.clientRequestNow(), 0))
1752
+ );
1753
+ }
1754
+ }
1755
+ async tryAcquireClientRequestLease(scope) {
1756
+ const created = await this.tryCreateClientRequestReservation(scope);
1757
+ if (created) return created;
1758
+ return this.tryTakeOverStaleClientRequestReservation(scope);
1759
+ }
1760
+ async tryCreateClientRequestReservation(scope) {
1761
+ const MessageRequest = this.resolveModel("request");
1762
+ const ownerId = randomUUID();
1763
+ try {
1764
+ await MessageRequest.create({
1765
+ ...scope,
1766
+ state: "pending",
1767
+ itemCount: null,
1768
+ leaseOwnerId: ownerId,
1769
+ leaseExpiresAt: new Date(this.clientRequestNow() + this.clientRequestLeaseMs)
1770
+ });
1771
+ return { ownerId };
1772
+ } catch (error) {
1773
+ if (isDuplicateKeyError(error)) {
1774
+ return null;
1775
+ }
1776
+ throw error;
1777
+ }
1778
+ }
1779
+ async tryTakeOverStaleClientRequestReservation(scope) {
1780
+ const MessageRequest = this.resolveModel("request");
1781
+ const ownerId = randomUUID();
1782
+ const reservation = await MessageRequest.findOneAndUpdate(
1783
+ { ...scope, state: "pending", leaseExpiresAt: { $lte: new Date(this.clientRequestNow()) } },
1784
+ {
1785
+ $set: {
1786
+ leaseOwnerId: ownerId,
1787
+ leaseExpiresAt: new Date(this.clientRequestNow() + this.clientRequestLeaseMs)
1788
+ }
1789
+ },
1790
+ { returnDocument: "after" }
1791
+ );
1792
+ return reservation ? { ownerId } : null;
1793
+ }
1794
+ async findClientRequestReservation(scope) {
1795
+ const MessageRequest = this.resolveModel("request");
1796
+ return await MessageRequest.findOne(scope);
1797
+ }
1798
+ async failClientRequestReservation(scope, lease, error) {
1799
+ const MessageRequest = this.resolveModel("request");
1800
+ await MessageRequest.updateOne(
1801
+ { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1802
+ {
1803
+ $set: {
1804
+ state: "failed",
1805
+ failedAt: /* @__PURE__ */ new Date(),
1806
+ failureMessage: error instanceof Error ? error.message : String(error),
1807
+ leaseExpiresAt: null
1808
+ }
1809
+ }
1810
+ );
1811
+ }
1812
+ delay(ms) {
1813
+ return new Promise((resolve) => setTimeout(resolve, ms));
1814
+ }
1815
+ isArchivedMessage(message) {
1816
+ return "archivedAt" in message;
1817
+ }
1818
+ applyPopulate(query, populate) {
1819
+ if (!populate) {
1820
+ return query;
1821
+ }
1822
+ const items = Array.isArray(populate) ? populate : [populate];
1823
+ let current = query;
1824
+ for (const item of items) {
1825
+ current = current.populate(item);
1826
+ }
1827
+ return current;
1828
+ }
1829
+ async findByIdWithOptions(model, id, options) {
1830
+ const baseQuery = model.findById(id);
1831
+ const selectedQuery = options.select === void 0 ? baseQuery : baseQuery.select(options.select);
1832
+ const query = this.applyPopulate(selectedQuery, options.populate);
1833
+ return query;
1834
+ }
1835
+ };
1836
+
1837
+ // src/schemas/message.ts
1838
+ function resolveDocumentModel(document, modelName, role) {
1839
+ const model = document.constructor;
1840
+ const connection = model.db;
1841
+ if (!connection) {
1842
+ throw new Error(
1843
+ `message-service: cannot resolve ${role} model "${modelName}" because the hydrated document has no owning connection`
1844
+ );
1845
+ }
1846
+ try {
1847
+ return connection.model(modelName);
1848
+ } catch (error) {
1849
+ const connectionName = connection.name || "<unnamed>";
1850
+ const resolutionError = new Error(
1851
+ `message-service: ${role} model "${modelName}" is not registered on Mongoose connection "${connectionName}"`
1852
+ );
1853
+ resolutionError.cause = error;
1854
+ throw resolutionError;
1855
+ }
1856
+ }
1857
+ function createArchiveMethod(ctx) {
1858
+ return async function archive(actionCd, archivedBy, registry) {
1859
+ if (typeof actionCd !== "string" || actionCd.length === 0) {
1860
+ throw new ActionNotFoundError(
1861
+ String(this.templateCd ?? ""),
1862
+ String(actionCd)
1863
+ );
1864
+ }
1865
+ if (!isValidMessageUserId(archivedBy)) {
1866
+ throw new InvalidMessageUserError();
1867
+ }
1868
+ if (!registry || typeof registry.find !== "function") {
1869
+ throw new TemplateNotFoundError(String(this.templateCd ?? ""));
1870
+ }
1871
+ const templateCd = this.templateCd;
1872
+ const template = registry.find(templateCd);
1873
+ if (!template) {
1874
+ throw new TemplateNotFoundError(templateCd);
1875
+ }
1876
+ if (!includesAction(templateCd, actionCd, registry)) {
1877
+ throw new ActionNotFoundError(templateCd, actionCd);
1878
+ }
1879
+ const activeModel = this.constructor;
1880
+ const connection = activeModel.db;
1881
+ if (!connection || typeof connection.startSession !== "function") {
1882
+ throw new MessageTransactionRequiredError(
1883
+ new Error("message-service: direct archive() requires a Mongoose connection with session/transaction support")
1884
+ );
1885
+ }
1886
+ const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
1887
+ const ActiveTyped = activeModel;
1888
+ const messageId = this._id;
1889
+ const existingSession = typeof this.$session === "function" ? this.$session() : null;
1890
+ const run = async (session) => {
1891
+ const now = /* @__PURE__ */ new Date();
1892
+ const fresh = await ActiveTyped.findOne({ _id: messageId }).session(session).lean();
1893
+ if (!fresh) {
1894
+ const archivedMatch = await MessageArchive.findOne({ _id: messageId }).session(session).select("_id").lean().catch(() => null);
1895
+ if (archivedMatch) {
1896
+ throw new MessageArchivedError(String(messageId));
1897
+ }
1898
+ throw new MessageNotFoundError(String(messageId));
1899
+ }
1900
+ const state = fresh.actionState;
1901
+ const leaseExpiresAt = fresh.actionLeaseExpiresAt;
1902
+ const liveLease = state === "processing" && leaseExpiresAt instanceof Date && leaseExpiresAt.getTime() > now.getTime();
1903
+ if (state === "processing" && !liveLease) {
1904
+ const expired = leaseExpiresAt instanceof Date && leaseExpiresAt.getTime() <= now.getTime();
1905
+ if (!expired) {
1906
+ throw new ActionConflictError(String(messageId));
1907
+ }
1908
+ } else if (liveLease) {
1909
+ throw new ActionConflictError(String(messageId));
1910
+ }
1911
+ const source = { ...fresh };
1912
+ delete source.actionState;
1913
+ delete source.actionClaimedBy;
1914
+ delete source.actionClaimedAt;
1915
+ delete source.actionLeaseExpiresAt;
1916
+ delete source.actionFailureMessage;
1917
+ const archiveDoc = {
1918
+ ...source,
1919
+ actionCd,
1920
+ archivedBy,
1921
+ archivedAt: /* @__PURE__ */ new Date(),
1922
+ actionNotificationState: "none",
1923
+ actionNotificationError: null,
1924
+ actionNotificationAttemptedAt: null
1925
+ };
1926
+ try {
1927
+ await MessageArchive.create([archiveDoc], { session, ordered: true });
1928
+ } catch (error) {
1929
+ if (isDuplicateKeyError(error)) {
1930
+ throw new MessageArchivedError(String(messageId));
1931
+ }
1932
+ throw error;
1933
+ }
1934
+ const deleted = await ActiveTyped.deleteOne(
1935
+ {
1936
+ _id: messageId,
1937
+ $or: [
1938
+ { actionState: "active" },
1939
+ { actionState: "retryable" },
1940
+ { actionState: "processing", actionLeaseExpiresAt: { $lte: now } },
1941
+ { actionState: null },
1942
+ { actionState: { $exists: false } }
1943
+ ]
1944
+ },
1945
+ { session }
1946
+ );
1947
+ if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
1948
+ throw new ActionConflictError(String(messageId));
1949
+ }
1950
+ };
1951
+ try {
1952
+ await runMessageTransaction(activeModel, run, {
1953
+ operation: "directArchive",
1954
+ session: existingSession ?? void 0,
1955
+ onTransactionCleanupFailure: ctx.onTransactionCleanupFailure
1956
+ });
1957
+ } catch (error) {
1958
+ if (isDuplicateKeyError(error)) {
1959
+ throw new MessageArchivedError(String(messageId));
1960
+ }
1961
+ throw error;
1962
+ }
1963
+ };
1964
+ }
1965
+ var EMAIL_WAS_NEW_LOCAL = "messageServiceEmailWasNew";
1966
+ function normalizeEmailTitle(value) {
1967
+ return value.trim().toLowerCase();
1968
+ }
1969
+ function createEmailStateCaptureHook() {
1970
+ return function captureEmailState() {
1971
+ this.$locals[EMAIL_WAS_NEW_LOCAL] = this.isNew;
1972
+ };
1973
+ }
1974
+ function createPostSaveEmailHook(ctx) {
1975
+ return async function sendNotificationEmail() {
1976
+ if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
1977
+ return;
1978
+ }
1979
+ if (this.$session()) {
1980
+ return;
1981
+ }
1982
+ if (!this.toUser || !this.receiverContent?.title) {
1983
+ return;
1984
+ }
1985
+ const title = this.receiverContent.title.trim();
1986
+ if (!title || ctx.emailNotificationExclusions.includes(normalizeEmailTitle(title))) {
1987
+ return;
1988
+ }
1989
+ let user;
1990
+ try {
1991
+ const User = resolveDocumentModel(this, ctx.userModelName, "user");
1992
+ user = await User.findById(this.toUser).select("email").lean();
1993
+ } catch (error) {
1994
+ await reportEmailFailure(ctx, "recipientLookup", error, this, title);
1995
+ return;
1531
1996
  }
1532
- }
1533
- async tryAcquireClientRequestLease(scope) {
1534
- const created = await this.tryCreateClientRequestReservation(scope);
1535
- if (created) return created;
1536
- return this.tryTakeOverStaleClientRequestReservation(scope);
1537
- }
1538
- async tryCreateClientRequestReservation(scope) {
1539
- const MessageRequest = this.resolveModel("request");
1540
- const ownerId = randomUUID();
1997
+ if (!user?.email) {
1998
+ return;
1999
+ }
2000
+ const long = this.receiverContent.long || "";
2001
+ const short = this.receiverContent.short || "";
2002
+ const body = long.length > short.length ? long : short;
1541
2003
  try {
1542
- await MessageRequest.create({
1543
- ...scope,
1544
- state: "pending",
1545
- itemCount: null,
1546
- leaseOwnerId: ownerId,
1547
- leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
1548
- });
1549
- return { ownerId };
2004
+ await ctx.emailNotifier(user.email, title, body);
1550
2005
  } catch (error) {
1551
- if (this.isDuplicateKeyError(error)) {
1552
- return null;
1553
- }
1554
- throw error;
2006
+ await reportEmailFailure(ctx, "notifier", error, this, title);
1555
2007
  }
2008
+ };
2009
+ }
2010
+ async function reportEmailFailure(ctx, stage, error, message, title) {
2011
+ try {
2012
+ await ctx.onEmailDeliveryFailure?.({
2013
+ stage,
2014
+ error,
2015
+ messageId: message._id,
2016
+ recipientId: message.toUser,
2017
+ title
2018
+ });
2019
+ } catch {
1556
2020
  }
1557
- async tryTakeOverStaleClientRequestReservation(scope) {
1558
- const MessageRequest = this.resolveModel("request");
1559
- const ownerId = randomUUID();
1560
- const reservation = await MessageRequest.findOneAndUpdate(
1561
- { ...scope, state: "pending", leaseExpiresAt: { $lte: /* @__PURE__ */ new Date() } },
1562
- {
1563
- $set: {
1564
- leaseOwnerId: ownerId,
1565
- leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
1566
- }
1567
- },
1568
- { returnDocument: "after" }
2021
+ }
2022
+ function assertModelRegistered(connection, name, role) {
2023
+ if (!connection.modelNames().includes(name)) {
2024
+ const connectionName = "name" in connection && connection.name ? connection.name : "global mongoose";
2025
+ throw new Error(
2026
+ `message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered on Mongoose connection "${connectionName}". Register the model on that connection before buildMessageSchema().`
1569
2027
  );
1570
- return reservation ? { ownerId } : null;
1571
2028
  }
1572
- async findClientRequestReservation(scope) {
1573
- const MessageRequest = this.resolveModel("request");
1574
- return await MessageRequest.findOne(scope);
2029
+ }
2030
+ function resolveConfig(config) {
2031
+ return {
2032
+ onTransactionCleanupFailure: config?.onTransactionCleanupFailure,
2033
+ emailNotifier: config?.emailNotifier ?? null,
2034
+ onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
2035
+ emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
2036
+ userModelName: config?.userModelName ?? "User",
2037
+ archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
2038
+ connection: config?.connection ?? mongoose3
2039
+ };
2040
+ }
2041
+ function buildMessageSchema(config) {
2042
+ const resolved = resolveConfig(config);
2043
+ if (resolved.emailNotifier) {
2044
+ assertModelRegistered(resolved.connection, resolved.userModelName, "user");
1575
2045
  }
1576
- async failClientRequestReservation(scope, lease, error) {
1577
- const MessageRequest = this.resolveModel("request");
1578
- await MessageRequest.updateOne(
1579
- { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1580
- {
1581
- $set: {
1582
- state: "failed",
1583
- failedAt: /* @__PURE__ */ new Date(),
1584
- failureMessage: error instanceof Error ? error.message : String(error),
1585
- leaseExpiresAt: null
1586
- }
2046
+ const schema = new mongoose3.Schema(BaseMessageFields, {
2047
+ timestamps: true
2048
+ });
2049
+ schema.index({ fromUser: 1, createdAt: -1, _id: -1 });
2050
+ schema.index({ toUser: 1, createdAt: -1, _id: -1 });
2051
+ schema.index({ toRoles: 1, createdAt: -1, _id: -1 });
2052
+ schema.index({ actionState: 1, actionLeaseExpiresAt: 1 });
2053
+ schema.index(
2054
+ { actionAttemptId: 1 },
2055
+ {
2056
+ unique: true,
2057
+ partialFilterExpression: { actionAttemptId: { $type: "string" } }
2058
+ }
2059
+ );
2060
+ schema.index(
2061
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
2062
+ {
2063
+ partialFilterExpression: {
2064
+ clientRequestId: { $type: "string" },
2065
+ clientRequestOwnerId: { $type: "string" },
2066
+ templateCd: { $type: "string" }
2067
+ }
2068
+ }
2069
+ );
2070
+ schema.index(
2071
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
2072
+ {
2073
+ unique: true,
2074
+ partialFilterExpression: {
2075
+ clientRequestId: { $type: "string" },
2076
+ clientRequestOwnerId: { $type: "string" },
2077
+ templateCd: { $type: "string" },
2078
+ clientRequestItemIndex: { $type: "number" }
1587
2079
  }
2080
+ }
2081
+ );
2082
+ schema.methods.isSender = isSender;
2083
+ schema.methods.isReceiver = isReceiver;
2084
+ schema.methods.archive = createArchiveMethod({
2085
+ archiveModelName: resolved.archiveModelName,
2086
+ onTransactionCleanupFailure: resolved.onTransactionCleanupFailure
2087
+ });
2088
+ if (resolved.emailNotifier) {
2089
+ schema.pre("save", createEmailStateCaptureHook());
2090
+ schema.post(
2091
+ "save",
2092
+ createPostSaveEmailHook({
2093
+ emailNotifier: resolved.emailNotifier,
2094
+ onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
2095
+ emailNotificationExclusions: resolved.emailNotificationExclusions,
2096
+ userModelName: resolved.userModelName
2097
+ })
1588
2098
  );
1589
2099
  }
1590
- isDuplicateKeyError(error) {
1591
- return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
1592
- }
1593
- isTransactionSupportError(error) {
1594
- if (!(error instanceof Error)) return false;
1595
- return error.message.includes("Transaction numbers are only allowed") || error.message.includes("Transaction is not supported") || error.message.includes("transactions are not supported");
1596
- }
1597
- delay(ms) {
1598
- return new Promise((resolve) => setTimeout(resolve, ms));
1599
- }
1600
- isArchivedMessage(message) {
1601
- return "archivedAt" in message;
1602
- }
1603
- applyPopulate(query, populate) {
1604
- if (!populate) {
1605
- return query;
2100
+ return schema;
2101
+ }
2102
+ var MessageSchema = buildMessageSchema();
2103
+
2104
+ // src/schemas/message-archive.ts
2105
+ import mongoose4 from "mongoose";
2106
+ function buildMessageArchiveSchema() {
2107
+ const schema = new mongoose4.Schema(
2108
+ {
2109
+ ...BaseMessageFields,
2110
+ actionCd: { type: String, default: "" },
2111
+ archivedBy: { type: mongoose4.Schema.Types.ObjectId, default: null, index: true },
2112
+ archivedAt: { type: Date, default: Date.now },
2113
+ actionAttemptId: { type: String, default: null, index: true },
2114
+ actionNotificationState: { type: String, enum: ["none", "pending", "sent", "failed"], default: "none" },
2115
+ actionNotificationError: { type: String, default: null },
2116
+ actionNotificationAttemptedAt: { type: Date, default: null }
2117
+ },
2118
+ {
2119
+ timestamps: true
1606
2120
  }
1607
- const items = Array.isArray(populate) ? populate : [populate];
1608
- let current = query;
1609
- for (const item of items) {
1610
- current = current.populate(item);
2121
+ );
2122
+ schema.index({ createdAt: 1 });
2123
+ schema.index(
2124
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
2125
+ {
2126
+ partialFilterExpression: {
2127
+ clientRequestId: { $type: "string" },
2128
+ clientRequestOwnerId: { $type: "string" },
2129
+ templateCd: { $type: "string" }
2130
+ }
1611
2131
  }
1612
- return current;
2132
+ );
2133
+ schema.index(
2134
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
2135
+ {
2136
+ unique: true,
2137
+ partialFilterExpression: {
2138
+ clientRequestId: { $type: "string" },
2139
+ clientRequestOwnerId: { $type: "string" },
2140
+ templateCd: { $type: "string" },
2141
+ clientRequestItemIndex: { $type: "number" }
2142
+ }
2143
+ }
2144
+ );
2145
+ schema.methods.isSender = isSender;
2146
+ schema.methods.isReceiver = isReceiver;
2147
+ return schema;
2148
+ }
2149
+ var MessageArchiveSchema = buildMessageArchiveSchema();
2150
+
2151
+ // src/schemas/message-request.ts
2152
+ import mongoose5 from "mongoose";
2153
+ function buildMessageRequestSchema() {
2154
+ const schema = new mongoose5.Schema(
2155
+ {
2156
+ clientRequestId: { type: String, required: true },
2157
+ clientRequestOwnerId: { type: String, required: true },
2158
+ templateCd: { type: String, required: true },
2159
+ state: { type: String, enum: ["pending", "completed", "failed"], default: "pending" },
2160
+ itemCount: { type: Number, default: null },
2161
+ leaseOwnerId: { type: String, default: null },
2162
+ leaseExpiresAt: { type: Date, default: null },
2163
+ completedAt: { type: Date, default: null },
2164
+ failedAt: { type: Date, default: null },
2165
+ failureMessage: { type: String, default: null }
2166
+ },
2167
+ {
2168
+ timestamps: true
2169
+ }
2170
+ );
2171
+ schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
2172
+ schema.index({ updatedAt: 1 });
2173
+ schema.index({ state: 1, leaseExpiresAt: 1 });
2174
+ return schema;
2175
+ }
2176
+ var MessageRequestSchema = buildMessageRequestSchema();
2177
+
2178
+ // src/providers/email.ts
2179
+ var NoopEmailProvider = class {
2180
+ async sendNotification(_to, _title, _body) {
1613
2181
  }
1614
- async findByIdWithOptions(model, id, options) {
1615
- const baseQuery = model.findById(id);
1616
- const selectedQuery = options.select === void 0 ? baseQuery : baseQuery.select(options.select);
1617
- const query = this.applyPopulate(selectedQuery, options.populate);
1618
- return query;
2182
+ };
2183
+
2184
+ // src/providers/payment.ts
2185
+ var NoopPaymentProvider = class {
2186
+ async createSession(_user, _code, _priceArgs) {
2187
+ return null;
2188
+ }
2189
+ async expireSession(_sessionId) {
2190
+ }
2191
+ async refundPayment(_sessionId) {
1619
2192
  }
1620
2193
  };
1621
2194
 
2195
+ // src/public-message.ts
2196
+ import { Types as Types2 } from "mongoose";
2197
+ function referenceId(value) {
2198
+ if (typeof value === "string" && value.length > 0) return value;
2199
+ if (value instanceof Types2.ObjectId) return value.toHexString();
2200
+ return null;
2201
+ }
2202
+ function populatedId(value) {
2203
+ return referenceId(value) ?? (value && typeof value === "object" && "_id" in value ? referenceId(value._id) : null);
2204
+ }
2205
+ function party(value) {
2206
+ const id = referenceId(value);
2207
+ if (id !== null) return id;
2208
+ if (!value || typeof value !== "object" || !("_id" in value)) return null;
2209
+ const populated = referenceId(value._id);
2210
+ if (populated === null) return null;
2211
+ const result = { _id: populated };
2212
+ if ("displayName" in value && typeof value.displayName === "string") result.displayName = value.displayName;
2213
+ if ("email" in value && typeof value.email === "string") result.email = value.email;
2214
+ return result;
2215
+ }
2216
+ function content(value) {
2217
+ return { title: value.title, long: value.long, short: value.short };
2218
+ }
2219
+ function serializePublicMessage(message) {
2220
+ const id = referenceId(message._id);
2221
+ if (id === null) throw new TypeError("Message must have a string or ObjectId _id");
2222
+ const result = {
2223
+ _id: id,
2224
+ templateCd: message.templateCd,
2225
+ type: message.type,
2226
+ fromUser: party(message.fromUser),
2227
+ toUser: party(message.toUser),
2228
+ toRoles: [...message.toRoles],
2229
+ senderContent: content(message.senderContent),
2230
+ receiverContent: content(message.receiverContent),
2231
+ documents: message.documents.map(populatedId).filter((value) => value !== null),
2232
+ paymentSession: message.paymentSession,
2233
+ paymentCd: message.paymentCd,
2234
+ payload: message.payload,
2235
+ display: message.display,
2236
+ actionState: message.actionState,
2237
+ actionCd: message.actionCd,
2238
+ actionAttemptId: message.actionAttemptId,
2239
+ createdAt: message.createdAt.toISOString(),
2240
+ updatedAt: message.updatedAt.toISOString()
2241
+ };
2242
+ if (message.archivedAt != null) {
2243
+ result.archivedAt = message.archivedAt.toISOString();
2244
+ result.archivedBy = populatedId(message.archivedBy);
2245
+ result.actionNotificationState = message.actionNotificationState ?? "none";
2246
+ }
2247
+ return result;
2248
+ }
2249
+
1622
2250
  // src/route-factory.ts
1623
2251
  import JsonRouter from "@web-ts-toolkit/express-json-router";
1624
2252
  var ACTION_CD_PATTERN = /^[a-zA-Z0-9_-]+$/;
@@ -1678,7 +2306,9 @@ function assertValidBody(body) {
1678
2306
  }
1679
2307
  }
1680
2308
  function requireUser(user) {
1681
- if (!user || user._id === void 0 || user._id === null || String(user._id).trim().length === 0) {
2309
+ try {
2310
+ requireMessageUserId(user);
2311
+ } catch {
1682
2312
  throw new JsonRouter.clientErrors.UnauthorizedError("authentication required");
1683
2313
  }
1684
2314
  return user;
@@ -1714,28 +2344,75 @@ function mapServiceError(error) {
1714
2344
  if (error instanceof InvalidMessageUserError) {
1715
2345
  throw new JsonRouter.clientErrors.UnauthorizedError("authentication required");
1716
2346
  }
1717
- if (error instanceof ClientRequestPendingError || error instanceof ClientRequestFailedError) {
2347
+ if (error instanceof ClientRequestPendingError) {
1718
2348
  throw new JsonRouter.clientErrors.ConflictError(error.message);
1719
2349
  }
2350
+ if (error instanceof ClientRequestFailedError) {
2351
+ throw new JsonRouter.clientErrors.ConflictError(
2352
+ `clientRequestId "${error.clientRequestId}" previously failed; retry with a new clientRequestId`
2353
+ );
2354
+ }
1720
2355
  if (isMongooseCastError(error)) {
1721
2356
  throw new JsonRouter.clientErrors.BadRequestError("id must be a valid ObjectId");
1722
2357
  }
1723
2358
  throw error;
1724
2359
  }
2360
+ var SERVICE_CONSTRUCTION_KEYS = [
2361
+ "getModel",
2362
+ "connection",
2363
+ "modelNames",
2364
+ "paymentProvider",
2365
+ "onPaymentCompensationFailure",
2366
+ "onTransactionCleanupFailure",
2367
+ "adminRoles",
2368
+ "registry",
2369
+ "defaultListLimit",
2370
+ "maxListLimit",
2371
+ "clientRequestLeaseMs",
2372
+ "clientRequestWaitMs",
2373
+ "clientRequestPollMs",
2374
+ "clientRequestDelay",
2375
+ "clientRequestNow"
2376
+ ];
1725
2377
  function createMessageRoutes(options) {
1726
2378
  const {
1727
- getModel,
1728
- paymentProvider = null,
1729
- onPaymentCompensationFailure,
1730
- adminRoles,
1731
- registry,
1732
2379
  authMiddleware = [],
1733
2380
  getUser = defaultGetUser,
1734
2381
  getPermissions = defaultGetPermissions,
1735
2382
  getIdentity = defaultGetIdentity,
1736
2383
  adminPermissionKey = "is.admin"
1737
2384
  } = options;
1738
- const service = new MessageService({ getModel, paymentProvider, onPaymentCompensationFailure, adminRoles, registry });
2385
+ let service;
2386
+ if (options.service !== void 0) {
2387
+ const conflicts = SERVICE_CONSTRUCTION_KEYS.filter(
2388
+ (key) => options[key] !== void 0
2389
+ );
2390
+ if (conflicts.length > 0) {
2391
+ throw new InvalidMessageServiceOptionError(
2392
+ `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".`
2393
+ );
2394
+ }
2395
+ service = options.service;
2396
+ } else {
2397
+ const construction = options;
2398
+ service = new MessageService({
2399
+ getModel: construction.getModel,
2400
+ connection: construction.connection,
2401
+ modelNames: construction.modelNames,
2402
+ paymentProvider: construction.paymentProvider,
2403
+ onPaymentCompensationFailure: construction.onPaymentCompensationFailure,
2404
+ onTransactionCleanupFailure: construction.onTransactionCleanupFailure,
2405
+ adminRoles: construction.adminRoles,
2406
+ registry: construction.registry,
2407
+ defaultListLimit: construction.defaultListLimit,
2408
+ maxListLimit: construction.maxListLimit,
2409
+ clientRequestLeaseMs: construction.clientRequestLeaseMs,
2410
+ clientRequestWaitMs: construction.clientRequestWaitMs,
2411
+ clientRequestPollMs: construction.clientRequestPollMs,
2412
+ clientRequestDelay: construction.clientRequestDelay,
2413
+ clientRequestNow: construction.clientRequestNow
2414
+ });
2415
+ }
1739
2416
  const router = new JsonRouter("", authMiddleware);
1740
2417
  router.post("/new/:templateCd", async (req) => {
1741
2418
  const templateCd = req.params.templateCd;
@@ -1750,7 +2427,7 @@ function createMessageRoutes(options) {
1750
2427
  const hasClientRequestId = Object.prototype.hasOwnProperty.call(body, "clientRequestId");
1751
2428
  assertValidClientRequestId(hasClientRequestId ? clientRequestId : void 0);
1752
2429
  try {
1753
- return await service.createMessage({
2430
+ const messages = await service.createMessage({
1754
2431
  templateCd,
1755
2432
  user,
1756
2433
  roles,
@@ -1761,6 +2438,7 @@ function createMessageRoutes(options) {
1761
2438
  req,
1762
2439
  clientRequestId: hasClientRequestId ? clientRequestId : void 0
1763
2440
  });
2441
+ return messages.map(serializePublicMessage);
1764
2442
  } catch (error) {
1765
2443
  mapServiceError(error);
1766
2444
  }
@@ -1772,7 +2450,7 @@ function createMessageRoutes(options) {
1772
2450
  assertValidMessageId(id);
1773
2451
  assertValidUsertype(usertype);
1774
2452
  const permissions = getPermissions(req);
1775
- const isAdmin = !!permissions[adminPermissionKey];
2453
+ const isAdmin = hasExplicitPermissionGrant(permissions, adminPermissionKey);
1776
2454
  let result;
1777
2455
  try {
1778
2456
  result = await service.getActions(id, usertype, { permissions, user, isAdmin });
@@ -1832,8 +2510,10 @@ export {
1832
2510
  ClientRequestPendingError,
1833
2511
  GENERIC_NOTIFICATION_TEMPLATE_CD,
1834
2512
  InvalidClientRequestIdError,
2513
+ InvalidMessageServiceOptionError,
1835
2514
  InvalidMessageUserError,
1836
2515
  InvalidPaginationValueError,
2516
+ MAX_MESSAGE_SERVICE_TIMEOUT_MS,
1837
2517
  MESSAGE_ARCHIVE_MODEL_NAME,
1838
2518
  MESSAGE_MODEL_NAME,
1839
2519
  MESSAGE_REQUEST_MODEL_NAME,
@@ -1848,6 +2528,7 @@ export {
1848
2528
  MessageTransactionRequiredError,
1849
2529
  NoopEmailProvider,
1850
2530
  NoopPaymentProvider,
2531
+ PaymentSessionCompensationAggregateError,
1851
2532
  PaymentSessionCompensationError,
1852
2533
  TemplateNotFoundError,
1853
2534
  TemplateRegistry,
@@ -1858,7 +2539,13 @@ export {
1858
2539
  createMessageRoutes,
1859
2540
  defaultRegistry,
1860
2541
  filterActions,
2542
+ hasExplicitPermissionGrant,
1861
2543
  includesAction,
2544
+ interpolateMessageContent,
1862
2545
  interpolateTemplate,
1863
- isActionAllowed
2546
+ isActionAllowed,
2547
+ isValidMessageUserId,
2548
+ requireMessageUserId,
2549
+ resolveUiTemplate,
2550
+ serializePublicMessage
1864
2551
  };