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