@pegma/support-desk-application 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3048 @@
1
+ import { auditRecordId, defineAudit } from "@pegma/audit";
2
+ import { hasPermission } from "@pegma/authorization-core";
3
+ import { defineMail, maxMailAttempts } from "@pegma/mail";
4
+ import { noopLogger } from "@pegma/spine";
5
+ import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
+ import { applyTicketEvent, createTicket } from "@pegma/support-desk-core";
7
+ export const supportPermissions = Object.freeze({
8
+ create: "support.ticket.create",
9
+ readOwn: "support.ticket.read.own",
10
+ replyOwn: "support.ticket.reply.own",
11
+ queueRead: "support.queue.read",
12
+ replyAny: "support.ticket.reply.any",
13
+ note: "support.ticket.note",
14
+ assign: "support.ticket.assign",
15
+ manage: "support.ticket.manage",
16
+ auditRead: "support.audit.read",
17
+ });
18
+ export const defaultSupportDeskLimits = Object.freeze({
19
+ maxSubjectCharacters: 200,
20
+ maxMessageCharacters: 20_000,
21
+ maxTicketsPerPrincipal: 100,
22
+ maxMessagesPerTicket: 100,
23
+ maxRecordsPerTicket: 512,
24
+ });
25
+ export class SupportDeskAuthorizationError extends Error {
26
+ permission;
27
+ constructor(permission) {
28
+ super(`Permission required: ${permission}`);
29
+ this.permission = permission;
30
+ this.name = "SupportDeskAuthorizationError";
31
+ }
32
+ }
33
+ export class SupportDeskNotFoundError extends Error {
34
+ constructor() {
35
+ super("Ticket not found");
36
+ this.name = "SupportDeskNotFoundError";
37
+ }
38
+ }
39
+ export class SupportDeskConflictError extends Error {
40
+ constructor(message = "The ticket changed before the command could commit") {
41
+ super(message);
42
+ this.name = "SupportDeskConflictError";
43
+ }
44
+ }
45
+ export class SupportDeskLimitError extends Error {
46
+ field;
47
+ maximum;
48
+ constructor(field, maximum) {
49
+ super(`${field} exceeds the configured limit of ${maximum}`);
50
+ this.field = field;
51
+ this.maximum = maximum;
52
+ this.name = "SupportDeskLimitError";
53
+ }
54
+ }
55
+ /**
56
+ * Staff queue online scan hit a configured materialization budget before the
57
+ * projection scan cycle completed. Hosts map this to HTTP 503; never return a
58
+ * partial queue page for this outcome.
59
+ */
60
+ export class SupportDeskQueueCapacityError extends Error {
61
+ budget;
62
+ maximum;
63
+ constructor(budget, maximum) {
64
+ super(`Staff queue scan exhausted the ${budget} budget of ${maximum} before a complete cycle`);
65
+ this.budget = budget;
66
+ this.maximum = maximum;
67
+ this.name = "SupportDeskQueueCapacityError";
68
+ }
69
+ }
70
+ function encodeIdempotencyPart(value) {
71
+ return encodeURIComponent(value).replaceAll(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
72
+ }
73
+ /** Closed MVP registry of durable accepted-change audit action names. */
74
+ export const supportTicketAuditActions = Object.freeze({
75
+ created: "support.ticket.created",
76
+ customerReplied: "support.ticket.customer_replied",
77
+ staffReplied: "support.ticket.staff_replied",
78
+ noteAdded: "support.ticket.note_added",
79
+ assigned: "support.ticket.assigned",
80
+ unassigned: "support.ticket.unassigned",
81
+ priorityChanged: "support.ticket.priority_changed",
82
+ resolved: "support.ticket.resolved",
83
+ closed: "support.ticket.closed",
84
+ reopened: "support.ticket.reopened",
85
+ });
86
+ export const deliveryCallbackDedupeDays = 30;
87
+ export const maxDeliveryCallbacksPerBucket = 256;
88
+ export const inboundReceiptDedupeDays = 30;
89
+ export const maxInboundReceiptsPerBucket = 256;
90
+ function receiptSlotId(slot, field) {
91
+ if (!/^[0-9a-f]{2}$/.test(slot)) {
92
+ throw new TypeError(`${field} must be exactly two lowercase hex digits`);
93
+ }
94
+ return `slot:${slot}`;
95
+ }
96
+ function jsonCodec(key) {
97
+ return {
98
+ encode(value) {
99
+ const location = key(value);
100
+ return {
101
+ partition: location.partition,
102
+ id: location.id,
103
+ payload: JSON.stringify(value),
104
+ };
105
+ },
106
+ decode(record) {
107
+ if (typeof record.payload !== "string") {
108
+ throw new TypeError("stored Support Desk record has no JSON payload");
109
+ }
110
+ return JSON.parse(record.payload);
111
+ },
112
+ };
113
+ }
114
+ const supportRecordKey = (record) => ({
115
+ partition: record.partition,
116
+ id: record.id,
117
+ });
118
+ export const supportRecords = defineCollection({
119
+ name: "support-desk.records.v1",
120
+ key: supportRecordKey,
121
+ codec: jsonCodec(supportRecordKey),
122
+ });
123
+ function requireSupportMailJob(job) {
124
+ requireIdentifier(job.partition, "mail job.partition");
125
+ requireIdentifier(job.id, "mail job.id");
126
+ requireIdentifier(job.contentRef, "mail job.contentRef");
127
+ }
128
+ function requireDeliveryWrapper(record) {
129
+ requireSupportMailJob(record.job);
130
+ if (record.partition !== record.job.partition ||
131
+ record.id !== `delivery:${record.job.id}` ||
132
+ record.ticketId !== record.job.partition ||
133
+ record.messageId !== record.job.contentRef) {
134
+ throw new TypeError("stored delivery wrapper does not match its nested mail job");
135
+ }
136
+ }
137
+ /**
138
+ * Project generic mail state into Support Desk's durable record union.
139
+ *
140
+ * Physical identity stays Support-owned (`delivery:*`), while the nested job
141
+ * round-trips exactly for authoritative collection-wide discovery.
142
+ */
143
+ export const supportMail = defineMail({
144
+ collection: supportRecords,
145
+ key: ({ partition, jobId }) => {
146
+ requireIdentifier(partition, "mail candidate.partition");
147
+ requireIdentifier(jobId, "mail candidate.jobId");
148
+ return {
149
+ partition,
150
+ id: `delivery:${jobId}`,
151
+ };
152
+ },
153
+ toRecord(job, previous) {
154
+ requireSupportMailJob(job);
155
+ if (previous !== null && previous.kind !== "delivery_job") {
156
+ throw new TypeError("mail projection collided with a non-mail record");
157
+ }
158
+ if (previous !== null &&
159
+ (previous.ticketId !== job.partition ||
160
+ previous.messageId !== job.contentRef)) {
161
+ throw new TypeError("mail transition does not match the delivery wrapper's causal binding");
162
+ }
163
+ return {
164
+ kind: "delivery_job",
165
+ partition: job.partition,
166
+ id: `delivery:${job.id}`,
167
+ ticketId: job.partition,
168
+ messageId: previous?.messageId ?? job.contentRef,
169
+ job,
170
+ };
171
+ },
172
+ toJob(record) {
173
+ if (record.kind !== "delivery_job")
174
+ return null;
175
+ requireDeliveryWrapper(record);
176
+ return record.job;
177
+ },
178
+ });
179
+ function requireAuditMember(record) {
180
+ if (record.partition !== record.event.subject ||
181
+ record.id !== auditRecordId(record.event.id)) {
182
+ throw new TypeError("stored audit member does not match its nested AuditEvent");
183
+ }
184
+ }
185
+ /**
186
+ * Project generic audit events into Support Desk's durable record union.
187
+ *
188
+ * Physical identity is `auditRecordId(event.id)`; the event itself is the
189
+ * accepted-change record. Domain `TicketEvent` values stay pure workflow input.
190
+ */
191
+ export const supportAudit = defineAudit({
192
+ collection: supportRecords,
193
+ toRecord(event) {
194
+ const record = {
195
+ kind: "audit",
196
+ partition: event.subject,
197
+ id: auditRecordId(event.id),
198
+ event,
199
+ };
200
+ requireAuditMember(record);
201
+ return record;
202
+ },
203
+ toEvent(record) {
204
+ if (record.kind !== "audit") {
205
+ return null;
206
+ }
207
+ requireAuditMember(record);
208
+ return record.event;
209
+ },
210
+ });
211
+ function ticketAuditAction(input) {
212
+ return supportAudit.action({
213
+ id: input.commandId,
214
+ occurredAt: input.occurredAt,
215
+ actor: { kind: "principal", principalId: input.actorId },
216
+ action: input.action,
217
+ subject: input.ticketId,
218
+ sequence: input.revision,
219
+ details: {
220
+ commandId: input.commandId,
221
+ correlationId: input.correlationId,
222
+ ...(input.details ?? {}),
223
+ },
224
+ });
225
+ }
226
+ const customerTicketIndexKey = (record) => ({
227
+ partition: record.principalId,
228
+ id: "tickets",
229
+ });
230
+ export const customerTicketIndex = defineCollection({
231
+ name: "support-desk.customer-ticket-index.v1",
232
+ key: customerTicketIndexKey,
233
+ codec: jsonCodec(customerTicketIndexKey),
234
+ });
235
+ const ticketNumberKey = (_record) => ({
236
+ partition: ticketNumberPartition,
237
+ id: ticketNumberRecordId,
238
+ });
239
+ /**
240
+ * One counter per Support Desk instance. Reservation is outside the ticket
241
+ * transaction; gaps are accepted when a later create step fails.
242
+ */
243
+ export const ticketNumbers = defineCollection({
244
+ name: "support-desk.ticket-numbers.v1",
245
+ key: ticketNumberKey,
246
+ codec: jsonCodec(ticketNumberKey),
247
+ });
248
+ const inboundReceiptKey = (record) => ({
249
+ partition: record.bucket,
250
+ id: receiptSlotId(record.slot, "inbound receipt slot"),
251
+ });
252
+ export const inboundReceipts = defineCollection({
253
+ name: "support-desk.inbound-receipts.v1",
254
+ key: inboundReceiptKey,
255
+ codec: jsonCodec(inboundReceiptKey),
256
+ });
257
+ const deliveryCallbackReceiptKey = (record) => ({
258
+ partition: record.bucket,
259
+ id: receiptSlotId(record.slot, "delivery callback slot"),
260
+ });
261
+ export const deliveryCallbackReceipts = defineCollection({
262
+ name: "support-desk.delivery-callback-receipts.v1",
263
+ key: deliveryCallbackReceiptKey,
264
+ codec: jsonCodec(deliveryCallbackReceiptKey),
265
+ });
266
+ const queueIndexKey = (record) => ({
267
+ partition: record.ticketId,
268
+ id: record.id,
269
+ });
270
+ export const queueIndex = defineCollection({
271
+ name: "support-desk.queue-index.v1",
272
+ key: queueIndexKey,
273
+ codec: jsonCodec(queueIndexKey),
274
+ });
275
+ /** Default terminal-retention cutoff shared by projection, repair, and sweep. */
276
+ export const defaultQueueTerminalRetentionMilliseconds = 30 * 86_400_000;
277
+ export const defaultQueueScanBudgets = Object.freeze({
278
+ maxPhysicalRows: 10_000,
279
+ maxScanPages: 100,
280
+ maxActiveResults: 1_000,
281
+ scanPageSize: 100,
282
+ });
283
+ export const ticketNumberPartition = "instance";
284
+ export const ticketNumberRecordId = "ticket-number";
285
+ const maxNotificationVariables = 32;
286
+ const maxNotificationVariableBytes = 8_192;
287
+ const maxNotificationVariableTotalBytes = 8_192;
288
+ const maxAllowedCategories = 32;
289
+ const categoryPattern = /^[a-z][a-z0-9_]{0,31}$/;
290
+ function requirePermission(access, permission) {
291
+ if (!hasPermission(access, permission)) {
292
+ throw new SupportDeskAuthorizationError(permission);
293
+ }
294
+ }
295
+ function requireNonempty(value, field) {
296
+ if (value.trim().length === 0) {
297
+ throw new TypeError(`${field} must be a non-empty string`);
298
+ }
299
+ }
300
+ function requireIdentifier(value, field) {
301
+ requireNonempty(value, field);
302
+ if (value.length > 200 || /[\u0000-\u001F\u007F]/.test(value)) {
303
+ throw new TypeError(`${field} must be at most 200 characters with no controls`);
304
+ }
305
+ }
306
+ const outboundMessageIdLocalPart = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
307
+ const outboundMessageIdDomain = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
308
+ export function validateOutboundMessageId(value, field = "outboundMessageId") {
309
+ if (typeof value !== "string") {
310
+ throw new TypeError(`${field} must be a header-safe ASCII Message-ID`);
311
+ }
312
+ const match = /^<([^<>@\s]+)@([^<>@\s]+)>$/.exec(value);
313
+ const local = match?.[1] ?? "";
314
+ const domain = match?.[2] ?? "";
315
+ if (value.length > 254 ||
316
+ /[\u0000-\u001F\u007F]/.test(value) ||
317
+ !outboundMessageIdLocalPart.test(local) ||
318
+ local.startsWith(".") ||
319
+ local.endsWith(".") ||
320
+ local.includes("..") ||
321
+ !outboundMessageIdDomain.test(domain)) {
322
+ throw new TypeError(`${field} must be a header-safe ASCII Message-ID`);
323
+ }
324
+ return value;
325
+ }
326
+ function enforceLimit(value, field, maximum) {
327
+ requireNonempty(value, field);
328
+ if ([...value].length > maximum) {
329
+ throw new SupportDeskLimitError(field, maximum);
330
+ }
331
+ }
332
+ function ticketKey(ticketId) {
333
+ return { partition: ticketId, id: "ticket" };
334
+ }
335
+ function ticketQuotaKey(ticketId) {
336
+ return { partition: ticketId, id: "quota" };
337
+ }
338
+ function ticketReservationKey(ticketId) {
339
+ return { partition: ticketId, id: "reservation" };
340
+ }
341
+ function commandKey(ticketId, commandId) {
342
+ return { partition: ticketId, id: `command:${commandId}` };
343
+ }
344
+ function ownDataProperty(source, key, field, optional = false) {
345
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
346
+ if (descriptor === undefined) {
347
+ if (optional) {
348
+ return undefined;
349
+ }
350
+ throw new TypeError(`${field} must be an own data property`);
351
+ }
352
+ if (!Object.hasOwn(descriptor, "value")) {
353
+ throw new TypeError(`${field} must be an own data property, not an accessor`);
354
+ }
355
+ return descriptor.value;
356
+ }
357
+ function snapshotNotificationVariables(input) {
358
+ if (input === null ||
359
+ (typeof input !== "object" && typeof input !== "function")) {
360
+ throw new TypeError("notification.variables must be an object");
361
+ }
362
+ const descriptors = Object.getOwnPropertyDescriptors(input);
363
+ const names = Reflect.ownKeys(descriptors);
364
+ if (names.length > maxNotificationVariables) {
365
+ throw new TypeError(`notification.variables may contain at most ${maxNotificationVariables} values`);
366
+ }
367
+ const values = new Map();
368
+ const encoder = new TextEncoder();
369
+ let totalBytes = 0;
370
+ for (const name of names) {
371
+ if (typeof name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(name)) {
372
+ throw new TypeError("notification.variables must use safe names");
373
+ }
374
+ const descriptor = descriptors[name];
375
+ if (descriptor === undefined ||
376
+ !descriptor.enumerable ||
377
+ !Object.hasOwn(descriptor, "value") ||
378
+ typeof descriptor.value !== "string") {
379
+ throw new TypeError("notification.variables must use enumerable own string values");
380
+ }
381
+ const valueBytes = encoder.encode(descriptor.value).byteLength;
382
+ if (valueBytes > maxNotificationVariableBytes) {
383
+ throw new TypeError(`notification variable values may contain at most ${maxNotificationVariableBytes} bytes`);
384
+ }
385
+ totalBytes += valueBytes;
386
+ if (totalBytes > maxNotificationVariableTotalBytes) {
387
+ throw new TypeError(`notification.variables exceed the ${maxNotificationVariableTotalBytes} byte limit`);
388
+ }
389
+ values.set(name, descriptor.value);
390
+ }
391
+ const sorted = {};
392
+ for (const name of [...values.keys()].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)) {
393
+ sorted[name] = values.get(name);
394
+ }
395
+ return Object.freeze(sorted);
396
+ }
397
+ function snapshotNotification(input) {
398
+ if (input === null ||
399
+ (typeof input !== "object" && typeof input !== "function")) {
400
+ throw new TypeError("notification must be an object");
401
+ }
402
+ const readString = (key) => {
403
+ const value = ownDataProperty(input, key, `notification.${key}`);
404
+ if (typeof value !== "string") {
405
+ throw new TypeError(`notification.${key} must be a string`);
406
+ }
407
+ return value;
408
+ };
409
+ const id = readString("id");
410
+ const recipientRef = readString("recipientRef");
411
+ const templateId = readString("templateId");
412
+ const templateVersion = ownDataProperty(input, "templateVersion", "notification.templateVersion");
413
+ const variables = snapshotNotificationVariables(ownDataProperty(input, "variables", "notification.variables"));
414
+ const subject = readString("subject");
415
+ const outboundMessageId = readString("outboundMessageId");
416
+ const maxAttempts = ownDataProperty(input, "maxAttempts", "notification.maxAttempts", true);
417
+ requireIdentifier(id, "notification.id");
418
+ requireIdentifier(recipientRef, "notification.recipientRef");
419
+ requireIdentifier(templateId, "notification.templateId");
420
+ if (!Number.isSafeInteger(templateVersion) ||
421
+ templateVersion <= 0) {
422
+ throw new TypeError("notification.templateVersion must be a positive safe integer");
423
+ }
424
+ if (subject.trim().length === 0 ||
425
+ subject.length > 500 ||
426
+ /[\u0000-\u001F\u007F]/.test(subject)) {
427
+ throw new TypeError("notification.subject must be at most 500 characters with no controls");
428
+ }
429
+ validateOutboundMessageId(outboundMessageId, "notification.outboundMessageId");
430
+ if (maxAttempts !== undefined &&
431
+ (!Number.isSafeInteger(maxAttempts) ||
432
+ maxAttempts <= 0 ||
433
+ maxAttempts > maxMailAttempts)) {
434
+ throw new TypeError(`notification.maxAttempts must be between 1 and ${maxMailAttempts}`);
435
+ }
436
+ return Object.freeze({
437
+ id,
438
+ recipientRef,
439
+ templateId,
440
+ templateVersion: templateVersion,
441
+ variables,
442
+ subject,
443
+ outboundMessageId,
444
+ ...(maxAttempts === undefined
445
+ ? {}
446
+ : { maxAttempts: maxAttempts }),
447
+ });
448
+ }
449
+ function boundaryObject(input, field) {
450
+ if (input === null ||
451
+ (typeof input !== "object" && typeof input !== "function")) {
452
+ throw new TypeError(`${field} must be an object`);
453
+ }
454
+ return input;
455
+ }
456
+ function boundaryString(value, field) {
457
+ if (typeof value !== "string") {
458
+ throw new TypeError(`${field} must be a string`);
459
+ }
460
+ return value;
461
+ }
462
+ const requesterEmailMaximumCharacters = 254;
463
+ const requesterEmailLocalPart = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
464
+ const requesterEmailDomain = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
465
+ function normalizeRequesterEmail(value) {
466
+ if (value === undefined) {
467
+ return undefined;
468
+ }
469
+ if (typeof value !== "string") {
470
+ throw new TypeError("requesterEmail must be a string");
471
+ }
472
+ if (value.length > requesterEmailMaximumCharacters ||
473
+ /[\u0000-\u001F\u007F<>&"\\]/.test(value)) {
474
+ throw new TypeError(`requesterEmail must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
475
+ }
476
+ const normalized = value.trim();
477
+ const separator = normalized.indexOf("@");
478
+ const local = normalized.slice(0, separator);
479
+ const domain = normalized.slice(separator + 1);
480
+ if (normalized.length === 0 ||
481
+ /\s/.test(normalized) ||
482
+ separator <= 0 ||
483
+ separator !== normalized.lastIndexOf("@") ||
484
+ local.length > 64 ||
485
+ local.startsWith(".") ||
486
+ local.endsWith(".") ||
487
+ local.includes("..") ||
488
+ !requesterEmailLocalPart.test(local) ||
489
+ domain.length > 253 ||
490
+ !requesterEmailDomain.test(domain)) {
491
+ throw new TypeError(`requesterEmail must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
492
+ }
493
+ return `${local}@${domain.toLowerCase()}`;
494
+ }
495
+ function snapshotCategory(value, field) {
496
+ if (value === undefined) {
497
+ return undefined;
498
+ }
499
+ if (typeof value !== "string") {
500
+ throw new TypeError(`${field} must be a string`);
501
+ }
502
+ if (!categoryPattern.test(value)) {
503
+ throw new TypeError(`${field} must match ${categoryPattern.source} and contain no controls`);
504
+ }
505
+ return value;
506
+ }
507
+ function snapshotCreateCustomerTicketCommand(input) {
508
+ const source = boundaryObject(input, "create command");
509
+ const raw = {
510
+ commandId: ownDataProperty(source, "commandId", "create command.commandId"),
511
+ correlationId: ownDataProperty(source, "correlationId", "create command.correlationId"),
512
+ ticketId: ownDataProperty(source, "ticketId", "create command.ticketId"),
513
+ messageId: ownDataProperty(source, "messageId", "create command.messageId"),
514
+ subject: ownDataProperty(source, "subject", "create command.subject"),
515
+ body: ownDataProperty(source, "body", "create command.body"),
516
+ category: ownDataProperty(source, "category", "create command.category", true),
517
+ requesterEmail: ownDataProperty(source, "requesterEmail", "create command.requesterEmail", true),
518
+ notification: ownDataProperty(source, "notification", "create command.notification", true),
519
+ };
520
+ const category = snapshotCategory(raw.category, "create command.category");
521
+ const requesterEmail = normalizeRequesterEmail(raw.requesterEmail);
522
+ const notification = raw.notification === undefined
523
+ ? undefined
524
+ : snapshotNotification(raw.notification);
525
+ return Object.freeze({
526
+ commandId: boundaryString(raw.commandId, "create command.commandId"),
527
+ correlationId: boundaryString(raw.correlationId, "create command.correlationId"),
528
+ ticketId: boundaryString(raw.ticketId, "create command.ticketId"),
529
+ messageId: boundaryString(raw.messageId, "create command.messageId"),
530
+ subject: boundaryString(raw.subject, "create command.subject"),
531
+ body: boundaryString(raw.body, "create command.body"),
532
+ ...(category === undefined ? {} : { category }),
533
+ ...(requesterEmail === undefined ? {} : { requesterEmail }),
534
+ ...(notification === undefined ? {} : { notification }),
535
+ });
536
+ }
537
+ function snapshotReplyToCustomerTicketCommand(input) {
538
+ const source = boundaryObject(input, "reply command");
539
+ const raw = {
540
+ commandId: ownDataProperty(source, "commandId", "reply command.commandId"),
541
+ correlationId: ownDataProperty(source, "correlationId", "reply command.correlationId"),
542
+ ticketId: ownDataProperty(source, "ticketId", "reply command.ticketId"),
543
+ messageId: ownDataProperty(source, "messageId", "reply command.messageId"),
544
+ body: ownDataProperty(source, "body", "reply command.body"),
545
+ notification: ownDataProperty(source, "notification", "reply command.notification", true),
546
+ };
547
+ const notification = raw.notification === undefined
548
+ ? undefined
549
+ : snapshotNotification(raw.notification);
550
+ return Object.freeze({
551
+ commandId: boundaryString(raw.commandId, "reply command.commandId"),
552
+ correlationId: boundaryString(raw.correlationId, "reply command.correlationId"),
553
+ ticketId: boundaryString(raw.ticketId, "reply command.ticketId"),
554
+ messageId: boundaryString(raw.messageId, "reply command.messageId"),
555
+ body: boundaryString(raw.body, "reply command.body"),
556
+ ...(notification === undefined ? {} : { notification }),
557
+ });
558
+ }
559
+ function deliveryJobAction(ticketId, messageId, now, input) {
560
+ return supportMail.action({
561
+ partition: ticketId,
562
+ id: input.id,
563
+ recipientRef: input.recipientRef,
564
+ contentRef: messageId,
565
+ createdAt: now,
566
+ ...(input.maxAttempts === undefined
567
+ ? {}
568
+ : { maxAttempts: input.maxAttempts }),
569
+ });
570
+ }
571
+ function deliveryContent(input) {
572
+ return input === undefined
573
+ ? undefined
574
+ : {
575
+ templateId: input.templateId,
576
+ templateVersion: input.templateVersion,
577
+ variables: input.variables,
578
+ subject: input.subject,
579
+ outboundMessageId: input.outboundMessageId,
580
+ };
581
+ }
582
+ function bindNotificationTicketNumber(input, ticketNumber) {
583
+ const ticketNumberText = String(ticketNumber);
584
+ const names = Object.keys(input.variables);
585
+ if (!Object.hasOwn(input.variables, "ticket_number") &&
586
+ names.length >= maxNotificationVariables) {
587
+ throw new TypeError(`notification.variables may contain at most ${maxNotificationVariables} values`);
588
+ }
589
+ const variables = {
590
+ ...input.variables,
591
+ ticket_number: ticketNumberText,
592
+ };
593
+ // Re-validate the final map so injection cannot exceed count/byte limits.
594
+ const frozenVariables = snapshotNotificationVariables(variables);
595
+ return Object.freeze({
596
+ ...input,
597
+ subject: input.subject.replaceAll("{{ticket_number}}", ticketNumberText),
598
+ variables: frozenVariables,
599
+ });
600
+ }
601
+ function toCustomerMessage(message) {
602
+ return Object.freeze({
603
+ id: message.id,
604
+ ticketId: message.ticketId,
605
+ authorKind: message.authorKind,
606
+ channel: message.channel,
607
+ visibility: message.visibility,
608
+ format: message.format,
609
+ body: message.body,
610
+ createdAt: message.createdAt,
611
+ });
612
+ }
613
+ function customerMessages(records) {
614
+ const messages = records.filter((record) => record.kind === "message" && record.message.visibility === "customer");
615
+ const ordinals = new Set();
616
+ for (const record of messages) {
617
+ if (!Number.isSafeInteger(record.ordinal) || record.ordinal <= 0) {
618
+ throw new TypeError("stored customer message has no valid explicit ordinal; migrate the record before reading it");
619
+ }
620
+ if (ordinals.has(record.ordinal)) {
621
+ throw new TypeError("stored customer messages contain a duplicate explicit ordinal");
622
+ }
623
+ ordinals.add(record.ordinal);
624
+ }
625
+ return messages
626
+ .sort((left, right) => left.ordinal - right.ordinal)
627
+ .map((record) => toCustomerMessage(record.message));
628
+ }
629
+ function toCustomerTicketSummary(ticket) {
630
+ // Pre-Task-2 durable tickets lack customerUpdatedAt. Fall back to createdAt
631
+ // (never staff-only updatedAt) so list/detail stay ordered and DTO-complete
632
+ // without leaking note/assignment/priority activity.
633
+ const customerUpdatedAt = ticket.customerUpdatedAt ?? ticket.createdAt;
634
+ return Object.freeze({
635
+ id: ticket.id,
636
+ number: ticket.number,
637
+ subject: ticket.subject,
638
+ ...(ticket.category === undefined ? {} : { category: ticket.category }),
639
+ status: ticket.status,
640
+ channel: ticket.channel,
641
+ createdAt: ticket.createdAt,
642
+ customerUpdatedAt,
643
+ });
644
+ }
645
+ async function authoritativeView(records, principalId, ticketId, maxRecordsPerTicket) {
646
+ const all = await records.list(ticketId);
647
+ const ticketRecord = all.find((record) => record.kind === "ticket");
648
+ // Ownership before partition errors so foreign ticket IDs never leak capacity.
649
+ if (ticketRecord === undefined ||
650
+ ticketRecord.ticket.requester.association !== "authenticated" ||
651
+ ticketRecord.ticket.requester.principalId !== principalId) {
652
+ throw new SupportDeskNotFoundError();
653
+ }
654
+ enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
655
+ return {
656
+ ticket: toCustomerTicketSummary(ticketRecord.ticket),
657
+ messages: customerMessages(all),
658
+ };
659
+ }
660
+ /**
661
+ * Validate and copy host-supplied category allowlist into a private Set.
662
+ * The host array is not retained; the Set is application-owned state.
663
+ */
664
+ function parseAllowedCategories(input) {
665
+ if (input === undefined) {
666
+ return undefined;
667
+ }
668
+ if (!Array.isArray(input)) {
669
+ throw new TypeError("application options.allowedCategories must be an array");
670
+ }
671
+ if (input.length > maxAllowedCategories) {
672
+ throw new TypeError(`application options.allowedCategories may contain at most ${maxAllowedCategories} values`);
673
+ }
674
+ const values = new Set();
675
+ for (let index = 0; index < input.length; index += 1) {
676
+ const field = `application options.allowedCategories[${index}]`;
677
+ const value = ownDataProperty(input, index, field);
678
+ if (typeof value !== "string") {
679
+ throw new TypeError(`${field} must be a string`);
680
+ }
681
+ if (!categoryPattern.test(value)) {
682
+ throw new TypeError(`${field} must match ${categoryPattern.source} and contain no controls`);
683
+ }
684
+ if (values.has(value)) {
685
+ throw new TypeError("application options.allowedCategories must not contain duplicates");
686
+ }
687
+ values.add(value);
688
+ }
689
+ return values;
690
+ }
691
+ function duplicateMatches(existing, commandType, messageId, requestFingerprint) {
692
+ return (existing?.kind === "command" &&
693
+ existing.commandType === commandType &&
694
+ existing.messageId === messageId &&
695
+ existing.requestFingerprint === requestFingerprint);
696
+ }
697
+ const TICKET_PRIORITIES = [
698
+ "low",
699
+ "normal",
700
+ "high",
701
+ "urgent",
702
+ ];
703
+ function staffMessages(records) {
704
+ const messages = records.filter((record) => record.kind === "message");
705
+ const ordinals = new Set();
706
+ for (const record of messages) {
707
+ if (!Number.isSafeInteger(record.ordinal) || record.ordinal <= 0) {
708
+ throw new TypeError("stored message has no valid explicit ordinal; migrate the record before reading it");
709
+ }
710
+ if (ordinals.has(record.ordinal)) {
711
+ throw new TypeError("stored messages contain a duplicate explicit ordinal");
712
+ }
713
+ ordinals.add(record.ordinal);
714
+ }
715
+ return messages
716
+ .sort((left, right) => left.ordinal - right.ordinal)
717
+ .map((record) => Object.freeze({ ...record.message }));
718
+ }
719
+ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
720
+ if (recordCount + additional > maximum) {
721
+ throw new SupportDeskLimitError("ticket_partition", maximum);
722
+ }
723
+ }
724
+ async function staffAuthoritativeView(records, ticketId, maxRecordsPerTicket) {
725
+ const all = await records.list(ticketId);
726
+ enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
727
+ const ticketRecord = all.find((record) => record.kind === "ticket");
728
+ if (ticketRecord === undefined) {
729
+ throw new SupportDeskNotFoundError();
730
+ }
731
+ return {
732
+ ticket: ticketRecord.ticket,
733
+ messages: staffMessages(all),
734
+ };
735
+ }
736
+ /** Staff mutations that return full ticket detail also require queue read. */
737
+ function requireStaffMutationAccess(access, permission) {
738
+ requirePermission(access, supportPermissions.queueRead);
739
+ requirePermission(access, permission);
740
+ }
741
+ function clampTicketTime(sampledNow, ticketUpdatedAt) {
742
+ const sampledEpoch = canonicalTimestamp(sampledNow, "clock.now()");
743
+ const storedEpoch = canonicalTimestamp(ticketUpdatedAt, "ticket.updatedAt");
744
+ return sampledEpoch < storedEpoch ? ticketUpdatedAt : sampledNow;
745
+ }
746
+ function snapshotStaffReplyCommand(input) {
747
+ const source = boundaryObject(input, "staff reply command");
748
+ const raw = {
749
+ commandId: ownDataProperty(source, "commandId", "staff reply command.commandId"),
750
+ correlationId: ownDataProperty(source, "correlationId", "staff reply command.correlationId"),
751
+ ticketId: ownDataProperty(source, "ticketId", "staff reply command.ticketId"),
752
+ messageId: ownDataProperty(source, "messageId", "staff reply command.messageId"),
753
+ body: ownDataProperty(source, "body", "staff reply command.body"),
754
+ notification: ownDataProperty(source, "notification", "staff reply command.notification", true),
755
+ };
756
+ const notification = raw.notification === undefined
757
+ ? undefined
758
+ : snapshotNotification(raw.notification);
759
+ return Object.freeze({
760
+ commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
761
+ correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
762
+ ticketId: boundaryString(raw.ticketId, "staff reply command.ticketId"),
763
+ messageId: boundaryString(raw.messageId, "staff reply command.messageId"),
764
+ body: boundaryString(raw.body, "staff reply command.body"),
765
+ ...(notification === undefined ? {} : { notification }),
766
+ });
767
+ }
768
+ function snapshotAddNoteCommand(input) {
769
+ const source = boundaryObject(input, "add note command");
770
+ const raw = {
771
+ commandId: ownDataProperty(source, "commandId", "add note command.commandId"),
772
+ correlationId: ownDataProperty(source, "correlationId", "add note command.correlationId"),
773
+ ticketId: ownDataProperty(source, "ticketId", "add note command.ticketId"),
774
+ messageId: ownDataProperty(source, "messageId", "add note command.messageId"),
775
+ body: ownDataProperty(source, "body", "add note command.body"),
776
+ };
777
+ return Object.freeze({
778
+ commandId: boundaryString(raw.commandId, "add note command.commandId"),
779
+ correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
780
+ ticketId: boundaryString(raw.ticketId, "add note command.ticketId"),
781
+ messageId: boundaryString(raw.messageId, "add note command.messageId"),
782
+ body: boundaryString(raw.body, "add note command.body"),
783
+ });
784
+ }
785
+ function snapshotAssignTicketCommand(input) {
786
+ const source = boundaryObject(input, "assign command");
787
+ const raw = {
788
+ commandId: ownDataProperty(source, "commandId", "assign command.commandId"),
789
+ correlationId: ownDataProperty(source, "correlationId", "assign command.correlationId"),
790
+ ticketId: ownDataProperty(source, "ticketId", "assign command.ticketId"),
791
+ assigneeId: ownDataProperty(source, "assigneeId", "assign command.assigneeId"),
792
+ messageId: ownDataProperty(source, "messageId", "assign command.messageId", true),
793
+ body: ownDataProperty(source, "body", "assign command.body", true),
794
+ notification: ownDataProperty(source, "notification", "assign command.notification", true),
795
+ };
796
+ if (raw.assigneeId !== null && typeof raw.assigneeId !== "string") {
797
+ throw new TypeError("assign command.assigneeId must be a string or null");
798
+ }
799
+ const notification = raw.notification === undefined
800
+ ? undefined
801
+ : snapshotNotification(raw.notification);
802
+ const messageId = raw.messageId === undefined
803
+ ? undefined
804
+ : boundaryString(raw.messageId, "assign command.messageId");
805
+ const body = raw.body === undefined
806
+ ? undefined
807
+ : boundaryString(raw.body, "assign command.body");
808
+ if (notification !== undefined ||
809
+ messageId !== undefined ||
810
+ body !== undefined) {
811
+ if (messageId === undefined || body === undefined) {
812
+ throw new TypeError("assign command requires messageId and body when recording a notification message");
813
+ }
814
+ }
815
+ return Object.freeze({
816
+ commandId: boundaryString(raw.commandId, "assign command.commandId"),
817
+ correlationId: boundaryString(raw.correlationId, "assign command.correlationId"),
818
+ ticketId: boundaryString(raw.ticketId, "assign command.ticketId"),
819
+ assigneeId: raw.assigneeId === null
820
+ ? null
821
+ : boundaryString(raw.assigneeId, "assign command.assigneeId"),
822
+ ...(messageId === undefined ? {} : { messageId }),
823
+ ...(body === undefined ? {} : { body }),
824
+ ...(notification === undefined ? {} : { notification }),
825
+ });
826
+ }
827
+ function snapshotChangePriorityCommand(input) {
828
+ const source = boundaryObject(input, "change priority command");
829
+ const raw = {
830
+ commandId: ownDataProperty(source, "commandId", "change priority command.commandId"),
831
+ correlationId: ownDataProperty(source, "correlationId", "change priority command.correlationId"),
832
+ ticketId: ownDataProperty(source, "ticketId", "change priority command.ticketId"),
833
+ priority: ownDataProperty(source, "priority", "change priority command.priority"),
834
+ };
835
+ if (typeof raw.priority !== "string" ||
836
+ !TICKET_PRIORITIES.includes(raw.priority)) {
837
+ throw new TypeError(`change priority command.priority must be one of: ${TICKET_PRIORITIES.join(", ")}`);
838
+ }
839
+ return Object.freeze({
840
+ commandId: boundaryString(raw.commandId, "change priority command.commandId"),
841
+ correlationId: boundaryString(raw.correlationId, "change priority command.correlationId"),
842
+ ticketId: boundaryString(raw.ticketId, "change priority command.ticketId"),
843
+ priority: raw.priority,
844
+ });
845
+ }
846
+ function snapshotLifecycleTicketCommand(input, field) {
847
+ const source = boundaryObject(input, field);
848
+ const raw = {
849
+ commandId: ownDataProperty(source, "commandId", `${field}.commandId`),
850
+ correlationId: ownDataProperty(source, "correlationId", `${field}.correlationId`),
851
+ ticketId: ownDataProperty(source, "ticketId", `${field}.ticketId`),
852
+ messageId: ownDataProperty(source, "messageId", `${field}.messageId`, true),
853
+ body: ownDataProperty(source, "body", `${field}.body`, true),
854
+ notification: ownDataProperty(source, "notification", `${field}.notification`, true),
855
+ };
856
+ const notification = raw.notification === undefined
857
+ ? undefined
858
+ : snapshotNotification(raw.notification);
859
+ const messageId = raw.messageId === undefined
860
+ ? undefined
861
+ : boundaryString(raw.messageId, `${field}.messageId`);
862
+ const body = raw.body === undefined
863
+ ? undefined
864
+ : boundaryString(raw.body, `${field}.body`);
865
+ if (notification !== undefined ||
866
+ messageId !== undefined ||
867
+ body !== undefined) {
868
+ if (messageId === undefined || body === undefined) {
869
+ throw new TypeError(`${field} requires messageId and body when recording a lifecycle message or notification`);
870
+ }
871
+ }
872
+ return Object.freeze({
873
+ commandId: boundaryString(raw.commandId, `${field}.commandId`),
874
+ correlationId: boundaryString(raw.correlationId, `${field}.correlationId`),
875
+ ticketId: boundaryString(raw.ticketId, `${field}.ticketId`),
876
+ ...(messageId === undefined ? {} : { messageId }),
877
+ ...(body === undefined ? {} : { body }),
878
+ ...(notification === undefined ? {} : { notification }),
879
+ });
880
+ }
881
+ function stableNotification(notification) {
882
+ return notification ?? null;
883
+ }
884
+ async function requestFingerprint(value) {
885
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value)));
886
+ return [...new Uint8Array(digest)]
887
+ .map((byte) => byte.toString(16).padStart(2, "0"))
888
+ .join("");
889
+ }
890
+ function isTerminalTicketStatus(status) {
891
+ return status === "resolved" || status === "closed";
892
+ }
893
+ function terminalTimestamp(ticket) {
894
+ if (ticket.status === "resolved") {
895
+ return ticket.resolvedAt ?? ticket.updatedAt;
896
+ }
897
+ if (ticket.status === "closed") {
898
+ return ticket.closedAt ?? ticket.resolvedAt ?? ticket.updatedAt;
899
+ }
900
+ return ticket.updatedAt;
901
+ }
902
+ function ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds) {
903
+ if (!isTerminalTicketStatus(ticket.status)) {
904
+ return false;
905
+ }
906
+ const nowEpoch = canonicalTimestamp(now, "clock.now()");
907
+ const terminalEpoch = canonicalTimestamp(terminalTimestamp(ticket), "ticket terminal time");
908
+ return nowEpoch - terminalEpoch >= terminalRetentionMilliseconds;
909
+ }
910
+ function deriveQueueIndexRecord(ticket) {
911
+ return {
912
+ ticketId: ticket.id,
913
+ id: "queue",
914
+ projectedRevision: ticket.revision,
915
+ state: isTerminalTicketStatus(ticket.status) ? "inactive" : "active",
916
+ status: ticket.status,
917
+ priority: ticket.priority,
918
+ ...(ticket.category === undefined ? {} : { category: ticket.category }),
919
+ requesterAssociation: ticket.requester.association,
920
+ channel: ticket.channel,
921
+ ...(ticket.assignedTo === undefined
922
+ ? {}
923
+ : { assignedTo: ticket.assignedTo }),
924
+ updatedAt: ticket.updatedAt,
925
+ };
926
+ }
927
+ function queueIndexContentEqual(left, right) {
928
+ return (left.ticketId === right.ticketId &&
929
+ left.id === right.id &&
930
+ left.projectedRevision === right.projectedRevision &&
931
+ left.state === right.state &&
932
+ left.status === right.status &&
933
+ left.priority === right.priority &&
934
+ left.category === right.category &&
935
+ left.requesterAssociation === right.requesterAssociation &&
936
+ left.channel === right.channel &&
937
+ left.assignedTo === right.assignedTo &&
938
+ left.updatedAt === right.updatedAt);
939
+ }
940
+ function queueProjectionKey(ticketId) {
941
+ return { partition: ticketId, id: "queue" };
942
+ }
943
+ /**
944
+ * Load the authoritative ticket and project the entire queue row from it.
945
+ *
946
+ * Never accepts a caller-supplied `Ticket` snapshot. Accepts only a newer
947
+ * revision or an identical equal-revision replay. Authoritative terminal
948
+ * tickets beyond the shared retention cutoff keep the projection absent or
949
+ * are conditionally deleted — they never recreate an inactive row.
950
+ */
951
+ export async function projectTicketToQueue(ticketId, options) {
952
+ requireIdentifier(ticketId, "ticketId");
953
+ const source = boundaryObject(options, "projectTicketToQueue options");
954
+ const store = ownDataProperty(source, "store", "projectTicketToQueue options.store");
955
+ const clock = ownDataProperty(source, "clock", "projectTicketToQueue options.clock");
956
+ const rawRetention = ownDataProperty(source, "terminalRetentionMilliseconds", "projectTicketToQueue options.terminalRetentionMilliseconds");
957
+ const rawAttempts = ownDataProperty(source, "maxConflictAttempts", "projectTicketToQueue options.maxConflictAttempts", true);
958
+ if (typeof rawRetention !== "number") {
959
+ throw new TypeError("projectTicketToQueue options.terminalRetentionMilliseconds must be a number");
960
+ }
961
+ const terminalRetentionMilliseconds = rawRetention;
962
+ if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
963
+ terminalRetentionMilliseconds < 0) {
964
+ throw new TypeError("terminalRetentionMilliseconds must be a non-negative safe integer");
965
+ }
966
+ const maxConflictAttempts = rawAttempts === undefined ? 4 : rawAttempts;
967
+ if (!Number.isSafeInteger(maxConflictAttempts) ||
968
+ maxConflictAttempts <= 0 ||
969
+ maxConflictAttempts > 20) {
970
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
971
+ }
972
+ const records = store.collection(supportRecords);
973
+ const projections = store.collection(queueIndex);
974
+ const ticketRecord = await records.get(ticketKey(ticketId));
975
+ if (ticketRecord?.kind !== "ticket") {
976
+ const existing = await projections.getVersioned(queueProjectionKey(ticketId));
977
+ if (existing === null) {
978
+ return "absent";
979
+ }
980
+ const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
981
+ return removed ? "deleted" : "kept";
982
+ }
983
+ const ticket = ticketRecord.ticket;
984
+ if (ticket.id !== ticketId) {
985
+ throw new TypeError("stored ticket id does not match its partition");
986
+ }
987
+ // Sample the host clock only for terminal retention. Active tickets never
988
+ // consult wall time, so post-commit projection does not consume sequence
989
+ // clocks used by ticket mutations.
990
+ if (isTerminalTicketStatus(ticket.status)) {
991
+ const now = clock.now();
992
+ if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
993
+ const existing = await projections.getVersioned(queueProjectionKey(ticketId));
994
+ if (existing === null) {
995
+ return "absent";
996
+ }
997
+ // Only prune a projection that still matches this terminal revision.
998
+ // A concurrent reopen can land a newer active row between the ticket
999
+ // read and this delete; that row must be preserved for repair to see.
1000
+ if (existing.value.projectedRevision !== ticket.revision) {
1001
+ return "kept";
1002
+ }
1003
+ const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
1004
+ return removed ? "deleted" : "kept";
1005
+ }
1006
+ }
1007
+ const derived = deriveQueueIndexRecord(ticket);
1008
+ const result = await projections.update(queueProjectionKey(ticketId), (current) => {
1009
+ if (current === null) {
1010
+ return { action: "write", value: derived };
1011
+ }
1012
+ if (current.ticketId !== ticketId || current.id !== "queue") {
1013
+ throw new TypeError("queue projection key does not match its payload");
1014
+ }
1015
+ if (current.projectedRevision > derived.projectedRevision) {
1016
+ return { action: "keep" };
1017
+ }
1018
+ if (current.projectedRevision < derived.projectedRevision) {
1019
+ return { action: "write", value: derived };
1020
+ }
1021
+ if (queueIndexContentEqual(current, derived)) {
1022
+ return { action: "keep" };
1023
+ }
1024
+ throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
1025
+ }, { maxAttempts: maxConflictAttempts });
1026
+ return result.written ? "written" : "kept";
1027
+ }
1028
+ function toStaffQueueItem(ticket) {
1029
+ return Object.freeze({
1030
+ ticketId: ticket.id,
1031
+ revision: ticket.revision,
1032
+ status: ticket.status,
1033
+ priority: ticket.priority,
1034
+ ...(ticket.category === undefined ? {} : { category: ticket.category }),
1035
+ requesterAssociation: ticket.requester.association,
1036
+ channel: ticket.channel,
1037
+ ...(ticket.assignedTo === undefined
1038
+ ? {}
1039
+ : { assignedTo: ticket.assignedTo }),
1040
+ updatedAt: ticket.updatedAt,
1041
+ });
1042
+ }
1043
+ function normalizeFilterList(value) {
1044
+ if (value === undefined) {
1045
+ return undefined;
1046
+ }
1047
+ return new Set(Array.isArray(value) ? value : [value]);
1048
+ }
1049
+ function staffQueueItemMatches(item, query) {
1050
+ const statuses = normalizeFilterList(query.status);
1051
+ if (statuses !== undefined && !statuses.has(item.status)) {
1052
+ return false;
1053
+ }
1054
+ const priorities = normalizeFilterList(query.priority);
1055
+ if (priorities !== undefined && !priorities.has(item.priority)) {
1056
+ return false;
1057
+ }
1058
+ const associations = normalizeFilterList(query.association);
1059
+ if (associations !== undefined &&
1060
+ !associations.has(item.requesterAssociation)) {
1061
+ return false;
1062
+ }
1063
+ const channels = normalizeFilterList(query.channel);
1064
+ if (channels !== undefined && !channels.has(item.channel)) {
1065
+ return false;
1066
+ }
1067
+ if (query.unassignedOnly === true && item.assignedTo !== undefined) {
1068
+ return false;
1069
+ }
1070
+ if (query.assignedTo !== undefined &&
1071
+ (item.assignedTo === undefined || item.assignedTo !== query.assignedTo)) {
1072
+ return false;
1073
+ }
1074
+ return true;
1075
+ }
1076
+ const QUEUE_FILTER_STATUSES = [
1077
+ "open",
1078
+ "waiting_on_support",
1079
+ "waiting_on_customer",
1080
+ "resolved",
1081
+ "closed",
1082
+ ];
1083
+ const QUEUE_FILTER_PRIORITIES = [
1084
+ "low",
1085
+ "normal",
1086
+ "high",
1087
+ "urgent",
1088
+ ];
1089
+ const QUEUE_FILTER_ASSOCIATIONS = [
1090
+ "authenticated",
1091
+ "matched_email",
1092
+ "unverified",
1093
+ ];
1094
+ const QUEUE_FILTER_CHANNELS = ["web", "email", "api"];
1095
+ function snapshotEnumOrEnumArray(value, field, allowed) {
1096
+ const allow = (candidate) => {
1097
+ if (!allowed.includes(candidate)) {
1098
+ throw new TypeError(`${field} must be one of: ${allowed.join(", ")}`);
1099
+ }
1100
+ return candidate;
1101
+ };
1102
+ if (typeof value === "string") {
1103
+ return allow(value);
1104
+ }
1105
+ if (Array.isArray(value)) {
1106
+ const items = [];
1107
+ for (let index = 0; index < value.length; index += 1) {
1108
+ const item = ownDataProperty(value, index, `${field}[${index}]`);
1109
+ if (typeof item !== "string") {
1110
+ throw new TypeError(`${field} must be a string or an array of strings`);
1111
+ }
1112
+ items.push(allow(item));
1113
+ }
1114
+ return items;
1115
+ }
1116
+ throw new TypeError(`${field} must be a string or an array of strings`);
1117
+ }
1118
+ function snapshotStaffQueueQuery(input) {
1119
+ if (input === undefined) {
1120
+ return Object.freeze({});
1121
+ }
1122
+ const source = boundaryObject(input, "staff queue query");
1123
+ const readOptional = (key) => ownDataProperty(source, key, `staff queue query.${key}`, true);
1124
+ const status = readOptional("status");
1125
+ const priority = readOptional("priority");
1126
+ const association = readOptional("association");
1127
+ const channel = readOptional("channel");
1128
+ const assignedTo = readOptional("assignedTo");
1129
+ const unassignedOnly = readOptional("unassignedOnly");
1130
+ const sort = readOptional("sort");
1131
+ if (assignedTo !== undefined) {
1132
+ if (typeof assignedTo !== "string") {
1133
+ throw new TypeError("staff queue query.assignedTo must be a string");
1134
+ }
1135
+ requireIdentifier(assignedTo, "staff queue query.assignedTo");
1136
+ }
1137
+ if (unassignedOnly !== undefined && typeof unassignedOnly !== "boolean") {
1138
+ throw new TypeError("staff queue query.unassignedOnly must be a boolean");
1139
+ }
1140
+ if (sort !== undefined &&
1141
+ sort !== "updated_newest" &&
1142
+ sort !== "updated_oldest") {
1143
+ throw new TypeError('staff queue query.sort must be "updated_newest" or "updated_oldest"');
1144
+ }
1145
+ const query = {};
1146
+ if (status !== undefined) {
1147
+ query.status =
1148
+ snapshotEnumOrEnumArray(status, "staff queue query.status", QUEUE_FILTER_STATUSES);
1149
+ }
1150
+ if (priority !== undefined) {
1151
+ query.priority =
1152
+ snapshotEnumOrEnumArray(priority, "staff queue query.priority", QUEUE_FILTER_PRIORITIES);
1153
+ }
1154
+ if (association !== undefined) {
1155
+ query.association =
1156
+ snapshotEnumOrEnumArray(association, "staff queue query.association", QUEUE_FILTER_ASSOCIATIONS);
1157
+ }
1158
+ if (channel !== undefined) {
1159
+ query.channel =
1160
+ snapshotEnumOrEnumArray(channel, "staff queue query.channel", QUEUE_FILTER_CHANNELS);
1161
+ }
1162
+ if (assignedTo !== undefined) {
1163
+ query.assignedTo =
1164
+ assignedTo;
1165
+ }
1166
+ if (unassignedOnly !== undefined) {
1167
+ query.unassignedOnly =
1168
+ unassignedOnly;
1169
+ }
1170
+ if (sort !== undefined) {
1171
+ query.sort =
1172
+ sort;
1173
+ }
1174
+ return Object.freeze(query);
1175
+ }
1176
+ /**
1177
+ * Scan one page of authoritative `support-desk.records.v1` rows and project
1178
+ * every ticket found. Hosts persist `nextCursor` only after this call returns
1179
+ * successfully for the whole page.
1180
+ */
1181
+ export async function repairQueueProjectionPage(options) {
1182
+ const source = boundaryObject(options, "queue repair page options");
1183
+ const store = ownDataProperty(source, "store", "queue repair page options.store");
1184
+ const clock = ownDataProperty(source, "clock", "queue repair page options.clock");
1185
+ const rawCursor = ownDataProperty(source, "cursor", "queue repair page options.cursor", true);
1186
+ const rawLimit = ownDataProperty(source, "limit", "queue repair page options.limit", true);
1187
+ const rawRetention = ownDataProperty(source, "terminalRetentionMilliseconds", "queue repair page options.terminalRetentionMilliseconds");
1188
+ const rawAttempts = ownDataProperty(source, "maxConflictAttempts", "queue repair page options.maxConflictAttempts", true);
1189
+ if (rawCursor !== undefined && typeof rawCursor !== "string") {
1190
+ throw new TypeError("queue repair page options.cursor must be a string");
1191
+ }
1192
+ if (typeof rawRetention !== "number") {
1193
+ throw new TypeError("queue repair page options.terminalRetentionMilliseconds must be a number");
1194
+ }
1195
+ const limit = rawLimit === undefined ? 100 : rawLimit;
1196
+ if (!Number.isSafeInteger(limit) ||
1197
+ limit <= 0 ||
1198
+ limit > MAX_SCAN_PAGE_SIZE) {
1199
+ throw new TypeError(`queue repair page options.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
1200
+ }
1201
+ const records = store.collection(supportRecords);
1202
+ const page = await records.scan({
1203
+ limit,
1204
+ ...(rawCursor === undefined ? {} : { cursor: rawCursor }),
1205
+ });
1206
+ let projected = 0;
1207
+ for (const entry of page.records) {
1208
+ if (entry.value.kind !== "ticket") {
1209
+ continue;
1210
+ }
1211
+ if (entry.key.id !== "ticket" ||
1212
+ entry.key.partition !== entry.value.partition) {
1213
+ continue;
1214
+ }
1215
+ await projectTicketToQueue(entry.value.ticket.id, {
1216
+ store,
1217
+ clock,
1218
+ terminalRetentionMilliseconds: rawRetention,
1219
+ ...(rawAttempts === undefined
1220
+ ? {}
1221
+ : { maxConflictAttempts: rawAttempts }),
1222
+ });
1223
+ projected += 1;
1224
+ }
1225
+ return {
1226
+ nextCursor: page.nextCursor,
1227
+ physicalRows: page.records.length,
1228
+ projected,
1229
+ };
1230
+ }
1231
+ /**
1232
+ * Sweep inactive queue rows only after reloading the authoritative ticket and
1233
+ * confirming it is still terminal beyond the shared retention cutoff, then
1234
+ * `deleteIfUnchanged`.
1235
+ */
1236
+ export async function sweepInactiveQueueProjections(options) {
1237
+ const source = boundaryObject(options, "queue inactive sweep options");
1238
+ const store = ownDataProperty(source, "store", "queue inactive sweep options.store");
1239
+ const clock = ownDataProperty(source, "clock", "queue inactive sweep options.clock");
1240
+ const rawCursor = ownDataProperty(source, "cursor", "queue inactive sweep options.cursor", true);
1241
+ const rawLimit = ownDataProperty(source, "limit", "queue inactive sweep options.limit", true);
1242
+ const rawRetention = ownDataProperty(source, "terminalRetentionMilliseconds", "queue inactive sweep options.terminalRetentionMilliseconds");
1243
+ if (rawCursor !== undefined && typeof rawCursor !== "string") {
1244
+ throw new TypeError("queue inactive sweep options.cursor must be a string");
1245
+ }
1246
+ if (typeof rawRetention !== "number") {
1247
+ throw new TypeError("queue inactive sweep options.terminalRetentionMilliseconds must be a number");
1248
+ }
1249
+ const limit = rawLimit === undefined ? 100 : rawLimit;
1250
+ if (!Number.isSafeInteger(limit) ||
1251
+ limit <= 0 ||
1252
+ limit > MAX_SCAN_PAGE_SIZE) {
1253
+ throw new TypeError(`queue inactive sweep options.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
1254
+ }
1255
+ const terminalRetentionMilliseconds = rawRetention;
1256
+ if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
1257
+ terminalRetentionMilliseconds < 0) {
1258
+ throw new TypeError("terminalRetentionMilliseconds must be a non-negative safe integer");
1259
+ }
1260
+ const projections = store.collection(queueIndex);
1261
+ const records = store.collection(supportRecords);
1262
+ const page = await projections.scan({
1263
+ limit,
1264
+ ...(rawCursor === undefined ? {} : { cursor: rawCursor }),
1265
+ });
1266
+ const now = clock.now();
1267
+ let deleted = 0;
1268
+ for (const entry of page.records) {
1269
+ if (entry.value.state !== "inactive") {
1270
+ continue;
1271
+ }
1272
+ if (entry.key.partition !== entry.value.ticketId ||
1273
+ entry.key.id !== "queue") {
1274
+ continue;
1275
+ }
1276
+ const ticketRecord = await records.get(ticketKey(entry.value.ticketId));
1277
+ if (ticketRecord?.kind !== "ticket") {
1278
+ if (await projections.deleteIfUnchanged(queueProjectionKey(entry.value.ticketId), entry.version)) {
1279
+ deleted += 1;
1280
+ }
1281
+ continue;
1282
+ }
1283
+ if (!ticketBeyondTerminalCutoff(ticketRecord.ticket, now, terminalRetentionMilliseconds)) {
1284
+ continue;
1285
+ }
1286
+ if (await projections.deleteIfUnchanged(queueProjectionKey(entry.value.ticketId), entry.version)) {
1287
+ deleted += 1;
1288
+ }
1289
+ }
1290
+ return {
1291
+ nextCursor: page.nextCursor,
1292
+ physicalRows: page.records.length,
1293
+ deleted,
1294
+ };
1295
+ }
1296
+ export function createSupportDeskApplication(options) {
1297
+ const source = boundaryObject(options, "application options");
1298
+ const store = ownDataProperty(source, "store", "application options.store");
1299
+ const clock = ownDataProperty(source, "clock", "application options.clock");
1300
+ const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
1301
+ const logger = rawLogger === undefined ? noopLogger : rawLogger;
1302
+ const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
1303
+ const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
1304
+ const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
1305
+ const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
1306
+ const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
1307
+ const limitOverrides = {};
1308
+ if (rawLimits !== undefined) {
1309
+ const limitSource = boundaryObject(rawLimits, "application options.limits");
1310
+ for (const field of [
1311
+ "maxSubjectCharacters",
1312
+ "maxMessageCharacters",
1313
+ "maxTicketsPerPrincipal",
1314
+ "maxMessagesPerTicket",
1315
+ "maxRecordsPerTicket",
1316
+ ]) {
1317
+ const value = ownDataProperty(limitSource, field, `application options.limits.${field}`, true);
1318
+ if (value !== undefined) {
1319
+ if (typeof value !== "number") {
1320
+ throw new TypeError(`application options.limits.${field} must be a number`);
1321
+ }
1322
+ limitOverrides[field] = value;
1323
+ }
1324
+ }
1325
+ }
1326
+ if (rawMaxConflictAttempts !== undefined &&
1327
+ typeof rawMaxConflictAttempts !== "number") {
1328
+ throw new TypeError("application options.maxConflictAttempts must be a number");
1329
+ }
1330
+ const records = store.collection(supportRecords);
1331
+ const index = store.collection(customerTicketIndex);
1332
+ const numbers = store.collection(ticketNumbers);
1333
+ const projections = store.collection(queueIndex);
1334
+ const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
1335
+ const maxConflictAttempts = rawMaxConflictAttempts ?? 4;
1336
+ if (!Number.isSafeInteger(maxConflictAttempts) ||
1337
+ maxConflictAttempts <= 0 ||
1338
+ maxConflictAttempts > 20) {
1339
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
1340
+ }
1341
+ const terminalRetentionMilliseconds = rawTerminalRetention === undefined
1342
+ ? defaultQueueTerminalRetentionMilliseconds
1343
+ : rawTerminalRetention;
1344
+ if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
1345
+ terminalRetentionMilliseconds < 0) {
1346
+ throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
1347
+ }
1348
+ const queueBudgetOverrides = {};
1349
+ if (rawQueueBudgets !== undefined) {
1350
+ const budgetSource = boundaryObject(rawQueueBudgets, "application options.queueScanBudgets");
1351
+ for (const field of [
1352
+ "maxPhysicalRows",
1353
+ "maxScanPages",
1354
+ "maxActiveResults",
1355
+ "scanPageSize",
1356
+ ]) {
1357
+ const value = ownDataProperty(budgetSource, field, `application options.queueScanBudgets.${field}`, true);
1358
+ if (value !== undefined) {
1359
+ if (typeof value !== "number") {
1360
+ throw new TypeError(`application options.queueScanBudgets.${field} must be a number`);
1361
+ }
1362
+ queueBudgetOverrides[field] = value;
1363
+ }
1364
+ }
1365
+ }
1366
+ const queueBudgets = {
1367
+ ...defaultQueueScanBudgets,
1368
+ ...queueBudgetOverrides,
1369
+ };
1370
+ if (!Number.isSafeInteger(queueBudgets.scanPageSize) ||
1371
+ queueBudgets.scanPageSize <= 0 ||
1372
+ queueBudgets.scanPageSize > MAX_SCAN_PAGE_SIZE) {
1373
+ throw new TypeError(`queueScanBudgets.scanPageSize must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
1374
+ }
1375
+ for (const field of [
1376
+ "maxPhysicalRows",
1377
+ "maxScanPages",
1378
+ "maxActiveResults",
1379
+ ]) {
1380
+ const value = queueBudgets[field];
1381
+ if (!Number.isSafeInteger(value) || value <= 0) {
1382
+ throw new TypeError(`queueScanBudgets.${field} must be a positive safe integer`);
1383
+ }
1384
+ }
1385
+ const limitMaximums = {
1386
+ maxSubjectCharacters: 1_000,
1387
+ maxMessageCharacters: 20_000,
1388
+ maxTicketsPerPrincipal: 1_000,
1389
+ maxMessagesPerTicket: 100,
1390
+ maxRecordsPerTicket: 2_000,
1391
+ };
1392
+ for (const field of Object.keys(limits)) {
1393
+ const value = limits[field];
1394
+ if (!Number.isSafeInteger(value) ||
1395
+ value <= 0 ||
1396
+ value > limitMaximums[field]) {
1397
+ throw new TypeError(`${field} must be a positive safe integer no greater than ${limitMaximums[field]}`);
1398
+ }
1399
+ }
1400
+ let projectionHealth = Object.freeze({
1401
+ consecutiveFailures: 0,
1402
+ });
1403
+ async function safeProjectQueue(ticketId) {
1404
+ try {
1405
+ await projectTicketToQueue(ticketId, {
1406
+ store,
1407
+ clock,
1408
+ terminalRetentionMilliseconds,
1409
+ maxConflictAttempts,
1410
+ });
1411
+ // Health success does not sample the host clock: ticket mutations and
1412
+ // sequence-clock tests must not observe projection bookkeeping as time.
1413
+ projectionHealth = Object.freeze({
1414
+ consecutiveFailures: 0,
1415
+ ...(projectionHealth.lastFailureAt === undefined
1416
+ ? {}
1417
+ : {
1418
+ lastFailureAt: projectionHealth.lastFailureAt,
1419
+ lastFailureTicketId: projectionHealth.lastFailureTicketId,
1420
+ lastFailureMessage: projectionHealth.lastFailureMessage,
1421
+ }),
1422
+ });
1423
+ }
1424
+ catch (error) {
1425
+ const message = error instanceof Error ? error.message : "queue projection failed";
1426
+ // Failure diagnostics must never fail a committed command response.
1427
+ let lastFailureAt;
1428
+ try {
1429
+ lastFailureAt = clock.now();
1430
+ }
1431
+ catch {
1432
+ lastFailureAt = undefined;
1433
+ }
1434
+ projectionHealth = Object.freeze({
1435
+ consecutiveFailures: projectionHealth.consecutiveFailures + 1,
1436
+ ...(lastFailureAt === undefined ? {} : { lastFailureAt }),
1437
+ lastFailureTicketId: ticketId,
1438
+ lastFailureMessage: message,
1439
+ });
1440
+ try {
1441
+ logger.log("error", "support-desk.queue.projection_failed", {
1442
+ ticketId,
1443
+ message,
1444
+ });
1445
+ }
1446
+ catch {
1447
+ // ignore logger transport failures
1448
+ }
1449
+ }
1450
+ }
1451
+ async function customerViewAfterChange(principalId, ticketId) {
1452
+ await safeProjectQueue(ticketId);
1453
+ return authoritativeView(records, principalId, ticketId, limits.maxRecordsPerTicket);
1454
+ }
1455
+ async function staffViewAfterChange(ticketId) {
1456
+ await safeProjectQueue(ticketId);
1457
+ return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
1458
+ }
1459
+ async function reserveCustomerTicket(principalId, ticketId, reservedAt) {
1460
+ requireIdentifier(principalId, "principalId");
1461
+ requireIdentifier(ticketId, "ticketId");
1462
+ canonicalTimestamp(reservedAt, "reservedAt");
1463
+ const observedFence = await records.get(ticketReservationKey(ticketId));
1464
+ const nextGeneration = observedFence?.kind === "ticket_reservation"
1465
+ ? observedFence.generation + 1
1466
+ : 1;
1467
+ if (!Number.isSafeInteger(nextGeneration)) {
1468
+ throw new SupportDeskConflictError("Customer ticket reservation generation is exhausted");
1469
+ }
1470
+ const newToken = crypto.randomUUID();
1471
+ const result = await index.update({ partition: principalId, id: "tickets" }, (current) => {
1472
+ if (current === null) {
1473
+ return {
1474
+ action: "write",
1475
+ value: {
1476
+ principalId,
1477
+ entries: [
1478
+ {
1479
+ ticketId,
1480
+ reservationToken: newToken,
1481
+ reservationGeneration: nextGeneration,
1482
+ reservedAt,
1483
+ state: "reserved",
1484
+ },
1485
+ ],
1486
+ },
1487
+ };
1488
+ }
1489
+ if (current.entries.some((entry) => entry.ticketId === ticketId)) {
1490
+ return { action: "keep" };
1491
+ }
1492
+ if (current.entries.length >= limits.maxTicketsPerPrincipal) {
1493
+ throw new SupportDeskLimitError("customer_tickets", limits.maxTicketsPerPrincipal);
1494
+ }
1495
+ return {
1496
+ action: "write",
1497
+ value: {
1498
+ ...current,
1499
+ entries: [
1500
+ ...current.entries,
1501
+ {
1502
+ ticketId,
1503
+ reservationToken: newToken,
1504
+ reservationGeneration: nextGeneration,
1505
+ reservedAt,
1506
+ state: "reserved",
1507
+ },
1508
+ ],
1509
+ },
1510
+ };
1511
+ }, { maxAttempts: maxConflictAttempts });
1512
+ const reservation = result.value?.entries.find((entry) => entry.ticketId === ticketId);
1513
+ if (reservation === undefined) {
1514
+ throw new SupportDeskConflictError("Customer ticket reservation was not stored");
1515
+ }
1516
+ const fence = await records.get(ticketReservationKey(ticketId));
1517
+ if (reservation.state === "reserved" &&
1518
+ fence?.kind === "ticket_reservation" &&
1519
+ fence.state === "cancelled" &&
1520
+ fence.generation >= reservation.reservationGeneration) {
1521
+ throw new SupportDeskConflictError("Customer ticket reservation cancellation is still being finalized");
1522
+ }
1523
+ return reservation;
1524
+ }
1525
+ async function confirmCustomerTicket(principalId, ticketId, reservationToken, reservationGeneration) {
1526
+ const result = await index.update({ partition: principalId, id: "tickets" }, (current) => {
1527
+ if (current === null) {
1528
+ return { action: "keep" };
1529
+ }
1530
+ const entry = current.entries.find((candidate) => candidate.ticketId === ticketId);
1531
+ if (entry === undefined ||
1532
+ entry.reservationToken !== reservationToken ||
1533
+ entry.reservationGeneration !== reservationGeneration) {
1534
+ return { action: "keep" };
1535
+ }
1536
+ if (entry.state === "confirmed") {
1537
+ return { action: "keep" };
1538
+ }
1539
+ return {
1540
+ action: "write",
1541
+ value: {
1542
+ ...current,
1543
+ entries: current.entries.map((candidate) => candidate.ticketId === ticketId
1544
+ ? { ...candidate, state: "confirmed" }
1545
+ : candidate),
1546
+ },
1547
+ };
1548
+ }, { maxAttempts: maxConflictAttempts });
1549
+ const confirmed = result.value?.entries.find((entry) => entry.ticketId === ticketId &&
1550
+ entry.reservationToken === reservationToken &&
1551
+ entry.reservationGeneration === reservationGeneration &&
1552
+ entry.state === "confirmed");
1553
+ if (confirmed === undefined) {
1554
+ throw new SupportDeskConflictError("Committed ticket reservation could not be confirmed");
1555
+ }
1556
+ }
1557
+ async function reserveTicketNumber() {
1558
+ const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
1559
+ const lastIssued = current?.lastIssued ?? 0;
1560
+ if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
1561
+ throw new SupportDeskConflictError("Ticket number counter is corrupt");
1562
+ }
1563
+ if (lastIssued >= Number.MAX_SAFE_INTEGER) {
1564
+ throw new SupportDeskConflictError("Ticket number sequence is exhausted");
1565
+ }
1566
+ return {
1567
+ action: "write",
1568
+ value: { lastIssued: lastIssued + 1 },
1569
+ };
1570
+ }, { maxAttempts: maxConflictAttempts });
1571
+ const issued = result.value?.lastIssued;
1572
+ if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
1573
+ throw new SupportDeskConflictError("Ticket number was not reserved");
1574
+ }
1575
+ return issued;
1576
+ }
1577
+ return {
1578
+ async createCustomerTicket(access, input) {
1579
+ requirePermission(access, supportPermissions.create);
1580
+ requireIdentifier(access.principalId, "principalId");
1581
+ const command = snapshotCreateCustomerTicketCommand(input);
1582
+ requireIdentifier(command.ticketId, "ticketId");
1583
+ requireIdentifier(command.messageId, "messageId");
1584
+ requireIdentifier(command.commandId, "commandId");
1585
+ requireIdentifier(command.correlationId, "correlationId");
1586
+ enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
1587
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
1588
+ const fingerprintPayload = {
1589
+ type: "create_customer_ticket",
1590
+ ticketId: command.ticketId,
1591
+ messageId: command.messageId,
1592
+ subject: command.subject,
1593
+ body: command.body,
1594
+ // Omit absent category so pre-category receipts still match retries.
1595
+ ...(command.category === undefined
1596
+ ? {}
1597
+ : { category: command.category }),
1598
+ requesterEmail: command.requesterEmail ?? null,
1599
+ notification: stableNotification(command.notification),
1600
+ };
1601
+ const fingerprint = await requestFingerprint(fingerprintPayload);
1602
+ // Replay an already committed create before host allowlist checks so a
1603
+ // later config change cannot break idempotent retries of the same command.
1604
+ // Replay never reserves another instance ticket number.
1605
+ const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
1606
+ let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
1607
+ if (!createIsReplay &&
1608
+ existingCreate?.kind === "command" &&
1609
+ existingCreate.commandType === "create_customer_ticket" &&
1610
+ existingCreate.messageId === command.messageId) {
1611
+ // Pre-Task-3 receipts included ticketNumber in the fingerprint.
1612
+ const existingTicket = await records.get(ticketKey(command.ticketId));
1613
+ if (existingTicket?.kind === "ticket") {
1614
+ // Pre-Task-3 hashed ticketNumber immediately after ticketId.
1615
+ const legacyFingerprint = await requestFingerprint({
1616
+ type: fingerprintPayload.type,
1617
+ ticketId: fingerprintPayload.ticketId,
1618
+ ticketNumber: existingTicket.ticket.number,
1619
+ messageId: fingerprintPayload.messageId,
1620
+ subject: fingerprintPayload.subject,
1621
+ body: fingerprintPayload.body,
1622
+ ...(command.category === undefined
1623
+ ? {}
1624
+ : { category: command.category }),
1625
+ requesterEmail: fingerprintPayload.requesterEmail,
1626
+ notification: fingerprintPayload.notification,
1627
+ });
1628
+ createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
1629
+ }
1630
+ }
1631
+ if (createIsReplay) {
1632
+ // A crash after ticket commit and before index confirmation must still
1633
+ // converge the principal hint on retry.
1634
+ const fence = await records.get(ticketReservationKey(command.ticketId));
1635
+ if (fence?.kind === "ticket_reservation" &&
1636
+ fence.state === "committed") {
1637
+ await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
1638
+ }
1639
+ return customerViewAfterChange(access.principalId, command.ticketId);
1640
+ }
1641
+ if (command.category !== undefined) {
1642
+ if (allowedCategories === undefined ||
1643
+ !allowedCategories.has(command.category)) {
1644
+ throw new TypeError("create command.category is not configured for this Support Desk instance");
1645
+ }
1646
+ }
1647
+ // Fail closed before durable number/index reservation so a too-small
1648
+ // partition budget cannot exhaust the principal ticket index with
1649
+ // reserved-but-never-created tickets.
1650
+ const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
1651
+ (command.notification === undefined ? 0 : 1);
1652
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows);
1653
+ // Reserve outside the ticket transaction; gaps are accepted if create fails.
1654
+ const ticketNumber = await reserveTicketNumber();
1655
+ // Bind host-supplied notification content to the reserved number so mail
1656
+ // cannot advertise a guessed ticket number.
1657
+ const notification = command.notification === undefined
1658
+ ? undefined
1659
+ : bindNotificationTicketNumber(command.notification, ticketNumber);
1660
+ const now = clock.now();
1661
+ const ticket = createTicket({
1662
+ id: command.ticketId,
1663
+ number: ticketNumber,
1664
+ subject: command.subject,
1665
+ channel: "web",
1666
+ ...(command.category === undefined
1667
+ ? {}
1668
+ : { category: command.category }),
1669
+ requester: {
1670
+ association: "authenticated",
1671
+ principalId: access.principalId,
1672
+ ...(command.requesterEmail === undefined
1673
+ ? {}
1674
+ : { email: command.requesterEmail }),
1675
+ },
1676
+ createdAt: now,
1677
+ });
1678
+ const message = {
1679
+ id: command.messageId,
1680
+ ticketId: command.ticketId,
1681
+ authorKind: "customer",
1682
+ authorPrincipalId: access.principalId,
1683
+ channel: "web",
1684
+ visibility: "customer",
1685
+ format: "plain_text",
1686
+ body: command.body,
1687
+ createdAt: now,
1688
+ };
1689
+ const notificationJob = notification === undefined
1690
+ ? undefined
1691
+ : deliveryJobAction(command.ticketId, command.messageId, now, notification);
1692
+ const notificationContent = deliveryContent(notification);
1693
+ const reservation = await reserveCustomerTicket(access.principalId, command.ticketId, now);
1694
+ const existingReservation = await records.getVersioned(ticketReservationKey(command.ticketId));
1695
+ if (existingReservation?.value.kind === "ticket_reservation" &&
1696
+ existingReservation.value.state === "committed" &&
1697
+ (existingReservation.value.token !== reservation.reservationToken ||
1698
+ existingReservation.value.generation !==
1699
+ reservation.reservationGeneration)) {
1700
+ throw new SupportDeskConflictError("Ticket reservation belongs to a different create attempt");
1701
+ }
1702
+ if (existingReservation?.value.kind === "ticket_reservation" &&
1703
+ existingReservation.value.state === "cancelled" &&
1704
+ existingReservation.value.generation >=
1705
+ reservation.reservationGeneration) {
1706
+ throw new SupportDeskConflictError("Customer ticket reservation was cancelled before commit");
1707
+ }
1708
+ const committedReservation = {
1709
+ kind: "ticket_reservation",
1710
+ partition: command.ticketId,
1711
+ id: "reservation",
1712
+ token: reservation.reservationToken,
1713
+ generation: reservation.reservationGeneration,
1714
+ state: "committed",
1715
+ };
1716
+ const reservationActions = existingReservation === null
1717
+ ? [{ action: "insert", value: committedReservation }]
1718
+ : existingReservation.value.kind === "ticket_reservation" &&
1719
+ existingReservation.value.state === "cancelled" &&
1720
+ existingReservation.value.generation <
1721
+ reservation.reservationGeneration
1722
+ ? [
1723
+ {
1724
+ action: "putIfUnchanged",
1725
+ value: committedReservation,
1726
+ version: existingReservation.version,
1727
+ },
1728
+ ]
1729
+ : [];
1730
+ const actions = [
1731
+ {
1732
+ action: "insert",
1733
+ value: {
1734
+ kind: "ticket",
1735
+ partition: command.ticketId,
1736
+ id: "ticket",
1737
+ ticket,
1738
+ },
1739
+ },
1740
+ {
1741
+ action: "insert",
1742
+ value: {
1743
+ kind: "ticket_quota",
1744
+ partition: command.ticketId,
1745
+ id: "quota",
1746
+ messageCount: 1,
1747
+ },
1748
+ },
1749
+ ...reservationActions,
1750
+ {
1751
+ action: "insert",
1752
+ value: {
1753
+ kind: "message",
1754
+ partition: command.ticketId,
1755
+ id: `message:${command.messageId}`,
1756
+ ordinal: ticket.revision,
1757
+ message,
1758
+ ...(notificationContent === undefined
1759
+ ? {}
1760
+ : { deliveryContent: notificationContent }),
1761
+ },
1762
+ },
1763
+ ticketAuditAction({
1764
+ commandId: command.commandId,
1765
+ correlationId: command.correlationId,
1766
+ ticketId: command.ticketId,
1767
+ revision: ticket.revision,
1768
+ actorId: access.principalId,
1769
+ occurredAt: now,
1770
+ action: supportTicketAuditActions.created,
1771
+ }),
1772
+ {
1773
+ action: "insert",
1774
+ value: {
1775
+ kind: "command",
1776
+ partition: command.ticketId,
1777
+ id: `command:${command.commandId}`,
1778
+ commandId: command.commandId,
1779
+ commandType: "create_customer_ticket",
1780
+ messageId: command.messageId,
1781
+ requestFingerprint: fingerprint,
1782
+ completedAt: now,
1783
+ },
1784
+ },
1785
+ ...(notificationJob === undefined ? [] : [notificationJob]),
1786
+ ];
1787
+ // Create starts an empty partition; fail closed if the first write would
1788
+ // exceed the configured physical-record budget.
1789
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
1790
+ const outcome = await records.transact(command.ticketId, actions);
1791
+ if (!outcome.committed) {
1792
+ const existing = await records.get(commandKey(command.ticketId, command.commandId));
1793
+ if (!duplicateMatches(existing, "create_customer_ticket", command.messageId, fingerprint)) {
1794
+ throw new SupportDeskConflictError();
1795
+ }
1796
+ }
1797
+ await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
1798
+ return customerViewAfterChange(access.principalId, command.ticketId);
1799
+ },
1800
+ async replyToCustomerTicket(access, input) {
1801
+ requirePermission(access, supportPermissions.replyOwn);
1802
+ requireIdentifier(access.principalId, "principalId");
1803
+ const command = snapshotReplyToCustomerTicketCommand(input);
1804
+ requireIdentifier(command.ticketId, "ticketId");
1805
+ requireIdentifier(command.messageId, "messageId");
1806
+ requireIdentifier(command.commandId, "commandId");
1807
+ requireIdentifier(command.correlationId, "correlationId");
1808
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
1809
+ const fingerprint = await requestFingerprint({
1810
+ type: "reply_customer_ticket",
1811
+ ticketId: command.ticketId,
1812
+ messageId: command.messageId,
1813
+ body: command.body,
1814
+ notification: stableNotification(command.notification),
1815
+ });
1816
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
1817
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
1818
+ if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1819
+ return customerViewAfterChange(access.principalId, command.ticketId);
1820
+ }
1821
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
1822
+ if (versioned?.value.kind !== "ticket" ||
1823
+ versioned.value.ticket.requester.association !== "authenticated" ||
1824
+ versioned.value.ticket.requester.principalId !== access.principalId) {
1825
+ throw new SupportDeskNotFoundError();
1826
+ }
1827
+ const partitionRows = await records.list(command.ticketId);
1828
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
1829
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
1830
+ if (quota?.value.kind !== "ticket_quota") {
1831
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
1832
+ }
1833
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
1834
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1835
+ }
1836
+ const sampledNow = clock.now();
1837
+ const sampledEpoch = canonicalTimestamp(sampledNow, "clock.now()");
1838
+ const storedEpoch = canonicalTimestamp(versioned.value.ticket.updatedAt, "ticket.updatedAt");
1839
+ const now = sampledEpoch < storedEpoch
1840
+ ? versioned.value.ticket.updatedAt
1841
+ : sampledNow;
1842
+ const updated = applyTicketEvent(versioned.value.ticket, {
1843
+ type: "customer_replied",
1844
+ actorId: access.principalId,
1845
+ occurredAt: now,
1846
+ });
1847
+ const message = {
1848
+ id: command.messageId,
1849
+ ticketId: command.ticketId,
1850
+ authorKind: "customer",
1851
+ authorPrincipalId: access.principalId,
1852
+ channel: "web",
1853
+ visibility: "customer",
1854
+ format: "plain_text",
1855
+ body: command.body,
1856
+ createdAt: now,
1857
+ };
1858
+ const notificationContent = deliveryContent(command.notification);
1859
+ const notificationJob = command.notification === undefined
1860
+ ? undefined
1861
+ : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
1862
+ const outcome = await records.transact(command.ticketId, [
1863
+ {
1864
+ action: "putIfUnchanged",
1865
+ version: versioned.version,
1866
+ value: { ...versioned.value, ticket: updated },
1867
+ },
1868
+ {
1869
+ action: "putIfUnchanged",
1870
+ version: quota.version,
1871
+ value: {
1872
+ ...quota.value,
1873
+ messageCount: quota.value.messageCount + 1,
1874
+ },
1875
+ },
1876
+ {
1877
+ action: "insert",
1878
+ value: {
1879
+ kind: "message",
1880
+ partition: command.ticketId,
1881
+ id: `message:${command.messageId}`,
1882
+ ordinal: updated.revision,
1883
+ message,
1884
+ ...(notificationContent === undefined
1885
+ ? {}
1886
+ : { deliveryContent: notificationContent }),
1887
+ },
1888
+ },
1889
+ ticketAuditAction({
1890
+ commandId: command.commandId,
1891
+ correlationId: command.correlationId,
1892
+ ticketId: command.ticketId,
1893
+ revision: updated.revision,
1894
+ actorId: access.principalId,
1895
+ occurredAt: now,
1896
+ action: supportTicketAuditActions.customerReplied,
1897
+ }),
1898
+ {
1899
+ action: "insert",
1900
+ value: {
1901
+ kind: "command",
1902
+ partition: command.ticketId,
1903
+ id: `command:${command.commandId}`,
1904
+ commandId: command.commandId,
1905
+ commandType: "reply_customer_ticket",
1906
+ messageId: command.messageId,
1907
+ requestFingerprint: fingerprint,
1908
+ completedAt: now,
1909
+ },
1910
+ },
1911
+ ...(notificationJob === undefined ? [] : [notificationJob]),
1912
+ ]);
1913
+ if (outcome.committed) {
1914
+ return customerViewAfterChange(access.principalId, command.ticketId);
1915
+ }
1916
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
1917
+ if (duplicateMatches(nowDuplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1918
+ return customerViewAfterChange(access.principalId, command.ticketId);
1919
+ }
1920
+ }
1921
+ throw new SupportDeskConflictError();
1922
+ },
1923
+ async listCustomerTickets(access) {
1924
+ requirePermission(access, supportPermissions.readOwn);
1925
+ requireIdentifier(access.principalId, "principalId");
1926
+ const hints = await index.get({
1927
+ partition: access.principalId,
1928
+ id: "tickets",
1929
+ });
1930
+ const tickets = [];
1931
+ for (const hint of hints?.entries ?? []) {
1932
+ try {
1933
+ tickets.push((await authoritativeView(records, access.principalId, hint.ticketId, limits.maxRecordsPerTicket)).ticket);
1934
+ }
1935
+ catch (error) {
1936
+ if (!(error instanceof SupportDeskNotFoundError)) {
1937
+ throw error;
1938
+ }
1939
+ }
1940
+ }
1941
+ return tickets.sort((left, right) => right.customerUpdatedAt.localeCompare(left.customerUpdatedAt));
1942
+ },
1943
+ async readCustomerTicket(access, ticketId) {
1944
+ requirePermission(access, supportPermissions.readOwn);
1945
+ requireIdentifier(access.principalId, "principalId");
1946
+ requireIdentifier(ticketId, "ticketId");
1947
+ return authoritativeView(records, access.principalId, ticketId, limits.maxRecordsPerTicket);
1948
+ },
1949
+ async readStaffTicket(access, ticketId) {
1950
+ requirePermission(access, supportPermissions.queueRead);
1951
+ requireIdentifier(access.principalId, "principalId");
1952
+ requireIdentifier(ticketId, "ticketId");
1953
+ return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
1954
+ },
1955
+ async listStaffQueue(access, input) {
1956
+ requirePermission(access, supportPermissions.queueRead);
1957
+ requireIdentifier(access.principalId, "principalId");
1958
+ const query = snapshotStaffQueueQuery(input);
1959
+ // Request-local cursor only: every online queue read starts without one
1960
+ // and consumes a single complete projection scan cycle.
1961
+ let cursor;
1962
+ let physicalRows = 0;
1963
+ let pages = 0;
1964
+ const confirmed = [];
1965
+ const seenTicketIds = new Set();
1966
+ const capacityExhausted = (budget, maximum) => {
1967
+ try {
1968
+ logger.log("warn", "support-desk.queue.capacity_exhausted", {
1969
+ budget,
1970
+ maximum,
1971
+ physicalRows,
1972
+ pages,
1973
+ activeResults: confirmed.length,
1974
+ });
1975
+ }
1976
+ catch {
1977
+ // ignore logger transport failures
1978
+ }
1979
+ throw new SupportDeskQueueCapacityError(budget, maximum);
1980
+ };
1981
+ for (;;) {
1982
+ if (pages >= queueBudgets.maxScanPages) {
1983
+ capacityExhausted("scan_pages", queueBudgets.maxScanPages);
1984
+ }
1985
+ const remainingPhysical = queueBudgets.maxPhysicalRows - physicalRows;
1986
+ if (remainingPhysical <= 0) {
1987
+ capacityExhausted("physical_rows", queueBudgets.maxPhysicalRows);
1988
+ }
1989
+ pages += 1;
1990
+ // Request only what the remaining physical-row budget allows so the
1991
+ // configured maximum actually bounds adapter work.
1992
+ const pageLimit = Math.min(queueBudgets.scanPageSize, remainingPhysical);
1993
+ // Codec failures abort the scan page; do not return a partial queue.
1994
+ const page = await projections.scan({
1995
+ limit: pageLimit,
1996
+ ...(cursor === undefined ? {} : { cursor }),
1997
+ });
1998
+ physicalRows += page.records.length;
1999
+ if (physicalRows > queueBudgets.maxPhysicalRows) {
2000
+ capacityExhausted("physical_rows", queueBudgets.maxPhysicalRows);
2001
+ }
2002
+ for (const entry of page.records) {
2003
+ const projection = entry.value;
2004
+ if (projection.id !== "queue" ||
2005
+ entry.key.partition !== projection.ticketId ||
2006
+ entry.key.id !== "queue") {
2007
+ continue;
2008
+ }
2009
+ if (projection.state !== "active") {
2010
+ continue;
2011
+ }
2012
+ // Confirm every candidate against the authoritative ticket.
2013
+ const ticketRecord = await records.get(ticketKey(projection.ticketId));
2014
+ if (ticketRecord?.kind !== "ticket") {
2015
+ continue;
2016
+ }
2017
+ const ticket = ticketRecord.ticket;
2018
+ if (ticket.id !== projection.ticketId ||
2019
+ ticket.revision !== projection.projectedRevision ||
2020
+ isTerminalTicketStatus(ticket.status)) {
2021
+ continue;
2022
+ }
2023
+ if (seenTicketIds.has(ticket.id)) {
2024
+ // Scan pages may repeat rows; de-duplicate after confirmation.
2025
+ continue;
2026
+ }
2027
+ if (confirmed.length >= queueBudgets.maxActiveResults) {
2028
+ capacityExhausted("active_results", queueBudgets.maxActiveResults);
2029
+ }
2030
+ seenTicketIds.add(ticket.id);
2031
+ confirmed.push(toStaffQueueItem(ticket));
2032
+ }
2033
+ if (page.nextCursor === null) {
2034
+ break;
2035
+ }
2036
+ cursor = page.nextCursor;
2037
+ }
2038
+ const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query));
2039
+ const sort = query.sort ?? "updated_newest";
2040
+ filtered.sort((left, right) => {
2041
+ const byTime = sort === "updated_oldest"
2042
+ ? left.updatedAt.localeCompare(right.updatedAt)
2043
+ : right.updatedAt.localeCompare(left.updatedAt);
2044
+ if (byTime !== 0) {
2045
+ return byTime;
2046
+ }
2047
+ return left.ticketId.localeCompare(right.ticketId);
2048
+ });
2049
+ return { items: filtered.map((item) => Object.freeze({ ...item })) };
2050
+ },
2051
+ queueProjectionHealth() {
2052
+ return projectionHealth;
2053
+ },
2054
+ async replyAsStaff(access, input) {
2055
+ requireStaffMutationAccess(access, supportPermissions.replyAny);
2056
+ requireIdentifier(access.principalId, "principalId");
2057
+ const command = snapshotStaffReplyCommand(input);
2058
+ requireIdentifier(command.ticketId, "ticketId");
2059
+ requireIdentifier(command.messageId, "messageId");
2060
+ requireIdentifier(command.commandId, "commandId");
2061
+ requireIdentifier(command.correlationId, "correlationId");
2062
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
2063
+ const fingerprint = await requestFingerprint({
2064
+ type: "staff_reply",
2065
+ ticketId: command.ticketId,
2066
+ messageId: command.messageId,
2067
+ body: command.body,
2068
+ notification: stableNotification(command.notification),
2069
+ });
2070
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2071
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2072
+ if (duplicateMatches(duplicate, "staff_reply", command.messageId, fingerprint)) {
2073
+ return staffViewAfterChange(command.ticketId);
2074
+ }
2075
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2076
+ if (versioned?.value.kind !== "ticket") {
2077
+ throw new SupportDeskNotFoundError();
2078
+ }
2079
+ const partitionRows = await records.list(command.ticketId);
2080
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
2081
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2082
+ if (quota?.value.kind !== "ticket_quota") {
2083
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2084
+ }
2085
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
2086
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2087
+ }
2088
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2089
+ const updated = applyTicketEvent(versioned.value.ticket, {
2090
+ type: "support_replied",
2091
+ actorId: access.principalId,
2092
+ occurredAt: now,
2093
+ });
2094
+ const message = {
2095
+ id: command.messageId,
2096
+ ticketId: command.ticketId,
2097
+ authorKind: "staff",
2098
+ authorPrincipalId: access.principalId,
2099
+ channel: "web",
2100
+ visibility: "customer",
2101
+ format: "plain_text",
2102
+ body: command.body,
2103
+ createdAt: now,
2104
+ };
2105
+ const notificationContent = deliveryContent(command.notification);
2106
+ const notificationJob = command.notification === undefined
2107
+ ? undefined
2108
+ : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2109
+ const outcome = await records.transact(command.ticketId, [
2110
+ {
2111
+ action: "putIfUnchanged",
2112
+ version: versioned.version,
2113
+ value: { ...versioned.value, ticket: updated },
2114
+ },
2115
+ {
2116
+ action: "putIfUnchanged",
2117
+ version: quota.version,
2118
+ value: {
2119
+ ...quota.value,
2120
+ messageCount: quota.value.messageCount + 1,
2121
+ },
2122
+ },
2123
+ {
2124
+ action: "insert",
2125
+ value: {
2126
+ kind: "message",
2127
+ partition: command.ticketId,
2128
+ id: `message:${command.messageId}`,
2129
+ ordinal: updated.revision,
2130
+ message,
2131
+ ...(notificationContent === undefined
2132
+ ? {}
2133
+ : { deliveryContent: notificationContent }),
2134
+ },
2135
+ },
2136
+ ticketAuditAction({
2137
+ commandId: command.commandId,
2138
+ correlationId: command.correlationId,
2139
+ ticketId: command.ticketId,
2140
+ revision: updated.revision,
2141
+ actorId: access.principalId,
2142
+ occurredAt: now,
2143
+ action: supportTicketAuditActions.staffReplied,
2144
+ }),
2145
+ {
2146
+ action: "insert",
2147
+ value: {
2148
+ kind: "command",
2149
+ partition: command.ticketId,
2150
+ id: `command:${command.commandId}`,
2151
+ commandId: command.commandId,
2152
+ commandType: "staff_reply",
2153
+ messageId: command.messageId,
2154
+ requestFingerprint: fingerprint,
2155
+ completedAt: now,
2156
+ },
2157
+ },
2158
+ ...(notificationJob === undefined ? [] : [notificationJob]),
2159
+ ]);
2160
+ if (outcome.committed) {
2161
+ return staffViewAfterChange(command.ticketId);
2162
+ }
2163
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2164
+ if (duplicateMatches(nowDuplicate, "staff_reply", command.messageId, fingerprint)) {
2165
+ return staffViewAfterChange(command.ticketId);
2166
+ }
2167
+ }
2168
+ throw new SupportDeskConflictError();
2169
+ },
2170
+ async addNote(access, input) {
2171
+ requireStaffMutationAccess(access, supportPermissions.note);
2172
+ requireIdentifier(access.principalId, "principalId");
2173
+ const command = snapshotAddNoteCommand(input);
2174
+ requireIdentifier(command.ticketId, "ticketId");
2175
+ requireIdentifier(command.messageId, "messageId");
2176
+ requireIdentifier(command.commandId, "commandId");
2177
+ requireIdentifier(command.correlationId, "correlationId");
2178
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
2179
+ const fingerprint = await requestFingerprint({
2180
+ type: "add_note",
2181
+ ticketId: command.ticketId,
2182
+ messageId: command.messageId,
2183
+ body: command.body,
2184
+ });
2185
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2186
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2187
+ if (duplicateMatches(duplicate, "add_note", command.messageId, fingerprint)) {
2188
+ return staffViewAfterChange(command.ticketId);
2189
+ }
2190
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2191
+ if (versioned?.value.kind !== "ticket") {
2192
+ throw new SupportDeskNotFoundError();
2193
+ }
2194
+ const partitionRows = await records.list(command.ticketId);
2195
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
2196
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2197
+ if (quota?.value.kind !== "ticket_quota") {
2198
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2199
+ }
2200
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
2201
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2202
+ }
2203
+ const statusBefore = versioned.value.ticket.status;
2204
+ const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
2205
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2206
+ const updated = applyTicketEvent(versioned.value.ticket, {
2207
+ type: "note_added",
2208
+ actorId: access.principalId,
2209
+ occurredAt: now,
2210
+ });
2211
+ // Notes must never change lifecycle or customer-visible timestamps.
2212
+ if (updated.status !== statusBefore ||
2213
+ updated.customerUpdatedAt !== customerUpdatedBefore) {
2214
+ throw new SupportDeskConflictError("Internal note unexpectedly changed customer-visible ticket state");
2215
+ }
2216
+ const message = {
2217
+ id: command.messageId,
2218
+ ticketId: command.ticketId,
2219
+ authorKind: "staff",
2220
+ authorPrincipalId: access.principalId,
2221
+ channel: "web",
2222
+ visibility: "internal",
2223
+ format: "plain_text",
2224
+ body: command.body,
2225
+ createdAt: now,
2226
+ };
2227
+ const outcome = await records.transact(command.ticketId, [
2228
+ {
2229
+ action: "putIfUnchanged",
2230
+ version: versioned.version,
2231
+ value: { ...versioned.value, ticket: updated },
2232
+ },
2233
+ {
2234
+ action: "putIfUnchanged",
2235
+ version: quota.version,
2236
+ value: {
2237
+ ...quota.value,
2238
+ messageCount: quota.value.messageCount + 1,
2239
+ },
2240
+ },
2241
+ {
2242
+ action: "insert",
2243
+ value: {
2244
+ kind: "message",
2245
+ partition: command.ticketId,
2246
+ id: `message:${command.messageId}`,
2247
+ ordinal: updated.revision,
2248
+ message,
2249
+ },
2250
+ },
2251
+ ticketAuditAction({
2252
+ commandId: command.commandId,
2253
+ correlationId: command.correlationId,
2254
+ ticketId: command.ticketId,
2255
+ revision: updated.revision,
2256
+ actorId: access.principalId,
2257
+ occurredAt: now,
2258
+ action: supportTicketAuditActions.noteAdded,
2259
+ }),
2260
+ {
2261
+ action: "insert",
2262
+ value: {
2263
+ kind: "command",
2264
+ partition: command.ticketId,
2265
+ id: `command:${command.commandId}`,
2266
+ commandId: command.commandId,
2267
+ commandType: "add_note",
2268
+ messageId: command.messageId,
2269
+ requestFingerprint: fingerprint,
2270
+ completedAt: now,
2271
+ },
2272
+ },
2273
+ ]);
2274
+ if (outcome.committed) {
2275
+ return staffViewAfterChange(command.ticketId);
2276
+ }
2277
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2278
+ if (duplicateMatches(nowDuplicate, "add_note", command.messageId, fingerprint)) {
2279
+ return staffViewAfterChange(command.ticketId);
2280
+ }
2281
+ }
2282
+ throw new SupportDeskConflictError();
2283
+ },
2284
+ async assignTicket(access, input) {
2285
+ requireStaffMutationAccess(access, supportPermissions.assign);
2286
+ requireIdentifier(access.principalId, "principalId");
2287
+ const command = snapshotAssignTicketCommand(input);
2288
+ requireIdentifier(command.ticketId, "ticketId");
2289
+ requireIdentifier(command.commandId, "commandId");
2290
+ requireIdentifier(command.correlationId, "correlationId");
2291
+ if (command.assigneeId !== null) {
2292
+ requireIdentifier(command.assigneeId, "assigneeId");
2293
+ }
2294
+ if (command.messageId !== undefined) {
2295
+ requireIdentifier(command.messageId, "messageId");
2296
+ }
2297
+ if (command.body !== undefined) {
2298
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
2299
+ }
2300
+ const fingerprint = await requestFingerprint({
2301
+ type: "assign_ticket",
2302
+ ticketId: command.ticketId,
2303
+ assigneeId: command.assigneeId,
2304
+ messageId: command.messageId ?? null,
2305
+ body: command.body ?? null,
2306
+ notification: stableNotification(command.notification),
2307
+ });
2308
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2309
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2310
+ if (duplicateMatches(duplicate, "assign_ticket", command.messageId, fingerprint)) {
2311
+ return staffViewAfterChange(command.ticketId);
2312
+ }
2313
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2314
+ if (versioned?.value.kind !== "ticket") {
2315
+ throw new SupportDeskNotFoundError();
2316
+ }
2317
+ const addedRecords = command.messageId === undefined
2318
+ ? 2
2319
+ : command.notification === undefined
2320
+ ? 3
2321
+ : 4;
2322
+ const partitionRows = await records.list(command.ticketId);
2323
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
2324
+ let quotaVersion = null;
2325
+ if (command.messageId !== undefined) {
2326
+ quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
2327
+ if (quotaVersion?.value.kind !== "ticket_quota") {
2328
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2329
+ }
2330
+ if (quotaVersion.value.messageCount >= limits.maxMessagesPerTicket) {
2331
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2332
+ }
2333
+ }
2334
+ const statusBefore = versioned.value.ticket.status;
2335
+ const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
2336
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2337
+ const updated = applyTicketEvent(versioned.value.ticket, {
2338
+ type: "assigned",
2339
+ actorId: access.principalId,
2340
+ assigneeId: command.assigneeId,
2341
+ occurredAt: now,
2342
+ });
2343
+ if (updated.status !== statusBefore ||
2344
+ updated.customerUpdatedAt !== customerUpdatedBefore) {
2345
+ throw new SupportDeskConflictError("Assignment unexpectedly changed customer-visible ticket state");
2346
+ }
2347
+ // Optional system message + mail so assignment notifications share the
2348
+ // ticket transaction with the assignment itself.
2349
+ const systemMessage = command.messageId === undefined || command.body === undefined
2350
+ ? undefined
2351
+ : {
2352
+ id: command.messageId,
2353
+ ticketId: command.ticketId,
2354
+ authorKind: "system",
2355
+ channel: "web",
2356
+ visibility: "internal",
2357
+ format: "plain_text",
2358
+ body: command.body,
2359
+ createdAt: now,
2360
+ };
2361
+ const notificationContent = deliveryContent(command.notification);
2362
+ const notificationJob = command.notification === undefined || command.messageId === undefined
2363
+ ? undefined
2364
+ : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2365
+ const outcome = await records.transact(command.ticketId, [
2366
+ {
2367
+ action: "putIfUnchanged",
2368
+ version: versioned.version,
2369
+ value: { ...versioned.value, ticket: updated },
2370
+ },
2371
+ ...(quotaVersion === null ||
2372
+ systemMessage === undefined ||
2373
+ quotaVersion.value.kind !== "ticket_quota"
2374
+ ? []
2375
+ : [
2376
+ {
2377
+ action: "putIfUnchanged",
2378
+ version: quotaVersion.version,
2379
+ value: {
2380
+ kind: "ticket_quota",
2381
+ partition: command.ticketId,
2382
+ id: "quota",
2383
+ messageCount: quotaVersion.value.messageCount + 1,
2384
+ },
2385
+ },
2386
+ ]),
2387
+ ...(systemMessage === undefined
2388
+ ? []
2389
+ : [
2390
+ {
2391
+ action: "insert",
2392
+ value: {
2393
+ kind: "message",
2394
+ partition: command.ticketId,
2395
+ id: `message:${command.messageId}`,
2396
+ ordinal: updated.revision,
2397
+ message: systemMessage,
2398
+ ...(notificationContent === undefined
2399
+ ? {}
2400
+ : { deliveryContent: notificationContent }),
2401
+ },
2402
+ },
2403
+ ]),
2404
+ ticketAuditAction({
2405
+ commandId: command.commandId,
2406
+ correlationId: command.correlationId,
2407
+ ticketId: command.ticketId,
2408
+ revision: updated.revision,
2409
+ actorId: access.principalId,
2410
+ occurredAt: now,
2411
+ action: command.assigneeId === null
2412
+ ? supportTicketAuditActions.unassigned
2413
+ : supportTicketAuditActions.assigned,
2414
+ details: command.assigneeId === null
2415
+ ? {}
2416
+ : { assigneeId: command.assigneeId },
2417
+ }),
2418
+ {
2419
+ action: "insert",
2420
+ value: {
2421
+ kind: "command",
2422
+ partition: command.ticketId,
2423
+ id: `command:${command.commandId}`,
2424
+ commandId: command.commandId,
2425
+ commandType: "assign_ticket",
2426
+ ...(command.messageId === undefined
2427
+ ? {}
2428
+ : { messageId: command.messageId }),
2429
+ requestFingerprint: fingerprint,
2430
+ completedAt: now,
2431
+ },
2432
+ },
2433
+ ...(notificationJob === undefined ? [] : [notificationJob]),
2434
+ ]);
2435
+ if (outcome.committed) {
2436
+ return staffViewAfterChange(command.ticketId);
2437
+ }
2438
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2439
+ if (duplicateMatches(nowDuplicate, "assign_ticket", command.messageId, fingerprint)) {
2440
+ return staffViewAfterChange(command.ticketId);
2441
+ }
2442
+ }
2443
+ throw new SupportDeskConflictError();
2444
+ },
2445
+ async changePriority(access, input) {
2446
+ requireStaffMutationAccess(access, supportPermissions.manage);
2447
+ requireIdentifier(access.principalId, "principalId");
2448
+ const command = snapshotChangePriorityCommand(input);
2449
+ requireIdentifier(command.ticketId, "ticketId");
2450
+ requireIdentifier(command.commandId, "commandId");
2451
+ requireIdentifier(command.correlationId, "correlationId");
2452
+ const fingerprint = await requestFingerprint({
2453
+ type: "change_priority",
2454
+ ticketId: command.ticketId,
2455
+ priority: command.priority,
2456
+ });
2457
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2458
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2459
+ if (duplicateMatches(duplicate, "change_priority", undefined, fingerprint)) {
2460
+ return staffViewAfterChange(command.ticketId);
2461
+ }
2462
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2463
+ if (versioned?.value.kind !== "ticket") {
2464
+ throw new SupportDeskNotFoundError();
2465
+ }
2466
+ const partitionRows = await records.list(command.ticketId);
2467
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
2468
+ const statusBefore = versioned.value.ticket.status;
2469
+ const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
2470
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2471
+ const updated = applyTicketEvent(versioned.value.ticket, {
2472
+ type: "priority_changed",
2473
+ actorId: access.principalId,
2474
+ priority: command.priority,
2475
+ occurredAt: now,
2476
+ });
2477
+ if (updated.status !== statusBefore ||
2478
+ updated.customerUpdatedAt !== customerUpdatedBefore) {
2479
+ throw new SupportDeskConflictError("Priority change unexpectedly changed customer-visible ticket state");
2480
+ }
2481
+ const outcome = await records.transact(command.ticketId, [
2482
+ {
2483
+ action: "putIfUnchanged",
2484
+ version: versioned.version,
2485
+ value: { ...versioned.value, ticket: updated },
2486
+ },
2487
+ ticketAuditAction({
2488
+ commandId: command.commandId,
2489
+ correlationId: command.correlationId,
2490
+ ticketId: command.ticketId,
2491
+ revision: updated.revision,
2492
+ actorId: access.principalId,
2493
+ occurredAt: now,
2494
+ action: supportTicketAuditActions.priorityChanged,
2495
+ details: { priority: command.priority },
2496
+ }),
2497
+ {
2498
+ action: "insert",
2499
+ value: {
2500
+ kind: "command",
2501
+ partition: command.ticketId,
2502
+ id: `command:${command.commandId}`,
2503
+ commandId: command.commandId,
2504
+ commandType: "change_priority",
2505
+ requestFingerprint: fingerprint,
2506
+ completedAt: now,
2507
+ },
2508
+ },
2509
+ ]);
2510
+ if (outcome.committed) {
2511
+ return staffViewAfterChange(command.ticketId);
2512
+ }
2513
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2514
+ if (duplicateMatches(nowDuplicate, "change_priority", undefined, fingerprint)) {
2515
+ return staffViewAfterChange(command.ticketId);
2516
+ }
2517
+ }
2518
+ throw new SupportDeskConflictError();
2519
+ },
2520
+ async resolveTicket(access, input) {
2521
+ return mutateLifecycle(access, input, {
2522
+ commandType: "resolve_ticket",
2523
+ eventType: "resolved",
2524
+ auditAction: supportTicketAuditActions.resolved,
2525
+ field: "resolve command",
2526
+ });
2527
+ },
2528
+ async closeTicket(access, input) {
2529
+ return mutateLifecycle(access, input, {
2530
+ commandType: "close_ticket",
2531
+ eventType: "closed",
2532
+ auditAction: supportTicketAuditActions.closed,
2533
+ field: "close command",
2534
+ });
2535
+ },
2536
+ async reopenTicket(access, input) {
2537
+ return mutateLifecycle(access, input, {
2538
+ commandType: "reopen_ticket",
2539
+ eventType: "reopened",
2540
+ auditAction: supportTicketAuditActions.reopened,
2541
+ field: "reopen command",
2542
+ });
2543
+ },
2544
+ async readTicketAuditHistory(access, ticketId) {
2545
+ requirePermission(access, supportPermissions.auditRead);
2546
+ requireIdentifier(access.principalId, "principalId");
2547
+ requireIdentifier(ticketId, "ticketId");
2548
+ const partitionRows = await records.list(ticketId);
2549
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket);
2550
+ const ticket = partitionRows.find((record) => record.kind === "ticket");
2551
+ if (ticket === undefined) {
2552
+ throw new SupportDeskNotFoundError();
2553
+ }
2554
+ return supportAudit.history(records, ticketId);
2555
+ },
2556
+ };
2557
+ async function mutateLifecycle(access, input, spec) {
2558
+ requireStaffMutationAccess(access, supportPermissions.manage);
2559
+ requireIdentifier(access.principalId, "principalId");
2560
+ const command = snapshotLifecycleTicketCommand(input, spec.field);
2561
+ requireIdentifier(command.ticketId, "ticketId");
2562
+ requireIdentifier(command.commandId, "commandId");
2563
+ requireIdentifier(command.correlationId, "correlationId");
2564
+ if (command.messageId !== undefined) {
2565
+ requireIdentifier(command.messageId, "messageId");
2566
+ }
2567
+ if (command.body !== undefined) {
2568
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
2569
+ }
2570
+ const fingerprint = await requestFingerprint({
2571
+ type: spec.commandType,
2572
+ ticketId: command.ticketId,
2573
+ messageId: command.messageId ?? null,
2574
+ body: command.body ?? null,
2575
+ notification: stableNotification(command.notification),
2576
+ });
2577
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2578
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2579
+ if (duplicateMatches(duplicate, spec.commandType, command.messageId, fingerprint)) {
2580
+ return staffViewAfterChange(command.ticketId);
2581
+ }
2582
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2583
+ if (versioned?.value.kind !== "ticket") {
2584
+ throw new SupportDeskNotFoundError();
2585
+ }
2586
+ const partitionRows = await records.list(command.ticketId);
2587
+ const addedRecords = command.messageId === undefined
2588
+ ? 2 // audit + command
2589
+ : command.notification === undefined
2590
+ ? 3 // message + audit + command
2591
+ : 4; // message + audit + command + delivery
2592
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
2593
+ let quotaVersion = null;
2594
+ if (command.messageId !== undefined) {
2595
+ quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
2596
+ if (quotaVersion?.value.kind !== "ticket_quota") {
2597
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2598
+ }
2599
+ if (quotaVersion.value.messageCount >= limits.maxMessagesPerTicket) {
2600
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2601
+ }
2602
+ }
2603
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2604
+ const updated = applyTicketEvent(versioned.value.ticket, {
2605
+ type: spec.eventType,
2606
+ actorId: access.principalId,
2607
+ occurredAt: now,
2608
+ });
2609
+ // Optional system message + mail commit with the lifecycle change so a
2610
+ // resolution notification cannot be lost after the ticket is resolved.
2611
+ const systemMessage = command.messageId === undefined || command.body === undefined
2612
+ ? undefined
2613
+ : {
2614
+ id: command.messageId,
2615
+ ticketId: command.ticketId,
2616
+ authorKind: "system",
2617
+ channel: "web",
2618
+ visibility: "customer",
2619
+ format: "plain_text",
2620
+ body: command.body,
2621
+ createdAt: now,
2622
+ };
2623
+ const notificationContent = deliveryContent(command.notification);
2624
+ const notificationJob = command.notification === undefined || command.messageId === undefined
2625
+ ? undefined
2626
+ : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2627
+ const outcome = await records.transact(command.ticketId, [
2628
+ {
2629
+ action: "putIfUnchanged",
2630
+ version: versioned.version,
2631
+ value: { ...versioned.value, ticket: updated },
2632
+ },
2633
+ ...(quotaVersion === null ||
2634
+ systemMessage === undefined ||
2635
+ quotaVersion.value.kind !== "ticket_quota"
2636
+ ? []
2637
+ : [
2638
+ {
2639
+ action: "putIfUnchanged",
2640
+ version: quotaVersion.version,
2641
+ value: {
2642
+ kind: "ticket_quota",
2643
+ partition: command.ticketId,
2644
+ id: "quota",
2645
+ messageCount: quotaVersion.value.messageCount + 1,
2646
+ },
2647
+ },
2648
+ ]),
2649
+ ...(systemMessage === undefined
2650
+ ? []
2651
+ : [
2652
+ {
2653
+ action: "insert",
2654
+ value: {
2655
+ kind: "message",
2656
+ partition: command.ticketId,
2657
+ id: `message:${command.messageId}`,
2658
+ ordinal: updated.revision,
2659
+ message: systemMessage,
2660
+ ...(notificationContent === undefined
2661
+ ? {}
2662
+ : { deliveryContent: notificationContent }),
2663
+ },
2664
+ },
2665
+ ]),
2666
+ ticketAuditAction({
2667
+ commandId: command.commandId,
2668
+ correlationId: command.correlationId,
2669
+ ticketId: command.ticketId,
2670
+ revision: updated.revision,
2671
+ actorId: access.principalId,
2672
+ occurredAt: now,
2673
+ action: spec.auditAction,
2674
+ }),
2675
+ {
2676
+ action: "insert",
2677
+ value: {
2678
+ kind: "command",
2679
+ partition: command.ticketId,
2680
+ id: `command:${command.commandId}`,
2681
+ commandId: command.commandId,
2682
+ commandType: spec.commandType,
2683
+ ...(command.messageId === undefined
2684
+ ? {}
2685
+ : { messageId: command.messageId }),
2686
+ requestFingerprint: fingerprint,
2687
+ completedAt: now,
2688
+ },
2689
+ },
2690
+ ...(notificationJob === undefined ? [] : [notificationJob]),
2691
+ ]);
2692
+ if (outcome.committed) {
2693
+ return staffViewAfterChange(command.ticketId);
2694
+ }
2695
+ const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2696
+ if (duplicateMatches(nowDuplicate, spec.commandType, command.messageId, fingerprint)) {
2697
+ return staffViewAfterChange(command.ticketId);
2698
+ }
2699
+ }
2700
+ throw new SupportDeskConflictError();
2701
+ }
2702
+ }
2703
+ function canonicalTimestamp(value, field) {
2704
+ const epoch = Date.parse(value);
2705
+ if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
2706
+ throw new TypeError(`${field} must be a canonical ISO timestamp`);
2707
+ }
2708
+ return epoch;
2709
+ }
2710
+ async function receiptHash(owner, providerEventId) {
2711
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${owner}\u0000${providerEventId}`));
2712
+ return [...new Uint8Array(digest).slice(0, 16)]
2713
+ .map((byte) => byte.toString(16).padStart(2, "0"))
2714
+ .join("");
2715
+ }
2716
+ export async function inboundReceiptLocation(channelId, providerEventId) {
2717
+ requireIdentifier(channelId, "channelId");
2718
+ requireIdentifier(providerEventId, "providerEventId");
2719
+ const hash = await receiptHash(channelId, providerEventId);
2720
+ const bucket = `${encodeIdempotencyPart(channelId)}:${hash.slice(0, 30)}`;
2721
+ if (bucket.length > 300) {
2722
+ throw new TypeError("inbound receipt bucket exceeds the safe key length");
2723
+ }
2724
+ return { bucket, slot: hash.slice(30) };
2725
+ }
2726
+ export async function inboundReceiptBucket(channelId, providerEventId) {
2727
+ return (await inboundReceiptLocation(channelId, providerEventId)).bucket;
2728
+ }
2729
+ async function deliveryCallbackLocation(provider, providerEventId) {
2730
+ requireIdentifier(provider, "provider");
2731
+ requireIdentifier(providerEventId, "providerEventId");
2732
+ const hash = await receiptHash(provider, providerEventId);
2733
+ const bucket = `${encodeIdempotencyPart(provider)}:${hash.slice(0, 30)}`;
2734
+ if (bucket.length > 300) {
2735
+ throw new TypeError("delivery callback bucket exceeds the safe key length");
2736
+ }
2737
+ return { bucket, slot: hash.slice(30) };
2738
+ }
2739
+ export async function deliveryCallbackBucket(provider, providerEventId) {
2740
+ return (await deliveryCallbackLocation(provider, providerEventId)).bucket;
2741
+ }
2742
+ function callbackReceiptKey(location) {
2743
+ return { partition: location.bucket, id: `slot:${location.slot}` };
2744
+ }
2745
+ function callbackMatches(stored, receipt) {
2746
+ return (stored.provider === receipt.provider &&
2747
+ stored.providerEventId === receipt.providerEventId &&
2748
+ stored.ticketId === receipt.ticketId &&
2749
+ stored.deliveryJobId === receipt.deliveryJobId &&
2750
+ stored.submissionGeneration === receipt.submissionGeneration &&
2751
+ stored.providerMessageRef === receipt.providerMessageRef &&
2752
+ stored.status === receipt.status &&
2753
+ stored.occurredAt === receipt.occurredAt &&
2754
+ stored.failureCategory === receipt.failureCategory);
2755
+ }
2756
+ const CALLBACK_FAILURE_CATEGORY = /^[a-z][a-z0-9_]{0,63}$/;
2757
+ const DEFAULT_CALLBACK_FAILURE_CATEGORY = "provider_callback_failure";
2758
+ function snapshotDeliveryCallback(input) {
2759
+ if (input === null ||
2760
+ (typeof input !== "object" && typeof input !== "function")) {
2761
+ throw new TypeError("delivery callback must be an object");
2762
+ }
2763
+ const readString = (key) => {
2764
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
2765
+ if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) {
2766
+ throw new TypeError(`delivery callback ${key} must be an own data property`);
2767
+ }
2768
+ if (typeof descriptor.value !== "string") {
2769
+ throw new TypeError(`delivery callback ${key} must be a string`);
2770
+ }
2771
+ return descriptor.value;
2772
+ };
2773
+ const readOptionalString = (key) => {
2774
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
2775
+ if (descriptor === undefined)
2776
+ return undefined;
2777
+ if (!Object.hasOwn(descriptor, "value")) {
2778
+ throw new TypeError(`delivery callback ${key} must be a string`);
2779
+ }
2780
+ if (descriptor.value === undefined)
2781
+ return undefined;
2782
+ if (typeof descriptor.value !== "string") {
2783
+ throw new TypeError(`delivery callback ${key} must be a string`);
2784
+ }
2785
+ return descriptor.value;
2786
+ };
2787
+ const generation = Object.getOwnPropertyDescriptor(input, "submissionGeneration");
2788
+ if (generation === undefined ||
2789
+ !Object.hasOwn(generation, "value") ||
2790
+ typeof generation.value !== "number") {
2791
+ throw new TypeError("delivery callback submissionGeneration must be an own numeric data property");
2792
+ }
2793
+ if (!Number.isSafeInteger(generation.value) ||
2794
+ generation.value < 1 ||
2795
+ generation.value > maxMailAttempts) {
2796
+ throw new TypeError(`delivery callback submissionGeneration must be between 1 and ${maxMailAttempts}`);
2797
+ }
2798
+ const status = readString("status");
2799
+ if (status !== "delivered" && status !== "failed") {
2800
+ throw new TypeError("callback status must be delivered or failed");
2801
+ }
2802
+ const providerMessageRef = readOptionalString("providerMessageRef");
2803
+ if (providerMessageRef !== undefined &&
2804
+ (providerMessageRef.trim().length === 0 ||
2805
+ providerMessageRef.length > 512 ||
2806
+ /[\u0000-\u001F\u007F]/.test(providerMessageRef))) {
2807
+ throw new TypeError("delivery callback providerMessageRef must be non-empty, at most 512 characters, and contain no controls");
2808
+ }
2809
+ const suppliedFailureCategory = readOptionalString("failureCategory");
2810
+ if (status === "delivered" && suppliedFailureCategory !== undefined) {
2811
+ throw new TypeError("a delivered callback cannot include a failureCategory");
2812
+ }
2813
+ const failureCategory = status === "failed"
2814
+ ? (suppliedFailureCategory ?? DEFAULT_CALLBACK_FAILURE_CATEGORY)
2815
+ : undefined;
2816
+ if (failureCategory !== undefined &&
2817
+ !CALLBACK_FAILURE_CATEGORY.test(failureCategory)) {
2818
+ throw new TypeError("delivery callback failureCategory must be a coarse safe token");
2819
+ }
2820
+ return Object.freeze({
2821
+ provider: readString("provider"),
2822
+ providerEventId: readString("providerEventId"),
2823
+ ticketId: readString("ticketId"),
2824
+ deliveryJobId: readString("deliveryJobId"),
2825
+ submissionGeneration: generation.value,
2826
+ ...(providerMessageRef === undefined ? {} : { providerMessageRef }),
2827
+ status,
2828
+ occurredAt: readString("occurredAt"),
2829
+ ...(failureCategory === undefined ? {} : { failureCategory }),
2830
+ });
2831
+ }
2832
+ export async function recordDeliveryCallback(store, input, clock) {
2833
+ const receipt = snapshotDeliveryCallback(input);
2834
+ requireIdentifier(receipt.provider, "provider");
2835
+ requireIdentifier(receipt.providerEventId, "providerEventId");
2836
+ requireIdentifier(receipt.ticketId, "ticketId");
2837
+ requireIdentifier(receipt.deliveryJobId, "deliveryJobId");
2838
+ canonicalTimestamp(receipt.occurredAt, "occurredAt");
2839
+ const callbackFailureCategory = receipt.status === "failed"
2840
+ ? (receipt.failureCategory ?? DEFAULT_CALLBACK_FAILURE_CATEGORY)
2841
+ : undefined;
2842
+ const processedAt = clock.now();
2843
+ canonicalTimestamp(processedAt, "clock.now()");
2844
+ const receipts = store.collection(deliveryCallbackReceipts);
2845
+ const location = await deliveryCallbackLocation(receipt.provider, receipt.providerEventId);
2846
+ const inserted = await receipts.insertIfAbsent({
2847
+ ...receipt,
2848
+ ...location,
2849
+ });
2850
+ if (!callbackMatches(inserted.value, receipt)) {
2851
+ throw new SupportDeskConflictError("A delivery callback idempotency key was reused for different input or its bounded hash slot collided");
2852
+ }
2853
+ if (!inserted.inserted && inserted.value.processedAt !== undefined) {
2854
+ return { duplicate: true, job: null };
2855
+ }
2856
+ const records = store.collection(supportRecords);
2857
+ const result = await supportMail.applyAuthenticatedCallback(records, {
2858
+ partition: receipt.ticketId,
2859
+ jobId: receipt.deliveryJobId,
2860
+ submissionGeneration: receipt.submissionGeneration,
2861
+ ...(receipt.providerMessageRef === undefined
2862
+ ? {}
2863
+ : { providerMessageRef: receipt.providerMessageRef }),
2864
+ status: receipt.status,
2865
+ occurredAt: receipt.occurredAt,
2866
+ ...(callbackFailureCategory === undefined
2867
+ ? {}
2868
+ : { failureCategory: callbackFailureCategory }),
2869
+ }, clock);
2870
+ await receipts.update(callbackReceiptKey(location), (current) => current === null || current.processedAt !== undefined
2871
+ ? { action: "keep" }
2872
+ : {
2873
+ action: "write",
2874
+ value: { ...current, processedAt },
2875
+ });
2876
+ return {
2877
+ duplicate: !inserted.inserted,
2878
+ job: result === null
2879
+ ? null
2880
+ : {
2881
+ kind: "delivery_job",
2882
+ partition: result.partition,
2883
+ id: `delivery:${result.id}`,
2884
+ ticketId: result.partition,
2885
+ messageId: result.contentRef,
2886
+ job: result,
2887
+ },
2888
+ };
2889
+ }
2890
+ function snapshotReceiptSweepInput(input, field) {
2891
+ const source = boundaryObject(input, field);
2892
+ const raw = {
2893
+ bucket: ownDataProperty(source, "bucket", `${field}.bucket`),
2894
+ processedBefore: ownDataProperty(source, "processedBefore", `${field}.processedBefore`),
2895
+ maxDeletes: ownDataProperty(source, "maxDeletes", `${field}.maxDeletes`, true),
2896
+ };
2897
+ if (raw.maxDeletes !== undefined && typeof raw.maxDeletes !== "number") {
2898
+ throw new TypeError(`${field}.maxDeletes must be a number`);
2899
+ }
2900
+ return Object.freeze({
2901
+ bucket: boundaryString(raw.bucket, `${field}.bucket`),
2902
+ processedBefore: boundaryString(raw.processedBefore, `${field}.processedBefore`),
2903
+ ...(raw.maxDeletes === undefined ? {} : { maxDeletes: raw.maxDeletes }),
2904
+ });
2905
+ }
2906
+ export async function sweepInboundReceipts(store, clock, input) {
2907
+ const request = snapshotReceiptSweepInput(input, "inbound receipt sweep");
2908
+ const { bucket, processedBefore } = request;
2909
+ if (bucket.length === 0 ||
2910
+ bucket.length > 300 ||
2911
+ /[\u0000-\u001F\u007F]/.test(bucket)) {
2912
+ throw new TypeError("bucket must be at most 300 characters with no controls");
2913
+ }
2914
+ const now = canonicalTimestamp(clock.now(), "clock.now()");
2915
+ const horizonBefore = new Date(now - inboundReceiptDedupeDays * 86_400_000).toISOString();
2916
+ canonicalTimestamp(processedBefore, "processedBefore");
2917
+ const maxDeletes = request.maxDeletes ?? 100;
2918
+ if (!Number.isSafeInteger(maxDeletes) ||
2919
+ maxDeletes <= 0 ||
2920
+ maxDeletes > 1_000) {
2921
+ throw new TypeError("maxDeletes must be between 1 and 1000");
2922
+ }
2923
+ const receipts = store.collection(inboundReceipts);
2924
+ const candidates = (await receipts.listVersioned(bucket))
2925
+ .filter((versioned) => versioned.value.status !== "processing" &&
2926
+ versioned.value.processedAt !== undefined &&
2927
+ versioned.value.processedAt <= processedBefore &&
2928
+ versioned.value.processedAt < horizonBefore)
2929
+ .slice(0, maxDeletes);
2930
+ let deleted = 0;
2931
+ for (const candidate of candidates) {
2932
+ if (await receipts.deleteIfUnchanged({
2933
+ partition: bucket,
2934
+ id: receiptSlotId(candidate.value.slot, "inbound receipt slot"),
2935
+ }, candidate.version)) {
2936
+ deleted += 1;
2937
+ }
2938
+ }
2939
+ return deleted;
2940
+ }
2941
+ export async function sweepDeliveryCallbackReceipts(store, clock, input) {
2942
+ const request = snapshotReceiptSweepInput(input, "delivery callback receipt sweep");
2943
+ const { bucket, processedBefore } = request;
2944
+ if (bucket.length === 0 ||
2945
+ bucket.length > 300 ||
2946
+ /[\u0000-\u001F\u007F]/.test(bucket)) {
2947
+ throw new TypeError("bucket must be at most 300 characters with no controls");
2948
+ }
2949
+ const now = canonicalTimestamp(clock.now(), "clock.now()");
2950
+ const horizonBefore = new Date(now - deliveryCallbackDedupeDays * 86_400_000).toISOString();
2951
+ canonicalTimestamp(processedBefore, "processedBefore");
2952
+ const maxDeletes = request.maxDeletes ?? 100;
2953
+ if (!Number.isSafeInteger(maxDeletes) ||
2954
+ maxDeletes <= 0 ||
2955
+ maxDeletes > 1_000) {
2956
+ throw new TypeError("maxDeletes must be between 1 and 1000");
2957
+ }
2958
+ const receipts = store.collection(deliveryCallbackReceipts);
2959
+ const candidates = (await receipts.listVersioned(bucket))
2960
+ .filter((versioned) => versioned.value.processedAt !== undefined &&
2961
+ versioned.value.processedAt <= processedBefore &&
2962
+ versioned.value.processedAt < horizonBefore)
2963
+ .slice(0, maxDeletes);
2964
+ let deleted = 0;
2965
+ for (const candidate of candidates) {
2966
+ if (await receipts.deleteIfUnchanged({
2967
+ partition: bucket,
2968
+ id: receiptSlotId(candidate.value.slot, "delivery callback slot"),
2969
+ }, candidate.version)) {
2970
+ deleted += 1;
2971
+ }
2972
+ }
2973
+ return deleted;
2974
+ }
2975
+ /**
2976
+ * Remove stale ticket hints from one bounded principal index record.
2977
+ *
2978
+ * The index is never an authorization answer. A reserved hint may remain
2979
+ * after a process stops before creating its ticket; this sweep confirms every
2980
+ * hint against authoritative ticket state before retaining it.
2981
+ */
2982
+ export async function pruneCustomerTicketIndex(store, principalId, input) {
2983
+ requireIdentifier(principalId, "principalId");
2984
+ const source = boundaryObject(input, "customer ticket index prune");
2985
+ const reservedBefore = boundaryString(ownDataProperty(source, "reservedBefore", "customer ticket index prune.reservedBefore"), "customer ticket index prune.reservedBefore");
2986
+ canonicalTimestamp(reservedBefore, "reservedBefore");
2987
+ const index = store.collection(customerTicketIndex);
2988
+ const records = store.collection(supportRecords);
2989
+ const result = await index.update({ partition: principalId, id: "tickets" }, async (current) => {
2990
+ if (current === null) {
2991
+ return { action: "keep" };
2992
+ }
2993
+ const retained = [];
2994
+ for (const entry of current.entries) {
2995
+ const ticket = await records.get(ticketKey(entry.ticketId));
2996
+ if (ticket?.kind === "ticket" &&
2997
+ ticket.ticket.requester.association === "authenticated" &&
2998
+ ticket.ticket.requester.principalId === principalId) {
2999
+ retained.push({ ...entry, state: "confirmed" });
3000
+ continue;
3001
+ }
3002
+ if (entry.state === "confirmed") {
3003
+ continue;
3004
+ }
3005
+ if (entry.reservedAt >= reservedBefore) {
3006
+ retained.push(entry);
3007
+ continue;
3008
+ }
3009
+ const fence = await records.update(ticketReservationKey(entry.ticketId), (current) => {
3010
+ if (current?.kind === "ticket_reservation" &&
3011
+ current.state === "committed") {
3012
+ return { action: "keep" };
3013
+ }
3014
+ if (current?.kind === "ticket_reservation" &&
3015
+ current.state === "cancelled" &&
3016
+ current.generation >= entry.reservationGeneration) {
3017
+ return { action: "keep" };
3018
+ }
3019
+ return {
3020
+ action: "write",
3021
+ value: {
3022
+ kind: "ticket_reservation",
3023
+ partition: entry.ticketId,
3024
+ id: "reservation",
3025
+ token: entry.reservationToken,
3026
+ generation: entry.reservationGeneration,
3027
+ state: "cancelled",
3028
+ },
3029
+ };
3030
+ });
3031
+ if (fence.value?.kind === "ticket_reservation" &&
3032
+ fence.value.state === "committed" &&
3033
+ fence.value.token === entry.reservationToken &&
3034
+ fence.value.generation === entry.reservationGeneration) {
3035
+ retained.push({ ...entry, state: "confirmed" });
3036
+ }
3037
+ }
3038
+ return retained.length === current.entries.length &&
3039
+ retained.every((entry, position) => entry === current.entries[position])
3040
+ ? { action: "keep" }
3041
+ : {
3042
+ action: "write",
3043
+ value: { ...current, entries: retained },
3044
+ };
3045
+ });
3046
+ return result.value?.entries.map((entry) => entry.ticketId) ?? [];
3047
+ }
3048
+ //# sourceMappingURL=index.js.map