@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.js CHANGED
@@ -30,24 +30,38 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ActionConflictError: () => ActionConflictError,
33
34
  ActionNotAllowedError: () => ActionNotAllowedError,
34
35
  ActionNotFoundError: () => ActionNotFoundError,
36
+ ActionNotificationPendingError: () => ActionNotificationPendingError,
37
+ ActionRetryableError: () => ActionRetryableError,
38
+ ActionTemplateMismatchError: () => ActionTemplateMismatchError,
35
39
  BaseMessageFields: () => BaseMessageFields,
40
+ ClientRequestFailedError: () => ClientRequestFailedError,
41
+ ClientRequestInconsistentStateError: () => ClientRequestInconsistentStateError,
42
+ ClientRequestPendingError: () => ClientRequestPendingError,
36
43
  GENERIC_NOTIFICATION_TEMPLATE_CD: () => GENERIC_NOTIFICATION_TEMPLATE_CD,
44
+ InvalidClientRequestIdError: () => InvalidClientRequestIdError,
45
+ InvalidMessageUserError: () => InvalidMessageUserError,
46
+ InvalidPaginationValueError: () => InvalidPaginationValueError,
37
47
  MESSAGE_ARCHIVE_MODEL_NAME: () => MESSAGE_ARCHIVE_MODEL_NAME,
38
48
  MESSAGE_MODEL_NAME: () => MESSAGE_MODEL_NAME,
39
49
  MESSAGE_REQUEST_MODEL_NAME: () => MESSAGE_REQUEST_MODEL_NAME,
40
50
  MessageArchiveSchema: () => MessageArchiveSchema,
41
51
  MessageArchivedError: () => MessageArchivedError,
42
52
  MessageContentSchema: () => MessageContentSchema,
53
+ MessageModelResolutionError: () => MessageModelResolutionError,
43
54
  MessageNotFoundError: () => MessageNotFoundError,
44
55
  MessageRequestSchema: () => MessageRequestSchema,
45
56
  MessageSchema: () => MessageSchema,
46
57
  MessageService: () => MessageService,
58
+ MessageTransactionRequiredError: () => MessageTransactionRequiredError,
47
59
  NoopEmailProvider: () => NoopEmailProvider,
48
60
  NoopPaymentProvider: () => NoopPaymentProvider,
61
+ PaymentSessionCompensationError: () => PaymentSessionCompensationError,
49
62
  TemplateNotFoundError: () => TemplateNotFoundError,
50
63
  TemplateRegistry: () => TemplateRegistry,
64
+ TemplateRegistryValidationError: () => TemplateRegistryValidationError,
51
65
  buildMessageArchiveSchema: () => buildMessageArchiveSchema,
52
66
  buildMessageRequestSchema: () => buildMessageRequestSchema,
53
67
  buildMessageSchema: () => buildMessageSchema,
@@ -90,13 +104,105 @@ var BaseMessageFields = {
90
104
  payload: { type: import_mongoose.default.Schema.Types.Mixed, default: {} },
91
105
  display: { type: import_mongoose.default.Schema.Types.Mixed, default: {} },
92
106
  clientRequestId: { type: String, default: null },
93
- clientRequestItemIndex: { type: Number, default: null }
107
+ clientRequestOwnerId: { type: String, default: null },
108
+ clientRequestItemIndex: { type: Number, default: null },
109
+ actionState: { type: String, enum: ["active", "processing", "retryable"], default: "active" },
110
+ actionCd: { type: String, default: null },
111
+ actionAttemptId: { type: String, default: null },
112
+ actionClaimedBy: { type: String, default: null },
113
+ actionClaimedAt: { type: Date, default: null },
114
+ actionLeaseExpiresAt: { type: Date, default: null },
115
+ actionFailureMessage: { type: String, default: null }
94
116
  };
95
117
 
96
118
  // src/schemas/message.ts
97
119
  var import_mongoose2 = __toESM(require("mongoose"));
98
120
 
121
+ // src/runtime-contract.ts
122
+ var ERROR_BRAND_PREFIX = "@web-ts-toolkit/message-service/error/";
123
+ function markRuntimeError(error, name) {
124
+ Object.defineProperty(error, /* @__PURE__ */ Symbol.for(`${ERROR_BRAND_PREFIX}${name}`), {
125
+ configurable: false,
126
+ enumerable: false,
127
+ value: true
128
+ });
129
+ }
130
+ function isRuntimeError(value, name) {
131
+ if (!value || typeof value !== "object") return false;
132
+ return value.name === name && value[/* @__PURE__ */ Symbol.for(`${ERROR_BRAND_PREFIX}${name}`)] === true;
133
+ }
134
+
99
135
  // src/template-registry.ts
136
+ var TemplateRegistryValidationError = class extends Error {
137
+ static [Symbol.hasInstance](value) {
138
+ return isRuntimeError(value, "TemplateRegistryValidationError");
139
+ }
140
+ constructor(message) {
141
+ super(message);
142
+ this.name = "TemplateRegistryValidationError";
143
+ markRuntimeError(this, this.name);
144
+ }
145
+ };
146
+ function requireNonEmptyString(value, field, templateCd) {
147
+ if (typeof value !== "string" || value.trim() === "") {
148
+ throw new TemplateRegistryValidationError(
149
+ `${templateCd ? `template "${templateCd}" ` : ""}${field} must be a non-empty string`
150
+ );
151
+ }
152
+ return value;
153
+ }
154
+ function validateTemplate(template) {
155
+ const templateCd = requireNonEmptyString(template.templateCd, "templateCd");
156
+ if (!Array.isArray(template.actions)) {
157
+ throw new TemplateRegistryValidationError(`template "${templateCd}" actions must be an array`);
158
+ }
159
+ const seenActionCodes = /* @__PURE__ */ new Set();
160
+ const defaultByUsertype = /* @__PURE__ */ new Map();
161
+ for (const action of template.actions) {
162
+ const actionCd = requireNonEmptyString(action.actionCd, "actionCd", templateCd);
163
+ if (seenActionCodes.has(actionCd)) {
164
+ throw new TemplateRegistryValidationError(`template "${templateCd}" has duplicate actionCd "${actionCd}"`);
165
+ }
166
+ seenActionCodes.add(actionCd);
167
+ requireNonEmptyString(action.name, `action "${actionCd}" name`, templateCd);
168
+ requireNonEmptyString(action.variant, `action "${actionCd}" variant`, templateCd);
169
+ if (!action.sender && !action.receiver) {
170
+ throw new TemplateRegistryValidationError(
171
+ `template "${templateCd}" action "${actionCd}" must be available to sender or receiver`
172
+ );
173
+ }
174
+ if (action.isDefault) {
175
+ for (const usertype of ["sender", "receiver"]) {
176
+ if (!action[usertype]) continue;
177
+ const existingDefault = defaultByUsertype.get(usertype);
178
+ if (existingDefault) {
179
+ throw new TemplateRegistryValidationError(
180
+ `template "${templateCd}" has multiple default ${usertype} actions: "${existingDefault}" and "${actionCd}"`
181
+ );
182
+ }
183
+ defaultByUsertype.set(usertype, actionCd);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ function snapshotTemplate(template) {
189
+ const snapshot = {
190
+ ...template,
191
+ senderContent: Object.freeze({ ...template.senderContent }),
192
+ receiverContent: Object.freeze({ ...template.receiverContent }),
193
+ uiTemplate: typeof template.uiTemplate === "string" ? template.uiTemplate : Object.freeze({ ...template.uiTemplate }),
194
+ actions: Object.freeze(
195
+ template.actions.map(
196
+ (action) => Object.freeze({
197
+ ...action,
198
+ confirmation: action.confirmation ? Object.freeze({ ...action.confirmation }) : void 0,
199
+ payload: action.payload ? Object.freeze({ ...action.payload }) : void 0
200
+ })
201
+ )
202
+ )
203
+ };
204
+ return Object.freeze(snapshot);
205
+ }
100
206
  var TemplateRegistry = class {
101
207
  constructor() {
102
208
  this.templates = /* @__PURE__ */ new Map();
@@ -105,7 +211,8 @@ var TemplateRegistry = class {
105
211
  * Register a template. Overwrites if templateCd already exists.
106
212
  */
107
213
  register(template) {
108
- this.templates.set(template.templateCd, template);
214
+ validateTemplate(template);
215
+ this.templates.set(template.templateCd, snapshotTemplate(template));
109
216
  }
110
217
  /**
111
218
  * Register multiple templates at once.
@@ -152,7 +259,9 @@ var TemplateRegistry = class {
152
259
  this.templates.clear();
153
260
  }
154
261
  };
155
- var defaultRegistry = new TemplateRegistry();
262
+ var DEFAULT_REGISTRY_KEY = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/message-service/defaultRegistry");
263
+ var registryGlobal = globalThis;
264
+ var defaultRegistry = registryGlobal[DEFAULT_REGISTRY_KEY] ??= new TemplateRegistry();
156
265
  function includesAction(templateCd, actionCd, registry) {
157
266
  const template = registry.find(templateCd);
158
267
  if (!template) return false;
@@ -161,20 +270,53 @@ function includesAction(templateCd, actionCd, registry) {
161
270
 
162
271
  // src/schemas/methods.ts
163
272
  function isSender(user) {
164
- return String(this.fromUser) === String(user.id || user._id);
273
+ const fromUser = normalizeStoredUserId(this.fromUser);
274
+ const userId = normalizeMessageUserId(user);
275
+ return fromUser !== null && userId !== null && fromUser === userId;
165
276
  }
166
277
  function isReceiver(user) {
167
- const id = String(user.id || user._id);
168
- return String(this.toUser) === id || this.toRoles.some((r) => user.roles?.includes(r));
278
+ const toUser = normalizeStoredUserId(this.toUser);
279
+ const userId = normalizeMessageUserId(user);
280
+ return toUser !== null && userId !== null && toUser === userId || this.toRoles.some((r) => user.roles?.includes(r));
281
+ }
282
+ function normalizeMessageUserId(user) {
283
+ return normalizeStoredUserId(user?._id ?? null);
284
+ }
285
+ function normalizeStoredUserId(value) {
286
+ if (value === null || value === void 0) return null;
287
+ if (typeof value === "string") {
288
+ const trimmed = value.trim();
289
+ return trimmed.length > 0 ? trimmed : null;
290
+ }
291
+ return String(value);
169
292
  }
170
293
 
171
294
  // src/schemas/message.ts
295
+ function resolveDocumentModel(document, modelName, role) {
296
+ const model = document.constructor;
297
+ const connection = model.db;
298
+ if (!connection) {
299
+ throw new Error(
300
+ `message-service: cannot resolve ${role} model "${modelName}" because the hydrated document has no owning connection`
301
+ );
302
+ }
303
+ try {
304
+ return connection.model(modelName);
305
+ } catch (error) {
306
+ const connectionName = connection.name || "<unnamed>";
307
+ const resolutionError = new Error(
308
+ `message-service: ${role} model "${modelName}" is not registered on Mongoose connection "${connectionName}"`
309
+ );
310
+ resolutionError.cause = error;
311
+ throw resolutionError;
312
+ }
313
+ }
172
314
  function createArchiveMethod(ctx) {
173
315
  return function archive(actionCd, archivedBy, registry) {
174
316
  if (!includesAction(this.templateCd, actionCd, registry) || !archivedBy) {
175
317
  return Promise.resolve();
176
318
  }
177
- const MessageArchive = import_mongoose2.default.model(ctx.archiveModelName);
319
+ const MessageArchive = resolveDocumentModel(this, ctx.archiveModelName, "archive");
178
320
  const data = this.toObject();
179
321
  return MessageArchive.create({
180
322
  ...data,
@@ -185,60 +327,118 @@ function createArchiveMethod(ctx) {
185
327
  });
186
328
  };
187
329
  }
188
- function createPreSaveHook(ctx) {
330
+ var EMAIL_WAS_NEW_LOCAL = "messageServiceEmailWasNew";
331
+ function normalizeEmailTitle(value) {
332
+ return value.trim().toLowerCase();
333
+ }
334
+ function createEmailStateCaptureHook() {
335
+ return function captureEmailState() {
336
+ this.$locals[EMAIL_WAS_NEW_LOCAL] = this.isNew;
337
+ };
338
+ }
339
+ function createPostSaveEmailHook(ctx) {
189
340
  return async function sendNotificationEmail() {
341
+ if (!this.$locals[EMAIL_WAS_NEW_LOCAL]) {
342
+ return;
343
+ }
344
+ if (this.$session()) {
345
+ return;
346
+ }
190
347
  if (!this.toUser || !this.receiverContent?.title) {
191
348
  return;
192
349
  }
193
350
  const title = this.receiverContent.title.trim();
194
- if (ctx.emailNotificationExclusions.includes(title.toLowerCase())) {
351
+ if (!title || ctx.emailNotificationExclusions.includes(normalizeEmailTitle(title))) {
352
+ return;
353
+ }
354
+ let user;
355
+ try {
356
+ const User = resolveDocumentModel(this, ctx.userModelName, "user");
357
+ user = await User.findById(this.toUser).select("email").lean();
358
+ } catch (error) {
359
+ await reportEmailFailure(ctx, "recipientLookup", error, this, title);
360
+ return;
361
+ }
362
+ if (!user?.email) {
195
363
  return;
196
364
  }
365
+ const long = this.receiverContent.long || "";
366
+ const short = this.receiverContent.short || "";
367
+ const body = long.length > short.length ? long : short;
197
368
  try {
198
- const User = import_mongoose2.default.model(ctx.userModelName);
199
- const user = await User.findById(this.toUser).select("email").lean();
200
- if (!user?.email) {
201
- return;
202
- }
203
- const long = this.receiverContent.long || "";
204
- const short = this.receiverContent.short || "";
205
- const body = long.length > short.length ? long : short;
206
369
  await ctx.emailNotifier(user.email, title, body);
207
- } catch {
370
+ } catch (error) {
371
+ await reportEmailFailure(ctx, "notifier", error, this, title);
208
372
  }
209
373
  };
210
374
  }
211
- function assertModelRegistered(name, role) {
212
- if (!import_mongoose2.default.modelNames().includes(name)) {
375
+ async function reportEmailFailure(ctx, stage, error, message, title) {
376
+ try {
377
+ await ctx.onEmailDeliveryFailure?.({
378
+ stage,
379
+ error,
380
+ messageId: message._id,
381
+ recipientId: message.toUser,
382
+ title
383
+ });
384
+ } catch {
385
+ }
386
+ }
387
+ function assertModelRegistered(connection, name, role) {
388
+ if (!connection.modelNames().includes(name)) {
389
+ const connectionName = "name" in connection && connection.name ? connection.name : "global mongoose";
213
390
  throw new Error(
214
- `message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered with Mongoose. Call mongoose.model("${name}", ...) before buildMessageSchema().`
391
+ `message-service: cannot configure schema \u2014 ${role} model "${name}" is not registered on Mongoose connection "${connectionName}". Register the model on that connection before buildMessageSchema().`
215
392
  );
216
393
  }
217
394
  }
218
395
  function resolveConfig(config) {
219
396
  return {
220
397
  emailNotifier: config?.emailNotifier ?? null,
221
- emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map((e) => e.toLowerCase()),
398
+ onEmailDeliveryFailure: config?.onEmailDeliveryFailure,
399
+ emailNotificationExclusions: (config?.emailNotificationExclusions ?? []).map(normalizeEmailTitle),
222
400
  userModelName: config?.userModelName ?? "User",
223
- archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME
401
+ archiveModelName: config?.archiveModelName ?? MESSAGE_ARCHIVE_MODEL_NAME,
402
+ connection: config?.connection ?? import_mongoose2.default
224
403
  };
225
404
  }
226
405
  function buildMessageSchema(config) {
227
406
  const resolved = resolveConfig(config);
228
407
  if (resolved.emailNotifier) {
229
- assertModelRegistered(resolved.userModelName, "user");
408
+ assertModelRegistered(resolved.connection, resolved.userModelName, "user");
230
409
  }
231
410
  const schema = new import_mongoose2.default.Schema(BaseMessageFields, {
232
411
  timestamps: true
233
412
  });
234
- schema.index({ createdAt: 1 });
235
- schema.index({ clientRequestId: 1 }, { sparse: true });
413
+ schema.index({ fromUser: 1, createdAt: -1, _id: -1 });
414
+ schema.index({ toUser: 1, createdAt: -1, _id: -1 });
415
+ schema.index({ toRoles: 1, createdAt: -1, _id: -1 });
416
+ schema.index({ actionState: 1, actionLeaseExpiresAt: 1 });
236
417
  schema.index(
237
- { clientRequestId: 1, clientRequestItemIndex: 1 },
418
+ { actionAttemptId: 1 },
238
419
  {
239
420
  unique: true,
421
+ partialFilterExpression: { actionAttemptId: { $type: "string" } }
422
+ }
423
+ );
424
+ schema.index(
425
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, createdAt: 1, _id: 1 },
426
+ {
240
427
  partialFilterExpression: {
241
428
  clientRequestId: { $type: "string" },
429
+ clientRequestOwnerId: { $type: "string" },
430
+ templateCd: { $type: "string" }
431
+ }
432
+ }
433
+ );
434
+ schema.index(
435
+ { clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1, clientRequestItemIndex: 1 },
436
+ {
437
+ unique: true,
438
+ partialFilterExpression: {
439
+ clientRequestId: { $type: "string" },
440
+ clientRequestOwnerId: { $type: "string" },
441
+ templateCd: { $type: "string" },
242
442
  clientRequestItemIndex: { $type: "number" }
243
443
  }
244
444
  }
@@ -247,10 +447,12 @@ function buildMessageSchema(config) {
247
447
  schema.methods.isReceiver = isReceiver;
248
448
  schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
249
449
  if (resolved.emailNotifier) {
250
- schema.pre(
450
+ schema.pre("save", createEmailStateCaptureHook());
451
+ schema.post(
251
452
  "save",
252
- createPreSaveHook({
453
+ createPostSaveEmailHook({
253
454
  emailNotifier: resolved.emailNotifier,
455
+ onEmailDeliveryFailure: resolved.onEmailDeliveryFailure,
254
456
  emailNotificationExclusions: resolved.emailNotificationExclusions,
255
457
  userModelName: resolved.userModelName
256
458
  })
@@ -268,7 +470,11 @@ function buildMessageArchiveSchema() {
268
470
  ...BaseMessageFields,
269
471
  actionCd: { type: String, default: "" },
270
472
  archivedBy: { type: import_mongoose3.default.Schema.Types.ObjectId, default: null, index: true },
271
- archivedAt: { type: Date, default: Date.now }
473
+ archivedAt: { type: Date, default: Date.now },
474
+ actionAttemptId: { type: String, default: null, index: true },
475
+ actionNotificationState: { type: String, enum: ["none", "pending", "sent", "failed"], default: "none" },
476
+ actionNotificationError: { type: String, default: null },
477
+ actionNotificationAttemptedAt: { type: Date, default: null }
272
478
  },
273
479
  {
274
480
  timestamps: true
@@ -287,15 +493,23 @@ function buildMessageRequestSchema() {
287
493
  const schema = new import_mongoose4.default.Schema(
288
494
  {
289
495
  clientRequestId: { type: String, required: true },
290
- state: { type: String, enum: ["pending", "completed"], default: "pending" },
291
- itemCount: { type: Number, default: null }
496
+ clientRequestOwnerId: { type: String, required: true },
497
+ templateCd: { type: String, required: true },
498
+ state: { type: String, enum: ["pending", "completed", "failed"], default: "pending" },
499
+ itemCount: { type: Number, default: null },
500
+ leaseOwnerId: { type: String, default: null },
501
+ leaseExpiresAt: { type: Date, default: null },
502
+ completedAt: { type: Date, default: null },
503
+ failedAt: { type: Date, default: null },
504
+ failureMessage: { type: String, default: null }
292
505
  },
293
506
  {
294
507
  timestamps: true
295
508
  }
296
509
  );
297
- schema.index({ clientRequestId: 1 }, { unique: true });
510
+ schema.index({ clientRequestOwnerId: 1, templateCd: 1, clientRequestId: 1 }, { unique: true });
298
511
  schema.index({ updatedAt: 1 });
512
+ schema.index({ state: 1, leaseExpiresAt: 1 });
299
513
  return schema;
300
514
  }
301
515
  var MessageRequestSchema = buildMessageRequestSchema();
@@ -386,52 +600,254 @@ var NoopPaymentProvider = class {
386
600
 
387
601
  // src/message-service.ts
388
602
  var import_utils = require("@web-ts-toolkit/utils");
603
+ var import_node_crypto = require("crypto");
604
+ var import_mongoose5 = __toESM(require("mongoose"));
389
605
  var DEFAULT_LIST_LIMIT = 50;
390
606
  var MAX_LIST_LIMIT = 100;
391
607
  var DUPLICATE_KEY_ERROR_CODE = 11e3;
392
- var CLIENT_REQUEST_BATCH_RETRY_COUNT = 20;
393
- var CLIENT_REQUEST_BATCH_RETRY_DELAY_MS = 10;
608
+ var CLIENT_REQUEST_LEASE_MS = 3e4;
609
+ var CLIENT_REQUEST_WAIT_MS = 5e3;
610
+ var CLIENT_REQUEST_POLL_MS = 200;
611
+ var ACTION_LEASE_MS = 3e4;
612
+ var MAX_CLIENT_REQUEST_ID_LENGTH = 128;
613
+ var TRANSACTION_REQUIRED_MESSAGE = "message-service requires MongoDB replica set or sharded-cluster transactions for idempotent batch creation";
614
+ var DEFAULT_MODEL_NAMES = {
615
+ active: MESSAGE_MODEL_NAME,
616
+ archive: MESSAGE_ARCHIVE_MODEL_NAME,
617
+ request: MESSAGE_REQUEST_MODEL_NAME,
618
+ user: "User"
619
+ };
394
620
  var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
395
621
  var MessageNotFoundError = class extends Error {
622
+ static [Symbol.hasInstance](value) {
623
+ return isRuntimeError(value, "MessageNotFoundError");
624
+ }
396
625
  constructor(messageId) {
397
626
  super(`message "${messageId}" not found`);
398
627
  this.name = "MessageNotFoundError";
628
+ markRuntimeError(this, this.name);
399
629
  }
400
630
  };
401
631
  var MessageArchivedError = class extends Error {
632
+ static [Symbol.hasInstance](value) {
633
+ return isRuntimeError(value, "MessageArchivedError");
634
+ }
402
635
  constructor(messageId) {
403
636
  super(`message "${messageId}" is archived`);
404
637
  this.name = "MessageArchivedError";
638
+ markRuntimeError(this, this.name);
405
639
  }
406
640
  };
407
641
  var TemplateNotFoundError = class extends Error {
642
+ static [Symbol.hasInstance](value) {
643
+ return isRuntimeError(value, "TemplateNotFoundError");
644
+ }
408
645
  constructor(templateCd) {
409
646
  super(`template "${templateCd}" not found`);
410
647
  this.name = "TemplateNotFoundError";
648
+ markRuntimeError(this, this.name);
411
649
  }
412
650
  };
413
651
  var ActionNotFoundError = class extends Error {
652
+ static [Symbol.hasInstance](value) {
653
+ return isRuntimeError(value, "ActionNotFoundError");
654
+ }
414
655
  constructor(templateCd, actionCd) {
415
656
  super(`action "${actionCd}" not found in template "${templateCd}"`);
416
657
  this.name = "ActionNotFoundError";
658
+ markRuntimeError(this, this.name);
417
659
  }
418
660
  };
419
661
  var ActionNotAllowedError = class extends Error {
662
+ static [Symbol.hasInstance](value) {
663
+ return isRuntimeError(value, "ActionNotAllowedError");
664
+ }
420
665
  constructor() {
421
666
  super("not allowed");
422
667
  this.name = "ActionNotAllowedError";
668
+ markRuntimeError(this, this.name);
669
+ }
670
+ };
671
+ var InvalidMessageUserError = class extends Error {
672
+ static [Symbol.hasInstance](value) {
673
+ return isRuntimeError(value, "InvalidMessageUserError");
674
+ }
675
+ constructor(message = "user._id must be a non-empty string or ObjectId") {
676
+ super(message);
677
+ this.name = "InvalidMessageUserError";
678
+ markRuntimeError(this, this.name);
679
+ }
680
+ };
681
+ var ActionTemplateMismatchError = class extends Error {
682
+ static [Symbol.hasInstance](value) {
683
+ return isRuntimeError(value, "ActionTemplateMismatchError");
684
+ }
685
+ constructor(expectedTemplateCd, receivedTemplateCd) {
686
+ super(`message template "${expectedTemplateCd}" does not match requested template "${receivedTemplateCd}"`);
687
+ this.name = "ActionTemplateMismatchError";
688
+ markRuntimeError(this, this.name);
689
+ }
690
+ };
691
+ var ActionConflictError = class extends Error {
692
+ static [Symbol.hasInstance](value) {
693
+ return isRuntimeError(value, "ActionConflictError");
694
+ }
695
+ constructor(messageId) {
696
+ super(`message "${messageId}" already has an action in progress`);
697
+ this.name = "ActionConflictError";
698
+ markRuntimeError(this, this.name);
699
+ }
700
+ };
701
+ var ActionRetryableError = class extends Error {
702
+ static [Symbol.hasInstance](value) {
703
+ return isRuntimeError(value, "ActionRetryableError");
704
+ }
705
+ constructor(messageId, actionAttemptId, cause) {
706
+ super(`message "${messageId}" action attempt "${actionAttemptId}" failed before commit and may be retried`);
707
+ this.name = "ActionRetryableError";
708
+ markRuntimeError(this, this.name);
709
+ this.actionAttemptId = actionAttemptId;
710
+ if (cause) {
711
+ this.cause = cause;
712
+ }
713
+ }
714
+ };
715
+ var ActionNotificationPendingError = class extends Error {
716
+ static [Symbol.hasInstance](value) {
717
+ return isRuntimeError(value, "ActionNotificationPendingError");
718
+ }
719
+ constructor(messageId, actionAttemptId, result, cause) {
720
+ super(`message "${messageId}" action committed but sender notification is pending`);
721
+ this.name = "ActionNotificationPendingError";
722
+ markRuntimeError(this, this.name);
723
+ this.actionAttemptId = actionAttemptId;
724
+ this.result = result;
725
+ if (cause) {
726
+ this.cause = cause;
727
+ }
728
+ }
729
+ };
730
+ var InvalidClientRequestIdError = class extends Error {
731
+ static [Symbol.hasInstance](value) {
732
+ return isRuntimeError(value, "InvalidClientRequestIdError");
733
+ }
734
+ constructor(message) {
735
+ super(message);
736
+ this.name = "InvalidClientRequestIdError";
737
+ markRuntimeError(this, this.name);
738
+ }
739
+ };
740
+ var InvalidPaginationValueError = class extends Error {
741
+ static [Symbol.hasInstance](value) {
742
+ return isRuntimeError(value, "InvalidPaginationValueError");
743
+ }
744
+ constructor(message) {
745
+ super(message);
746
+ this.name = "InvalidPaginationValueError";
747
+ markRuntimeError(this, this.name);
748
+ }
749
+ };
750
+ var ClientRequestPendingError = class extends Error {
751
+ static [Symbol.hasInstance](value) {
752
+ return isRuntimeError(value, "ClientRequestPendingError");
753
+ }
754
+ constructor(clientRequestId) {
755
+ super(
756
+ `clientRequestId "${clientRequestId}" is still pending; retry after the current reservation completes or its lease expires`
757
+ );
758
+ this.name = "ClientRequestPendingError";
759
+ markRuntimeError(this, this.name);
760
+ }
761
+ };
762
+ var ClientRequestFailedError = class extends Error {
763
+ static [Symbol.hasInstance](value) {
764
+ return isRuntimeError(value, "ClientRequestFailedError");
765
+ }
766
+ constructor(clientRequestId, reason) {
767
+ super(`clientRequestId "${clientRequestId}" previously failed${reason ? `: ${reason}` : ""}`);
768
+ this.name = "ClientRequestFailedError";
769
+ markRuntimeError(this, this.name);
770
+ }
771
+ };
772
+ var ClientRequestInconsistentStateError = class extends Error {
773
+ static [Symbol.hasInstance](value) {
774
+ return isRuntimeError(value, "ClientRequestInconsistentStateError");
775
+ }
776
+ constructor(clientRequestId, message) {
777
+ super(`clientRequestId "${clientRequestId}" has inconsistent persisted state: ${message}`);
778
+ this.name = "ClientRequestInconsistentStateError";
779
+ markRuntimeError(this, this.name);
780
+ }
781
+ };
782
+ var MessageTransactionRequiredError = class extends Error {
783
+ static [Symbol.hasInstance](value) {
784
+ return isRuntimeError(value, "MessageTransactionRequiredError");
785
+ }
786
+ constructor(cause) {
787
+ super(TRANSACTION_REQUIRED_MESSAGE);
788
+ this.name = "MessageTransactionRequiredError";
789
+ markRuntimeError(this, this.name);
790
+ if (cause) {
791
+ this.cause = cause;
792
+ }
793
+ }
794
+ };
795
+ var MessageModelResolutionError = class extends Error {
796
+ static [Symbol.hasInstance](value) {
797
+ return isRuntimeError(value, "MessageModelResolutionError");
798
+ }
799
+ constructor(role, modelName, connectionName, cause) {
800
+ super(`message-service could not resolve ${role} model "${modelName}" on ${connectionName}`);
801
+ this.name = "MessageModelResolutionError";
802
+ markRuntimeError(this, this.name);
803
+ this.role = role;
804
+ this.modelName = modelName;
805
+ this.connectionName = connectionName;
806
+ if (cause) {
807
+ this.cause = cause;
808
+ }
809
+ }
810
+ };
811
+ var PaymentSessionCompensationError = class extends Error {
812
+ static [Symbol.hasInstance](value) {
813
+ return isRuntimeError(value, "PaymentSessionCompensationError");
814
+ }
815
+ constructor(sessionId, operation, compensationError, originalError, hookError) {
816
+ super(`payment session compensation failed for session "${sessionId}" during ${operation}`);
817
+ this.name = "PaymentSessionCompensationError";
818
+ markRuntimeError(this, this.name);
819
+ this.sessionId = sessionId;
820
+ this.operation = operation;
821
+ this.compensationError = compensationError;
822
+ this.originalError = originalError;
823
+ this.hookError = hookError;
824
+ this.cause = compensationError;
423
825
  }
424
826
  };
425
827
  var MessageService = class {
426
828
  constructor(options) {
427
829
  this.getModel = options.getModel;
830
+ this.connection = options.connection;
831
+ this.modelNames = { ...DEFAULT_MODEL_NAMES, ...options.modelNames };
428
832
  this.paymentProvider = options.paymentProvider ?? null;
429
833
  this.expirePaymentSession = this.paymentProvider?.expireSession.bind(this.paymentProvider);
430
834
  this.refundPaymentSession = this.paymentProvider?.refundPayment.bind(this.paymentProvider);
835
+ this.onPaymentCompensationFailure = options.onPaymentCompensationFailure;
431
836
  this.adminRoles = options.adminRoles ?? [];
432
837
  this.registry = options.registry ?? defaultRegistry;
433
- this.defaultListLimit = options.defaultListLimit ?? DEFAULT_LIST_LIMIT;
434
- this.maxListLimit = options.maxListLimit ?? MAX_LIST_LIMIT;
838
+ this.maxListLimit = this.validatePositiveIntegerOption("maxListLimit", options.maxListLimit ?? MAX_LIST_LIMIT);
839
+ this.defaultListLimit = this.validatePositiveIntegerOption(
840
+ "defaultListLimit",
841
+ options.defaultListLimit ?? DEFAULT_LIST_LIMIT
842
+ );
843
+ if (this.defaultListLimit > this.maxListLimit) {
844
+ throw new InvalidPaginationValueError("defaultListLimit must be less than or equal to maxListLimit");
845
+ }
846
+ this.clientRequestLeaseMs = options.clientRequestLeaseMs ?? CLIENT_REQUEST_LEASE_MS;
847
+ this.clientRequestWaitMs = options.clientRequestWaitMs ?? CLIENT_REQUEST_WAIT_MS;
848
+ this.clientRequestPollMs = options.clientRequestPollMs ?? CLIENT_REQUEST_POLL_MS;
849
+ this.clientRequestDelay = options.clientRequestDelay ?? this.delay;
850
+ this.actionLeaseMs = ACTION_LEASE_MS;
435
851
  }
436
852
  // -------------------------------------------------------------------------
437
853
  // Message lookup
@@ -441,8 +857,8 @@ var MessageService = class {
441
857
  * the message does not exist in either collection.
442
858
  */
443
859
  async findMessage(messageId, options = {}) {
444
- const Message = this.getModel(MESSAGE_MODEL_NAME);
445
- const MessageArchive = this.getModel(MESSAGE_ARCHIVE_MODEL_NAME);
860
+ const Message = this.resolveModel("active");
861
+ const MessageArchive = this.resolveModel("archive");
446
862
  const message = await this.findByIdWithOptions(Message, messageId, options);
447
863
  if (message) return message;
448
864
  return await this.findByIdWithOptions(
@@ -473,7 +889,8 @@ var MessageService = class {
473
889
  req,
474
890
  clientRequestId
475
891
  } = params;
476
- if (clientRequestId) {
892
+ const normalizedClientRequestId = this.normalizeClientRequestId(clientRequestId);
893
+ if (normalizedClientRequestId) {
477
894
  return this.createMessageWithReservation({
478
895
  templateCd,
479
896
  user,
@@ -483,7 +900,7 @@ var MessageService = class {
483
900
  payload,
484
901
  payerUser,
485
902
  req,
486
- clientRequestId
903
+ clientRequestId: normalizedClientRequestId
487
904
  });
488
905
  }
489
906
  return this.createPreparedMessageBatch({
@@ -498,31 +915,38 @@ var MessageService = class {
498
915
  });
499
916
  }
500
917
  async createMessageWithReservation(params) {
501
- while (true) {
502
- const existing = await this.findByClientRequestId(params.clientRequestId);
503
- if (existing) {
504
- return existing;
505
- }
506
- const acquired = await this.tryCreateClientRequestReservation(params.clientRequestId);
507
- if (acquired) {
508
- break;
509
- }
510
- const waited = await this.waitForClientRequestOutcome(params.clientRequestId);
511
- if (waited !== null) {
512
- return waited;
513
- }
514
- }
918
+ const clientRequestScope = this.buildClientRequestScope(params.clientRequestId, params.user, params.templateCd);
919
+ const replay = await this.findCompletedClientRequestReplay(clientRequestScope);
920
+ if (replay !== null) return replay;
921
+ const start = await this.acquireClientRequestStart(clientRequestScope);
922
+ if (start.kind === "replay") return start.replay;
515
923
  try {
516
- const results = await this.createPreparedMessageBatch(params);
517
- await this.completeClientRequestReservation(params.clientRequestId, results.length);
924
+ const results = await this.createPreparedMessageBatch({
925
+ ...params,
926
+ clientRequestScope,
927
+ clientRequestLease: start.lease
928
+ });
518
929
  return results;
519
930
  } catch (error) {
520
- await this.releaseClientRequestReservation(params.clientRequestId);
931
+ if (!(error instanceof ClientRequestPendingError)) {
932
+ await this.failClientRequestReservation(clientRequestScope, start.lease, error);
933
+ }
521
934
  throw error;
522
935
  }
523
936
  }
524
937
  async createPreparedMessageBatch(params) {
525
- const { templateCd, user, roles, identity, permissions, payload, payerUser, req, clientRequestId } = params;
938
+ const {
939
+ templateCd,
940
+ user,
941
+ roles,
942
+ identity,
943
+ permissions,
944
+ payload,
945
+ payerUser,
946
+ req,
947
+ clientRequestScope,
948
+ clientRequestLease
949
+ } = params;
526
950
  const template = this.registry.find(templateCd);
527
951
  if (!template) throw new TemplateNotFoundError(templateCd);
528
952
  const messageData = await template.prepareMessage({
@@ -531,24 +955,36 @@ var MessageService = class {
531
955
  identity,
532
956
  permissions,
533
957
  payload,
534
- getModel: this.getModel,
958
+ getModel: this.getTemplateModelResolver(),
535
959
  req
536
960
  });
537
- if (!messageData) return [];
961
+ if (!messageData) {
962
+ if (clientRequestScope && clientRequestLease) {
963
+ await this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, []);
964
+ }
965
+ return [];
966
+ }
538
967
  const ctx = { user, roles, identity, permissions, payload, payerUser, req };
539
968
  const items = Array.isArray(messageData) ? messageData : [messageData];
969
+ if (clientRequestScope && clientRequestLease) {
970
+ const docs = [];
971
+ for (let index = 0; index < items.length; index++) {
972
+ docs.push(await this.buildMessageDocument(template, items[index], ctx, clientRequestScope, index));
973
+ }
974
+ return this.persistPreparedBatchTransaction(clientRequestScope, clientRequestLease, docs);
975
+ }
540
976
  const results = [];
541
977
  for (let index = 0; index < items.length; index++) {
542
978
  const item = items[index];
543
- results.push(await this.persistItem(template, item, ctx, clientRequestId, clientRequestId ? index : null));
979
+ results.push(await this.persistItem(template, item, ctx, clientRequestScope, clientRequestScope ? index : null));
544
980
  }
545
981
  return results;
546
982
  }
547
983
  // -------------------------------------------------------------------------
548
984
  // Create generic notification (no template, no actions)
549
985
  // -------------------------------------------------------------------------
550
- async createNotification(params) {
551
- const Message = this.getModel(MESSAGE_MODEL_NAME);
986
+ async createNotification(params, sourceDocument) {
987
+ const Message = this.resolveModel("active", sourceDocument);
552
988
  return Message.create({
553
989
  type: "notification",
554
990
  templateCd: GENERIC_NOTIFICATION_TEMPLATE_CD,
@@ -569,7 +1005,7 @@ var MessageService = class {
569
1005
  * queries (e.g. with `populate`).
570
1006
  */
571
1007
  buildVisibilityFilter(user) {
572
- const userId = user.id || String(user._id);
1008
+ const userId = this.requireUserId(user);
573
1009
  return {
574
1010
  $or: [{ fromUser: userId }, { toUser: userId }, { toRoles: { $in: user.roles ?? [] } }]
575
1011
  };
@@ -581,11 +1017,11 @@ var MessageService = class {
581
1017
  */
582
1018
  async listMessages(params) {
583
1019
  const { user, limit: rawLimit, skip: rawSkip, populate } = params;
584
- const limit = Math.min(Math.max(rawLimit ?? this.defaultListLimit, 1), this.maxListLimit);
585
- const skip = Math.max(rawSkip ?? 0, 0);
586
- const Message = this.getModel(MESSAGE_MODEL_NAME);
1020
+ const limit = this.normalizeListLimit(rawLimit);
1021
+ const skip = this.normalizeListSkip(rawSkip);
1022
+ const Message = this.resolveModel("active");
587
1023
  const query = this.applyPopulate(
588
- Message.find(this.buildVisibilityFilter(user)).sort({ createdAt: -1 }).skip(skip).limit(limit),
1024
+ Message.find(this.buildVisibilityFilter(user)).sort({ createdAt: -1, _id: -1 }).skip(skip).limit(limit),
589
1025
  populate
590
1026
  );
591
1027
  return query;
@@ -595,7 +1031,7 @@ var MessageService = class {
595
1031
  * Useful for badge indicators ("3 new messages").
596
1032
  */
597
1033
  async countMessages(user) {
598
- const Message = this.getModel(MESSAGE_MODEL_NAME);
1034
+ const Message = this.resolveModel("active");
599
1035
  return Message.countDocuments(this.buildVisibilityFilter(user));
600
1036
  }
601
1037
  // -------------------------------------------------------------------------
@@ -604,8 +1040,10 @@ var MessageService = class {
604
1040
  async getActions(messageId, usertype, options = {}) {
605
1041
  const message = options.message ?? await this.findMessage(messageId, { populate: options.populate });
606
1042
  if (!message) return null;
607
- if (!options.isAdmin && options.user) {
608
- const isAllowedUsertype = usertype === "sender" ? message.isSender(options.user) : message.isReceiver(options.user);
1043
+ const user = this.requireUserId(options.user);
1044
+ const authorizedUser = { ...options.user, _id: user };
1045
+ if (!options.isAdmin) {
1046
+ const isAllowedUsertype = usertype === "sender" ? message.isSender(authorizedUser) : message.isReceiver(authorizedUser);
609
1047
  if (!isAllowedUsertype) {
610
1048
  return null;
611
1049
  }
@@ -620,7 +1058,7 @@ var MessageService = class {
620
1058
  permissions: options.permissions,
621
1059
  message
622
1060
  });
623
- if (options.isAdmin) {
1061
+ if (options.isAdmin || this.isArchivedMessage(message)) {
624
1062
  return { uiTemplate: interpolated.uiTemplate, actions: [] };
625
1063
  }
626
1064
  return { uiTemplate: interpolated.uiTemplate, actions: interpolated.actions };
@@ -629,68 +1067,387 @@ var MessageService = class {
629
1067
  // Handle an action on a message
630
1068
  // -------------------------------------------------------------------------
631
1069
  async handleAction(templateCd, actionCd, data) {
1070
+ const userId = this.requireUserId(data.user);
1071
+ const authorizedUser = { ...data.user, _id: userId };
632
1072
  if (this.isArchivedMessage(data.message)) {
1073
+ if (data.message.actionNotificationState === "pending" || data.message.actionNotificationState === "failed") {
1074
+ throw new ActionNotificationPendingError(
1075
+ String(data.message._id),
1076
+ data.message.actionAttemptId ?? "",
1077
+ void 0
1078
+ );
1079
+ }
633
1080
  throw new MessageArchivedError(String(data.message._id));
634
1081
  }
635
- const template = this.registry.find(templateCd);
636
- if (!template) throw new TemplateNotFoundError(templateCd);
1082
+ if (templateCd !== data.message.templateCd) {
1083
+ throw new ActionTemplateMismatchError(data.message.templateCd, templateCd);
1084
+ }
1085
+ const template = this.registry.find(data.message.templateCd);
1086
+ if (!template) throw new TemplateNotFoundError(data.message.templateCd);
637
1087
  const action = template.actions.find((a) => a.actionCd === actionCd);
638
- if (!action) throw new ActionNotFoundError(templateCd, actionCd);
639
- if (!isActionAllowed(action, data.user, data.message, { permissions: data.permissions })) {
1088
+ if (!action) throw new ActionNotFoundError(data.message.templateCd, actionCd);
1089
+ if (!isActionAllowed(action, authorizedUser, data.message, { permissions: data.permissions })) {
640
1090
  throw new ActionNotAllowedError();
641
1091
  }
642
- const ctx = this.buildActionContext(data);
643
- const result = await action.runHandler(ctx);
644
- await data.message.archive(actionCd, data.user._id, this.registry);
645
- await this.runSenderNotification(action, ctx, data.message);
1092
+ const claim = await this.claimAction(data.message, actionCd, authorizedUser);
1093
+ const ctx = this.buildActionContext({
1094
+ ...data,
1095
+ user: authorizedUser,
1096
+ message: claim.message,
1097
+ actionAttemptId: claim.actionAttemptId
1098
+ });
1099
+ let result;
1100
+ try {
1101
+ result = await action.runHandler(ctx);
1102
+ } catch (error) {
1103
+ await this.markActionRetryable(data.message._id, claim.actionAttemptId, error, claim.message);
1104
+ throw new ActionRetryableError(String(data.message._id), claim.actionAttemptId, error);
1105
+ }
1106
+ await this.archiveClaimedMessage(claim.message, action, authorizedUser, claim.actionAttemptId);
1107
+ if (action.senderNotification) {
1108
+ try {
1109
+ await this.runSenderNotification(action, ctx, claim.message);
1110
+ await this.markActionNotificationState(
1111
+ data.message._id,
1112
+ claim.actionAttemptId,
1113
+ "sent",
1114
+ void 0,
1115
+ claim.message
1116
+ );
1117
+ } catch (error) {
1118
+ await this.markActionNotificationState(data.message._id, claim.actionAttemptId, "failed", error, claim.message);
1119
+ throw new ActionNotificationPendingError(String(data.message._id), claim.actionAttemptId, result, error);
1120
+ }
1121
+ }
646
1122
  return result;
647
1123
  }
648
1124
  // -------------------------------------------------------------------------
649
1125
  // Private helpers
650
1126
  // -------------------------------------------------------------------------
651
- async persistItem(template, m, ctx, clientRequestId, clientRequestItemIndex) {
1127
+ resolveModel(role, sourceDocument, sourceModel) {
1128
+ const modelName = this.modelNames[role];
1129
+ const sourceConnection = this.getModelConnection(sourceModel) ?? this.getDocumentConnection(sourceDocument);
1130
+ try {
1131
+ if (this.getModel) {
1132
+ const model = this.getModel(modelName);
1133
+ if (!model) {
1134
+ throw new Error(`resolver returned no model for "${modelName}"`);
1135
+ }
1136
+ const resolvedConnection = this.getModelConnection(model);
1137
+ if (sourceConnection && resolvedConnection && resolvedConnection !== sourceConnection) {
1138
+ throw new Error(
1139
+ `resolver returned model "${modelName}" from connection "${resolvedConnection.name || "<unnamed>"}" instead of source connection "${sourceConnection.name || "<unnamed>"}"`
1140
+ );
1141
+ }
1142
+ return model;
1143
+ }
1144
+ if (sourceConnection) {
1145
+ return sourceConnection.model(modelName);
1146
+ }
1147
+ if (this.connection) {
1148
+ return this.connection.model(modelName);
1149
+ }
1150
+ return import_mongoose5.default.model(modelName);
1151
+ } catch (error) {
1152
+ throw new MessageModelResolutionError(role, modelName, this.describeModelSource(sourceConnection), error);
1153
+ }
1154
+ }
1155
+ getTemplateModelResolver(sourceDocument) {
1156
+ const sourceConnection = this.getDocumentConnection(sourceDocument);
1157
+ return (name) => {
1158
+ try {
1159
+ if (sourceConnection) return sourceConnection.model(name);
1160
+ if (this.getModel) return this.getModel(name);
1161
+ if (this.connection) return this.connection.model(name);
1162
+ return import_mongoose5.default.model(name);
1163
+ } catch (error) {
1164
+ throw new MessageModelResolutionError("user", name, this.describeModelSource(sourceConnection), error);
1165
+ }
1166
+ };
1167
+ }
1168
+ getDocumentConnection(sourceDocument) {
1169
+ if (!sourceDocument) return void 0;
1170
+ const model = sourceDocument.constructor;
1171
+ return this.getModelConnection(model);
1172
+ }
1173
+ getModelConnection(model) {
1174
+ const connection = model?.db;
1175
+ return connection && typeof connection.model === "function" ? connection : void 0;
1176
+ }
1177
+ describeModelSource(sourceConnection) {
1178
+ const connection = sourceConnection ?? this.connection;
1179
+ if (connection) {
1180
+ return `Mongoose connection "${connection.name || "<unnamed>"}"`;
1181
+ }
1182
+ if (this.getModel) {
1183
+ return "the configured model resolver";
1184
+ }
1185
+ return "global mongoose";
1186
+ }
1187
+ async persistItem(template, m, ctx, clientRequestScope, clientRequestItemIndex) {
1188
+ const Message = this.resolveModel("active");
1189
+ const doc = await this.buildMessageDocument(template, m, ctx, clientRequestScope, clientRequestItemIndex);
1190
+ try {
1191
+ return await Message.create(doc);
1192
+ } catch (error) {
1193
+ await this.compensatePaymentSessions([doc], error, clientRequestScope);
1194
+ throw error;
1195
+ }
1196
+ }
1197
+ async buildMessageDocument(template, m, ctx, clientRequestScope, clientRequestItemIndex) {
652
1198
  const toUser = m.toUser ?? null;
653
1199
  const toRoles = (m.toRoles && m.toRoles.length > 0 ? m.toRoles : this.adminRoles).slice();
654
1200
  const type = m.type || template.type || "notification";
655
1201
  const paymentCd = m.paymentCd || template.paymentCd || "";
656
1202
  let paymentSession = null;
657
- if (paymentCd && this.paymentProvider && (toUser || toRoles.length > 0)) {
658
- paymentSession = await this.paymentProvider.createSession(
659
- (ctx.payerUser || ctx.user)._id,
1203
+ try {
1204
+ if (paymentCd && this.paymentProvider && (toUser || toRoles.length > 0)) {
1205
+ paymentSession = await this.paymentProvider.createSession(
1206
+ (ctx.payerUser || ctx.user)._id,
1207
+ paymentCd,
1208
+ m.priceArgs
1209
+ );
1210
+ if (!paymentSession) throw new Error("payment session creation failed");
1211
+ }
1212
+ const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
1213
+ return {
1214
+ type,
1215
+ templateCd: template.templateCd,
1216
+ fromUser: m.fromUser || ctx.user._id,
1217
+ toUser,
1218
+ toRoles,
1219
+ senderContent: interpolated.senderContent,
1220
+ receiverContent: interpolated.receiverContent,
1221
+ documents: ctx.payload.documents || [],
1222
+ paymentSession,
660
1223
  paymentCd,
661
- m.priceArgs
1224
+ payload: m.payload || ctx.payload,
1225
+ display: m.display,
1226
+ clientRequestId: clientRequestScope?.clientRequestId ?? null,
1227
+ clientRequestOwnerId: clientRequestScope?.clientRequestOwnerId ?? null,
1228
+ clientRequestItemIndex
1229
+ };
1230
+ } catch (error) {
1231
+ if (paymentSession) {
1232
+ await this.compensatePaymentSession(paymentSession, error, clientRequestScope);
1233
+ }
1234
+ throw error;
1235
+ }
1236
+ }
1237
+ async persistPreparedBatchTransaction(scope, lease, docs) {
1238
+ const Message = this.resolveModel("active");
1239
+ const MessageRequest = this.resolveModel("request", void 0, Message);
1240
+ const operation = async (session2) => {
1241
+ const created = docs.length > 0 ? await Message.create(docs, session2 ? { session: session2, ordered: true } : void 0) : [];
1242
+ const result = await MessageRequest.updateOne(
1243
+ { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1244
+ {
1245
+ $set: {
1246
+ state: "completed",
1247
+ itemCount: docs.length,
1248
+ completedAt: /* @__PURE__ */ new Date(),
1249
+ leaseExpiresAt: null
1250
+ }
1251
+ },
1252
+ session2 ? { session: session2 } : void 0
662
1253
  );
663
- if (!paymentSession) throw new Error("payment session creation failed");
1254
+ if ((result.matchedCount ?? result.n ?? 0) !== 1) {
1255
+ throw new ClientRequestPendingError(scope.clientRequestId);
1256
+ }
1257
+ return created;
1258
+ };
1259
+ const startSession = Message.db?.startSession;
1260
+ if (!startSession) {
1261
+ try {
1262
+ return await operation();
1263
+ } catch (error) {
1264
+ await this.compensatePaymentSessions(docs, error, scope);
1265
+ throw error;
1266
+ }
1267
+ }
1268
+ const session = await startSession.call(Message.db);
1269
+ try {
1270
+ let result = [];
1271
+ await session.withTransaction(async () => {
1272
+ result = await operation(session);
1273
+ });
1274
+ return result;
1275
+ } catch (error) {
1276
+ const wrappedError = this.isTransactionSupportError(error) ? new MessageTransactionRequiredError(error) : error;
1277
+ await this.compensatePaymentSessions(docs, wrappedError, scope);
1278
+ if (this.isTransactionSupportError(error)) {
1279
+ throw wrappedError;
1280
+ }
1281
+ throw wrappedError;
1282
+ } finally {
1283
+ await session.endSession();
1284
+ }
1285
+ }
1286
+ async compensatePaymentSessions(docs, originalError, scope) {
1287
+ const sessionIds = docs.map((doc) => doc.paymentSession).filter((sessionId) => typeof sessionId === "string" && sessionId.length > 0);
1288
+ for (const sessionId of sessionIds) {
1289
+ await this.compensatePaymentSession(sessionId, originalError, scope);
1290
+ }
1291
+ }
1292
+ async compensatePaymentSession(sessionId, originalError, scope) {
1293
+ if (!this.expirePaymentSession) return;
1294
+ try {
1295
+ await this.expirePaymentSession(sessionId);
1296
+ } catch (error) {
1297
+ let hookError;
1298
+ try {
1299
+ await this.onPaymentCompensationFailure?.({
1300
+ operation: "expire",
1301
+ sessionId,
1302
+ error,
1303
+ originalError,
1304
+ clientRequestId: scope?.clientRequestId,
1305
+ clientRequestOwnerId: scope?.clientRequestOwnerId,
1306
+ templateCd: scope?.templateCd
1307
+ });
1308
+ } catch (eventError) {
1309
+ hookError = eventError;
1310
+ }
1311
+ throw new PaymentSessionCompensationError(sessionId, "expire", error, originalError, hookError);
664
1312
  }
665
- const interpolated = interpolateTemplate(template, m.templateData || {}, "receiver");
666
- const Message = this.getModel(MESSAGE_MODEL_NAME);
667
- return Message.create({
668
- type,
669
- templateCd: template.templateCd,
670
- fromUser: m.fromUser || ctx.user._id,
671
- toUser,
672
- toRoles,
673
- senderContent: interpolated.senderContent,
674
- receiverContent: interpolated.receiverContent,
675
- documents: ctx.payload.documents || [],
676
- paymentSession,
677
- paymentCd,
678
- payload: m.payload || ctx.payload,
679
- display: m.display,
680
- clientRequestId: clientRequestId ?? null,
681
- clientRequestItemIndex
682
- });
683
1313
  }
684
1314
  buildActionContext(data) {
685
1315
  return {
686
1316
  message: data.message,
687
1317
  user: data.user,
688
- getModel: this.getModel,
1318
+ actionAttemptId: data.actionAttemptId,
1319
+ getModel: this.getTemplateModelResolver(data.message),
689
1320
  expireSession: this.expirePaymentSession,
690
1321
  refundPayment: this.refundPaymentSession,
691
1322
  req: data.req
692
1323
  };
693
1324
  }
1325
+ async claimAction(message, actionCd, user) {
1326
+ const Message = this.resolveModel("active", message);
1327
+ const messageId = message._id;
1328
+ const now = /* @__PURE__ */ new Date();
1329
+ const claimBase = {
1330
+ actionCd,
1331
+ actionClaimedBy: String(user._id),
1332
+ actionClaimedAt: now,
1333
+ actionLeaseExpiresAt: new Date(now.getTime() + this.actionLeaseMs),
1334
+ actionFailureMessage: null
1335
+ };
1336
+ const firstAttemptId = (0, import_node_crypto.randomUUID)();
1337
+ const firstClaim = await Message.findOneAndUpdate(
1338
+ { _id: messageId, $or: [{ actionState: "active" }, { actionState: null }, { actionState: { $exists: false } }] },
1339
+ { $set: { ...claimBase, actionState: "processing", actionAttemptId: firstAttemptId } },
1340
+ { returnDocument: "after" }
1341
+ );
1342
+ if (firstClaim?.actionAttemptId) {
1343
+ return { message: firstClaim, actionAttemptId: firstClaim.actionAttemptId };
1344
+ }
1345
+ const retryClaim = await Message.findOneAndUpdate(
1346
+ {
1347
+ _id: messageId,
1348
+ actionCd,
1349
+ actionAttemptId: { $type: "string" },
1350
+ $or: [{ actionState: "retryable" }, { actionState: "processing", actionLeaseExpiresAt: { $lte: now } }]
1351
+ },
1352
+ { $set: { ...claimBase, actionState: "processing" } },
1353
+ { returnDocument: "after" }
1354
+ );
1355
+ if (retryClaim?.actionAttemptId) {
1356
+ return { message: retryClaim, actionAttemptId: retryClaim.actionAttemptId };
1357
+ }
1358
+ const archived = await this.resolveModel("archive", message, Message).findById(
1359
+ messageId
1360
+ );
1361
+ if (archived) {
1362
+ if (archived.actionNotificationState === "pending" || archived.actionNotificationState === "failed") {
1363
+ throw new ActionNotificationPendingError(String(messageId), archived.actionAttemptId ?? "", void 0);
1364
+ }
1365
+ throw new MessageArchivedError(String(messageId));
1366
+ }
1367
+ throw new ActionConflictError(String(messageId));
1368
+ }
1369
+ async markActionRetryable(messageId, actionAttemptId, error, sourceDocument) {
1370
+ const Message = this.resolveModel("active", sourceDocument);
1371
+ await Message.updateOne(
1372
+ { _id: messageId, actionState: "processing", actionAttemptId },
1373
+ {
1374
+ $set: {
1375
+ actionState: "retryable",
1376
+ actionFailureMessage: error instanceof Error ? error.message : String(error),
1377
+ actionLeaseExpiresAt: null
1378
+ }
1379
+ }
1380
+ );
1381
+ }
1382
+ async archiveClaimedMessage(message, action, user, actionAttemptId) {
1383
+ const Message = this.resolveModel("active", message);
1384
+ const MessageArchive = this.resolveModel("archive", message, Message);
1385
+ const messageId = message._id;
1386
+ const data = message.toObject();
1387
+ const notificationState = action.senderNotification ? "pending" : "none";
1388
+ delete data.actionState;
1389
+ delete data.actionClaimedBy;
1390
+ delete data.actionClaimedAt;
1391
+ delete data.actionLeaseExpiresAt;
1392
+ delete data.actionFailureMessage;
1393
+ const operation = async (session2) => {
1394
+ await MessageArchive.create(
1395
+ [
1396
+ {
1397
+ ...data,
1398
+ actionCd: action.actionCd,
1399
+ archivedBy: user._id,
1400
+ archivedAt: /* @__PURE__ */ new Date(),
1401
+ actionAttemptId,
1402
+ actionNotificationState: notificationState,
1403
+ actionNotificationError: null,
1404
+ actionNotificationAttemptedAt: null
1405
+ }
1406
+ ],
1407
+ session2 ? { session: session2, ordered: true } : void 0
1408
+ );
1409
+ const deleted = await Message.deleteOne(
1410
+ { _id: messageId, actionState: "processing", actionAttemptId },
1411
+ session2 ? { session: session2 } : void 0
1412
+ );
1413
+ if ((deleted.deletedCount ?? deleted.n ?? 0) !== 1) {
1414
+ throw new ActionConflictError(String(messageId));
1415
+ }
1416
+ };
1417
+ const startSession = Message.db?.startSession;
1418
+ if (!startSession) {
1419
+ await operation();
1420
+ return;
1421
+ }
1422
+ const session = await startSession.call(Message.db);
1423
+ try {
1424
+ await session.withTransaction(async () => {
1425
+ await operation(session);
1426
+ });
1427
+ } catch (error) {
1428
+ if (this.isTransactionSupportError(error)) {
1429
+ await this.markActionRetryable(messageId, actionAttemptId, error, message);
1430
+ throw new MessageTransactionRequiredError(error);
1431
+ }
1432
+ await this.markActionRetryable(messageId, actionAttemptId, error, message);
1433
+ throw error;
1434
+ } finally {
1435
+ await session.endSession();
1436
+ }
1437
+ }
1438
+ async markActionNotificationState(messageId, actionAttemptId, state, error, sourceDocument) {
1439
+ const MessageArchive = this.resolveModel("archive", sourceDocument);
1440
+ await MessageArchive.updateOne(
1441
+ { _id: messageId, actionAttemptId },
1442
+ {
1443
+ $set: {
1444
+ actionNotificationState: state,
1445
+ actionNotificationError: state === "failed" ? error instanceof Error ? error.message : String(error) : null,
1446
+ actionNotificationAttemptedAt: /* @__PURE__ */ new Date()
1447
+ }
1448
+ }
1449
+ );
1450
+ }
694
1451
  async runSenderNotification(action, ctx, message) {
695
1452
  if (!action.senderNotification) return;
696
1453
  let content;
@@ -707,70 +1464,212 @@ var MessageService = class {
707
1464
  };
708
1465
  const documents = !(0, import_utils.isString)(content) ? content.documents || [] : [];
709
1466
  if (message.fromUser) {
710
- await this.createNotification({
711
- toUser: message.fromUser,
712
- receiverContent: senderNotificationContent,
713
- documents
714
- });
1467
+ await this.createNotification(
1468
+ {
1469
+ toUser: message.fromUser,
1470
+ receiverContent: senderNotificationContent,
1471
+ documents
1472
+ },
1473
+ message
1474
+ );
1475
+ }
1476
+ }
1477
+ normalizeClientRequestId(clientRequestId) {
1478
+ if (clientRequestId === void 0) {
1479
+ return void 0;
715
1480
  }
1481
+ if (typeof clientRequestId !== "string") {
1482
+ throw new InvalidClientRequestIdError("clientRequestId must be a string when provided");
1483
+ }
1484
+ const trimmed = clientRequestId.trim();
1485
+ if (trimmed.length === 0) {
1486
+ throw new InvalidClientRequestIdError("clientRequestId must be a non-empty string after trimming whitespace");
1487
+ }
1488
+ if (trimmed.length > MAX_CLIENT_REQUEST_ID_LENGTH) {
1489
+ throw new InvalidClientRequestIdError(
1490
+ `clientRequestId must be at most ${MAX_CLIENT_REQUEST_ID_LENGTH} characters`
1491
+ );
1492
+ }
1493
+ return trimmed;
716
1494
  }
717
- async findByClientRequestId(clientRequestId) {
718
- const Message = this.getModel(MESSAGE_MODEL_NAME);
719
- const docs = await Message.find({ clientRequestId }).sort({ createdAt: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
720
- return docs.length > 0 ? docs : null;
1495
+ validatePositiveIntegerOption(name, value) {
1496
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1497
+ throw new InvalidPaginationValueError(`${name} must be a finite integer`);
1498
+ }
1499
+ if (value < 1) {
1500
+ throw new InvalidPaginationValueError(`${name} must be at least 1`);
1501
+ }
1502
+ return value;
721
1503
  }
722
- async waitForClientRequestOutcome(clientRequestId) {
723
- for (let attempt = 0; attempt < CLIENT_REQUEST_BATCH_RETRY_COUNT; attempt++) {
724
- const docs2 = await this.findByClientRequestId(clientRequestId);
725
- if (docs2) {
726
- return docs2;
727
- }
728
- const reservation2 = await this.findClientRequestReservation(clientRequestId);
729
- if (!reservation2) {
730
- return null;
1504
+ normalizeListLimit(value) {
1505
+ if (value === void 0) {
1506
+ return this.defaultListLimit;
1507
+ }
1508
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1509
+ throw new InvalidPaginationValueError("listMessages limit must be a finite integer");
1510
+ }
1511
+ return Math.min(Math.max(value, 1), this.maxListLimit);
1512
+ }
1513
+ normalizeListSkip(value) {
1514
+ if (value === void 0) {
1515
+ return 0;
1516
+ }
1517
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1518
+ throw new InvalidPaginationValueError("listMessages skip must be a finite integer");
1519
+ }
1520
+ return Math.max(value, 0);
1521
+ }
1522
+ buildClientRequestScope(clientRequestId, user, templateCd) {
1523
+ return {
1524
+ clientRequestId,
1525
+ clientRequestOwnerId: this.requireUserId(user),
1526
+ templateCd
1527
+ };
1528
+ }
1529
+ requireUserId(user) {
1530
+ if (!user || user._id === void 0 || user._id === null) {
1531
+ throw new InvalidMessageUserError();
1532
+ }
1533
+ if (typeof user._id === "string") {
1534
+ const id = user._id.trim();
1535
+ if (id.length === 0) {
1536
+ throw new InvalidMessageUserError();
731
1537
  }
732
- if (reservation2.state === "completed") {
733
- return [];
1538
+ return id;
1539
+ }
1540
+ if (user._id instanceof import_mongoose5.default.Types.ObjectId) {
1541
+ return String(user._id);
1542
+ }
1543
+ throw new InvalidMessageUserError();
1544
+ }
1545
+ async findByClientRequestScope(scope) {
1546
+ const Message = this.resolveModel("active");
1547
+ const docs = await Message.find(scope).sort({ clientRequestItemIndex: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
1548
+ return docs;
1549
+ }
1550
+ async findCompletedClientRequestReplay(scope) {
1551
+ const reservation = await this.findClientRequestReservation(scope);
1552
+ const docs = await this.findByClientRequestScope(scope);
1553
+ if (!reservation) {
1554
+ if (docs.length > 0) {
1555
+ throw new ClientRequestInconsistentStateError(scope.clientRequestId, "messages exist without a reservation");
734
1556
  }
735
- await this.delay(CLIENT_REQUEST_BATCH_RETRY_DELAY_MS);
1557
+ return null;
736
1558
  }
737
- const docs = await this.findByClientRequestId(clientRequestId);
738
- if (docs) {
739
- return docs;
1559
+ if (reservation.state === "failed") {
1560
+ throw new ClientRequestFailedError(scope.clientRequestId, reservation.failureMessage);
740
1561
  }
741
- const reservation = await this.findClientRequestReservation(clientRequestId);
742
- if (reservation?.state === "completed") {
1562
+ if (reservation.state !== "completed") {
1563
+ return null;
1564
+ }
1565
+ if (reservation.itemCount === null || reservation.itemCount < 0 || !Number.isInteger(reservation.itemCount)) {
1566
+ throw new ClientRequestInconsistentStateError(
1567
+ scope.clientRequestId,
1568
+ "completed reservation has an invalid itemCount"
1569
+ );
1570
+ }
1571
+ if (reservation.itemCount === 0) {
1572
+ if (docs.length > 0) {
1573
+ throw new ClientRequestInconsistentStateError(
1574
+ scope.clientRequestId,
1575
+ "completed zero-item reservation has messages"
1576
+ );
1577
+ }
743
1578
  return [];
744
1579
  }
745
- return null;
1580
+ const indexes = new Set(docs.map((doc) => doc.clientRequestItemIndex));
1581
+ const hasExpectedIndexes = docs.length === reservation.itemCount && indexes.size === reservation.itemCount && Array.from({ length: reservation.itemCount }, (_, index) => indexes.has(index)).every(Boolean);
1582
+ if (!hasExpectedIndexes) {
1583
+ throw new ClientRequestInconsistentStateError(
1584
+ scope.clientRequestId,
1585
+ `completed reservation expects item indexes 0..${reservation.itemCount - 1} but found ${JSON.stringify(
1586
+ Array.from(indexes)
1587
+ )}`
1588
+ );
1589
+ }
1590
+ return docs;
746
1591
  }
747
- async tryCreateClientRequestReservation(clientRequestId) {
748
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
1592
+ async acquireClientRequestStart(scope) {
1593
+ const deadline = Date.now() + this.clientRequestWaitMs;
1594
+ while (true) {
1595
+ const lease = await this.tryAcquireClientRequestLease(scope);
1596
+ if (lease) {
1597
+ return { kind: "lease", lease };
1598
+ }
1599
+ const replay = await this.findCompletedClientRequestReplay(scope);
1600
+ if (replay !== null) {
1601
+ return { kind: "replay", replay };
1602
+ }
1603
+ if (Date.now() >= deadline) {
1604
+ throw new ClientRequestPendingError(scope.clientRequestId);
1605
+ }
1606
+ await this.clientRequestDelay(Math.min(this.clientRequestPollMs, Math.max(deadline - Date.now(), 0)));
1607
+ }
1608
+ }
1609
+ async tryAcquireClientRequestLease(scope) {
1610
+ const created = await this.tryCreateClientRequestReservation(scope);
1611
+ if (created) return created;
1612
+ return this.tryTakeOverStaleClientRequestReservation(scope);
1613
+ }
1614
+ async tryCreateClientRequestReservation(scope) {
1615
+ const MessageRequest = this.resolveModel("request");
1616
+ const ownerId = (0, import_node_crypto.randomUUID)();
749
1617
  try {
750
- await MessageRequest.create({ clientRequestId, state: "pending", itemCount: null });
751
- return true;
1618
+ await MessageRequest.create({
1619
+ ...scope,
1620
+ state: "pending",
1621
+ itemCount: null,
1622
+ leaseOwnerId: ownerId,
1623
+ leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
1624
+ });
1625
+ return { ownerId };
752
1626
  } catch (error) {
753
1627
  if (this.isDuplicateKeyError(error)) {
754
- return false;
1628
+ return null;
755
1629
  }
756
1630
  throw error;
757
1631
  }
758
1632
  }
759
- async findClientRequestReservation(clientRequestId) {
760
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
761
- return await MessageRequest.findOne({ clientRequestId });
1633
+ async tryTakeOverStaleClientRequestReservation(scope) {
1634
+ const MessageRequest = this.resolveModel("request");
1635
+ const ownerId = (0, import_node_crypto.randomUUID)();
1636
+ const reservation = await MessageRequest.findOneAndUpdate(
1637
+ { ...scope, state: "pending", leaseExpiresAt: { $lte: /* @__PURE__ */ new Date() } },
1638
+ {
1639
+ $set: {
1640
+ leaseOwnerId: ownerId,
1641
+ leaseExpiresAt: new Date(Date.now() + this.clientRequestLeaseMs)
1642
+ }
1643
+ },
1644
+ { returnDocument: "after" }
1645
+ );
1646
+ return reservation ? { ownerId } : null;
762
1647
  }
763
- async completeClientRequestReservation(clientRequestId, itemCount) {
764
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
765
- await MessageRequest.updateOne({ clientRequestId }, { state: "completed", itemCount });
1648
+ async findClientRequestReservation(scope) {
1649
+ const MessageRequest = this.resolveModel("request");
1650
+ return await MessageRequest.findOne(scope);
766
1651
  }
767
- async releaseClientRequestReservation(clientRequestId) {
768
- const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
769
- await MessageRequest.deleteOne({ clientRequestId });
1652
+ async failClientRequestReservation(scope, lease, error) {
1653
+ const MessageRequest = this.resolveModel("request");
1654
+ await MessageRequest.updateOne(
1655
+ { ...scope, state: "pending", leaseOwnerId: lease.ownerId },
1656
+ {
1657
+ $set: {
1658
+ state: "failed",
1659
+ failedAt: /* @__PURE__ */ new Date(),
1660
+ failureMessage: error instanceof Error ? error.message : String(error),
1661
+ leaseExpiresAt: null
1662
+ }
1663
+ }
1664
+ );
770
1665
  }
771
1666
  isDuplicateKeyError(error) {
772
1667
  return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
773
1668
  }
1669
+ isTransactionSupportError(error) {
1670
+ if (!(error instanceof Error)) return false;
1671
+ return error.message.includes("Transaction numbers are only allowed") || error.message.includes("Transaction is not supported") || error.message.includes("transactions are not supported");
1672
+ }
774
1673
  delay(ms) {
775
1674
  return new Promise((resolve) => setTimeout(resolve, ms));
776
1675
  }
@@ -799,10 +1698,45 @@ var MessageService = class {
799
1698
  // src/route-factory.ts
800
1699
  var import_express_json_router = __toESM(require("@web-ts-toolkit/express-json-router"));
801
1700
  var ACTION_CD_PATTERN = /^[a-zA-Z0-9_-]+$/;
1701
+ var ROUTE_CODE_PATTERN = /^[a-zA-Z0-9_.-]+$/;
1702
+ var OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
1703
+ var MAX_ROUTE_CODE_LENGTH = 128;
1704
+ var MAX_CLIENT_REQUEST_ID_LENGTH2 = 128;
1705
+ function assertRouteCode(value, name) {
1706
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_ROUTE_CODE_LENGTH || !ROUTE_CODE_PATTERN.test(value)) {
1707
+ throw new import_express_json_router.default.clientErrors.BadRequestError(
1708
+ `${name} must be a non-empty string of at most ${MAX_ROUTE_CODE_LENGTH} letters, digits, dots, underscores, and hyphens`
1709
+ );
1710
+ }
1711
+ }
802
1712
  function assertValidActionCd(actionCd) {
803
- if (typeof actionCd !== "string" || !ACTION_CD_PATTERN.test(actionCd)) {
1713
+ if (typeof actionCd !== "string" || actionCd.length === 0 || actionCd.length > MAX_ROUTE_CODE_LENGTH || !ACTION_CD_PATTERN.test(actionCd)) {
804
1714
  throw new import_express_json_router.default.clientErrors.BadRequestError(
805
- "actionCd must be a string of letters, digits, underscores, and hyphens"
1715
+ `actionCd must be a non-empty string of at most ${MAX_ROUTE_CODE_LENGTH} letters, digits, underscores, and hyphens`
1716
+ );
1717
+ }
1718
+ }
1719
+ function assertValidMessageId(id) {
1720
+ if (typeof id !== "string" || !OBJECT_ID_PATTERN.test(id)) {
1721
+ throw new import_express_json_router.default.clientErrors.BadRequestError("id must be a 24-character hex ObjectId");
1722
+ }
1723
+ }
1724
+ function assertValidClientRequestId(clientRequestId) {
1725
+ if (clientRequestId === void 0) {
1726
+ return;
1727
+ }
1728
+ if (typeof clientRequestId !== "string") {
1729
+ throw new import_express_json_router.default.clientErrors.BadRequestError("clientRequestId must be a string when provided");
1730
+ }
1731
+ const trimmed = clientRequestId.trim();
1732
+ if (trimmed.length === 0) {
1733
+ throw new import_express_json_router.default.clientErrors.BadRequestError(
1734
+ "clientRequestId must be a non-empty string after trimming whitespace"
1735
+ );
1736
+ }
1737
+ if (trimmed.length > MAX_CLIENT_REQUEST_ID_LENGTH2) {
1738
+ throw new import_express_json_router.default.clientErrors.BadRequestError(
1739
+ `clientRequestId must be at most ${MAX_CLIENT_REQUEST_ID_LENGTH2} characters`
806
1740
  );
807
1741
  }
808
1742
  }
@@ -811,6 +1745,23 @@ function assertValidUsertype(usertype) {
811
1745
  throw new import_express_json_router.default.clientErrors.BadRequestError('usertype must be "sender" or "receiver"');
812
1746
  }
813
1747
  }
1748
+ function assertValidBody(body) {
1749
+ if (body === void 0 || body === null) {
1750
+ return;
1751
+ }
1752
+ if (typeof body !== "object" || Array.isArray(body)) {
1753
+ throw new import_express_json_router.default.clientErrors.BadRequestError("body must be a JSON object");
1754
+ }
1755
+ }
1756
+ function requireUser(user) {
1757
+ if (!user || user._id === void 0 || user._id === null || String(user._id).trim().length === 0) {
1758
+ throw new import_express_json_router.default.clientErrors.UnauthorizedError("authentication required");
1759
+ }
1760
+ return user;
1761
+ }
1762
+ function isMongooseCastError(error) {
1763
+ return !!error && typeof error === "object" && error.name === "CastError";
1764
+ }
814
1765
  function mapServiceError(error) {
815
1766
  if (error instanceof MessageNotFoundError) {
816
1767
  throw new import_express_json_router.default.clientErrors.NotFoundError("message not found");
@@ -824,15 +1775,34 @@ function mapServiceError(error) {
824
1775
  if (error instanceof ActionNotAllowedError) {
825
1776
  throw new import_express_json_router.default.clientErrors.ForbiddenError(error.message);
826
1777
  }
1778
+ if (error instanceof ActionConflictError) {
1779
+ throw new import_express_json_router.default.clientErrors.ConflictError(error.message);
1780
+ }
1781
+ if (error instanceof ActionRetryableError) {
1782
+ throw new import_express_json_router.default.clientErrors.ConflictError(error.message);
1783
+ }
827
1784
  if (error instanceof MessageArchivedError) {
828
1785
  throw new import_express_json_router.default.clientErrors.GoneError(error.message);
829
1786
  }
1787
+ if (error instanceof InvalidClientRequestIdError) {
1788
+ throw new import_express_json_router.default.clientErrors.BadRequestError(error.message);
1789
+ }
1790
+ if (error instanceof InvalidMessageUserError) {
1791
+ throw new import_express_json_router.default.clientErrors.UnauthorizedError("authentication required");
1792
+ }
1793
+ if (error instanceof ClientRequestPendingError || error instanceof ClientRequestFailedError) {
1794
+ throw new import_express_json_router.default.clientErrors.ConflictError(error.message);
1795
+ }
1796
+ if (isMongooseCastError(error)) {
1797
+ throw new import_express_json_router.default.clientErrors.BadRequestError("id must be a valid ObjectId");
1798
+ }
830
1799
  throw error;
831
1800
  }
832
1801
  function createMessageRoutes(options) {
833
1802
  const {
834
1803
  getModel,
835
1804
  paymentProvider = null,
1805
+ onPaymentCompensationFailure,
836
1806
  adminRoles,
837
1807
  registry,
838
1808
  authMiddleware = [],
@@ -841,17 +1811,20 @@ function createMessageRoutes(options) {
841
1811
  getIdentity = defaultGetIdentity,
842
1812
  adminPermissionKey = "is.admin"
843
1813
  } = options;
844
- const service = new MessageService({ getModel, paymentProvider, adminRoles, registry });
1814
+ const service = new MessageService({ getModel, paymentProvider, onPaymentCompensationFailure, adminRoles, registry });
845
1815
  const router = new import_express_json_router.default("", authMiddleware);
846
1816
  router.post("/new/:templateCd", async (req) => {
847
1817
  const templateCd = req.params.templateCd;
848
- const user = getUser(req) || { _id: "" };
1818
+ const user = requireUser(getUser(req));
1819
+ assertRouteCode(templateCd, "templateCd");
1820
+ assertValidBody(req.body);
849
1821
  const roles = user.roles || [];
850
1822
  const identity = getIdentity(req);
851
1823
  const permissions = getPermissions(req);
852
1824
  const body = req.body || {};
853
1825
  const { clientRequestId, ...payload } = body;
854
- const clientRequestIdStr = typeof clientRequestId === "string" ? clientRequestId : void 0;
1826
+ const hasClientRequestId = Object.prototype.hasOwnProperty.call(body, "clientRequestId");
1827
+ assertValidClientRequestId(hasClientRequestId ? clientRequestId : void 0);
855
1828
  try {
856
1829
  return await service.createMessage({
857
1830
  templateCd,
@@ -862,7 +1835,7 @@ function createMessageRoutes(options) {
862
1835
  payload,
863
1836
  payerUser: user,
864
1837
  req,
865
- clientRequestId: clientRequestIdStr
1838
+ clientRequestId: hasClientRequestId ? clientRequestId : void 0
866
1839
  });
867
1840
  } catch (error) {
868
1841
  mapServiceError(error);
@@ -871,11 +1844,17 @@ function createMessageRoutes(options) {
871
1844
  router.get("/:id/actions/:usertype", async (req) => {
872
1845
  const id = req.params.id;
873
1846
  const usertype = req.params.usertype;
1847
+ const user = requireUser(getUser(req));
1848
+ assertValidMessageId(id);
874
1849
  assertValidUsertype(usertype);
875
1850
  const permissions = getPermissions(req);
876
1851
  const isAdmin = !!permissions[adminPermissionKey];
877
- const user = getUser(req) || { _id: "" };
878
- const result = await service.getActions(id, usertype, { permissions, user, isAdmin });
1852
+ let result;
1853
+ try {
1854
+ result = await service.getActions(id, usertype, { permissions, user, isAdmin });
1855
+ } catch (error) {
1856
+ mapServiceError(error);
1857
+ }
879
1858
  if (!result) {
880
1859
  throw new import_express_json_router.default.clientErrors.NotFoundError("message not found");
881
1860
  }
@@ -884,6 +1863,8 @@ function createMessageRoutes(options) {
884
1863
  async function handleAction(req) {
885
1864
  const id = req.params.id;
886
1865
  const actionCd = req.params.actionCd;
1866
+ const user = requireUser(getUser(req));
1867
+ assertValidMessageId(id);
887
1868
  assertValidActionCd(actionCd);
888
1869
  let message;
889
1870
  try {
@@ -891,15 +1872,16 @@ function createMessageRoutes(options) {
891
1872
  } catch (error) {
892
1873
  mapServiceError(error);
893
1874
  }
894
- const user = getUser(req) || { _id: "" };
895
1875
  const permissions = getPermissions(req);
896
1876
  try {
897
1877
  return await service.handleAction(message.templateCd, actionCd, { message, user, permissions, req });
898
1878
  } catch (error) {
1879
+ if (error instanceof ActionNotificationPendingError) {
1880
+ return new import_express_json_router.default.success.Accepted({ message: error.message, actionAttemptId: error.actionAttemptId });
1881
+ }
899
1882
  mapServiceError(error);
900
1883
  }
901
1884
  }
902
- router.get("/:id/action/:actionCd", handleAction);
903
1885
  router.post("/:id/action/:actionCd", handleAction);
904
1886
  return { router, service };
905
1887
  }
@@ -915,24 +1897,38 @@ function defaultGetIdentity(req) {
915
1897
  }
916
1898
  // Annotate the CommonJS export names for ESM import in node:
917
1899
  0 && (module.exports = {
1900
+ ActionConflictError,
918
1901
  ActionNotAllowedError,
919
1902
  ActionNotFoundError,
1903
+ ActionNotificationPendingError,
1904
+ ActionRetryableError,
1905
+ ActionTemplateMismatchError,
920
1906
  BaseMessageFields,
1907
+ ClientRequestFailedError,
1908
+ ClientRequestInconsistentStateError,
1909
+ ClientRequestPendingError,
921
1910
  GENERIC_NOTIFICATION_TEMPLATE_CD,
1911
+ InvalidClientRequestIdError,
1912
+ InvalidMessageUserError,
1913
+ InvalidPaginationValueError,
922
1914
  MESSAGE_ARCHIVE_MODEL_NAME,
923
1915
  MESSAGE_MODEL_NAME,
924
1916
  MESSAGE_REQUEST_MODEL_NAME,
925
1917
  MessageArchiveSchema,
926
1918
  MessageArchivedError,
927
1919
  MessageContentSchema,
1920
+ MessageModelResolutionError,
928
1921
  MessageNotFoundError,
929
1922
  MessageRequestSchema,
930
1923
  MessageSchema,
931
1924
  MessageService,
1925
+ MessageTransactionRequiredError,
932
1926
  NoopEmailProvider,
933
1927
  NoopPaymentProvider,
1928
+ PaymentSessionCompensationError,
934
1929
  TemplateNotFoundError,
935
1930
  TemplateRegistry,
1931
+ TemplateRegistryValidationError,
936
1932
  buildMessageArchiveSchema,
937
1933
  buildMessageRequestSchema,
938
1934
  buildMessageSchema,