@pegma/support-desk-application 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -11
- package/dist/assistant-turns.d.ts.map +1 -1
- package/dist/assistant-turns.js +7 -5
- package/dist/assistant-turns.js.map +1 -1
- package/dist/attachments.d.ts +57 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +91 -0
- package/dist/attachments.js.map +1 -0
- package/dist/engineering-followup-source.d.ts +1 -1
- package/dist/engineering-followup-source.d.ts.map +1 -1
- package/dist/engineering-followup-source.js +15 -1
- package/dist/engineering-followup-source.js.map +1 -1
- package/dist/engineering-followup-types.d.ts +22 -1
- package/dist/engineering-followup-types.d.ts.map +1 -1
- package/dist/index.d.ts +104 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1065 -70
- package/dist/index.js.map +1 -1
- package/dist/resolution-learning-records.d.ts +2 -0
- package/dist/resolution-learning-records.d.ts.map +1 -1
- package/dist/resolution-learning-records.js +7 -0
- package/dist/resolution-learning-records.js.map +1 -1
- package/dist/resolution-learning-review-commit.d.ts +18 -0
- package/dist/resolution-learning-review-commit.d.ts.map +1 -0
- package/dist/resolution-learning-review-commit.js +199 -0
- package/dist/resolution-learning-review-commit.js.map +1 -0
- package/dist/resolution-learning-review-identity.d.ts +28 -0
- package/dist/resolution-learning-review-identity.d.ts.map +1 -0
- package/dist/resolution-learning-review-identity.js +110 -0
- package/dist/resolution-learning-review-identity.js.map +1 -0
- package/dist/resolution-learning-staged-records.d.ts +45 -0
- package/dist/resolution-learning-staged-records.d.ts.map +1 -0
- package/dist/resolution-learning-staged-records.js +193 -0
- package/dist/resolution-learning-staged-records.js.map +1 -0
- package/dist/resolution-learning-staged-types.d.ts +88 -0
- package/dist/resolution-learning-staged-types.d.ts.map +1 -0
- package/dist/resolution-learning-staged-types.js +2 -0
- package/dist/resolution-learning-staged-types.js.map +1 -0
- package/dist/resolution-learning-staged.d.ts +12 -0
- package/dist/resolution-learning-staged.d.ts.map +1 -0
- package/dist/resolution-learning-staged.js +585 -0
- package/dist/resolution-learning-staged.js.map +1 -0
- package/dist/resolution-learning-types.d.ts +6 -0
- package/dist/resolution-learning-types.d.ts.map +1 -1
- package/dist/resolution-learning-worker.d.ts.map +1 -1
- package/dist/resolution-learning-worker.js +58 -186
- package/dist/resolution-learning-worker.js.map +1 -1
- package/dist/resolution-learning.d.ts +3 -2
- package/dist/resolution-learning.d.ts.map +1 -1
- package/dist/resolution-learning.js +83 -7
- package/dist/resolution-learning.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { attachmentConfiguration, attachmentDescriptors, snapshotAttachmentInputs, newAttachment, attachmentDigest, } from "./attachments.js";
|
|
2
|
+
import { assertResolutionLearningReviewAttempt, assertResolutionLearningReviewReservation, } from "./resolution-learning-staged-records.js";
|
|
1
3
|
import { auditRecordId, defineAudit } from "@pegma/audit";
|
|
2
4
|
import { hasPermission } from "@pegma/authorization-core";
|
|
3
5
|
import { defineMail, maxMailAttempts } from "@pegma/mail";
|
|
4
6
|
import { noopLogger } from "@pegma/spine";
|
|
5
7
|
import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
6
|
-
import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
|
|
8
|
+
import { applyTicketEvent, createTicket, effectiveTicketHandling, normalizeAttachmentFilename, } from "@pegma/support-desk-core";
|
|
7
9
|
import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
|
|
8
10
|
import { createAssistantTurnServices, } from "./assistant-turns.js";
|
|
9
11
|
import { createEngineeringEvidenceServices, planEngineeringTicketMutation, } from "./engineering-evidence.js";
|
|
@@ -16,10 +18,113 @@ import { createEngineeringFollowupSourceServices, planEngineeringFollowupSourceC
|
|
|
16
18
|
import { createEngineeringFollowupWorkServices } from "./engineering-followup-work.js";
|
|
17
19
|
import { fields as followupFields, integer as followupInteger, text as followupText, } from "./assistant-turn-records.js";
|
|
18
20
|
import { engineeringFollowupCommandKey, engineeringFollowupHeadKey, safeEngineeringFollowupSnapshot as followupSnapshot, checkEngineeringFollowupRecordCapacity, } from "./engineering-followup-records.js";
|
|
19
|
-
import { engineeringFollowupNow, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
|
|
21
|
+
import { engineeringFollowupNow, engineeringFollowupTrusted, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
|
|
20
22
|
import { assertResolutionLearningRecord, resolveResolutionLearningOptions, } from "./resolution-learning-records.js";
|
|
21
23
|
import { createResolutionLearningServices, createResolutionLearningSource, planResolutionLearningTicketMutation, } from "./resolution-learning.js";
|
|
22
24
|
export { resolutionLearningDeliveryBinding, resolutionLearningManifestHash, } from "./resolution-learning-worker.js";
|
|
25
|
+
/** Reserve Audit/receipt slots so attachment cleanup can still finish at capacity. */
|
|
26
|
+
function partitionSizeWithAttachmentReserve(rows) {
|
|
27
|
+
return rows.reduce((size, row) => size +
|
|
28
|
+
(row.kind === "message"
|
|
29
|
+
? (row.message.attachments ?? []).reduce((reserved, item) => reserved +
|
|
30
|
+
(item.scanStatus === "pending_scan"
|
|
31
|
+
? 6
|
|
32
|
+
: item.scanStatus === "clean" ||
|
|
33
|
+
item.scanStatus === "quarantined"
|
|
34
|
+
? 4
|
|
35
|
+
: item.scanStatus === "deleting"
|
|
36
|
+
? 2
|
|
37
|
+
: 0), 0)
|
|
38
|
+
: 0), rows.length);
|
|
39
|
+
}
|
|
40
|
+
/** One bounded physical page per call; host persists the returned cursor. */
|
|
41
|
+
export async function sweepExpiredAttachments(options) {
|
|
42
|
+
requireStaffMutationAccess(options.access, supportPermissions.manage);
|
|
43
|
+
const limit = options.limit ?? 20;
|
|
44
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
45
|
+
throw new TypeError("Invalid attachment sweep limit");
|
|
46
|
+
const now = canonicalTimestamp(options.clock.now(), "attachment sweep now");
|
|
47
|
+
const page = await options.store.collection(supportRecords).scan({
|
|
48
|
+
limit,
|
|
49
|
+
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
|
|
50
|
+
});
|
|
51
|
+
let deleted = 0;
|
|
52
|
+
const failures = [];
|
|
53
|
+
for (const row of page.records) {
|
|
54
|
+
if (row.value.kind !== "message")
|
|
55
|
+
continue;
|
|
56
|
+
for (const attachment of row.value.message.attachments ?? []) {
|
|
57
|
+
if (attachment.scanStatus === "deleted" ||
|
|
58
|
+
(attachment.scanStatus !== "deleting" &&
|
|
59
|
+
canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") >
|
|
60
|
+
now))
|
|
61
|
+
continue;
|
|
62
|
+
const target = {
|
|
63
|
+
ticketId: row.key.partition,
|
|
64
|
+
messageId: row.value.message.id,
|
|
65
|
+
attachmentId: attachment.id,
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
await options.application.deleteAttachment(options.access, {
|
|
69
|
+
...target,
|
|
70
|
+
commandId: `expire-${attachment.id}`,
|
|
71
|
+
correlationId: `expire-${attachment.id}`,
|
|
72
|
+
});
|
|
73
|
+
deleted++;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
failures.push(target);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
nextCursor: page.nextCursor,
|
|
82
|
+
physicalRows: page.records.length,
|
|
83
|
+
deleted,
|
|
84
|
+
failures,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Host inventory supplies at most 100 candidates with the put-time context.
|
|
89
|
+
* The host MUST stop and drain ALL attachment writers across every process,
|
|
90
|
+
* including uncertain transaction outcomes, throughout withUploadsPaused.
|
|
91
|
+
* This explicit maintenance fence prevents deleting an in-flight commit's blob.
|
|
92
|
+
*/
|
|
93
|
+
export async function cleanupUnreferencedAttachmentBlobs(options) {
|
|
94
|
+
if (options.candidates.length > 100)
|
|
95
|
+
throw new TypeError("At most 100 orphan candidates");
|
|
96
|
+
const candidates = options.candidates.map((candidate) => {
|
|
97
|
+
const source = boundaryObject(candidate, "orphan candidate");
|
|
98
|
+
const read = (field) => boundaryString(ownDataProperty(source, field, field), field);
|
|
99
|
+
const value = {
|
|
100
|
+
key: read("key"),
|
|
101
|
+
ticketId: read("ticketId"),
|
|
102
|
+
messageId: read("messageId"),
|
|
103
|
+
};
|
|
104
|
+
requireIdentifier(value.ticketId, "ticketId");
|
|
105
|
+
requireIdentifier(value.messageId, "messageId");
|
|
106
|
+
if (!/^support-attachments\/[a-f0-9-]{36}$/.test(value.key))
|
|
107
|
+
throw new TypeError("Invalid attachment key");
|
|
108
|
+
return Object.freeze(value);
|
|
109
|
+
});
|
|
110
|
+
return options.withUploadsPaused(async () => {
|
|
111
|
+
let deleted = 0;
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
if (!/^support-attachments\/[a-f0-9-]{36}$/.test(candidate.key))
|
|
114
|
+
throw new TypeError("Invalid attachment key");
|
|
115
|
+
const row = await options.store.collection(supportRecords).get({
|
|
116
|
+
partition: candidate.ticketId,
|
|
117
|
+
id: `message:${candidate.messageId}`,
|
|
118
|
+
});
|
|
119
|
+
if (row?.kind === "message" &&
|
|
120
|
+
row.message.attachments?.some((item) => item.blobKey === candidate.key && item.scanStatus !== "deleted"))
|
|
121
|
+
continue;
|
|
122
|
+
await options.blobs.delete(candidate.key);
|
|
123
|
+
deleted++;
|
|
124
|
+
}
|
|
125
|
+
return { deleted };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
23
128
|
export { engineeringFollowupModelManifestHash, engineeringFollowupContextHash, engineeringFollowupPublicFactsHash, engineeringFollowupChecksBinding, engineeringFollowupRetestRequestId, engineeringFollowupRetestProductBinding, engineeringFollowupRetestRequestHash, engineeringFollowupRetestSummary, engineeringFollowupModelAttemptCount, } from "./engineering-followup-records.js";
|
|
24
129
|
export { engineeringPolicyRecords, engineeringPolicyAudit, engineeringPolicyAuditActions, createEngineeringPolicyServices, } from "./engineering-policy.js";
|
|
25
130
|
export { engineeringModelManifestHash, engineeringRoutingBinding, engineeringExportChecksBinding, engineeringRoutingBindingHash, engineeringStaffSelectionHash, } from "./engineering-work.js";
|
|
@@ -139,6 +244,9 @@ export const supportTicketAuditActions = Object.freeze({
|
|
|
139
244
|
resolved: "support.ticket.resolved",
|
|
140
245
|
closed: "support.ticket.closed",
|
|
141
246
|
reopened: "support.ticket.reopened",
|
|
247
|
+
attachmentScan: "support.ticket.attachment_scan",
|
|
248
|
+
attachmentDeleting: "support.ticket.attachment_deleting",
|
|
249
|
+
attachmentDeleted: "support.ticket.attachment_deleted",
|
|
142
250
|
assistantReplied: "support.ticket.assistant_replied",
|
|
143
251
|
humanRequested: "support.ticket.human_requested",
|
|
144
252
|
humanEscalated: "support.ticket.human_escalated",
|
|
@@ -148,6 +256,7 @@ export const supportTicketAuditActions = Object.freeze({
|
|
|
148
256
|
learningEvidenceRecorded: "support.learning.evidence_recorded",
|
|
149
257
|
learningEvidenceInvalidated: "support.learning.evidence_invalidated",
|
|
150
258
|
learningWithdrawn: "support.learning.withdrawn",
|
|
259
|
+
learningReviewRequested: "support.learning.review_requested",
|
|
151
260
|
learningReviewed: "support.learning.reviewed",
|
|
152
261
|
learningReviewCancelled: "support.learning.review_cancelled",
|
|
153
262
|
engineeringEnrolled: "support.engineering.enrolled",
|
|
@@ -205,10 +314,46 @@ function receiptSlotId(slot, field) {
|
|
|
205
314
|
}
|
|
206
315
|
return `slot:${slot}`;
|
|
207
316
|
}
|
|
317
|
+
function assertAttachmentDeletionReceipt(value, location) {
|
|
318
|
+
if (value === null || typeof value !== "object")
|
|
319
|
+
return;
|
|
320
|
+
const record = value;
|
|
321
|
+
if (record.attachmentDeletion === undefined)
|
|
322
|
+
return;
|
|
323
|
+
const marker = boundaryObject(record.attachmentDeletion, "attachment deletion receipt");
|
|
324
|
+
const expected = [
|
|
325
|
+
"phase",
|
|
326
|
+
"actorId",
|
|
327
|
+
"messageId",
|
|
328
|
+
"attachmentId",
|
|
329
|
+
"correlationId",
|
|
330
|
+
];
|
|
331
|
+
if (Object.keys(marker).length !== expected.length ||
|
|
332
|
+
Object.keys(marker).some((key) => !expected.includes(key)))
|
|
333
|
+
throw new TypeError("Invalid attachment deletion receipt fields");
|
|
334
|
+
for (const name of expected.slice(1))
|
|
335
|
+
requireIdentifier(boundaryString(ownDataProperty(marker, name, name), name), name);
|
|
336
|
+
const phase = ownDataProperty(marker, "phase", "attachment deletion phase");
|
|
337
|
+
if (phase !== "pending" && phase !== "complete")
|
|
338
|
+
throw new TypeError("Invalid attachment deletion phase");
|
|
339
|
+
if (record.kind !== "command" ||
|
|
340
|
+
record.commandType !== "attachment_state" ||
|
|
341
|
+
record.partition !== location.partition ||
|
|
342
|
+
record.id !== location.id ||
|
|
343
|
+
location.id !== `command:${record.commandId}` ||
|
|
344
|
+
record.messageId !== undefined ||
|
|
345
|
+
typeof record.requestFingerprint !== "string" ||
|
|
346
|
+
!/^[a-f0-9]{64}$/.test(record.requestFingerprint))
|
|
347
|
+
throw new TypeError("Invalid attachment deletion receipt identity");
|
|
348
|
+
requireIdentifier(record.commandId, "attachment deletion commandId");
|
|
349
|
+
requireIdentifier(record.partition, "attachment deletion ticketId");
|
|
350
|
+
canonicalTimestamp(record.completedAt, "attachment deletion timestamp");
|
|
351
|
+
}
|
|
208
352
|
function jsonCodec(key) {
|
|
209
353
|
return {
|
|
210
354
|
encode(value) {
|
|
211
355
|
const location = key(value);
|
|
356
|
+
assertAttachmentDeletionReceipt(value, location);
|
|
212
357
|
if ([
|
|
213
358
|
"engineering_followup_head",
|
|
214
359
|
"engineering_followup_work",
|
|
@@ -227,6 +372,15 @@ function jsonCodec(key) {
|
|
|
227
372
|
location.id.startsWith("engineering-work:") ||
|
|
228
373
|
location.id.startsWith("engineering-command:"))
|
|
229
374
|
assertEngineeringRecord(value, location);
|
|
375
|
+
if (value.kind ===
|
|
376
|
+
"resolution_learning_review_attempt" ||
|
|
377
|
+
location.id.startsWith("learning-review-attempt:"))
|
|
378
|
+
assertResolutionLearningReviewAttempt(value, location);
|
|
379
|
+
if (value.kind ===
|
|
380
|
+
"resolution_learning_review_reservation" ||
|
|
381
|
+
(location.id.startsWith("learning-command:") &&
|
|
382
|
+
value.kind !== "resolution_learning_receipt"))
|
|
383
|
+
assertResolutionLearningReviewReservation(value, location);
|
|
230
384
|
if ([
|
|
231
385
|
"resolution_learning_source",
|
|
232
386
|
"resolution_learning_work",
|
|
@@ -250,6 +404,16 @@ function jsonCodec(key) {
|
|
|
250
404
|
throw new TypeError("stored Support Desk record has no JSON payload");
|
|
251
405
|
}
|
|
252
406
|
const decoded = JSON.parse(record.payload);
|
|
407
|
+
if (decoded?.attachmentDeletion !==
|
|
408
|
+
undefined) {
|
|
409
|
+
if (typeof record.partition !== "string" ||
|
|
410
|
+
typeof record.id !== "string")
|
|
411
|
+
throw new TypeError("Attachment deletion receipt has invalid physical envelope");
|
|
412
|
+
assertAttachmentDeletionReceipt(decoded, {
|
|
413
|
+
partition: record.partition,
|
|
414
|
+
id: record.id,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
253
417
|
if ([
|
|
254
418
|
"engineering_followup_head",
|
|
255
419
|
"engineering_followup_work",
|
|
@@ -284,6 +448,32 @@ function jsonCodec(key) {
|
|
|
284
448
|
id: record.id,
|
|
285
449
|
});
|
|
286
450
|
}
|
|
451
|
+
if (decoded?.kind ===
|
|
452
|
+
"resolution_learning_review_attempt" ||
|
|
453
|
+
(typeof record.id === "string" &&
|
|
454
|
+
record.id.startsWith("learning-review-attempt:"))) {
|
|
455
|
+
if (typeof record.partition !== "string" ||
|
|
456
|
+
typeof record.id !== "string")
|
|
457
|
+
throw new TypeError("Review attempt has invalid physical envelope");
|
|
458
|
+
assertResolutionLearningReviewAttempt(decoded, {
|
|
459
|
+
partition: record.partition,
|
|
460
|
+
id: record.id,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (decoded?.kind ===
|
|
464
|
+
"resolution_learning_review_reservation" ||
|
|
465
|
+
(typeof record.id === "string" &&
|
|
466
|
+
record.id.startsWith("learning-command:") &&
|
|
467
|
+
decoded?.kind !==
|
|
468
|
+
"resolution_learning_receipt")) {
|
|
469
|
+
if (typeof record.partition !== "string" ||
|
|
470
|
+
typeof record.id !== "string")
|
|
471
|
+
throw new TypeError("Review reservation has invalid physical envelope");
|
|
472
|
+
assertResolutionLearningReviewReservation(decoded, {
|
|
473
|
+
partition: record.partition,
|
|
474
|
+
id: record.id,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
287
477
|
if ([
|
|
288
478
|
"resolution_learning_source",
|
|
289
479
|
"resolution_learning_work",
|
|
@@ -415,7 +605,7 @@ export const supportAudit = defineAudit({
|
|
|
415
605
|
});
|
|
416
606
|
function ticketAuditAction(input) {
|
|
417
607
|
return supportAudit.action({
|
|
418
|
-
id: input.commandId,
|
|
608
|
+
id: input.eventId ?? input.commandId,
|
|
419
609
|
occurredAt: input.occurredAt,
|
|
420
610
|
actor: { kind: "principal", principalId: input.actorId },
|
|
421
611
|
action: input.action,
|
|
@@ -493,6 +683,20 @@ const maxNotificationVariableBytes = 8_192;
|
|
|
493
683
|
const maxNotificationVariableTotalBytes = 8_192;
|
|
494
684
|
const maxAllowedCategories = 32;
|
|
495
685
|
const categoryPattern = /^[a-z][a-z0-9_]{0,31}$/;
|
|
686
|
+
function snapshotAttachmentStateCommand(input) {
|
|
687
|
+
const source = boundaryObject(input, "attachment command");
|
|
688
|
+
const read = (field) => boundaryString(ownDataProperty(source, field, field), field);
|
|
689
|
+
const command = {
|
|
690
|
+
ticketId: read("ticketId"),
|
|
691
|
+
messageId: read("messageId"),
|
|
692
|
+
attachmentId: read("attachmentId"),
|
|
693
|
+
commandId: read("commandId"),
|
|
694
|
+
correlationId: read("correlationId"),
|
|
695
|
+
};
|
|
696
|
+
for (const [field, value] of Object.entries(command))
|
|
697
|
+
requireIdentifier(value, field);
|
|
698
|
+
return Object.freeze(command);
|
|
699
|
+
}
|
|
496
700
|
function requirePermission(access, permission) {
|
|
497
701
|
if (!hasPermission(access, permission)) {
|
|
498
702
|
throw new SupportDeskAuthorizationError(permission);
|
|
@@ -849,6 +1053,7 @@ function snapshotCreateCustomerTicketCommand(input) {
|
|
|
849
1053
|
? undefined
|
|
850
1054
|
: snapshotNotification(raw.notification);
|
|
851
1055
|
return Object.freeze({
|
|
1056
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
852
1057
|
commandId: boundaryString(raw.commandId, "create command.commandId"),
|
|
853
1058
|
correlationId: boundaryString(raw.correlationId, "create command.correlationId"),
|
|
854
1059
|
ticketId: boundaryString(raw.ticketId, "create command.ticketId"),
|
|
@@ -861,6 +1066,66 @@ function snapshotCreateCustomerTicketCommand(input) {
|
|
|
861
1066
|
...(initialHandling === undefined ? {} : { initialHandling }),
|
|
862
1067
|
});
|
|
863
1068
|
}
|
|
1069
|
+
function snapshotCustomerTicketAdmission(input) {
|
|
1070
|
+
const source = boundaryObject(input, "admitted create command");
|
|
1071
|
+
const allowed = [
|
|
1072
|
+
"commandId",
|
|
1073
|
+
"correlationId",
|
|
1074
|
+
"ticketId",
|
|
1075
|
+
"messageId",
|
|
1076
|
+
"subject",
|
|
1077
|
+
"body",
|
|
1078
|
+
"category",
|
|
1079
|
+
"requesterEmail",
|
|
1080
|
+
"attachments",
|
|
1081
|
+
"preference",
|
|
1082
|
+
"disclosureVersion",
|
|
1083
|
+
];
|
|
1084
|
+
const keys = Reflect.ownKeys(source);
|
|
1085
|
+
if (Array.isArray(source) ||
|
|
1086
|
+
keys.some((key) => typeof key !== "string" || !allowed.includes(key)))
|
|
1087
|
+
throw new TypeError("Invalid admitted create command fields");
|
|
1088
|
+
const command = Object.freeze(Object.fromEntries(keys.map((key) => [
|
|
1089
|
+
key,
|
|
1090
|
+
ownDataProperty(source, key, "admitted create command field"),
|
|
1091
|
+
])));
|
|
1092
|
+
const preference = ownDataProperty(command, "preference", "preference");
|
|
1093
|
+
if (preference !== "human" && preference !== "assistant_if_available")
|
|
1094
|
+
throw new TypeError("Invalid admitted create preference");
|
|
1095
|
+
const raw = ownDataProperty(command, "disclosureVersion", "disclosureVersion", true);
|
|
1096
|
+
const disclosureVersion = raw === undefined ? undefined : boundaryString(raw, "disclosureVersion");
|
|
1097
|
+
if (disclosureVersion !== undefined)
|
|
1098
|
+
requireIdentifier(disclosureVersion, "disclosureVersion");
|
|
1099
|
+
return {
|
|
1100
|
+
command,
|
|
1101
|
+
intent: Object.freeze({
|
|
1102
|
+
preference,
|
|
1103
|
+
...(disclosureVersion === undefined ? {} : { disclosureVersion }),
|
|
1104
|
+
}),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function customerCreateMethods(create) {
|
|
1108
|
+
return {
|
|
1109
|
+
async createCustomerTicket(access, input) {
|
|
1110
|
+
return (await create(access, input)).view;
|
|
1111
|
+
},
|
|
1112
|
+
async createAdmittedCustomerTicket(access, input, options) {
|
|
1113
|
+
requirePermission(access, supportPermissions.create);
|
|
1114
|
+
requirePermission(access, supportPermissions.readOwn);
|
|
1115
|
+
const { command, intent } = snapshotCustomerTicketAdmission(input);
|
|
1116
|
+
return create(access, command, {
|
|
1117
|
+
intent,
|
|
1118
|
+
...(options === undefined ? {} : { options }),
|
|
1119
|
+
});
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
function admittedCreateMode(record) {
|
|
1124
|
+
if (record?.kind !== "command" ||
|
|
1125
|
+
(record.initialMode !== "human" && record.initialMode !== "assistant"))
|
|
1126
|
+
throw new SupportDeskConflictError("Admitted create receipt mode is invalid");
|
|
1127
|
+
return record.initialMode;
|
|
1128
|
+
}
|
|
864
1129
|
function snapshotReplyToCustomerTicketCommand(input) {
|
|
865
1130
|
const source = boundaryObject(input, "reply command");
|
|
866
1131
|
const raw = {
|
|
@@ -875,6 +1140,7 @@ function snapshotReplyToCustomerTicketCommand(input) {
|
|
|
875
1140
|
? undefined
|
|
876
1141
|
: snapshotNotification(raw.notification);
|
|
877
1142
|
return Object.freeze({
|
|
1143
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
878
1144
|
commandId: boundaryString(raw.commandId, "reply command.commandId"),
|
|
879
1145
|
correlationId: boundaryString(raw.correlationId, "reply command.correlationId"),
|
|
880
1146
|
ticketId: boundaryString(raw.ticketId, "reply command.ticketId"),
|
|
@@ -938,8 +1204,24 @@ function toCustomerMessage(message) {
|
|
|
938
1204
|
format: message.format,
|
|
939
1205
|
body: message.body,
|
|
940
1206
|
createdAt: message.createdAt,
|
|
1207
|
+
...(message.attachments === undefined
|
|
1208
|
+
? {}
|
|
1209
|
+
: {
|
|
1210
|
+
attachments: message.attachments.map((item) => ({
|
|
1211
|
+
id: item.id,
|
|
1212
|
+
filename: item.filename,
|
|
1213
|
+
contentType: item.contentType,
|
|
1214
|
+
size: item.size,
|
|
1215
|
+
expiresAt: item.expiresAt,
|
|
1216
|
+
scanStatus: item.scanStatus,
|
|
1217
|
+
})),
|
|
1218
|
+
}),
|
|
941
1219
|
});
|
|
942
1220
|
}
|
|
1221
|
+
/** Assistant ports remain body-only even when browser views include safe attachments. */
|
|
1222
|
+
function assistantConversationMessages(records) {
|
|
1223
|
+
return customerMessages(records).map(({ attachments: _attachments, ...message }) => Object.freeze(message));
|
|
1224
|
+
}
|
|
943
1225
|
function customerMessages(records) {
|
|
944
1226
|
const messages = records.filter((record) => record.kind === "message" && record.message.visibility === "customer");
|
|
945
1227
|
const ordinals = new Set();
|
|
@@ -1023,6 +1305,7 @@ function parseAllowedCategories(input, name = "application options.allowedCatego
|
|
|
1023
1305
|
}
|
|
1024
1306
|
function duplicateMatches(existing, commandType, messageId, requestFingerprint) {
|
|
1025
1307
|
return (existing?.kind === "command" &&
|
|
1308
|
+
existing.attachmentDeletion?.phase !== "pending" &&
|
|
1026
1309
|
existing.commandType === commandType &&
|
|
1027
1310
|
existing.messageId === messageId &&
|
|
1028
1311
|
existing.requestFingerprint === requestFingerprint);
|
|
@@ -1152,7 +1435,11 @@ function derivationReserveRows(rows, consuming = false) {
|
|
|
1152
1435
|
? row.terminalReserveRows
|
|
1153
1436
|
: 0), 0);
|
|
1154
1437
|
const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
|
|
1155
|
-
|
|
1438
|
+
const stagedReviews = rows.reduce((sum, row) => sum +
|
|
1439
|
+
(row.kind === "resolution_learning_review_attempt"
|
|
1440
|
+
? row.completionRows
|
|
1441
|
+
: 0), 0);
|
|
1442
|
+
return learning + engineering + terminal + followup + stagedReviews;
|
|
1156
1443
|
}
|
|
1157
1444
|
/** Reserve is opt-in metadata on the existing quota, never a second counter. */
|
|
1158
1445
|
function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
|
|
@@ -1160,7 +1447,7 @@ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessa
|
|
|
1160
1447
|
const reserved = quota?.assistantManaged === true &&
|
|
1161
1448
|
ticket.firstStaffRespondedAt === undefined &&
|
|
1162
1449
|
!firstHumanReply;
|
|
1163
|
-
enforcePartitionRecordLimit(rows
|
|
1450
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
|
|
1164
1451
|
(reserved ? humanReserveRows(rows) : 0) -
|
|
1165
1452
|
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1166
1453
|
if (addsMessage &&
|
|
@@ -1182,7 +1469,7 @@ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, c
|
|
|
1182
1469
|
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1183
1470
|
const reserved = quota?.assistantManaged === true &&
|
|
1184
1471
|
ticket.firstStaffRespondedAt === undefined;
|
|
1185
|
-
enforcePartitionRecordLimit(rows
|
|
1472
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
|
|
1186
1473
|
(reserved ? firstHumanReplyReserveRows : 0) -
|
|
1187
1474
|
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1188
1475
|
if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
|
|
@@ -1293,6 +1580,7 @@ function snapshotStaffReplyCommand(input) {
|
|
|
1293
1580
|
? undefined
|
|
1294
1581
|
: snapshotNotification(raw.notification);
|
|
1295
1582
|
return Object.freeze({
|
|
1583
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
1296
1584
|
...snapshotRevisionHint(source),
|
|
1297
1585
|
commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
|
|
1298
1586
|
correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
|
|
@@ -1312,6 +1600,7 @@ function snapshotAddNoteCommand(input) {
|
|
|
1312
1600
|
body: ownDataProperty(source, "body", "add note command.body"),
|
|
1313
1601
|
};
|
|
1314
1602
|
return Object.freeze({
|
|
1603
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
1315
1604
|
...snapshotRevisionHint(source),
|
|
1316
1605
|
commandId: boundaryString(raw.commandId, "add note command.commandId"),
|
|
1317
1606
|
correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
|
|
@@ -2032,6 +2321,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2032
2321
|
typeof rawMaxConflictAttempts !== "number") {
|
|
2033
2322
|
throw new TypeError("application options.maxConflictAttempts must be a number");
|
|
2034
2323
|
}
|
|
2324
|
+
const attachmentConfig = attachmentConfiguration(ownDataProperty(source, "attachments", "application options.attachments", true));
|
|
2035
2325
|
const records = store.collection(supportRecords);
|
|
2036
2326
|
const index = store.collection(customerTicketIndex);
|
|
2037
2327
|
const numbers = store.collection(ticketNumbers);
|
|
@@ -2322,6 +2612,466 @@ export function createSupportDeskApplication(options) {
|
|
|
2322
2612
|
async function reserveTicketNumber() {
|
|
2323
2613
|
return reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
2324
2614
|
}
|
|
2615
|
+
function validateNewAttachmentPolicy(inputs, descriptors) {
|
|
2616
|
+
if (!inputs.length)
|
|
2617
|
+
return;
|
|
2618
|
+
if (!attachmentConfig.blobs)
|
|
2619
|
+
throw new TypeError("Attachments require a host blob port");
|
|
2620
|
+
if (inputs.length > attachmentConfig.perMessage ||
|
|
2621
|
+
inputs.reduce((n, item) => n + item.bytes.byteLength, 0) >
|
|
2622
|
+
attachmentConfig.messageBytes)
|
|
2623
|
+
throw new TypeError("Attachment message limit exceeded");
|
|
2624
|
+
if (descriptors.some((item) => item.size > attachmentConfig.bytes ||
|
|
2625
|
+
!attachmentConfig.allowed.has(item.contentType)))
|
|
2626
|
+
throw new TypeError("Attachment size or MIME type refused");
|
|
2627
|
+
if (inputs.length > attachmentConfig.perTicket)
|
|
2628
|
+
throw new TypeError("Ticket attachment limit exceeded");
|
|
2629
|
+
}
|
|
2630
|
+
async function transactWithAttachments(ticketId, actions, inputs, descriptors, beforeCommit) {
|
|
2631
|
+
if (!inputs.length) {
|
|
2632
|
+
const assertCurrent = await beforeCommit?.();
|
|
2633
|
+
if (assertCurrent !== undefined)
|
|
2634
|
+
assertCurrent();
|
|
2635
|
+
return records.transact(ticketId, actions);
|
|
2636
|
+
}
|
|
2637
|
+
validateNewAttachmentPolicy(inputs, descriptors);
|
|
2638
|
+
const rows = await records.list(ticketId);
|
|
2639
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, inputs.length * 6 +
|
|
2640
|
+
actions.filter((action) => action.action === "insert").length);
|
|
2641
|
+
const count = rows.reduce((n, row) => n +
|
|
2642
|
+
(row.kind === "message" ? (row.message.attachments?.length ?? 0) : 0), 0);
|
|
2643
|
+
if (count + inputs.length > attachmentConfig.perTicket)
|
|
2644
|
+
throw new TypeError("Ticket attachment limit exceeded");
|
|
2645
|
+
const attachments = descriptors.map((item) => newAttachment({
|
|
2646
|
+
...item,
|
|
2647
|
+
filename: normalizeAttachmentFilename(item.filename, attachmentConfig.filename),
|
|
2648
|
+
}, clock.now(), attachmentConfig.retention));
|
|
2649
|
+
const blobs = attachmentConfig.blobs;
|
|
2650
|
+
const attemptedKeys = [];
|
|
2651
|
+
const cleanup = async () => {
|
|
2652
|
+
await Promise.allSettled(attemptedKeys.map((key) => blobs.delete(key)));
|
|
2653
|
+
};
|
|
2654
|
+
try {
|
|
2655
|
+
const message = actions.find((action) => action.action === "insert" && action.value.kind === "message");
|
|
2656
|
+
if (!message ||
|
|
2657
|
+
message.action !== "insert" ||
|
|
2658
|
+
message.value.kind !== "message")
|
|
2659
|
+
throw new TypeError("Attachment transaction requires a message");
|
|
2660
|
+
for (let index = 0; index < inputs.length; index++) {
|
|
2661
|
+
const key = attachments[index].blobKey;
|
|
2662
|
+
attemptedKeys.push(key);
|
|
2663
|
+
const result = await blobs.put(key, inputs[index].bytes, {
|
|
2664
|
+
ticketId,
|
|
2665
|
+
messageId: message.value.message.id,
|
|
2666
|
+
});
|
|
2667
|
+
if (result === "exists") {
|
|
2668
|
+
attemptedKeys.pop();
|
|
2669
|
+
throw new SupportDeskConflictError("Attachment blob key collision; retry command");
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
const assertCurrent = await beforeCommit?.();
|
|
2673
|
+
if (assertCurrent !== undefined)
|
|
2674
|
+
assertCurrent();
|
|
2675
|
+
}
|
|
2676
|
+
catch (error) {
|
|
2677
|
+
await cleanup();
|
|
2678
|
+
throw error;
|
|
2679
|
+
}
|
|
2680
|
+
const prepared = actions.map((action) => {
|
|
2681
|
+
if (action.action === "insert" && action.value.kind === "message")
|
|
2682
|
+
return {
|
|
2683
|
+
...action,
|
|
2684
|
+
value: {
|
|
2685
|
+
...action.value,
|
|
2686
|
+
message: { ...action.value.message, attachments },
|
|
2687
|
+
},
|
|
2688
|
+
};
|
|
2689
|
+
return action;
|
|
2690
|
+
});
|
|
2691
|
+
// A thrown transaction has an unknown outcome. Preserve its blobs for
|
|
2692
|
+
// reconciliation; deleting them could damage a successfully committed row.
|
|
2693
|
+
const result = await records.transact(ticketId, prepared);
|
|
2694
|
+
if (!result.committed)
|
|
2695
|
+
await cleanup();
|
|
2696
|
+
return result;
|
|
2697
|
+
}
|
|
2698
|
+
async function changeAttachmentState(access, input, status, digest) {
|
|
2699
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
2700
|
+
for (const [field, value] of Object.entries(input))
|
|
2701
|
+
requireIdentifier(value, field);
|
|
2702
|
+
const fingerprint = await requestFingerprint({
|
|
2703
|
+
...input,
|
|
2704
|
+
status,
|
|
2705
|
+
digest: digest ?? null,
|
|
2706
|
+
});
|
|
2707
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
|
|
2708
|
+
const duplicate = await records.get(commandKey(input.ticketId, input.commandId));
|
|
2709
|
+
if (duplicateMatches(duplicate, "attachment_state", undefined, fingerprint))
|
|
2710
|
+
return;
|
|
2711
|
+
if (duplicate)
|
|
2712
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2713
|
+
const ticket = await records.getVersioned(ticketKey(input.ticketId));
|
|
2714
|
+
const message = await records.getVersioned({
|
|
2715
|
+
partition: input.ticketId,
|
|
2716
|
+
id: `message:${input.messageId}`,
|
|
2717
|
+
});
|
|
2718
|
+
if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
|
|
2719
|
+
throw new SupportDeskNotFoundError();
|
|
2720
|
+
const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
|
|
2721
|
+
if (!attachment)
|
|
2722
|
+
throw new SupportDeskNotFoundError();
|
|
2723
|
+
if (digest !== undefined && digest !== attachment.sha256)
|
|
2724
|
+
throw new SupportDeskConflictError("Scan digest mismatch");
|
|
2725
|
+
if ((status === "clean" || status === "quarantined") &&
|
|
2726
|
+
attachment.scanStatus !== "pending_scan")
|
|
2727
|
+
throw new SupportDeskConflictError("Scan result is already final");
|
|
2728
|
+
if (status === "deleting" && attachment.scanStatus === "deleted")
|
|
2729
|
+
return;
|
|
2730
|
+
if (status === "deleting" && attachment.scanStatus === "deleting")
|
|
2731
|
+
return;
|
|
2732
|
+
if (status === "deleted" && attachment.scanStatus === "deleted")
|
|
2733
|
+
return;
|
|
2734
|
+
if (status === "deleted" && attachment.scanStatus !== "deleting")
|
|
2735
|
+
throw new SupportDeskConflictError("Deletion must be fenced first");
|
|
2736
|
+
const rows = await records.list(input.ticketId);
|
|
2737
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
|
|
2738
|
+
const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
|
|
2739
|
+
const updated = {
|
|
2740
|
+
...ticket.value.ticket,
|
|
2741
|
+
revision: ticket.value.ticket.revision + 1,
|
|
2742
|
+
updatedAt: now,
|
|
2743
|
+
};
|
|
2744
|
+
const result = await records.transact(input.ticketId, [
|
|
2745
|
+
{
|
|
2746
|
+
action: "putIfUnchanged",
|
|
2747
|
+
version: ticket.version,
|
|
2748
|
+
value: { ...ticket.value, ticket: updated },
|
|
2749
|
+
},
|
|
2750
|
+
{
|
|
2751
|
+
action: "putIfUnchanged",
|
|
2752
|
+
version: message.version,
|
|
2753
|
+
value: {
|
|
2754
|
+
...message.value,
|
|
2755
|
+
message: {
|
|
2756
|
+
...message.value.message,
|
|
2757
|
+
attachments: message.value.message.attachments.map((item) => item.id === attachment.id
|
|
2758
|
+
? { ...item, scanStatus: status }
|
|
2759
|
+
: item),
|
|
2760
|
+
},
|
|
2761
|
+
},
|
|
2762
|
+
},
|
|
2763
|
+
ticketAuditAction({
|
|
2764
|
+
...input,
|
|
2765
|
+
actorId: access.principalId,
|
|
2766
|
+
revision: updated.revision,
|
|
2767
|
+
occurredAt: now,
|
|
2768
|
+
action: status === "deleting"
|
|
2769
|
+
? supportTicketAuditActions.attachmentDeleting
|
|
2770
|
+
: status === "deleted"
|
|
2771
|
+
? supportTicketAuditActions.attachmentDeleted
|
|
2772
|
+
: supportTicketAuditActions.attachmentScan,
|
|
2773
|
+
details: { attachmentId: attachment.id, status },
|
|
2774
|
+
}),
|
|
2775
|
+
{
|
|
2776
|
+
action: "insert",
|
|
2777
|
+
value: {
|
|
2778
|
+
kind: "command",
|
|
2779
|
+
partition: input.ticketId,
|
|
2780
|
+
id: `command:${input.commandId}`,
|
|
2781
|
+
commandId: input.commandId,
|
|
2782
|
+
commandType: "attachment_state",
|
|
2783
|
+
requestFingerprint: fingerprint,
|
|
2784
|
+
completedAt: now,
|
|
2785
|
+
},
|
|
2786
|
+
},
|
|
2787
|
+
]);
|
|
2788
|
+
if (result.committed)
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
throw new SupportDeskConflictError();
|
|
2792
|
+
}
|
|
2793
|
+
async function deleteAttachmentWithReceipt(access, input) {
|
|
2794
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
2795
|
+
const actorId = access.principalId;
|
|
2796
|
+
const authorize = () => {
|
|
2797
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
2798
|
+
if (access.principalId !== actorId)
|
|
2799
|
+
throw new SupportDeskAuthorizationError(supportPermissions.manage);
|
|
2800
|
+
};
|
|
2801
|
+
const key = commandKey(input.ticketId, input.commandId);
|
|
2802
|
+
const fingerprint = await requestFingerprint({
|
|
2803
|
+
...input,
|
|
2804
|
+
status: "deleted",
|
|
2805
|
+
digest: null,
|
|
2806
|
+
});
|
|
2807
|
+
const fenceId = `attachment-fence-${await requestFingerprint({ commandId: input.commandId })}`;
|
|
2808
|
+
const fenceEventId = "\u0000support.attachment.fence:" + input.commandId;
|
|
2809
|
+
const fenceFingerprint = await requestFingerprint({
|
|
2810
|
+
...input,
|
|
2811
|
+
commandId: fenceId,
|
|
2812
|
+
status: "deleting",
|
|
2813
|
+
digest: null,
|
|
2814
|
+
});
|
|
2815
|
+
const matches = (row) => row.kind === "command" &&
|
|
2816
|
+
row.commandType === "attachment_state" &&
|
|
2817
|
+
row.messageId === undefined &&
|
|
2818
|
+
row.requestFingerprint === fingerprint;
|
|
2819
|
+
const requireIdentity = (row) => {
|
|
2820
|
+
const owner = row.attachmentDeletion;
|
|
2821
|
+
if (owner !== undefined &&
|
|
2822
|
+
(owner.actorId !== actorId ||
|
|
2823
|
+
owner.messageId !== input.messageId ||
|
|
2824
|
+
owner.attachmentId !== input.attachmentId ||
|
|
2825
|
+
owner.correlationId !== input.correlationId))
|
|
2826
|
+
throw new SupportDeskConflictError("Attachment deletion belongs to another identity");
|
|
2827
|
+
};
|
|
2828
|
+
let reserved = false;
|
|
2829
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
|
|
2830
|
+
const receipt = await records.getVersioned(key);
|
|
2831
|
+
authorize();
|
|
2832
|
+
if (receipt !== null) {
|
|
2833
|
+
if (!matches(receipt.value))
|
|
2834
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2835
|
+
requireIdentity(receipt.value);
|
|
2836
|
+
if (receipt.value.attachmentDeletion?.phase !== "pending")
|
|
2837
|
+
return;
|
|
2838
|
+
reserved = true;
|
|
2839
|
+
break;
|
|
2840
|
+
}
|
|
2841
|
+
const [ticket, message] = await Promise.all([
|
|
2842
|
+
records.getVersioned(ticketKey(input.ticketId)),
|
|
2843
|
+
records.getVersioned({
|
|
2844
|
+
partition: input.ticketId,
|
|
2845
|
+
id: `message:${input.messageId}`,
|
|
2846
|
+
}),
|
|
2847
|
+
]);
|
|
2848
|
+
authorize();
|
|
2849
|
+
if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
|
|
2850
|
+
throw new SupportDeskNotFoundError();
|
|
2851
|
+
const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
|
|
2852
|
+
if (attachment === undefined)
|
|
2853
|
+
throw new SupportDeskNotFoundError();
|
|
2854
|
+
if (attachment.scanStatus === "deleted")
|
|
2855
|
+
return;
|
|
2856
|
+
const rows = await records.list(input.ticketId);
|
|
2857
|
+
authorize();
|
|
2858
|
+
const pendingOwner = rows.find((row) => row.kind === "command" &&
|
|
2859
|
+
row.attachmentDeletion?.phase === "pending" &&
|
|
2860
|
+
row.attachmentDeletion.messageId === input.messageId &&
|
|
2861
|
+
row.attachmentDeletion.attachmentId === input.attachmentId);
|
|
2862
|
+
if (pendingOwner !== undefined) {
|
|
2863
|
+
if (pendingOwner.id === key.id) {
|
|
2864
|
+
if (!matches(pendingOwner))
|
|
2865
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2866
|
+
requireIdentity(pendingOwner);
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
throw new SupportDeskConflictError("Attachment deletion belongs to another identity");
|
|
2870
|
+
}
|
|
2871
|
+
const changing = attachment.scanStatus !== "deleting";
|
|
2872
|
+
if (!changing) {
|
|
2873
|
+
// Legacy deleting metadata has no pending marker. Recover only its
|
|
2874
|
+
// original tuple, backed by the old atomic fence receipt and Audit.
|
|
2875
|
+
const oldReceipt = rows.find((row) => row.id === `command:${fenceId}`);
|
|
2876
|
+
const oldAudit = rows.find((row) => row.id === auditRecordId(fenceId));
|
|
2877
|
+
if (oldReceipt === undefined ||
|
|
2878
|
+
!duplicateMatches(oldReceipt, "attachment_state", undefined, fenceFingerprint) ||
|
|
2879
|
+
oldAudit?.kind !== "audit" ||
|
|
2880
|
+
oldAudit.event.actor.kind !== "principal" ||
|
|
2881
|
+
oldAudit.event.actor.principalId !== actorId ||
|
|
2882
|
+
oldAudit.event.action !== supportTicketAuditActions.attachmentDeleting)
|
|
2883
|
+
throw new SupportDeskConflictError("Original attachment deletion identity is required");
|
|
2884
|
+
}
|
|
2885
|
+
if (!attachmentConfig.blobs)
|
|
2886
|
+
throw new TypeError("Host blob port required");
|
|
2887
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, changing ? 2 : 1);
|
|
2888
|
+
const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
|
|
2889
|
+
const pending = {
|
|
2890
|
+
kind: "command",
|
|
2891
|
+
partition: input.ticketId,
|
|
2892
|
+
id: key.id,
|
|
2893
|
+
commandId: input.commandId,
|
|
2894
|
+
commandType: "attachment_state",
|
|
2895
|
+
requestFingerprint: fingerprint,
|
|
2896
|
+
completedAt: now,
|
|
2897
|
+
attachmentDeletion: {
|
|
2898
|
+
phase: "pending",
|
|
2899
|
+
actorId,
|
|
2900
|
+
messageId: input.messageId,
|
|
2901
|
+
attachmentId: input.attachmentId,
|
|
2902
|
+
correlationId: input.correlationId,
|
|
2903
|
+
},
|
|
2904
|
+
};
|
|
2905
|
+
authorize();
|
|
2906
|
+
const result = await records.transact(input.ticketId, [
|
|
2907
|
+
{
|
|
2908
|
+
action: "putIfUnchanged",
|
|
2909
|
+
version: ticket.version,
|
|
2910
|
+
value: {
|
|
2911
|
+
...ticket.value,
|
|
2912
|
+
ticket: changing
|
|
2913
|
+
? {
|
|
2914
|
+
...ticket.value.ticket,
|
|
2915
|
+
revision: ticket.value.ticket.revision + 1,
|
|
2916
|
+
updatedAt: now,
|
|
2917
|
+
}
|
|
2918
|
+
: ticket.value.ticket,
|
|
2919
|
+
},
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
action: "putIfUnchanged",
|
|
2923
|
+
version: message.version,
|
|
2924
|
+
value: {
|
|
2925
|
+
...message.value,
|
|
2926
|
+
message: {
|
|
2927
|
+
...message.value.message,
|
|
2928
|
+
attachments: message.value.message.attachments.map((item) => item.id === input.attachmentId
|
|
2929
|
+
? { ...item, scanStatus: "deleting" }
|
|
2930
|
+
: item),
|
|
2931
|
+
},
|
|
2932
|
+
},
|
|
2933
|
+
},
|
|
2934
|
+
{ action: "insert", value: pending },
|
|
2935
|
+
...(changing
|
|
2936
|
+
? [
|
|
2937
|
+
ticketAuditAction({
|
|
2938
|
+
...input,
|
|
2939
|
+
eventId: fenceEventId,
|
|
2940
|
+
actorId,
|
|
2941
|
+
revision: ticket.value.ticket.revision + 1,
|
|
2942
|
+
occurredAt: now,
|
|
2943
|
+
action: supportTicketAuditActions.attachmentDeleting,
|
|
2944
|
+
details: {
|
|
2945
|
+
attachmentId: input.attachmentId,
|
|
2946
|
+
status: "deleting",
|
|
2947
|
+
},
|
|
2948
|
+
}),
|
|
2949
|
+
]
|
|
2950
|
+
: []),
|
|
2951
|
+
]);
|
|
2952
|
+
if (result.committed) {
|
|
2953
|
+
reserved = true;
|
|
2954
|
+
break;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (!reserved)
|
|
2958
|
+
throw new SupportDeskConflictError();
|
|
2959
|
+
// The original identity is now durable. Unknown blob outcomes leave it
|
|
2960
|
+
// pending, so only the same actor and exact command can safely retry.
|
|
2961
|
+
const [currentTicket, message] = await Promise.all([
|
|
2962
|
+
records.get(ticketKey(input.ticketId)),
|
|
2963
|
+
records.get({
|
|
2964
|
+
partition: input.ticketId,
|
|
2965
|
+
id: `message:${input.messageId}`,
|
|
2966
|
+
}),
|
|
2967
|
+
]);
|
|
2968
|
+
authorize();
|
|
2969
|
+
if (currentTicket?.kind !== "ticket")
|
|
2970
|
+
throw new SupportDeskNotFoundError();
|
|
2971
|
+
const attachment = message?.kind === "message"
|
|
2972
|
+
? message.message.attachments?.find((item) => item.id === input.attachmentId)
|
|
2973
|
+
: undefined;
|
|
2974
|
+
if (attachment === undefined)
|
|
2975
|
+
throw new SupportDeskNotFoundError();
|
|
2976
|
+
if (attachment.scanStatus !== "deleting" &&
|
|
2977
|
+
attachment.scanStatus !== "deleted")
|
|
2978
|
+
throw new SupportDeskConflictError("Attachment deletion fence changed");
|
|
2979
|
+
if (attachment.scanStatus !== "deleted") {
|
|
2980
|
+
if (!attachmentConfig.blobs)
|
|
2981
|
+
throw new TypeError("Host blob port required");
|
|
2982
|
+
await attachmentConfig.blobs.delete(attachment.blobKey);
|
|
2983
|
+
authorize();
|
|
2984
|
+
}
|
|
2985
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
|
|
2986
|
+
const [receipt, ticket, message] = await Promise.all([
|
|
2987
|
+
records.getVersioned(key),
|
|
2988
|
+
records.getVersioned(ticketKey(input.ticketId)),
|
|
2989
|
+
records.getVersioned({
|
|
2990
|
+
partition: input.ticketId,
|
|
2991
|
+
id: `message:${input.messageId}`,
|
|
2992
|
+
}),
|
|
2993
|
+
]);
|
|
2994
|
+
authorize();
|
|
2995
|
+
if (receipt === null || !matches(receipt.value))
|
|
2996
|
+
throw new SupportDeskConflictError("Attachment deletion receipt changed");
|
|
2997
|
+
requireIdentity(receipt.value);
|
|
2998
|
+
if (receipt.value.attachmentDeletion?.phase !== "pending")
|
|
2999
|
+
return;
|
|
3000
|
+
if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
|
|
3001
|
+
throw new SupportDeskNotFoundError();
|
|
3002
|
+
const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
|
|
3003
|
+
if (attachment === undefined)
|
|
3004
|
+
throw new SupportDeskNotFoundError();
|
|
3005
|
+
if (attachment.scanStatus !== "deleting" &&
|
|
3006
|
+
attachment.scanStatus !== "deleted")
|
|
3007
|
+
throw new SupportDeskConflictError("Attachment deletion fence changed");
|
|
3008
|
+
const changing = attachment.scanStatus !== "deleted";
|
|
3009
|
+
const rows = await records.list(input.ticketId);
|
|
3010
|
+
authorize();
|
|
3011
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, changing ? 1 : 0);
|
|
3012
|
+
const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
|
|
3013
|
+
authorize();
|
|
3014
|
+
const result = await records.transact(input.ticketId, [
|
|
3015
|
+
{
|
|
3016
|
+
action: "putIfUnchanged",
|
|
3017
|
+
version: ticket.version,
|
|
3018
|
+
value: {
|
|
3019
|
+
...ticket.value,
|
|
3020
|
+
ticket: changing
|
|
3021
|
+
? {
|
|
3022
|
+
...ticket.value.ticket,
|
|
3023
|
+
revision: ticket.value.ticket.revision + 1,
|
|
3024
|
+
updatedAt: now,
|
|
3025
|
+
}
|
|
3026
|
+
: ticket.value.ticket,
|
|
3027
|
+
},
|
|
3028
|
+
},
|
|
3029
|
+
{
|
|
3030
|
+
action: "putIfUnchanged",
|
|
3031
|
+
version: message.version,
|
|
3032
|
+
value: {
|
|
3033
|
+
...message.value,
|
|
3034
|
+
message: {
|
|
3035
|
+
...message.value.message,
|
|
3036
|
+
attachments: message.value.message.attachments.map((item) => item.id === input.attachmentId
|
|
3037
|
+
? { ...item, scanStatus: "deleted" }
|
|
3038
|
+
: item),
|
|
3039
|
+
},
|
|
3040
|
+
},
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
action: "putIfUnchanged",
|
|
3044
|
+
version: receipt.version,
|
|
3045
|
+
value: {
|
|
3046
|
+
...receipt.value,
|
|
3047
|
+
completedAt: now,
|
|
3048
|
+
attachmentDeletion: {
|
|
3049
|
+
...receipt.value.attachmentDeletion,
|
|
3050
|
+
phase: "complete",
|
|
3051
|
+
},
|
|
3052
|
+
},
|
|
3053
|
+
},
|
|
3054
|
+
...(changing
|
|
3055
|
+
? [
|
|
3056
|
+
ticketAuditAction({
|
|
3057
|
+
...input,
|
|
3058
|
+
actorId,
|
|
3059
|
+
revision: ticket.value.ticket.revision + 1,
|
|
3060
|
+
occurredAt: now,
|
|
3061
|
+
action: supportTicketAuditActions.attachmentDeleted,
|
|
3062
|
+
details: {
|
|
3063
|
+
attachmentId: input.attachmentId,
|
|
3064
|
+
status: "deleted",
|
|
3065
|
+
},
|
|
3066
|
+
}),
|
|
3067
|
+
]
|
|
3068
|
+
: []),
|
|
3069
|
+
]);
|
|
3070
|
+
if (result.committed)
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
throw new SupportDeskConflictError();
|
|
3074
|
+
}
|
|
2325
3075
|
const resolutionLearning = createResolutionLearningServices({
|
|
2326
3076
|
records,
|
|
2327
3077
|
clock,
|
|
@@ -2330,14 +3080,15 @@ export function createSupportDeskApplication(options) {
|
|
|
2330
3080
|
maxConflictAttempts,
|
|
2331
3081
|
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2332
3082
|
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
|
|
2333
|
-
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
3083
|
+
audit: (command, ticket, actorId, now, action, eventId) => ticketAuditAction({
|
|
3084
|
+
...(eventId === undefined ? {} : { eventId }),
|
|
2334
3085
|
commandId: command.commandId,
|
|
2335
3086
|
correlationId: command.correlationId,
|
|
2336
3087
|
ticketId: ticket.id,
|
|
2337
3088
|
revision: ticket.revision,
|
|
2338
3089
|
actorId,
|
|
2339
3090
|
occurredAt: now,
|
|
2340
|
-
action
|
|
3091
|
+
action,
|
|
2341
3092
|
}),
|
|
2342
3093
|
});
|
|
2343
3094
|
const engineeringDependencies = {
|
|
@@ -2356,7 +3107,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2356
3107
|
if (current !== undefined)
|
|
2357
3108
|
enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
|
|
2358
3109
|
else
|
|
2359
|
-
enforcePartitionRecordLimit(rows
|
|
3110
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
|
|
2360
3111
|
},
|
|
2361
3112
|
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2362
3113
|
commandId: command.commandId,
|
|
@@ -2408,9 +3159,12 @@ export function createSupportDeskApplication(options) {
|
|
|
2408
3159
|
};
|
|
2409
3160
|
const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
|
|
2410
3161
|
const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
|
|
2411
|
-
const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
|
|
3162
|
+
const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard, options = {}) => {
|
|
2412
3163
|
const actorId = access.principalId;
|
|
3164
|
+
const { signal, verifyBeforeCommit } = options;
|
|
2413
3165
|
const authorize = () => {
|
|
3166
|
+
if (signal?.aborted)
|
|
3167
|
+
throw new SupportDeskConflictError("Follow-up posting cancelled");
|
|
2414
3168
|
requireIdentifier(access.principalId, "principalId");
|
|
2415
3169
|
if (access.principalId !== actorId)
|
|
2416
3170
|
throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
|
|
@@ -2495,7 +3249,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2495
3249
|
requireNonempty(body, "body");
|
|
2496
3250
|
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
2497
3251
|
const bodyHash = await requestFingerprint({ body });
|
|
2498
|
-
await guard();
|
|
3252
|
+
const assertProofCurrent = await guard();
|
|
2499
3253
|
authorize();
|
|
2500
3254
|
const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
2501
3255
|
const updated = applyTicketEvent(ticket, {
|
|
@@ -2566,8 +3320,22 @@ export function createSupportDeskApplication(options) {
|
|
|
2566
3320
|
if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
|
|
2567
3321
|
continue;
|
|
2568
3322
|
authorize();
|
|
2569
|
-
|
|
2570
|
-
|
|
3323
|
+
const proofDeadline = Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil));
|
|
3324
|
+
if (now() >= proofDeadline)
|
|
3325
|
+
throw new SupportDeskConflictError("Follow-up posting proof expired");
|
|
3326
|
+
if (verifyBeforeCommit !== undefined) {
|
|
3327
|
+
const authorityDeadline = Math.min(proofDeadline, now() + engineeringFollowupOptions.trustTimeoutMilliseconds);
|
|
3328
|
+
await engineeringFollowupTrusted(followupDependencies, async () => {
|
|
3329
|
+
authorize();
|
|
3330
|
+
await verifyBeforeCommit();
|
|
3331
|
+
}, signal);
|
|
3332
|
+
authorize();
|
|
3333
|
+
if (now() >= authorityDeadline)
|
|
3334
|
+
throw new SupportDeskConflictError("Follow-up posting authority or proof expired");
|
|
3335
|
+
}
|
|
3336
|
+
if (assertProofCurrent !== undefined &&
|
|
3337
|
+
(typeof assertProofCurrent !== "function" ||
|
|
3338
|
+
assertProofCurrent() !== true))
|
|
2571
3339
|
throw new SupportDeskConflictError("Follow-up posting proof expired");
|
|
2572
3340
|
const outcome = await records.transact(command.ticketId, [
|
|
2573
3341
|
...derivations.actions,
|
|
@@ -2663,7 +3431,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2663
3431
|
: { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
|
|
2664
3432
|
});
|
|
2665
3433
|
const completion = assistantTurnCommit;
|
|
2666
|
-
const parallel = completion?.kind === "complete";
|
|
3434
|
+
const parallel = completion?.kind === "complete" || completion?.kind === "finish";
|
|
2667
3435
|
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2668
3436
|
const ticketRead = records.getVersioned(ticketKey(command.ticketId));
|
|
2669
3437
|
const duplicateRead = parallel
|
|
@@ -2692,8 +3460,8 @@ export function createSupportDeskApplication(options) {
|
|
|
2692
3460
|
ticket.revision !== command.expectedRevision ||
|
|
2693
3461
|
effectiveTicketHandling(ticket).generation !==
|
|
2694
3462
|
command.expectedHandlingGeneration)) {
|
|
2695
|
-
// The speculative receipt may predate a concurrent
|
|
2696
|
-
//
|
|
3463
|
+
// The speculative receipt may predate a concurrent completion or
|
|
3464
|
+
// finish already reflected in this Ticket. Recover its exact receipt
|
|
2697
3465
|
// before rejecting the now-stale live fence; ordinary paired reads
|
|
2698
3466
|
// retain their existing cost and original whole-attempt CAS versions.
|
|
2699
3467
|
const accepted = await records.get(commandKey(command.ticketId, command.commandId));
|
|
@@ -2709,7 +3477,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2709
3477
|
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2710
3478
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2711
3479
|
const rows = await records.list(command.ticketId);
|
|
2712
|
-
enforcePartitionRecordLimit(rows
|
|
3480
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket);
|
|
2713
3481
|
const latest = latestVisibleRecord(rows);
|
|
2714
3482
|
if (operation === "assistant_reply" || operation === "assistant_escalate")
|
|
2715
3483
|
requireAssistantTurn(ticket, rows, command);
|
|
@@ -2780,7 +3548,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2780
3548
|
? await planMutation()
|
|
2781
3549
|
: readValue(await mutationRead);
|
|
2782
3550
|
const learningConfirmation = operation === "confirm_customer_success"
|
|
2783
|
-
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
|
|
3551
|
+
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows, partitionSizeWithAttachmentReserve(rows))
|
|
2784
3552
|
: { actions: [], additionalRows: 0 };
|
|
2785
3553
|
const newRows = (operation === "take_over" ? 2 : 3) +
|
|
2786
3554
|
learningMutation.additionalRows +
|
|
@@ -2942,7 +3710,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2942
3710
|
conversation(ticket, rows) {
|
|
2943
3711
|
return {
|
|
2944
3712
|
ticket: toCustomerTicketSummary(ticket),
|
|
2945
|
-
messages:
|
|
3713
|
+
messages: assistantConversationMessages(rows),
|
|
2946
3714
|
revision: ticket.revision,
|
|
2947
3715
|
handling: { ...effectiveTicketHandling(ticket) },
|
|
2948
3716
|
...(latestVisibleRecord(rows) === undefined
|
|
@@ -2953,6 +3721,82 @@ export function createSupportDeskApplication(options) {
|
|
|
2953
3721
|
mutateHandling,
|
|
2954
3722
|
});
|
|
2955
3723
|
return {
|
|
3724
|
+
async downloadAttachment(access, input) {
|
|
3725
|
+
requireIdentifier(access.principalId, "principalId");
|
|
3726
|
+
const actorId = access.principalId;
|
|
3727
|
+
const staff = hasPermission(access, supportPermissions.queueRead);
|
|
3728
|
+
if (!staff)
|
|
3729
|
+
requirePermission(access, supportPermissions.readOwn);
|
|
3730
|
+
const source = boundaryObject(input, "attachment download");
|
|
3731
|
+
input = Object.freeze({
|
|
3732
|
+
ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
|
|
3733
|
+
messageId: boundaryString(ownDataProperty(source, "messageId", "messageId"), "messageId"),
|
|
3734
|
+
attachmentId: boundaryString(ownDataProperty(source, "attachmentId", "attachmentId"), "attachmentId"),
|
|
3735
|
+
});
|
|
3736
|
+
for (const [field, value] of Object.entries(input))
|
|
3737
|
+
requireIdentifier(value, field);
|
|
3738
|
+
const ticket = await records.getVersioned(ticketKey(input.ticketId));
|
|
3739
|
+
if (ticket?.value.kind !== "ticket" ||
|
|
3740
|
+
(!staff &&
|
|
3741
|
+
(ticket.value.ticket.requester.association !== "authenticated" ||
|
|
3742
|
+
ticket.value.ticket.requester.principalId !== actorId)))
|
|
3743
|
+
throw new SupportDeskNotFoundError();
|
|
3744
|
+
const key = {
|
|
3745
|
+
partition: input.ticketId,
|
|
3746
|
+
id: `message:${input.messageId}`,
|
|
3747
|
+
};
|
|
3748
|
+
const row = await records.getVersioned(key);
|
|
3749
|
+
if (row?.value.kind !== "message" ||
|
|
3750
|
+
(!staff && row.value.message.visibility !== "customer"))
|
|
3751
|
+
throw new SupportDeskNotFoundError();
|
|
3752
|
+
const attachment = row.value.message.attachments?.find((item) => item.id === input.attachmentId);
|
|
3753
|
+
if (!attachment ||
|
|
3754
|
+
attachment.scanStatus !== "clean" ||
|
|
3755
|
+
canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") <=
|
|
3756
|
+
canonicalTimestamp(clock.now(), "attachment download now"))
|
|
3757
|
+
throw new SupportDeskNotFoundError();
|
|
3758
|
+
if (!attachmentConfig.blobs)
|
|
3759
|
+
throw new TypeError("Host blob port required");
|
|
3760
|
+
const bytes = await attachmentConfig.blobs.get(attachment.blobKey, attachment.size);
|
|
3761
|
+
if (bytes.byteLength !== attachment.size ||
|
|
3762
|
+
(await attachmentDigest(bytes)) !== attachment.sha256)
|
|
3763
|
+
throw new SupportDeskConflictError("Stored attachment digest mismatch");
|
|
3764
|
+
const [currentTicket, current] = await Promise.all([
|
|
3765
|
+
records.getVersioned(ticketKey(input.ticketId)),
|
|
3766
|
+
records.getVersioned(key),
|
|
3767
|
+
]);
|
|
3768
|
+
// Blob I/O and hashing may outlive the original resource or access snapshot.
|
|
3769
|
+
// These final observations withhold bytes; they do not lock either record.
|
|
3770
|
+
requirePermission(access, staff ? supportPermissions.queueRead : supportPermissions.readOwn);
|
|
3771
|
+
if (access.principalId !== actorId)
|
|
3772
|
+
throw new SupportDeskNotFoundError();
|
|
3773
|
+
if (currentTicket?.version !== ticket.version ||
|
|
3774
|
+
current?.version !== row.version ||
|
|
3775
|
+
canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") <=
|
|
3776
|
+
canonicalTimestamp(clock.now(), "attachment download now"))
|
|
3777
|
+
throw new SupportDeskConflictError("Attachment changed during download");
|
|
3778
|
+
return {
|
|
3779
|
+
bytes,
|
|
3780
|
+
contentType: attachment.contentType,
|
|
3781
|
+
contentDisposition: `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.filename).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)}`,
|
|
3782
|
+
cacheControl: "private, no-store",
|
|
3783
|
+
xContentTypeOptions: "nosniff",
|
|
3784
|
+
};
|
|
3785
|
+
},
|
|
3786
|
+
async recordAttachmentScanResult(access, input) {
|
|
3787
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
3788
|
+
const source = boundaryObject(input, "attachment scan");
|
|
3789
|
+
const verdict = ownDataProperty(source, "verdict", "verdict");
|
|
3790
|
+
const sha256 = boundaryString(ownDataProperty(source, "sha256", "sha256"), "sha256");
|
|
3791
|
+
if (verdict !== "clean" && verdict !== "quarantined")
|
|
3792
|
+
throw new TypeError("Invalid scan verdict");
|
|
3793
|
+
const command = snapshotAttachmentStateCommand(input);
|
|
3794
|
+
return changeAttachmentState(access, command, verdict, sha256);
|
|
3795
|
+
},
|
|
3796
|
+
async deleteAttachment(access, input) {
|
|
3797
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
3798
|
+
return deleteAttachmentWithReceipt(access, snapshotAttachmentStateCommand(input));
|
|
3799
|
+
},
|
|
2956
3800
|
...resolutionLearning.application,
|
|
2957
3801
|
...engineeringEvidence.application,
|
|
2958
3802
|
...engineeringWorkers,
|
|
@@ -3006,7 +3850,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3006
3850
|
const latest = latestVisibleRecord(rows);
|
|
3007
3851
|
return {
|
|
3008
3852
|
ticket: toCustomerTicketSummary(ticket),
|
|
3009
|
-
messages:
|
|
3853
|
+
messages: assistantConversationMessages(rows),
|
|
3010
3854
|
revision: ticket.revision,
|
|
3011
3855
|
handling: { ...effectiveTicketHandling(ticket) },
|
|
3012
3856
|
...(latest?.message.authorKind === "customer"
|
|
@@ -3106,25 +3950,72 @@ export function createSupportDeskApplication(options) {
|
|
|
3106
3950
|
.sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
|
|
3107
3951
|
.map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
|
|
3108
3952
|
},
|
|
3109
|
-
async
|
|
3953
|
+
...customerCreateMethods(async (access, input, admitted) => {
|
|
3110
3954
|
requirePermission(access, supportPermissions.create);
|
|
3111
3955
|
requireIdentifier(access.principalId, "principalId");
|
|
3956
|
+
const actorId = access.principalId;
|
|
3957
|
+
const principal = () => admitted === undefined ? access.principalId : actorId;
|
|
3958
|
+
const signal = admitted?.options?.signal;
|
|
3959
|
+
function authorizeAdmitted(current = access, assistant = false) {
|
|
3960
|
+
if (admitted === undefined)
|
|
3961
|
+
return;
|
|
3962
|
+
if (signal?.aborted)
|
|
3963
|
+
throw new SupportDeskConflictError("Admitted create was cancelled");
|
|
3964
|
+
if (current.principalId !== actorId)
|
|
3965
|
+
throw new SupportDeskAuthorizationError(supportPermissions.create);
|
|
3966
|
+
requirePermission(current, supportPermissions.create);
|
|
3967
|
+
requirePermission(current, supportPermissions.readOwn);
|
|
3968
|
+
if (assistant)
|
|
3969
|
+
requirePermission(current, supportPermissions.assistantStart);
|
|
3970
|
+
}
|
|
3971
|
+
async function waitForAdmission(check) {
|
|
3972
|
+
authorizeAdmitted();
|
|
3973
|
+
if (signal === undefined)
|
|
3974
|
+
return check();
|
|
3975
|
+
let onAbort;
|
|
3976
|
+
try {
|
|
3977
|
+
return await Promise.race([
|
|
3978
|
+
Promise.resolve().then(() => {
|
|
3979
|
+
authorizeAdmitted();
|
|
3980
|
+
return check();
|
|
3981
|
+
}),
|
|
3982
|
+
new Promise((_resolve, reject) => {
|
|
3983
|
+
onAbort = () => reject(new SupportDeskConflictError("Admitted create was cancelled"));
|
|
3984
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3985
|
+
if (signal.aborted)
|
|
3986
|
+
onAbort();
|
|
3987
|
+
}),
|
|
3988
|
+
]);
|
|
3989
|
+
}
|
|
3990
|
+
finally {
|
|
3991
|
+
if (onAbort !== undefined)
|
|
3992
|
+
signal.removeEventListener("abort", onAbort);
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
authorizeAdmitted();
|
|
3112
3996
|
const command = snapshotCreateCustomerTicketCommand(input);
|
|
3113
|
-
|
|
3997
|
+
let initialHandling = command.initialHandling;
|
|
3998
|
+
if (admitted === undefined && initialHandling === "assistant") {
|
|
3114
3999
|
requirePermission(access, supportPermissions.assistantStart);
|
|
3115
4000
|
}
|
|
3116
4001
|
requireIdentifier(command.ticketId, "ticketId");
|
|
3117
4002
|
requireIdentifier(command.messageId, "messageId");
|
|
3118
4003
|
requireIdentifier(command.commandId, "commandId");
|
|
3119
4004
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3120
|
-
|
|
3121
|
-
|
|
4005
|
+
// Admitted replay keeps fixed ceilings when later instance limits shrink.
|
|
4006
|
+
// Fresh admission applies the configured limits after receipt recovery.
|
|
4007
|
+
const inputLimits = admitted === undefined ? limits : supportDeskLimitMaximums;
|
|
4008
|
+
enforceLimit(command.subject, "subject", inputLimits.maxSubjectCharacters);
|
|
4009
|
+
enforceLimit(command.body, "body", inputLimits.maxMessageCharacters);
|
|
4010
|
+
const attachmentInputs = command.attachments ?? [];
|
|
4011
|
+
const descriptors = await attachmentDescriptors(attachmentInputs);
|
|
3122
4012
|
const fingerprintPayload = {
|
|
3123
4013
|
type: "create_customer_ticket",
|
|
3124
4014
|
ticketId: command.ticketId,
|
|
3125
4015
|
messageId: command.messageId,
|
|
3126
4016
|
subject: command.subject,
|
|
3127
4017
|
body: command.body,
|
|
4018
|
+
...(descriptors.length === 0 ? {} : { attachments: descriptors }),
|
|
3128
4019
|
// Omit absent category so pre-category receipts still match retries.
|
|
3129
4020
|
...(command.category === undefined
|
|
3130
4021
|
? {}
|
|
@@ -3135,7 +4026,20 @@ export function createSupportDeskApplication(options) {
|
|
|
3135
4026
|
? {}
|
|
3136
4027
|
: { initialHandling: command.initialHandling }),
|
|
3137
4028
|
};
|
|
3138
|
-
const
|
|
4029
|
+
const commandType = admitted === undefined
|
|
4030
|
+
? "create_customer_ticket"
|
|
4031
|
+
: "create_admitted_customer_ticket";
|
|
4032
|
+
const fingerprint = await requestFingerprint(admitted === undefined
|
|
4033
|
+
? fingerprintPayload
|
|
4034
|
+
: {
|
|
4035
|
+
...fingerprintPayload,
|
|
4036
|
+
type: commandType,
|
|
4037
|
+
version: 1,
|
|
4038
|
+
actorId,
|
|
4039
|
+
commandId: command.commandId,
|
|
4040
|
+
preference: admitted.intent.preference,
|
|
4041
|
+
disclosureVersion: admitted.intent.disclosureVersion ?? null,
|
|
4042
|
+
});
|
|
3139
4043
|
const createThreading = {
|
|
3140
4044
|
ticketNumber: true,
|
|
3141
4045
|
...(command.notification === undefined
|
|
@@ -3146,8 +4050,11 @@ export function createSupportDeskApplication(options) {
|
|
|
3146
4050
|
// later config change cannot break idempotent retries of the same command.
|
|
3147
4051
|
// Replay never reserves another instance ticket number.
|
|
3148
4052
|
const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
3149
|
-
|
|
3150
|
-
|
|
4053
|
+
authorizeAdmitted();
|
|
4054
|
+
let createIsReplay = duplicateMatches(existingCreate, commandType, command.messageId, fingerprint);
|
|
4055
|
+
if (admitted === undefined &&
|
|
4056
|
+
!createIsReplay &&
|
|
4057
|
+
descriptors.length === 0 &&
|
|
3151
4058
|
command.initialHandling === undefined &&
|
|
3152
4059
|
existingCreate?.kind === "command" &&
|
|
3153
4060
|
existingCreate.commandType === "create_customer_ticket" &&
|
|
@@ -3174,19 +4081,30 @@ export function createSupportDeskApplication(options) {
|
|
|
3174
4081
|
}
|
|
3175
4082
|
if (createIsReplay) {
|
|
3176
4083
|
const current = await records.get(ticketKey(command.ticketId));
|
|
3177
|
-
|
|
3178
|
-
|
|
4084
|
+
authorizeAdmitted();
|
|
4085
|
+
if (current?.kind !== "ticket" || !owns(current.ticket, principal()))
|
|
3179
4086
|
throw new SupportDeskNotFoundError();
|
|
4087
|
+
const initialMode = admitted === undefined
|
|
4088
|
+
? (initialHandling ?? "human")
|
|
4089
|
+
: admittedCreateMode(existingCreate);
|
|
3180
4090
|
// A crash after ticket commit and before index confirmation must still
|
|
3181
4091
|
// converge the principal hint on retry.
|
|
3182
4092
|
const fence = await records.get(ticketReservationKey(command.ticketId));
|
|
4093
|
+
authorizeAdmitted();
|
|
3183
4094
|
if (fence?.kind === "ticket_reservation" &&
|
|
3184
4095
|
fence.state === "committed") {
|
|
3185
|
-
await confirmCustomerTicket(
|
|
4096
|
+
await confirmCustomerTicket(principal(), command.ticketId, fence.token, fence.generation);
|
|
3186
4097
|
}
|
|
3187
|
-
|
|
4098
|
+
authorizeAdmitted();
|
|
4099
|
+
const view = await customerViewAfterChange(principal(), command.ticketId, createThreading);
|
|
4100
|
+
authorizeAdmitted();
|
|
4101
|
+
return { view, initialMode };
|
|
3188
4102
|
}
|
|
3189
|
-
if (
|
|
4103
|
+
if (existingCreate !== null)
|
|
4104
|
+
throw new SupportDeskConflictError(admitted === undefined
|
|
4105
|
+
? "Command ID reused with different input"
|
|
4106
|
+
: "Admitted create command changed");
|
|
4107
|
+
if (initialHandling === "assistant" && !assistantHandlingEnabled)
|
|
3190
4108
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
3191
4109
|
if (command.category !== undefined) {
|
|
3192
4110
|
if (allowedCategories === undefined ||
|
|
@@ -3194,6 +4112,32 @@ export function createSupportDeskApplication(options) {
|
|
|
3194
4112
|
throw new TypeError("create command.category is not configured for this Support Desk instance");
|
|
3195
4113
|
}
|
|
3196
4114
|
}
|
|
4115
|
+
const verifyBeforeCommit = admitted?.options?.verifyBeforeCommit;
|
|
4116
|
+
let assistantAccess;
|
|
4117
|
+
if (admitted !== undefined) {
|
|
4118
|
+
enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
|
|
4119
|
+
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
4120
|
+
if (typeof verifyBeforeCommit !== "function")
|
|
4121
|
+
throw new SupportDeskConflictError("Fresh create admission is required");
|
|
4122
|
+
const resolveAssistantStart = admitted.options?.resolveAssistantStart;
|
|
4123
|
+
if (admitted.intent.preference === "assistant_if_available" &&
|
|
4124
|
+
admitted.intent.disclosureVersion !== undefined &&
|
|
4125
|
+
assistantHandlingEnabled &&
|
|
4126
|
+
assistantTurnOptions !== undefined &&
|
|
4127
|
+
resolveAssistantStart !== undefined) {
|
|
4128
|
+
const current = await waitForAdmission(resolveAssistantStart);
|
|
4129
|
+
authorizeAdmitted();
|
|
4130
|
+
if (current !== null) {
|
|
4131
|
+
authorizeAdmitted(current, true);
|
|
4132
|
+
assistantAccess = current;
|
|
4133
|
+
initialHandling = "assistant";
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
let initialMode = initialHandling ?? "human";
|
|
4138
|
+
// Exact accepted receipts returned above. Apply current attachment policy
|
|
4139
|
+
// only to fresh intake, before it can consume a number or customer slot.
|
|
4140
|
+
validateNewAttachmentPolicy(attachmentInputs, descriptors);
|
|
3197
4141
|
// Fail closed before durable number/index reservation so a too-small
|
|
3198
4142
|
// partition budget cannot exhaust the principal ticket index with
|
|
3199
4143
|
// reserved-but-never-created tickets.
|
|
@@ -3201,18 +4145,18 @@ export function createSupportDeskApplication(options) {
|
|
|
3201
4145
|
(resolutionLearningOptions.enabled ? 1 : 0) +
|
|
3202
4146
|
(engineeringOptions.enabled ? 1 : 0) +
|
|
3203
4147
|
(command.notification === undefined ? 0 : 1) +
|
|
3204
|
-
(assistantTurnOptions !== undefined &&
|
|
3205
|
-
command.initialHandling === "assistant"
|
|
4148
|
+
(assistantTurnOptions !== undefined && initialHandling === "assistant"
|
|
3206
4149
|
? 1
|
|
3207
4150
|
: 0);
|
|
3208
4151
|
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
|
|
4152
|
+
attachmentInputs.length * 6 +
|
|
3209
4153
|
(resolutionLearningOptions.enabled ? 2 : 0) +
|
|
3210
4154
|
(engineeringOptions.enabled ? 2 : 0) +
|
|
3211
|
-
(
|
|
4155
|
+
(initialHandling === "assistant"
|
|
3212
4156
|
? assistantHumanReserveRows +
|
|
3213
4157
|
(resolutionLearningOptions.enabled ? 1 : 0)
|
|
3214
4158
|
: 0));
|
|
3215
|
-
if (
|
|
4159
|
+
if (initialHandling === "assistant" &&
|
|
3216
4160
|
limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
|
|
3217
4161
|
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
3218
4162
|
// Reserve outside the ticket transaction; gaps are accepted if create fails.
|
|
@@ -3228,15 +4172,13 @@ export function createSupportDeskApplication(options) {
|
|
|
3228
4172
|
number: ticketNumber,
|
|
3229
4173
|
subject: command.subject,
|
|
3230
4174
|
channel: "web",
|
|
3231
|
-
...(
|
|
3232
|
-
? {}
|
|
3233
|
-
: { initialHandling: command.initialHandling }),
|
|
4175
|
+
...(initialHandling === undefined ? {} : { initialHandling }),
|
|
3234
4176
|
...(command.category === undefined
|
|
3235
4177
|
? {}
|
|
3236
4178
|
: { category: command.category }),
|
|
3237
4179
|
requester: {
|
|
3238
4180
|
association: "authenticated",
|
|
3239
|
-
principalId:
|
|
4181
|
+
principalId: principal(),
|
|
3240
4182
|
...(command.requesterEmail === undefined
|
|
3241
4183
|
? {}
|
|
3242
4184
|
: { email: command.requesterEmail }),
|
|
@@ -3247,22 +4189,21 @@ export function createSupportDeskApplication(options) {
|
|
|
3247
4189
|
id: command.messageId,
|
|
3248
4190
|
ticketId: command.ticketId,
|
|
3249
4191
|
authorKind: "customer",
|
|
3250
|
-
authorPrincipalId:
|
|
4192
|
+
authorPrincipalId: principal(),
|
|
3251
4193
|
channel: "web",
|
|
3252
4194
|
visibility: "customer",
|
|
3253
4195
|
format: "plain_text",
|
|
3254
4196
|
body: command.body,
|
|
3255
4197
|
createdAt: now,
|
|
3256
4198
|
};
|
|
3257
|
-
const assistantTurn = assistantTurnOptions !== undefined &&
|
|
3258
|
-
command.initialHandling === "assistant"
|
|
4199
|
+
const assistantTurn = assistantTurnOptions !== undefined && initialHandling === "assistant"
|
|
3259
4200
|
? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
|
|
3260
4201
|
: undefined;
|
|
3261
4202
|
const notificationJob = notification === undefined
|
|
3262
4203
|
? undefined
|
|
3263
4204
|
: deliveryJobAction(command.ticketId, command.messageId, now, notification);
|
|
3264
4205
|
const notificationContent = deliveryContent(notification);
|
|
3265
|
-
const reservation = await reserveCustomerTicket(
|
|
4206
|
+
const reservation = await reserveCustomerTicket(principal(), command.ticketId, now);
|
|
3266
4207
|
const existingReservation = await records.getVersioned(ticketReservationKey(command.ticketId));
|
|
3267
4208
|
if (existingReservation?.value.kind === "ticket_reservation" &&
|
|
3268
4209
|
existingReservation.value.state === "committed" &&
|
|
@@ -3332,7 +4273,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3332
4273
|
partition: command.ticketId,
|
|
3333
4274
|
id: "quota",
|
|
3334
4275
|
messageCount: 1,
|
|
3335
|
-
...(
|
|
4276
|
+
...(initialHandling === "assistant"
|
|
3336
4277
|
? { assistantManaged: true }
|
|
3337
4278
|
: {}),
|
|
3338
4279
|
},
|
|
@@ -3356,7 +4297,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3356
4297
|
correlationId: command.correlationId,
|
|
3357
4298
|
ticketId: command.ticketId,
|
|
3358
4299
|
revision: ticket.revision,
|
|
3359
|
-
actorId:
|
|
4300
|
+
actorId: principal(),
|
|
3360
4301
|
occurredAt: now,
|
|
3361
4302
|
action: supportTicketAuditActions.created,
|
|
3362
4303
|
}),
|
|
@@ -3367,10 +4308,11 @@ export function createSupportDeskApplication(options) {
|
|
|
3367
4308
|
partition: command.ticketId,
|
|
3368
4309
|
id: `command:${command.commandId}`,
|
|
3369
4310
|
commandId: command.commandId,
|
|
3370
|
-
commandType
|
|
4311
|
+
commandType,
|
|
3371
4312
|
messageId: command.messageId,
|
|
3372
4313
|
requestFingerprint: fingerprint,
|
|
3373
4314
|
completedAt: now,
|
|
4315
|
+
...(admitted === undefined ? {} : { initialMode }),
|
|
3374
4316
|
},
|
|
3375
4317
|
},
|
|
3376
4318
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
@@ -3381,16 +4323,43 @@ export function createSupportDeskApplication(options) {
|
|
|
3381
4323
|
// Create starts an empty partition; fail closed if the first write would
|
|
3382
4324
|
// exceed the configured physical-record budget.
|
|
3383
4325
|
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
|
|
3384
|
-
|
|
4326
|
+
if (attachmentInputs.length > 0) {
|
|
4327
|
+
const initialRows = actions.flatMap((action) => action.action === "insert" ? [action.value] : []);
|
|
4328
|
+
enforceManagedAdmission(ticket, initialRows, limits, attachmentInputs.length * 6);
|
|
4329
|
+
}
|
|
4330
|
+
const beforeAttachmentCommit = async () => {
|
|
4331
|
+
authorizeAdmitted();
|
|
4332
|
+
const assertCurrent = admitted !== undefined && verifyBeforeCommit !== undefined
|
|
4333
|
+
? await waitForAdmission(() => verifyBeforeCommit(initialMode))
|
|
4334
|
+
: undefined;
|
|
4335
|
+
return () => {
|
|
4336
|
+
authorizeAdmitted();
|
|
4337
|
+
if (assistantAccess !== undefined)
|
|
4338
|
+
authorizeAdmitted(assistantAccess, true);
|
|
4339
|
+
if (admitted !== undefined &&
|
|
4340
|
+
(typeof assertCurrent !== "function" || assertCurrent() !== true))
|
|
4341
|
+
throw new SupportDeskConflictError("Admitted create authority changed");
|
|
4342
|
+
return true;
|
|
4343
|
+
};
|
|
4344
|
+
};
|
|
4345
|
+
const outcome = await transactWithAttachments(command.ticketId, actions, attachmentInputs, descriptors, beforeAttachmentCommit);
|
|
3385
4346
|
if (!outcome.committed) {
|
|
3386
4347
|
const existing = await records.get(commandKey(command.ticketId, command.commandId));
|
|
3387
|
-
if (!duplicateMatches(existing,
|
|
4348
|
+
if (!duplicateMatches(existing, commandType, command.messageId, fingerprint)) {
|
|
3388
4349
|
throw new SupportDeskConflictError();
|
|
3389
4350
|
}
|
|
4351
|
+
// An exact competing commit recovers the winner's historical mode,
|
|
4352
|
+
// using the same create/read-own authority as the early receipt path.
|
|
4353
|
+
if (admitted !== undefined)
|
|
4354
|
+
initialMode = admittedCreateMode(existing);
|
|
3390
4355
|
}
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
4356
|
+
authorizeAdmitted();
|
|
4357
|
+
await confirmCustomerTicket(principal(), command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
|
|
4358
|
+
authorizeAdmitted();
|
|
4359
|
+
const view = await customerViewAfterChange(principal(), command.ticketId, createThreading);
|
|
4360
|
+
authorizeAdmitted();
|
|
4361
|
+
return { view, initialMode };
|
|
4362
|
+
}),
|
|
3394
4363
|
async replyToCustomerTicket(access, input) {
|
|
3395
4364
|
requirePermission(access, supportPermissions.replyOwn);
|
|
3396
4365
|
requireIdentifier(access.principalId, "principalId");
|
|
@@ -3400,11 +4369,17 @@ export function createSupportDeskApplication(options) {
|
|
|
3400
4369
|
requireIdentifier(command.commandId, "commandId");
|
|
3401
4370
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3402
4371
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
4372
|
+
const attachmentInputs = command.attachments ?? [];
|
|
4373
|
+
const descriptors = await attachmentDescriptors(attachmentInputs);
|
|
4374
|
+
const attachmentFingerprint = descriptors.length
|
|
4375
|
+
? { attachments: descriptors }
|
|
4376
|
+
: {};
|
|
3403
4377
|
const fingerprint = await requestFingerprint({
|
|
3404
4378
|
type: "reply_customer_ticket",
|
|
3405
4379
|
ticketId: command.ticketId,
|
|
3406
4380
|
messageId: command.messageId,
|
|
3407
4381
|
body: command.body,
|
|
4382
|
+
...attachmentFingerprint,
|
|
3408
4383
|
notification: stableNotification(command.notification),
|
|
3409
4384
|
});
|
|
3410
4385
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
@@ -3412,6 +4387,8 @@ export function createSupportDeskApplication(options) {
|
|
|
3412
4387
|
if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
3413
4388
|
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
3414
4389
|
}
|
|
4390
|
+
if (duplicate !== null)
|
|
4391
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
3415
4392
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
3416
4393
|
if (versioned?.value.kind !== "ticket" ||
|
|
3417
4394
|
versioned.value.ticket.requester.association !== "authenticated" ||
|
|
@@ -3453,8 +4430,12 @@ export function createSupportDeskApplication(options) {
|
|
|
3453
4430
|
});
|
|
3454
4431
|
}
|
|
3455
4432
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3456
|
-
enforcePartitionRecordLimit(partitionRows
|
|
3457
|
-
|
|
4433
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedReplyRows +
|
|
4434
|
+
learningMutation.additionalRows +
|
|
4435
|
+
attachmentInputs.length * 6);
|
|
4436
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows +
|
|
4437
|
+
learningMutation.additionalRows +
|
|
4438
|
+
attachmentInputs.length * 6, true);
|
|
3458
4439
|
const message = {
|
|
3459
4440
|
id: command.messageId,
|
|
3460
4441
|
ticketId: command.ticketId,
|
|
@@ -3481,7 +4462,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3481
4462
|
const notificationJob = command.notification === undefined
|
|
3482
4463
|
? undefined
|
|
3483
4464
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
3484
|
-
const outcome = await
|
|
4465
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
3485
4466
|
...learningMutation.actions,
|
|
3486
4467
|
{
|
|
3487
4468
|
action: "putIfUnchanged",
|
|
@@ -3530,7 +4511,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3530
4511
|
...(assistantIngress === undefined
|
|
3531
4512
|
? []
|
|
3532
4513
|
: [{ action: "insert", value: assistantIngress }]),
|
|
3533
|
-
]);
|
|
4514
|
+
], attachmentInputs, descriptors);
|
|
3534
4515
|
if (outcome.committed) {
|
|
3535
4516
|
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
3536
4517
|
}
|
|
@@ -3716,12 +4697,18 @@ export function createSupportDeskApplication(options) {
|
|
|
3716
4697
|
requireIdentifier(command.commandId, "commandId");
|
|
3717
4698
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3718
4699
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
4700
|
+
const attachmentInputs = command.attachments ?? [];
|
|
4701
|
+
const descriptors = await attachmentDescriptors(attachmentInputs);
|
|
4702
|
+
const attachmentFingerprint = descriptors.length
|
|
4703
|
+
? { attachments: descriptors }
|
|
4704
|
+
: {};
|
|
3719
4705
|
const fingerprint = await requestFingerprint({
|
|
3720
4706
|
...snapshotRevisionHint(command),
|
|
3721
4707
|
type: "staff_reply",
|
|
3722
4708
|
ticketId: command.ticketId,
|
|
3723
4709
|
messageId: command.messageId,
|
|
3724
4710
|
body: command.body,
|
|
4711
|
+
...attachmentFingerprint,
|
|
3725
4712
|
notification: stableNotification(command.notification),
|
|
3726
4713
|
});
|
|
3727
4714
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
@@ -3752,10 +4739,12 @@ export function createSupportDeskApplication(options) {
|
|
|
3752
4739
|
occurredAt: now,
|
|
3753
4740
|
});
|
|
3754
4741
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3755
|
-
enforcePartitionRecordLimit(partitionRows
|
|
3756
|
-
learningMutation.additionalRows
|
|
4742
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
|
|
4743
|
+
learningMutation.additionalRows +
|
|
4744
|
+
attachmentInputs.length * 6);
|
|
3757
4745
|
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
|
|
3758
|
-
learningMutation.additionalRows
|
|
4746
|
+
learningMutation.additionalRows +
|
|
4747
|
+
attachmentInputs.length * 6, true, true);
|
|
3759
4748
|
const message = {
|
|
3760
4749
|
id: command.messageId,
|
|
3761
4750
|
ticketId: command.ticketId,
|
|
@@ -3771,7 +4760,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3771
4760
|
const notificationJob = command.notification === undefined
|
|
3772
4761
|
? undefined
|
|
3773
4762
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
3774
|
-
const outcome = await
|
|
4763
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
3775
4764
|
...learningMutation.actions,
|
|
3776
4765
|
{
|
|
3777
4766
|
action: "putIfUnchanged",
|
|
@@ -3818,7 +4807,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3818
4807
|
},
|
|
3819
4808
|
},
|
|
3820
4809
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
3821
|
-
]);
|
|
4810
|
+
], attachmentInputs, descriptors);
|
|
3822
4811
|
if (outcome.committed) {
|
|
3823
4812
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
3824
4813
|
}
|
|
@@ -3838,12 +4827,18 @@ export function createSupportDeskApplication(options) {
|
|
|
3838
4827
|
requireIdentifier(command.commandId, "commandId");
|
|
3839
4828
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3840
4829
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
4830
|
+
const attachmentInputs = command.attachments ?? [];
|
|
4831
|
+
const descriptors = await attachmentDescriptors(attachmentInputs);
|
|
4832
|
+
const attachmentFingerprint = descriptors.length
|
|
4833
|
+
? { attachments: descriptors }
|
|
4834
|
+
: {};
|
|
3841
4835
|
const fingerprint = await requestFingerprint({
|
|
3842
4836
|
...snapshotRevisionHint(command),
|
|
3843
4837
|
type: "add_note",
|
|
3844
4838
|
ticketId: command.ticketId,
|
|
3845
4839
|
messageId: command.messageId,
|
|
3846
4840
|
body: command.body,
|
|
4841
|
+
...attachmentFingerprint,
|
|
3847
4842
|
});
|
|
3848
4843
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
3849
4844
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
@@ -3859,8 +4854,8 @@ export function createSupportDeskApplication(options) {
|
|
|
3859
4854
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3860
4855
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
3861
4856
|
const partitionRows = await records.list(command.ticketId);
|
|
3862
|
-
enforcePartitionRecordLimit(partitionRows
|
|
3863
|
-
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
|
|
4857
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 3);
|
|
4858
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3 + attachmentInputs.length * 6, true);
|
|
3864
4859
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
3865
4860
|
if (quota?.value.kind !== "ticket_quota") {
|
|
3866
4861
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -3892,7 +4887,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3892
4887
|
body: command.body,
|
|
3893
4888
|
createdAt: now,
|
|
3894
4889
|
};
|
|
3895
|
-
const outcome = await
|
|
4890
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
3896
4891
|
{
|
|
3897
4892
|
action: "putIfUnchanged",
|
|
3898
4893
|
version: versioned.version,
|
|
@@ -3939,7 +4934,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3939
4934
|
resultTicket: updated,
|
|
3940
4935
|
},
|
|
3941
4936
|
},
|
|
3942
|
-
]);
|
|
4937
|
+
], attachmentInputs, descriptors);
|
|
3943
4938
|
if (outcome.committed) {
|
|
3944
4939
|
return staffCommandResult(command.ticketId, command.commandId);
|
|
3945
4940
|
}
|
|
@@ -3994,7 +4989,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3994
4989
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3995
4990
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
3996
4991
|
const partitionRows = await records.list(command.ticketId);
|
|
3997
|
-
enforcePartitionRecordLimit(partitionRows
|
|
4992
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords);
|
|
3998
4993
|
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
|
|
3999
4994
|
let quotaVersion = null;
|
|
4000
4995
|
if (command.messageId !== undefined) {
|
|
@@ -4143,7 +5138,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4143
5138
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4144
5139
|
return staffCommandResult(command.ticketId, command.commandId);
|
|
4145
5140
|
const partitionRows = await records.list(command.ticketId);
|
|
4146
|
-
enforcePartitionRecordLimit(partitionRows
|
|
5141
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 2);
|
|
4147
5142
|
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
|
|
4148
5143
|
const statusBefore = versioned.value.ticket.status;
|
|
4149
5144
|
const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
|
|
@@ -4234,7 +5229,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4234
5229
|
if (command.tags.some((tag) => !allowedTags.has(tag)))
|
|
4235
5230
|
throw new TypeError("tags must be on the host allowlist");
|
|
4236
5231
|
const rows = await records.list(command.ticketId);
|
|
4237
|
-
enforcePartitionRecordLimit(rows
|
|
5232
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, 2);
|
|
4238
5233
|
enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
|
|
4239
5234
|
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
4240
5235
|
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
@@ -4374,7 +5369,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4374
5369
|
occurredAt: now,
|
|
4375
5370
|
});
|
|
4376
5371
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
|
|
4377
|
-
enforcePartitionRecordLimit(partitionRows
|
|
5372
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
|
|
4378
5373
|
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
|
|
4379
5374
|
// Optional system message + mail commit with the lifecycle change so a
|
|
4380
5375
|
// resolution notification cannot be lost after the ticket is resolved.
|
|
@@ -5868,7 +6863,7 @@ export function createInboundMailProcessor(options) {
|
|
|
5868
6863
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
5869
6864
|
}
|
|
5870
6865
|
const partitionRows = await records.list(input.ticketId);
|
|
5871
|
-
enforcePartitionRecordLimit(partitionRows
|
|
6866
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 1);
|
|
5872
6867
|
enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
|
|
5873
6868
|
const outcome = await records.transact(input.ticketId, [
|
|
5874
6869
|
{
|