@web-ts-toolkit/message-service 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +161 -29
  2. package/index.d.mts +1118 -36
  3. package/index.d.ts +1118 -36
  4. package/index.js +1153 -157
  5. package/index.mjs +1139 -157
  6. package/package.json +8 -4
package/index.mjs CHANGED
@@ -28,13 +28,105 @@ var BaseMessageFields = {
28
28
  payload: { type: mongoose.Schema.Types.Mixed, default: {} },
29
29
  display: { type: mongoose.Schema.Types.Mixed, default: {} },
30
30
  clientRequestId: { type: String, default: null },
31
- clientRequestItemIndex: { type: Number, default: null }
31
+ clientRequestOwnerId: { type: String, default: null },
32
+ clientRequestItemIndex: { type: Number, default: null },
33
+ actionState: { type: String, enum: ["active", "processing", "retryable"], default: "active" },
34
+ actionCd: { type: String, default: null },
35
+ actionAttemptId: { type: String, default: null },
36
+ actionClaimedBy: { type: String, default: null },
37
+ actionClaimedAt: { type: Date, default: null },
38
+ actionLeaseExpiresAt: { type: Date, default: null },
39
+ actionFailureMessage: { type: String, default: null }
32
40
  };
33
41
 
34
42
  // src/schemas/message.ts
35
43
  import mongoose2 from "mongoose";
36
44
 
45
+ // src/runtime-contract.ts
46
+ var ERROR_BRAND_PREFIX = "@web-ts-toolkit/message-service/error/";
47
+ function markRuntimeError(error, name) {
48
+ Object.defineProperty(error, /* @__PURE__ */ Symbol.for(`${ERROR_BRAND_PREFIX}${name}`), {
49
+ configurable: false,
50
+ enumerable: false,
51
+ value: true
52
+ });
53
+ }
54
+ function isRuntimeError(value, name) {
55
+ if (!value || typeof value !== "object") return false;
56
+ return value.name === name && value[/* @__PURE__ */ Symbol.for(`${ERROR_BRAND_PREFIX}${name}`)] === true;
57
+ }
58
+
37
59
  // src/template-registry.ts
60
+ var TemplateRegistryValidationError = class extends Error {
61
+ static [Symbol.hasInstance](value) {
62
+ return isRuntimeError(value, "TemplateRegistryValidationError");
63
+ }
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = "TemplateRegistryValidationError";
67
+ markRuntimeError(this, this.name);
68
+ }
69
+ };
70
+ function requireNonEmptyString(value, field, templateCd) {
71
+ if (typeof value !== "string" || value.trim() === "") {
72
+ throw new TemplateRegistryValidationError(
73
+ `${templateCd ? `template "${templateCd}" ` : ""}${field} must be a non-empty string`
74
+ );
75
+ }
76
+ return value;
77
+ }
78
+ function validateTemplate(template) {
79
+ const templateCd = requireNonEmptyString(template.templateCd, "templateCd");
80
+ if (!Array.isArray(template.actions)) {
81
+ throw new TemplateRegistryValidationError(`template "${templateCd}" actions must be an array`);
82
+ }
83
+ const seenActionCodes = /* @__PURE__ */ new Set();
84
+ const defaultByUsertype = /* @__PURE__ */ new Map();
85
+ for (const action of template.actions) {
86
+ const actionCd = requireNonEmptyString(action.actionCd, "actionCd", templateCd);
87
+ if (seenActionCodes.has(actionCd)) {
88
+ throw new TemplateRegistryValidationError(`template "${templateCd}" has duplicate actionCd "${actionCd}"`);
89
+ }
90
+ seenActionCodes.add(actionCd);
91
+ requireNonEmptyString(action.name, `action "${actionCd}" name`, templateCd);
92
+ requireNonEmptyString(action.variant, `action "${actionCd}" variant`, templateCd);
93
+ if (!action.sender && !action.receiver) {
94
+ throw new TemplateRegistryValidationError(
95
+ `template "${templateCd}" action "${actionCd}" must be available to sender or receiver`
96
+ );
97
+ }
98
+ if (action.isDefault) {
99
+ for (const usertype of ["sender", "receiver"]) {
100
+ if (!action[usertype]) continue;
101
+ const existingDefault = defaultByUsertype.get(usertype);
102
+ if (existingDefault) {
103
+ throw new TemplateRegistryValidationError(
104
+ `template "${templateCd}" has multiple default ${usertype} actions: "${existingDefault}" and "${actionCd}"`
105
+ );
106
+ }
107
+ defaultByUsertype.set(usertype, actionCd);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ function snapshotTemplate(template) {
113
+ const snapshot = {
114
+ ...template,
115
+ senderContent: Object.freeze({ ...template.senderContent }),
116
+ receiverContent: Object.freeze({ ...template.receiverContent }),
117
+ uiTemplate: typeof template.uiTemplate === "string" ? template.uiTemplate : Object.freeze({ ...template.uiTemplate }),
118
+ actions: Object.freeze(
119
+ template.actions.map(
120
+ (action) => Object.freeze({
121
+ ...action,
122
+ confirmation: action.confirmation ? Object.freeze({ ...action.confirmation }) : void 0,
123
+ payload: action.payload ? Object.freeze({ ...action.payload }) : void 0
124
+ })
125
+ )
126
+ )
127
+ };
128
+ return Object.freeze(snapshot);
129
+ }
38
130
  var TemplateRegistry = class {
39
131
  constructor() {
40
132
  this.templates = /* @__PURE__ */ new Map();
@@ -43,7 +135,8 @@ var TemplateRegistry = class {
43
135
  * Register a template. Overwrites if templateCd already exists.
44
136
  */
45
137
  register(template) {
46
- this.templates.set(template.templateCd, template);
138
+ validateTemplate(template);
139
+ this.templates.set(template.templateCd, snapshotTemplate(template));
47
140
  }
48
141
  /**
49
142
  * Register multiple templates at once.
@@ -90,7 +183,9 @@ var TemplateRegistry = class {
90
183
  this.templates.clear();
91
184
  }
92
185
  };
93
- var defaultRegistry = new TemplateRegistry();
186
+ var DEFAULT_REGISTRY_KEY = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/message-service/defaultRegistry");
187
+ var registryGlobal = globalThis;
188
+ var defaultRegistry = registryGlobal[DEFAULT_REGISTRY_KEY] ??= new TemplateRegistry();
94
189
  function includesAction(templateCd, actionCd, registry) {
95
190
  const template = registry.find(templateCd);
96
191
  if (!template) return false;
@@ -99,20 +194,53 @@ function includesAction(templateCd, actionCd, registry) {
99
194
 
100
195
  // src/schemas/methods.ts
101
196
  function isSender(user) {
102
- return String(this.fromUser) === String(user.id || user._id);
197
+ const fromUser = normalizeStoredUserId(this.fromUser);
198
+ const userId = normalizeMessageUserId(user);
199
+ return fromUser !== null && userId !== null && fromUser === userId;
103
200
  }
104
201
  function isReceiver(user) {
105
- const id = String(user.id || user._id);
106
- return String(this.toUser) === id || this.toRoles.some((r) => user.roles?.includes(r));
202
+ const toUser = normalizeStoredUserId(this.toUser);
203
+ const userId = normalizeMessageUserId(user);
204
+ return toUser !== null && userId !== null && toUser === userId || this.toRoles.some((r) => user.roles?.includes(r));
205
+ }
206
+ function normalizeMessageUserId(user) {
207
+ return normalizeStoredUserId(user?._id ?? null);
208
+ }
209
+ function normalizeStoredUserId(value) {
210
+ if (value === null || value === void 0) return null;
211
+ if (typeof value === "string") {
212
+ const trimmed = value.trim();
213
+ return trimmed.length > 0 ? trimmed : null;
214
+ }
215
+ return String(value);
107
216
  }
108
217
 
109
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
+ );
226
+ }
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;
236
+ }
237
+ }
110
238
  function createArchiveMethod(ctx) {
111
239
  return function archive(actionCd, archivedBy, registry) {
112
240
  if (!includesAction(this.templateCd, actionCd, registry) || !archivedBy) {
113
241
  return Promise.resolve();
114
242
  }
115
- const MessageArchive = mongoose2.model(ctx.archiveModelName);
243
+ const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
116
244
  const data = this.toObject();
117
245
  return MessageArchive.create({
118
246
  ...data,
@@ -123,60 +251,118 @@ function createArchiveMethod(ctx) {
123
251
  });
124
252
  };
125
253
  }
126
- function createPreSaveHook(ctx) {
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) {
127
264
  return async function sendNotificationEmail() {
265
+ if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
266
+ return;
267
+ }
268
+ if (this.$session()) {
269
+ return;
270
+ }
128
271
  if (!this.toUser || !this.receiverContent?.title) {
129
272
  return;
130
273
  }
131
274
  const title = this.receiverContent.title.trim();
132
- if (ctx.emailNotificationExclusions.includes(title.toLowerCase())) {
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) {
133
287
  return;
134
288
  }
289
+ const long = this.receiverContent.long || "";
290
+ const short = this.receiverContent.short || "";
291
+ const body = long.length > short.length ? long : short;
135
292
  try {
136
- const User = mongoose2.model(ctx.userModelName);
137
- const user = await User.findById(this.toUser).select("email").lean();
138
- if (!user?.email) {
139
- return;
140
- }
141
- const long = this.receiverContent.long || "";
142
- const short = this.receiverContent.short || "";
143
- const body = long.length > short.length ? long : short;
144
293
  await ctx.emailNotifier(user.email, title, body);
145
- } catch {
294
+ } catch (error) {
295
+ await reportEmailFailure(ctx, "notifier", error, this, title);
146
296
  }
147
297
  };
148
298
  }
149
- function assertModelRegistered(name, role) {
150
- if (!mongoose2.modelNames().includes(name)) {
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 {
309
+ }
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";
151
314
  throw new Error(
152
- `message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered with Mongoose. Call mongoose.model("${name}", ...) before buildMessageSchema().`
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().`
153
316
  );
154
317
  }
155
318
  }
156
319
  function resolveConfig(config) {
157
320
  return {
158
321
  emailNotifier: config?.emailNotifier ?? null,
159
- emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map((e) => e.toLowerCase()),
322
+ onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
323
+ emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
160
324
  userModelName: config?.userModelName ?? "User",
161
- archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME
325
+ archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
326
+ connection: config?.connection ?? mongoose2
162
327
  };
163
328
  }
164
329
  function buildMessageSchema(config) {
165
330
  const resolved = resolveConfig(config);
166
331
  if (resolved.emailNotifier) {
167
- assertModelRegistered(resolved.userModelName, "user");
332
+ assertModelRegistered(resolved.connection, resolved.userModelName, "user");
168
333
  }
169
334
  const schema = new mongoose2.Schema(BaseMessageFields, {
170
335
  timestamps: true
171
336
  });
172
- schema.index({ createdAt: 1 });
173
- schema.index({ clientRequestId: 1 }, { sparse: true });
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 });
174
341
  schema.index(
175
- { clientRequestId: 1, clientRequestItemIndex: 1 },
342
+ { actionAttemptId: 1 },
176
343
  {
177
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
+ {
178
351
  partialFilterExpression: {
179
352
  clientRequestId: { $type: "string" },
353
+ clientRequestOwnerId: { $type: "string" },
354
+ templateCd: { $type: "string" }
355
+ }
356
+ }
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" },
180
366
  clientRequestItemIndex: { $type: "number" }
181
367
  }
182
368
  }
@@ -185,10 +371,12 @@ function buildMessageSchema(config) {
185
371
  schema.methods.isReceiver = isReceiver;
186
372
  schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
187
373
  if (resolved.emailNotifier) {
188
- schema.pre(
374
+ schema.pre("save", createEmailStateCaptureHook());
375
+ schema.post(
189
376
  "save",
190
- createPreSaveHook({
377
+ createPostSaveEmailHook({
191
378
  emailNotifier: resolved.emailNotifier,
379
+ onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
192
380
  emailNotificationExclusions: resolved.emailNotificationExclusions,
193
381
  userModelName: resolved.userModelName
194
382
  })
@@ -206,7 +394,11 @@ function buildMessageArchiveSchema() {
206
394
  ...BaseMessageFields,
207
395
  actionCd: { type: String, default: "" },
208
396
  archivedBy: { type: mongoose3.Schema.Types.ObjectId, default: null, index: true },
209
- archivedAt: { type: Date, default: Date.now }
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 }
210
402
  },
211
403
  {
212
404
  timestamps: true
@@ -225,15 +417,23 @@ function buildMessageRequestSchema() {
225
417
  const schema = new mongoose4.Schema(
226
418
  {
227
419
  clientRequestId: { type: String, required: true },
228
- state: { type: String, enum: ["pending", "completed"], default: "pending" },
229
- itemCount: { type: Number, default: null }
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 }
230
429
  },
231
430
  {
232
431
  timestamps: true
233
432
  }
234
433
  );
235
- schema.index({ clientRequestId: 1 }, { unique: true });
434
+ schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
236
435
  schema.index({ updatedAt: 1 });
436
+ schema.index({ state: 1, leaseExpiresAt: 1 });
237
437
  return schema;
238
438
  }
239
439
  var MessageRequestSchema = buildMessageRequestSchema();
@@ -324,52 +524,254 @@ var NoopPaymentProvider = class {
324
524
 
325
525
  // src/message-service.ts
326
526
  import { isString } from "@web-ts-toolkit/utils";
527
+ import { randomUUID } from "crypto";
528
+ import mongoose5 from "mongoose";
327
529
  var DEFAULT_LIST_LIMIT = 50;
328
530
  var MAX_LIST_LIMIT = 100;
329
531
  var DUPLICATE_KEY_ERROR_CODE = 11e3;
330
- var CLIENT_REQUEST_BATCH_RETRY_COUNT = 20;
331
- var CLIENT_REQUEST_BATCH_RETRY_DELAY_MS = 10;
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
+ };
332
544
  var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
333
545
  var MessageNotFoundError = class extends Error {
546
+ static [Symbol.hasInstance](value) {
547
+ return isRuntimeError(value, "MessageNotFoundError");
548
+ }
334
549
  constructor(messageId) {
335
550
  super(`message "${messageId}" not found`);
336
551
  this.name = "MessageNotFoundError";
552
+ markRuntimeError(this, this.name);
337
553
  }
338
554
  };
339
555
  var MessageArchivedError = class extends Error {
556
+ static [Symbol.hasInstance](value) {
557
+ return isRuntimeError(value, "MessageArchivedError");
558
+ }
340
559
  constructor(messageId) {
341
560
  super(`message "${messageId}" is archived`);
342
561
  this.name = "MessageArchivedError";
562
+ markRuntimeError(this, this.name);
343
563
  }
344
564
  };
345
565
  var TemplateNotFoundError = class extends Error {
566
+ static [Symbol.hasInstance](value) {
567
+ return isRuntimeError(value, "TemplateNotFoundError");
568
+ }
346
569
  constructor(templateCd) {
347
570
  super(`template "${templateCd}" not found`);
348
571
  this.name = "TemplateNotFoundError";
572
+ markRuntimeError(this, this.name);
349
573
  }
350
574
  };
351
575
  var ActionNotFoundError = class extends Error {
576
+ static [Symbol.hasInstance](value) {
577
+ return isRuntimeError(value, "ActionNotFoundError");
578
+ }
352
579
  constructor(templateCd, actionCd) {
353
580
  super(`action "${actionCd}" not found in template "${templateCd}"`);
354
581
  this.name = "ActionNotFoundError";
582
+ markRuntimeError(this, this.name);
355
583
  }
356
584
  };
357
585
  var ActionNotAllowedError = class extends Error {
586
+ static [Symbol.hasInstance](value) {
587
+ return isRuntimeError(value, "ActionNotAllowedError");
588
+ }
358
589
  constructor() {
359
590
  super("not allowed");
360
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;
651
+ }
652
+ }
653
+ };
654
+ var InvalidClientRequestIdError = class extends Error {
655
+ static [Symbol.hasInstance](value) {
656
+ return isRuntimeError(value, "InvalidClientRequestIdError");
657
+ }
658
+ constructor(message) {
659
+ super(message);
660
+ this.name = "InvalidClientRequestIdError";
661
+ markRuntimeError(this, this.name);
662
+ }
663
+ };
664
+ var InvalidPaginationValueError = class extends Error {
665
+ static [Symbol.hasInstance](value) {
666
+ return isRuntimeError(value, "InvalidPaginationValueError");
667
+ }
668
+ constructor(message) {
669
+ super(message);
670
+ this.name = "InvalidPaginationValueError";
671
+ markRuntimeError(this, this.name);
672
+ }
673
+ };
674
+ var ClientRequestPendingError = class extends Error {
675
+ static [Symbol.hasInstance](value) {
676
+ return isRuntimeError(value, "ClientRequestPendingError");
677
+ }
678
+ constructor(clientRequestId) {
679
+ super(
680
+ `clientRequestId "${clientRequestId}" is still pending; retry after the current reservation completes or its lease expires`
681
+ );
682
+ this.name = "ClientRequestPendingError";
683
+ markRuntimeError(this, this.name);
684
+ }
685
+ };
686
+ var ClientRequestFailedError = class extends Error {
687
+ static [Symbol.hasInstance](value) {
688
+ return isRuntimeError(value, "ClientRequestFailedError");
689
+ }
690
+ constructor(clientRequestId, reason) {
691
+ super(`clientRequestId "${clientRequestId}" previously failed${reason ? `: ${reason}` : ""}`);
692
+ this.name = "ClientRequestFailedError";
693
+ markRuntimeError(this, this.name);
694
+ }
695
+ };
696
+ var ClientRequestInconsistentStateError = class extends Error {
697
+ static [Symbol.hasInstance](value) {
698
+ return isRuntimeError(value, "ClientRequestInconsistentStateError");
699
+ }
700
+ constructor(clientRequestId, message) {
701
+ super(`clientRequestId "${clientRequestId}" has inconsistent persisted state: ${message}`);
702
+ this.name = "ClientRequestInconsistentStateError";
703
+ markRuntimeError(this, this.name);
704
+ }
705
+ };
706
+ var MessageTransactionRequiredError = class extends Error {
707
+ static [Symbol.hasInstance](value) {
708
+ return isRuntimeError(value, "MessageTransactionRequiredError");
709
+ }
710
+ constructor(cause) {
711
+ super(TRANSACTION_REQUIRED_MESSAGE);
712
+ this.name = "MessageTransactionRequiredError";
713
+ markRuntimeError(this, this.name);
714
+ if (cause) {
715
+ this.cause = cause;
716
+ }
717
+ }
718
+ };
719
+ var MessageModelResolutionError = class extends Error {
720
+ static [Symbol.hasInstance](value) {
721
+ return isRuntimeError(value, "MessageModelResolutionError");
722
+ }
723
+ constructor(role, modelName, connectionName, cause) {
724
+ super(`message-service could not resolve ${role} model "${modelName}" on ${connectionName}`);
725
+ this.name = "MessageModelResolutionError";
726
+ markRuntimeError(this, this.name);
727
+ this.role = role;
728
+ this.modelName = modelName;
729
+ this.connectionName = connectionName;
730
+ if (cause) {
731
+ this.cause = cause;
732
+ }
733
+ }
734
+ };
735
+ var PaymentSessionCompensationError = class extends Error {
736
+ static [Symbol.hasInstance](value) {
737
+ return isRuntimeError(value, "PaymentSessionCompensationError");
738
+ }
739
+ constructor(sessionId, operation, compensationError, originalError, hookError) {
740
+ super(`payment session compensation failed for session "${sessionId}" during ${operation}`);
741
+ this.name = "PaymentSessionCompensationError";
742
+ markRuntimeError(this, this.name);
743
+ this.sessionId = sessionId;
744
+ this.operation = operation;
745
+ this.compensationError = compensationError;
746
+ this.originalError = originalError;
747
+ this.hookError = hookError;
748
+ this.cause = compensationError;
361
749
  }
362
750
  };
363
751
  var MessageService = class {
364
752
  constructor(options) {
365
753
  this.getModel = options.getModel;
754
+ this.connection = options.connection;
755
+ this.modelNames = { ...DEFAULT_MODEL_NAMES, ...options.modelNames };
366
756
  this.paymentProvider = options.paymentProvider ?? null;
367
757
  this.expirePaymentSession = this.paymentProvider?.expireSession.bind(this.paymentProvider);
368
758
  this.refundPaymentSession = this.paymentProvider?.refundPayment.bind(this.paymentProvider);
759
+ this.onPaymentCompensationFailure = options.onPaymentCompensationFailure;
369
760
  this.adminRoles = options.adminRoles ?? [];
370
761
  this.registry = options.registry ?? defaultRegistry;
371
- this.defaultListLimit = options.defaultListLimit ?? DEFAULT_LIST_LIMIT;
372
- this.maxListLimit = options.maxListLimit ?? MAX_LIST_LIMIT;
762
+ this.maxListLimit = this.validatePositiveIntegerOption("maxListLimit", options.maxListLimit ?? MAX_LIST_LIMIT);
763
+ this.defaultListLimit = this.validatePositiveIntegerOption(
764
+ "defaultListLimit",
765
+ options.defaultListLimit ?? DEFAULT_LIST_LIMIT
766
+ );
767
+ if (this.defaultListLimit > this.maxListLimit) {
768
+ throw new InvalidPaginationValueError("defaultListLimit must be less than or equal to maxListLimit");
769
+ }
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;
773
+ this.clientRequestDelay = options.clientRequestDelay ?? this.delay;
774
+ this.actionLeaseMs = ACTION_LEASE_MS;
373
775
  }
374
776
  // -------------------------------------------------------------------------
375
777
  // Message lookup
@@ -379,8 +781,8 @@ var MessageService = class {
379
781
  * the message does not exist in either collection.
380
782
  */
381
783
  async findMessage(messageId, options = {}) {
382
- const Message = this.getModel(MESSAGE_MODEL_NAME);
383
- const MessageArchive = this.getModel(MESSAGE_ARCHIVE_MODEL_NAME);
784
+ const Message = this.resolveModel("active");
785
+ const MessageArchive = this.resolveModel("archive");
384
786
  const message = await this.findByIdWithOptions(Message, messageId, options);
385
787
  if (message) return message;
386
788
  return await this.findByIdWithOptions(
@@ -411,7 +813,8 @@ var MessageService = class {
411
813
  req,
412
814
  clientRequestId
413
815
  } = params;
414
- if (clientRequestId) {
816
+ const normalizedClientRequestId = this.normalizeClientRequestId(clientRequestId);
817
+ if (normalizedClientRequestId) {
415
818
  return this.createMessageWithReservation({
416
819
  templateCd,
417
820
  user,
@@ -421,7 +824,7 @@ var MessageService = class {
421
824
  payload,
422
825
  payerUser,
423
826
  req,
424
- clientRequestId
827
+ clientRequestId: normalizedClientRequestId
425
828
  });
426
829
  }
427
830
  return this.createPreparedMessageBatch({
@@ -436,31 +839,38 @@ var MessageService = class {
436
839
  });
437
840
  }
438
841
  async createMessageWithReservation(params) {
439
- while (true) {
440
- const existing = await this.findByClientRequestId(params.clientRequestId);
441
- if (existing) {
442
- return existing;
443
- }
444
- const acquired = await this.tryCreateClientRequestReservation(params.clientRequestId);
445
- if (acquired) {
446
- break;
447
- }
448
- const waited = await this.waitForClientRequestOutcome(params.clientRequestId);
449
- if (waited !== null) {
450
- return waited;
451
- }
452
- }
842
+ const clientRequestScope = this.buildClientRequestScope(params.clientRequestId, params.user, params.templateCd);
843
+ const replay = await this.findCompletedClientRequestReplay(clientRequestScope);
844
+ if (replay !== null) return replay;
845
+ const start = await this.acquireClientRequestStart(clientRequestScope);
846
+ if (start.kind === "replay") return start.replay;
453
847
  try {
454
- const results = await this.createPreparedMessageBatch(params);
455
- await this.completeClientRequestReservation(params.clientRequestId, results.length);
848
+ const results = await this.createPreparedMessageBatch({
849
+ ...params,
850
+ clientRequestScope,
851
+ clientRequestLease: start.lease
852
+ });
456
853
  return results;
457
854
  } catch (error) {
458
- await this.releaseClientRequestReservation(params.clientRequestId);
855
+ if (!(error instanceof ClientRequestPendingError)) {
856
+ await this.failClientRequestReservation(clientRequestScope, start.lease, error);
857
+ }
459
858
  throw error;
460
859
  }
461
860
  }
462
861
  async createPreparedMessageBatch(params) {
463
- const { templateCd, user, roles, identity, permissions, payload, payerUser, req, clientRequestId } = params;
862
+ const {
863
+ templateCd,
864
+ user,
865
+ roles,
866
+ identity,
867
+ permissions,
868
+ payload,
869
+ payerUser,
870
+ req,
871
+ clientRequestScope,
872
+ clientRequestLease
873
+ } = params;
464
874
  const template = this.registry.find(templateCd);
465
875
  if (!template) throw new TemplateNotFoundError(templateCd);
466
876
  const messageData = await template.prepareMessage({
@@ -469,24 +879,36 @@ var MessageService = class {
469
879
  identity,
470
880
  permissions,
471
881
  payload,
472
- getModel: this.getModel,
882
+ getModel: this.getTemplateModelResolver(),
473
883
  req
474
884
  });
475
- if (!messageData) return [];
885
+ if (!messageData) {
886
+ if (clientRequestScope && clientRequestLease) {
887
+ await this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, []);
888
+ }
889
+ return [];
890
+ }
476
891
  const ctx = { user, roles, identity, permissions, payload, payerUser, req };
477
892
  const items = Array.isArray(messageData) ? messageData : [messageData];
893
+ if (clientRequestScope && clientRequestLease) {
894
+ const docs = [];
895
+ for (let index = 0; index < items.length; index++) {
896
+ docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
897
+ }
898
+ return this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, docs);
899
+ }
478
900
  const results = [];
479
901
  for (let index = 0; index < items.length; index++) {
480
902
  const item = items[index];
481
- results.push(await this.persistItem(template, item, ctx, clientRequestId, clientRequestId ? index : null));
903
+ results.push(await this.persistItem(template, item, ctx, clientRequestScope, clientRequestScope ? index : null));
482
904
  }
483
905
  return results;
484
906
  }
485
907
  // -------------------------------------------------------------------------
486
908
  // Create generic notification (no template, no actions)
487
909
  // -------------------------------------------------------------------------
488
- async createNotification(params) {
489
- const Message = this.getModel(MESSAGE_MODEL_NAME);
910
+ async createNotification(params, sourceDocument) {
911
+ const Message = this.resolveModel("active", sourceDocument);
490
912
  return Message.create({
491
913
  type: "notification",
492
914
  templateCd: GENERIC_NOTIFICATION_TEMPLATE_CD,
@@ -507,7 +929,7 @@ var MessageService = class {
507
929
  * queries (e.g. with `populate`).
508
930
  */
509
931
  buildVisibilityFilter(user) {
510
- const userId = user.id || String(user._id);
932
+ const userId = this.requireUserId(user);
511
933
  return {
512
934
  $or: [{ fromUser: userId }, { toUser: userId }, { toRoles: { $in: user.roles ?? [] } }]
513
935
  };
@@ -519,11 +941,11 @@ var MessageService = class {
519
941
  */
520
942
  async listMessages(params) {
521
943
  const { user, limit: rawLimit, skip: rawSkip, populate } = params;
522
- const limit = Math.min(Math.max(rawLimit ?? this.defaultListLimit, 1), this.maxListLimit);
523
- const skip = Math.max(rawSkip ?? 0, 0);
524
- const Message = this.getModel(MESSAGE_MODEL_NAME);
944
+ const limit = this.normalizeListLimit(rawLimit);
945
+ const skip = this.normalizeListSkip(rawSkip);
946
+ const Message = this.resolveModel("active");
525
947
  const query = this.applyPopulate(
526
- Message.find(this.buildVisibilityFilter(user)).sort({ createdAt: -1 }).skip(skip).limit(limit),
948
+ Message.find(this.buildVisibilityFilter(user)).sort({ createdAt: -1, _id: -1 }).skip(skip).limit(limit),
527
949
  populate
528
950
  );
529
951
  return query;
@@ -533,7 +955,7 @@ var MessageService = class {
533
955
  * Useful for badge indicators ("3 new messages").
534
956
  */
535
957
  async countMessages(user) {
536
- const Message = this.getModel(MESSAGE_MODEL_NAME);
958
+ const Message = this.resolveModel("active");
537
959
  return Message.countDocuments(this.buildVisibilityFilter(user));
538
960
  }
539
961
  // -------------------------------------------------------------------------
@@ -542,8 +964,10 @@ var MessageService = class {
542
964
  async getActions(messageId, usertype, options = {}) {
543
965
  const message = options.message ?? await this.findMessage(messageId, { populate: options.populate });
544
966
  if (!message) return null;
545
- if (!options.isAdmin && options.user) {
546
- const isAllowedUsertype = usertype === "sender" ? message.isSender(options.user) : message.isReceiver(options.user);
967
+ const user = this.requireUserId(options.user);
968
+ const authorizedUser = { ...options.user, _id: user };
969
+ if (!options.isAdmin) {
970
+ const isAllowedUsertype = usertype === "sender" ? message.isSender(authorizedUser) : message.isReceiver(authorizedUser);
547
971
  if (!isAllowedUsertype) {
548
972
  return null;
549
973
  }
@@ -558,7 +982,7 @@ var MessageService = class {
558
982
  permissions: options.permissions,
559
983
  message
560
984
  });
561
- if (options.isAdmin) {
985
+ if (options.isAdmin || this.isArchivedMessage(message)) {
562
986
  return { uiTemplate: interpolated.uiTemplate, actions: [] };
563
987
  }
564
988
  return { uiTemplate: interpolated.uiTemplate, actions: interpolated.actions };
@@ -567,68 +991,387 @@ var MessageService = class {
567
991
  // Handle an action on a message
568
992
  // -------------------------------------------------------------------------
569
993
  async handleAction(templateCd, actionCd, data) {
994
+ const userId = this.requireUserId(data.user);
995
+ const authorizedUser = { ...data.user, _id: userId };
570
996
  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
+ }
571
1004
  throw new MessageArchivedError(String(data.message._id));
572
1005
  }
573
- const template = this.registry.find(templateCd);
574
- if (!template) throw new TemplateNotFoundError(templateCd);
1006
+ if (templateCd !== data.message.templateCd) {
1007
+ throw new ActionTemplateMismatchError(data.message.templateCd, templateCd);
1008
+ }
1009
+ const template = this.registry.find(data.message.templateCd);
1010
+ if (!template) throw new TemplateNotFoundError(data.message.templateCd);
575
1011
  const action = template.actions.find((a) => a.actionCd === actionCd);
576
- if (!action) throw new ActionNotFoundError(templateCd, actionCd);
577
- if (!isActionAllowed(action, data.user, data.message, { permissions: data.permissions })) {
1012
+ if (!action) throw new ActionNotFoundError(data.message.templateCd, actionCd);
1013
+ if (!isActionAllowed(action, authorizedUser, data.message, { permissions: data.permissions })) {
578
1014
  throw new ActionNotAllowedError();
579
1015
  }
580
- const ctx = this.buildActionContext(data);
581
- const result = await action.runHandler(ctx);
582
- await data.message.archive(actionCd, data.user._id, this.registry);
583
- await this.runSenderNotification(action, ctx, data.message);
1016
+ const claim = await this.claimAction(data.message, actionCd, authorizedUser);
1017
+ const ctx = this.buildActionContext({
1018
+ ...data,
1019
+ user: authorizedUser,
1020
+ message: claim.message,
1021
+ actionAttemptId: claim.actionAttemptId
1022
+ });
1023
+ let result;
1024
+ try {
1025
+ result = await action.runHandler(ctx);
1026
+ } catch (error) {
1027
+ await this.markActionRetryable(data.message._id, claim.actionAttemptId, error, claim.message);
1028
+ throw new ActionRetryableError(String(data.message._id), claim.actionAttemptId, error);
1029
+ }
1030
+ await this.archiveClaimedMessage(claim.message, action, authorizedUser, claim.actionAttemptId);
1031
+ if (action.senderNotification) {
1032
+ try {
1033
+ await this.runSenderNotification(action, ctx, claim.message);
1034
+ await this.markActionNotificationState(
1035
+ data.message._id,
1036
+ claim.actionAttemptId,
1037
+ "sent",
1038
+ void 0,
1039
+ claim.message
1040
+ );
1041
+ } catch (error) {
1042
+ await this.markActionNotificationState(data.message._id, claim.actionAttemptId, "failed", error, claim.message);
1043
+ throw new ActionNotificationPendingError(String(data.message._id), claim.actionAttemptId, result, error);
1044
+ }
1045
+ }
584
1046
  return result;
585
1047
  }
586
1048
  // -------------------------------------------------------------------------
587
1049
  // Private helpers
588
1050
  // -------------------------------------------------------------------------
589
- async persistItem(template, m, ctx, clientRequestId, clientRequestItemIndex) {
1051
+ resolveModel(role, sourceDocument, sourceModel) {
1052
+ const modelName = this.modelNames[role];
1053
+ const sourceConnection = this.getModelConnection(sourceModel) ?? this.getDocumentConnection(sourceDocument);
1054
+ try {
1055
+ if (this.getModel) {
1056
+ const model = this.getModel(modelName);
1057
+ if (!model) {
1058
+ throw new Error(`resolver returned no model for "${modelName}"`);
1059
+ }
1060
+ const resolvedConnection = this.getModelConnection(model);
1061
+ if (sourceConnection && resolvedConnection && resolvedConnection !== sourceConnection) {
1062
+ throw new Error(
1063
+ `resolver returned model "${modelName}" from connection "${resolvedConnection.name || "<unnamed>"}" instead of source connection "${sourceConnection.name || "<unnamed>"}"`
1064
+ );
1065
+ }
1066
+ return model;
1067
+ }
1068
+ if (sourceConnection) {
1069
+ return sourceConnection.model(modelName);
1070
+ }
1071
+ if (this.connection) {
1072
+ return this.connection.model(modelName);
1073
+ }
1074
+ return mongoose5.model(modelName);
1075
+ } catch (error) {
1076
+ throw new MessageModelResolutionError(role, modelName, this.describeModelSource(sourceConnection), error);
1077
+ }
1078
+ }
1079
+ getTemplateModelResolver(sourceDocument) {
1080
+ const sourceConnection = this.getDocumentConnection(sourceDocument);
1081
+ return (name) => {
1082
+ try {
1083
+ if (sourceConnection) return sourceConnection.model(name);
1084
+ if (this.getModel) return this.getModel(name);
1085
+ if (this.connection) return this.connection.model(name);
1086
+ return mongoose5.model(name);
1087
+ } catch (error) {
1088
+ throw new MessageModelResolutionError("user", name, this.describeModelSource(sourceConnection), error);
1089
+ }
1090
+ };
1091
+ }
1092
+ getDocumentConnection(sourceDocument) {
1093
+ if (!sourceDocument) return void 0;
1094
+ const model = sourceDocument.constructor;
1095
+ return this.getModelConnection(model);
1096
+ }
1097
+ getModelConnection(model) {
1098
+ const connection = model?.db;
1099
+ return connection && typeof connection.model === "function" ? connection : void 0;
1100
+ }
1101
+ describeModelSource(sourceConnection) {
1102
+ const connection = sourceConnection ?? this.connection;
1103
+ if (connection) {
1104
+ return `Mongoose connection "${connection.name || "<unnamed>"}"`;
1105
+ }
1106
+ if (this.getModel) {
1107
+ return "the configured model resolver";
1108
+ }
1109
+ return "global mongoose";
1110
+ }
1111
+ async persistItem(template, m, ctx, clientRequestScope, clientRequestItemIndex) {
1112
+ const Message = this.resolveModel("active");
1113
+ const doc = await this.buildMessageDocument(template, m, ctx, clientRequestScope, clientRequestItemIndex);
1114
+ try {
1115
+ return await Message.create(doc);
1116
+ } catch (error) {
1117
+ await this.compensatePaymentSessions([doc], error, clientRequestScope);
1118
+ throw error;
1119
+ }
1120
+ }
1121
+ async buildMessageDocument(template, m, ctx, clientRequestScope, clientRequestItemIndex) {
590
1122
  const toUser = m.toUser ?? null;
591
1123
  const toRoles = (m.toRoles && m.toRoles.length > 0 ? m.toRoles : this.adminRoles).slice();
592
1124
  const type = m.type || template.type || "notification";
593
1125
  const paymentCd = m.paymentCd || template.paymentCd || "";
594
1126
  let paymentSession = null;
595
- if (paymentCd && this.paymentProvider && (toUser || toRoles.length > 0)) {
596
- paymentSession = await this.paymentProvider.createSession(
597
- (ctx.payerUser || ctx.user)._id,
1127
+ try {
1128
+ if (paymentCd && this.paymentProvider && (toUser || toRoles.length > 0)) {
1129
+ paymentSession = await this.paymentProvider.createSession(
1130
+ (ctx.payerUser || ctx.user)._id,
1131
+ paymentCd,
1132
+ m.priceArgs
1133
+ );
1134
+ if (!paymentSession) throw new Error("payment session creation failed");
1135
+ }
1136
+ const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
1137
+ return {
1138
+ type,
1139
+ templateCd: template.templateCd,
1140
+ fromUser: m.fromUser || ctx.user._id,
1141
+ toUser,
1142
+ toRoles,
1143
+ senderContent: interpolated.senderContent,
1144
+ receiverContent: interpolated.receiverContent,
1145
+ documents: ctx.payload.documents || [],
1146
+ paymentSession,
598
1147
  paymentCd,
599
- m.priceArgs
1148
+ payload: m.payload || ctx.payload,
1149
+ display: m.display,
1150
+ clientRequestId: clientRequestScope?.clientRequestId ?? null,
1151
+ clientRequestOwnerId: clientRequestScope?.clientRequestOwnerId ?? null,
1152
+ clientRequestItemIndex
1153
+ };
1154
+ } catch (error) {
1155
+ if (paymentSession) {
1156
+ await this.compensatePaymentSession(paymentSession, error, clientRequestScope);
1157
+ }
1158
+ throw error;
1159
+ }
1160
+ }
1161
+ 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) : [];
1166
+ const result = await MessageRequest.updateOne(
1167
+ { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1168
+ {
1169
+ $set: {
1170
+ state: "completed",
1171
+ itemCount: docs.length,
1172
+ completedAt: /* @__PURE__ */ new Date(),
1173
+ leaseExpiresAt: null
1174
+ }
1175
+ },
1176
+ session2 ? { session: session2 } : void 0
600
1177
  );
601
- if (!paymentSession) throw new Error("payment session creation failed");
1178
+ if ((result.matchedCount ?? result.n ?? 0) !== 1) {
1179
+ throw new ClientRequestPendingError(scope.clientRequestId);
1180
+ }
1181
+ return created;
1182
+ };
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
+ try {
1194
+ let result = [];
1195
+ await session.withTransaction(async () => {
1196
+ result = await operation(session);
1197
+ });
1198
+ return result;
1199
+ } 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();
1208
+ }
1209
+ }
1210
+ async compensatePaymentSessions(docs, originalError, scope) {
1211
+ const sessionIds = docs.map((doc) => doc.paymentSession).filter((sessionId) => typeof sessionId === "string" && sessionId.length > 0);
1212
+ for (const sessionId of sessionIds) {
1213
+ await this.compensatePaymentSession(sessionId, originalError, scope);
1214
+ }
1215
+ }
1216
+ async compensatePaymentSession(sessionId, originalError, scope) {
1217
+ if (!this.expirePaymentSession) return;
1218
+ try {
1219
+ await this.expirePaymentSession(sessionId);
1220
+ } catch (error) {
1221
+ let hookError;
1222
+ try {
1223
+ await this.onPaymentCompensationFailure?.({
1224
+ operation: "expire",
1225
+ sessionId,
1226
+ error,
1227
+ originalError,
1228
+ clientRequestId: scope?.clientRequestId,
1229
+ clientRequestOwnerId: scope?.clientRequestOwnerId,
1230
+ templateCd: scope?.templateCd
1231
+ });
1232
+ } catch (eventError) {
1233
+ hookError = eventError;
1234
+ }
1235
+ throw new PaymentSessionCompensationError(sessionId, "expire", error, originalError, hookError);
602
1236
  }
603
- const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
604
- const Message = this.getModel(MESSAGE_MODEL_NAME);
605
- return Message.create({
606
- type,
607
- templateCd: template.templateCd,
608
- fromUser: m.fromUser || ctx.user._id,
609
- toUser,
610
- toRoles,
611
- senderContent: interpolated.senderContent,
612
- receiverContent: interpolated.receiverContent,
613
- documents: ctx.payload.documents || [],
614
- paymentSession,
615
- paymentCd,
616
- payload: m.payload || ctx.payload,
617
- display: m.display,
618
- clientRequestId: clientRequestId ?? null,
619
- clientRequestItemIndex
620
- });
621
1237
  }
622
1238
  buildActionContext(data) {
623
1239
  return {
624
1240
  message: data.message,
625
1241
  user: data.user,
626
- getModel: this.getModel,
1242
+ actionAttemptId: data.actionAttemptId,
1243
+ getModel: this.getTemplateModelResolver(data.message),
627
1244
  expireSession: this.expirePaymentSession,
628
1245
  refundPayment: this.refundPaymentSession,
629
1246
  req: data.req
630
1247
  };
631
1248
  }
1249
+ async claimAction(message, actionCd, user) {
1250
+ const Message = this.resolveModel("active", message);
1251
+ const messageId = message._id;
1252
+ const now = /* @__PURE__ */ new Date();
1253
+ const claimBase = {
1254
+ actionCd,
1255
+ actionClaimedBy: String(user._id),
1256
+ actionClaimedAt: now,
1257
+ actionLeaseExpiresAt: new Date(now.getTime() + this.actionLeaseMs),
1258
+ actionFailureMessage: null
1259
+ };
1260
+ const firstAttemptId = randomUUID();
1261
+ const firstClaim = await Message.findOneAndUpdate(
1262
+ { _id: messageId, $or: [{ actionState: "active" }, { actionState: null }, { actionState: { $exists: false } }] },
1263
+ { $set: { ...claimBase, actionState: "processing", actionAttemptId: firstAttemptId } },
1264
+ { returnDocument: "after" }
1265
+ );
1266
+ if (firstClaim?.actionAttemptId) {
1267
+ return { message: firstClaim, actionAttemptId: firstClaim.actionAttemptId };
1268
+ }
1269
+ const retryClaim = await Message.findOneAndUpdate(
1270
+ {
1271
+ _id: messageId,
1272
+ actionCd,
1273
+ actionAttemptId: { $type: "string" },
1274
+ $or: [{ actionState: "retryable" }, { actionState: "processing", actionLeaseExpiresAt: { $lte: now } }]
1275
+ },
1276
+ { $set: { ...claimBase, actionState: "processing" } },
1277
+ { returnDocument: "after" }
1278
+ );
1279
+ if (retryClaim?.actionAttemptId) {
1280
+ return { message: retryClaim, actionAttemptId: retryClaim.actionAttemptId };
1281
+ }
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);
1288
+ }
1289
+ throw new MessageArchivedError(String(messageId));
1290
+ }
1291
+ throw new ActionConflictError(String(messageId));
1292
+ }
1293
+ async markActionRetryable(messageId, actionAttemptId, error, sourceDocument) {
1294
+ const Message = this.resolveModel("active", sourceDocument);
1295
+ await Message.updateOne(
1296
+ { _id: messageId, actionState: "processing", actionAttemptId },
1297
+ {
1298
+ $set: {
1299
+ actionState: "retryable",
1300
+ actionFailureMessage: error instanceof Error ? error.message : String(error),
1301
+ actionLeaseExpiresAt: null
1302
+ }
1303
+ }
1304
+ );
1305
+ }
1306
+ async archiveClaimedMessage(message, action, user, actionAttemptId) {
1307
+ const Message = this.resolveModel("active", message);
1308
+ const MessageArchive = this.resolveModel("archive", message, Message);
1309
+ const messageId = message._id;
1310
+ const data = message.toObject();
1311
+ const notificationState = action.senderNotification ? "pending" : "none";
1312
+ delete data.actionState;
1313
+ delete data.actionClaimedBy;
1314
+ delete data.actionClaimedAt;
1315
+ delete data.actionLeaseExpiresAt;
1316
+ 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
+ );
1333
+ const deleted = await Message.deleteOne(
1334
+ { _id: messageId, actionState: "processing", actionAttemptId },
1335
+ session2 ? { session: session2 } : void 0
1336
+ );
1337
+ if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
1338
+ throw new ActionConflictError(String(messageId));
1339
+ }
1340
+ };
1341
+ const startSession = Message.db?.startSession;
1342
+ if (!startSession) {
1343
+ await operation();
1344
+ return;
1345
+ }
1346
+ const session = await startSession.call(Message.db);
1347
+ try {
1348
+ await session.withTransaction(async () => {
1349
+ await operation(session);
1350
+ });
1351
+ } 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);
1357
+ throw error;
1358
+ } finally {
1359
+ await session.endSession();
1360
+ }
1361
+ }
1362
+ async markActionNotificationState(messageId, actionAttemptId, state, error, sourceDocument) {
1363
+ const MessageArchive = this.resolveModel("archive", sourceDocument);
1364
+ await MessageArchive.updateOne(
1365
+ { _id: messageId, actionAttemptId },
1366
+ {
1367
+ $set: {
1368
+ actionNotificationState: state,
1369
+ actionNotificationError: state === "failed" ? error instanceof Error ? error.message : String(error) : null,
1370
+ actionNotificationAttemptedAt: /* @__PURE__ */ new Date()
1371
+ }
1372
+ }
1373
+ );
1374
+ }
632
1375
  async runSenderNotification(action, ctx, message) {
633
1376
  if (!action.senderNotification) return;
634
1377
  let content;
@@ -645,70 +1388,212 @@ var MessageService = class {
645
1388
  };
646
1389
  const documents = !isString(content) ? content.documents || [] : [];
647
1390
  if (message.fromUser) {
648
- await this.createNotification({
649
- toUser: message.fromUser,
650
- receiverContent: senderNotificationContent,
651
- documents
652
- });
1391
+ await this.createNotification(
1392
+ {
1393
+ toUser: message.fromUser,
1394
+ receiverContent: senderNotificationContent,
1395
+ documents
1396
+ },
1397
+ message
1398
+ );
1399
+ }
1400
+ }
1401
+ normalizeClientRequestId(clientRequestId) {
1402
+ if (clientRequestId === void 0) {
1403
+ return void 0;
653
1404
  }
1405
+ if (typeof clientRequestId !== "string") {
1406
+ throw new InvalidClientRequestIdError("clientRequestId must be a string when provided");
1407
+ }
1408
+ const trimmed = clientRequestId.trim();
1409
+ if (trimmed.length === 0) {
1410
+ throw new InvalidClientRequestIdError("clientRequestId must be a non-empty string after trimming whitespace");
1411
+ }
1412
+ if (trimmed.length > MAX_CLIENT_REQUEST_ID_LENGTH) {
1413
+ throw new InvalidClientRequestIdError(
1414
+ `clientRequestId must be at most ${MAX_CLIENT_REQUEST_ID_LENGTH} characters`
1415
+ );
1416
+ }
1417
+ return trimmed;
654
1418
  }
655
- async findByClientRequestId(clientRequestId) {
656
- const Message = this.getModel(MESSAGE_MODEL_NAME);
657
- const docs = await Message.find({ clientRequestId }).sort({ createdAt: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
658
- return docs.length > 0 ? docs : null;
1419
+ validatePositiveIntegerOption(name, value) {
1420
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1421
+ throw new InvalidPaginationValueError(`${name} must be a finite integer`);
1422
+ }
1423
+ if (value < 1) {
1424
+ throw new InvalidPaginationValueError(`${name} must be at least 1`);
1425
+ }
1426
+ return value;
659
1427
  }
660
- async waitForClientRequestOutcome(clientRequestId) {
661
- for (let attempt = 0; attempt < CLIENT_REQUEST_BATCH_RETRY_COUNT; attempt++) {
662
- const docs2 = await this.findByClientRequestId(clientRequestId);
663
- if (docs2) {
664
- return docs2;
665
- }
666
- const reservation2 = await this.findClientRequestReservation(clientRequestId);
667
- if (!reservation2) {
668
- return null;
1428
+ normalizeListLimit(value) {
1429
+ if (value === void 0) {
1430
+ return this.defaultListLimit;
1431
+ }
1432
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1433
+ throw new InvalidPaginationValueError("listMessages limit must be a finite integer");
1434
+ }
1435
+ return Math.min(Math.max(value, 1), this.maxListLimit);
1436
+ }
1437
+ normalizeListSkip(value) {
1438
+ if (value === void 0) {
1439
+ return 0;
1440
+ }
1441
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1442
+ throw new InvalidPaginationValueError("listMessages skip must be a finite integer");
1443
+ }
1444
+ return Math.max(value, 0);
1445
+ }
1446
+ buildClientRequestScope(clientRequestId, user, templateCd) {
1447
+ return {
1448
+ clientRequestId,
1449
+ clientRequestOwnerId: this.requireUserId(user),
1450
+ templateCd
1451
+ };
1452
+ }
1453
+ 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();
669
1461
  }
670
- if (reservation2.state === "completed") {
671
- return [];
1462
+ return id;
1463
+ }
1464
+ if (user._id instanceof mongoose5.Types.ObjectId) {
1465
+ return String(user._id);
1466
+ }
1467
+ throw new InvalidMessageUserError();
1468
+ }
1469
+ async findByClientRequestScope(scope) {
1470
+ const Message = this.resolveModel("active");
1471
+ const docs = await Message.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
1472
+ return docs;
1473
+ }
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");
672
1480
  }
673
- await this.delay(CLIENT_REQUEST_BATCH_RETRY_DELAY_MS);
1481
+ return null;
674
1482
  }
675
- const docs = await this.findByClientRequestId(clientRequestId);
676
- if (docs) {
677
- return docs;
1483
+ if (reservation.state === "failed") {
1484
+ throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
678
1485
  }
679
- const reservation = await this.findClientRequestReservation(clientRequestId);
680
- if (reservation?.state === "completed") {
1486
+ if (reservation.state !== "completed") {
1487
+ return null;
1488
+ }
1489
+ if (reservation.itemCount === null || reservation.itemCount < 0 || !Number.isInteger(reservation.itemCount)) {
1490
+ throw new ClientRequestInconsistentStateError(
1491
+ scope.clientRequestId,
1492
+ "completed reservation has an invalid itemCount"
1493
+ );
1494
+ }
1495
+ if (reservation.itemCount === 0) {
1496
+ if (docs.length > 0) {
1497
+ throw new ClientRequestInconsistentStateError(
1498
+ scope.clientRequestId,
1499
+ "completed zero-item reservation has messages"
1500
+ );
1501
+ }
681
1502
  return [];
682
1503
  }
683
- return null;
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
+ );
1513
+ }
1514
+ return docs;
684
1515
  }
685
- async tryCreateClientRequestReservation(clientRequestId) {
686
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
1516
+ async acquireClientRequestStart(scope) {
1517
+ const deadline = Date.now() + this.clientRequestWaitMs;
1518
+ while (true) {
1519
+ const lease = await this.tryAcquireClientRequestLease(scope);
1520
+ if (lease) {
1521
+ return { kind: "lease", lease };
1522
+ }
1523
+ const replay = await this.findCompletedClientRequestReplay(scope);
1524
+ if (replay !== null) {
1525
+ return { kind: "replay", replay };
1526
+ }
1527
+ if (Date.now() >= deadline) {
1528
+ throw new ClientRequestPendingError(scope.clientRequestId);
1529
+ }
1530
+ await this.clientRequestDelay(Math.min(this.clientRequestPollMs, Math.max(deadline - Date.now(), 0)));
1531
+ }
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();
687
1541
  try {
688
- await MessageRequest.create({ clientRequestId, state: "pending", itemCount: null });
689
- return true;
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 };
690
1550
  } catch (error) {
691
1551
  if (this.isDuplicateKeyError(error)) {
692
- return false;
1552
+ return null;
693
1553
  }
694
1554
  throw error;
695
1555
  }
696
1556
  }
697
- async findClientRequestReservation(clientRequestId) {
698
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
699
- return await MessageRequest.findOne({ clientRequestId });
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" }
1569
+ );
1570
+ return reservation ? { ownerId } : null;
700
1571
  }
701
- async completeClientRequestReservation(clientRequestId, itemCount) {
702
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
703
- await MessageRequest.updateOne({ clientRequestId }, { state: "completed", itemCount });
1572
+ async findClientRequestReservation(scope) {
1573
+ const MessageRequest = this.resolveModel("request");
1574
+ return await MessageRequest.findOne(scope);
704
1575
  }
705
- async releaseClientRequestReservation(clientRequestId) {
706
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
707
- await MessageRequest.deleteOne({ clientRequestId });
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
+ }
1587
+ }
1588
+ );
708
1589
  }
709
1590
  isDuplicateKeyError(error) {
710
1591
  return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
711
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
+ }
712
1597
  delay(ms) {
713
1598
  return new Promise((resolve) => setTimeout(resolve, ms));
714
1599
  }
@@ -737,10 +1622,45 @@ var MessageService = class {
737
1622
  // src/route-factory.ts
738
1623
  import JsonRouter from "@web-ts-toolkit/express-json-router";
739
1624
  var ACTION_CD_PATTERN = /^[a-zA-Z0-9_-]+$/;
1625
+ var ROUTE_CODE_PATTERN = /^[a-zA-Z0-9_.-]+$/;
1626
+ var OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
1627
+ var MAX_ROUTE_CODE_LENGTH = 128;
1628
+ var MAX_CLIENT_REQUEST_ID_LENGTH2 = 128;
1629
+ function assertRouteCode(value, name) {
1630
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_ROUTE_CODE_LENGTH || !ROUTE_CODE_PATTERN.test(value)) {
1631
+ throw new JsonRouter.clientErrors.BadRequestError(
1632
+ `${name} must be a non-empty string of at most ${MAX_ROUTE_CODE_LENGTH} letters, digits, dots, underscores, and hyphens`
1633
+ );
1634
+ }
1635
+ }
740
1636
  function assertValidActionCd(actionCd) {
741
- if (typeof actionCd !== "string" || !ACTION_CD_PATTERN.test(actionCd)) {
1637
+ if (typeof actionCd !== "string" || actionCd.length === 0 || actionCd.length > MAX_ROUTE_CODE_LENGTH || !ACTION_CD_PATTERN.test(actionCd)) {
742
1638
  throw new JsonRouter.clientErrors.BadRequestError(
743
- "actionCd must be a string of letters, digits, underscores, and hyphens"
1639
+ `actionCd must be a non-empty string of at most ${MAX_ROUTE_CODE_LENGTH} letters, digits, underscores, and hyphens`
1640
+ );
1641
+ }
1642
+ }
1643
+ function assertValidMessageId(id) {
1644
+ if (typeof id !== "string" || !OBJECT_ID_PATTERN.test(id)) {
1645
+ throw new JsonRouter.clientErrors.BadRequestError("id must be a 24-character hex ObjectId");
1646
+ }
1647
+ }
1648
+ function assertValidClientRequestId(clientRequestId) {
1649
+ if (clientRequestId === void 0) {
1650
+ return;
1651
+ }
1652
+ if (typeof clientRequestId !== "string") {
1653
+ throw new JsonRouter.clientErrors.BadRequestError("clientRequestId must be a string when provided");
1654
+ }
1655
+ const trimmed = clientRequestId.trim();
1656
+ if (trimmed.length === 0) {
1657
+ throw new JsonRouter.clientErrors.BadRequestError(
1658
+ "clientRequestId must be a non-empty string after trimming whitespace"
1659
+ );
1660
+ }
1661
+ if (trimmed.length > MAX_CLIENT_REQUEST_ID_LENGTH2) {
1662
+ throw new JsonRouter.clientErrors.BadRequestError(
1663
+ `clientRequestId must be at most ${MAX_CLIENT_REQUEST_ID_LENGTH2} characters`
744
1664
  );
745
1665
  }
746
1666
  }
@@ -749,6 +1669,23 @@ function assertValidUsertype(usertype) {
749
1669
  throw new JsonRouter.clientErrors.BadRequestError('usertype must be "sender" or "receiver"');
750
1670
  }
751
1671
  }
1672
+ function assertValidBody(body) {
1673
+ if (body === void 0 || body === null) {
1674
+ return;
1675
+ }
1676
+ if (typeof body !== "object" || Array.isArray(body)) {
1677
+ throw new JsonRouter.clientErrors.BadRequestError("body must be a JSON object");
1678
+ }
1679
+ }
1680
+ function requireUser(user) {
1681
+ if (!user || user._id === void 0 || user._id === null || String(user._id).trim().length === 0) {
1682
+ throw new JsonRouter.clientErrors.UnauthorizedError("authentication required");
1683
+ }
1684
+ return user;
1685
+ }
1686
+ function isMongooseCastError(error) {
1687
+ return !!error && typeof error === "object" && error.name === "CastError";
1688
+ }
752
1689
  function mapServiceError(error) {
753
1690
  if (error instanceof MessageNotFoundError) {
754
1691
  throw new JsonRouter.clientErrors.NotFoundError("message not found");
@@ -762,15 +1699,34 @@ function mapServiceError(error) {
762
1699
  if (error instanceof ActionNotAllowedError) {
763
1700
  throw new JsonRouter.clientErrors.ForbiddenError(error.message);
764
1701
  }
1702
+ if (error instanceof ActionConflictError) {
1703
+ throw new JsonRouter.clientErrors.ConflictError(error.message);
1704
+ }
1705
+ if (error instanceof ActionRetryableError) {
1706
+ throw new JsonRouter.clientErrors.ConflictError(error.message);
1707
+ }
765
1708
  if (error instanceof MessageArchivedError) {
766
1709
  throw new JsonRouter.clientErrors.GoneError(error.message);
767
1710
  }
1711
+ if (error instanceof InvalidClientRequestIdError) {
1712
+ throw new JsonRouter.clientErrors.BadRequestError(error.message);
1713
+ }
1714
+ if (error instanceof InvalidMessageUserError) {
1715
+ throw new JsonRouter.clientErrors.UnauthorizedError("authentication required");
1716
+ }
1717
+ if (error instanceof ClientRequestPendingError || error instanceof ClientRequestFailedError) {
1718
+ throw new JsonRouter.clientErrors.ConflictError(error.message);
1719
+ }
1720
+ if (isMongooseCastError(error)) {
1721
+ throw new JsonRouter.clientErrors.BadRequestError("id must be a valid ObjectId");
1722
+ }
768
1723
  throw error;
769
1724
  }
770
1725
  function createMessageRoutes(options) {
771
1726
  const {
772
1727
  getModel,
773
1728
  paymentProvider = null,
1729
+ onPaymentCompensationFailure,
774
1730
  adminRoles,
775
1731
  registry,
776
1732
  authMiddleware = [],
@@ -779,17 +1735,20 @@ function createMessageRoutes(options) {
779
1735
  getIdentity = defaultGetIdentity,
780
1736
  adminPermissionKey = "is.admin"
781
1737
  } = options;
782
- const service = new MessageService({ getModel, paymentProvider, adminRoles, registry });
1738
+ const service = new MessageService({ getModel, paymentProvider, onPaymentCompensationFailure, adminRoles, registry });
783
1739
  const router = new JsonRouter("", authMiddleware);
784
1740
  router.post("/new/:templateCd", async (req) => {
785
1741
  const templateCd = req.params.templateCd;
786
- const user = getUser(req) || { _id: "" };
1742
+ const user = requireUser(getUser(req));
1743
+ assertRouteCode(templateCd, "templateCd");
1744
+ assertValidBody(req.body);
787
1745
  const roles = user.roles || [];
788
1746
  const identity = getIdentity(req);
789
1747
  const permissions = getPermissions(req);
790
1748
  const body = req.body || {};
791
1749
  const { clientRequestId, ...payload } = body;
792
- const clientRequestIdStr = typeof clientRequestId === "string" ? clientRequestId : void 0;
1750
+ const hasClientRequestId = Object.prototype.hasOwnProperty.call(body, "clientRequestId");
1751
+ assertValidClientRequestId(hasClientRequestId ? clientRequestId : void 0);
793
1752
  try {
794
1753
  return await service.createMessage({
795
1754
  templateCd,
@@ -800,7 +1759,7 @@ function createMessageRoutes(options) {
800
1759
  payload,
801
1760
  payerUser: user,
802
1761
  req,
803
- clientRequestId: clientRequestIdStr
1762
+ clientRequestId: hasClientRequestId ? clientRequestId : void 0
804
1763
  });
805
1764
  } catch (error) {
806
1765
  mapServiceError(error);
@@ -809,11 +1768,17 @@ function createMessageRoutes(options) {
809
1768
  router.get("/:id/actions/:usertype", async (req) => {
810
1769
  const id = req.params.id;
811
1770
  const usertype = req.params.usertype;
1771
+ const user = requireUser(getUser(req));
1772
+ assertValidMessageId(id);
812
1773
  assertValidUsertype(usertype);
813
1774
  const permissions = getPermissions(req);
814
1775
  const isAdmin = !!permissions[adminPermissionKey];
815
- const user = getUser(req) || { _id: "" };
816
- const result = await service.getActions(id, usertype, { permissions, user, isAdmin });
1776
+ let result;
1777
+ try {
1778
+ result = await service.getActions(id, usertype, { permissions, user, isAdmin });
1779
+ } catch (error) {
1780
+ mapServiceError(error);
1781
+ }
817
1782
  if (!result) {
818
1783
  throw new JsonRouter.clientErrors.NotFoundError("message not found");
819
1784
  }
@@ -822,6 +1787,8 @@ function createMessageRoutes(options) {
822
1787
  async function handleAction(req) {
823
1788
  const id = req.params.id;
824
1789
  const actionCd = req.params.actionCd;
1790
+ const user = requireUser(getUser(req));
1791
+ assertValidMessageId(id);
825
1792
  assertValidActionCd(actionCd);
826
1793
  let message;
827
1794
  try {
@@ -829,15 +1796,16 @@ function createMessageRoutes(options) {
829
1796
  } catch (error) {
830
1797
  mapServiceError(error);
831
1798
  }
832
- const user = getUser(req) || { _id: "" };
833
1799
  const permissions = getPermissions(req);
834
1800
  try {
835
1801
  return await service.handleAction(message.templateCd, actionCd, { message, user, permissions, req });
836
1802
  } catch (error) {
1803
+ if (error instanceof ActionNotificationPendingError) {
1804
+ return new JsonRouter.success.Accepted({ message: error.message, actionAttemptId: error.actionAttemptId });
1805
+ }
837
1806
  mapServiceError(error);
838
1807
  }
839
1808
  }
840
- router.get("/:id/action/:actionCd", handleAction);
841
1809
  router.post("/:id/action/:actionCd", handleAction);
842
1810
  return { router, service };
843
1811
  }
@@ -852,24 +1820,38 @@ function defaultGetIdentity(req) {
852
1820
  return req._identity || {};
853
1821
  }
854
1822
  export {
1823
+ ActionConflictError,
855
1824
  ActionNotAllowedError,
856
1825
  ActionNotFoundError,
1826
+ ActionNotificationPendingError,
1827
+ ActionRetryableError,
1828
+ ActionTemplateMismatchError,
857
1829
  BaseMessageFields,
1830
+ ClientRequestFailedError,
1831
+ ClientRequestInconsistentStateError,
1832
+ ClientRequestPendingError,
858
1833
  GENERIC_NOTIFICATION_TEMPLATE_CD,
1834
+ InvalidClientRequestIdError,
1835
+ InvalidMessageUserError,
1836
+ InvalidPaginationValueError,
859
1837
  MESSAGE_ARCHIVE_MODEL_NAME,
860
1838
  MESSAGE_MODEL_NAME,
861
1839
  MESSAGE_REQUEST_MODEL_NAME,
862
1840
  MessageArchiveSchema,
863
1841
  MessageArchivedError,
864
1842
  MessageContentSchema,
1843
+ MessageModelResolutionError,
865
1844
  MessageNotFoundError,
866
1845
  MessageRequestSchema,
867
1846
  MessageSchema,
868
1847
  MessageService,
1848
+ MessageTransactionRequiredError,
869
1849
  NoopEmailProvider,
870
1850
  NoopPaymentProvider,
1851
+ PaymentSessionCompensationError,
871
1852
  TemplateNotFoundError,
872
1853
  TemplateRegistry,
1854
+ TemplateRegistryValidationError,
873
1855
  buildMessageArchiveSchema,
874
1856
  buildMessageRequestSchema,
875
1857
  buildMessageSchema,