@pegma/support-desk-application 0.2.4 → 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 +10 -7
- 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/index.d.ts +80 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +855 -41
- 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 +29 -2
- 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";
|
|
@@ -20,6 +22,109 @@ import { engineeringFollowupNow, engineeringFollowupTrusted, engineeringFollowup
|
|
|
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"),
|
|
@@ -872,6 +1077,7 @@ function snapshotCustomerTicketAdmission(input) {
|
|
|
872
1077
|
"body",
|
|
873
1078
|
"category",
|
|
874
1079
|
"requesterEmail",
|
|
1080
|
+
"attachments",
|
|
875
1081
|
"preference",
|
|
876
1082
|
"disclosureVersion",
|
|
877
1083
|
];
|
|
@@ -934,6 +1140,7 @@ function snapshotReplyToCustomerTicketCommand(input) {
|
|
|
934
1140
|
? undefined
|
|
935
1141
|
: snapshotNotification(raw.notification);
|
|
936
1142
|
return Object.freeze({
|
|
1143
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
937
1144
|
commandId: boundaryString(raw.commandId, "reply command.commandId"),
|
|
938
1145
|
correlationId: boundaryString(raw.correlationId, "reply command.correlationId"),
|
|
939
1146
|
ticketId: boundaryString(raw.ticketId, "reply command.ticketId"),
|
|
@@ -997,8 +1204,24 @@ function toCustomerMessage(message) {
|
|
|
997
1204
|
format: message.format,
|
|
998
1205
|
body: message.body,
|
|
999
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
|
+
}),
|
|
1000
1219
|
});
|
|
1001
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
|
+
}
|
|
1002
1225
|
function customerMessages(records) {
|
|
1003
1226
|
const messages = records.filter((record) => record.kind === "message" && record.message.visibility === "customer");
|
|
1004
1227
|
const ordinals = new Set();
|
|
@@ -1082,6 +1305,7 @@ function parseAllowedCategories(input, name = "application options.allowedCatego
|
|
|
1082
1305
|
}
|
|
1083
1306
|
function duplicateMatches(existing, commandType, messageId, requestFingerprint) {
|
|
1084
1307
|
return (existing?.kind === "command" &&
|
|
1308
|
+
existing.attachmentDeletion?.phase !== "pending" &&
|
|
1085
1309
|
existing.commandType === commandType &&
|
|
1086
1310
|
existing.messageId === messageId &&
|
|
1087
1311
|
existing.requestFingerprint === requestFingerprint);
|
|
@@ -1211,7 +1435,11 @@ function derivationReserveRows(rows, consuming = false) {
|
|
|
1211
1435
|
? row.terminalReserveRows
|
|
1212
1436
|
: 0), 0);
|
|
1213
1437
|
const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
|
|
1214
|
-
|
|
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;
|
|
1215
1443
|
}
|
|
1216
1444
|
/** Reserve is opt-in metadata on the existing quota, never a second counter. */
|
|
1217
1445
|
function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
|
|
@@ -1219,7 +1447,7 @@ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessa
|
|
|
1219
1447
|
const reserved = quota?.assistantManaged === true &&
|
|
1220
1448
|
ticket.firstStaffRespondedAt === undefined &&
|
|
1221
1449
|
!firstHumanReply;
|
|
1222
|
-
enforcePartitionRecordLimit(rows
|
|
1450
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
|
|
1223
1451
|
(reserved ? humanReserveRows(rows) : 0) -
|
|
1224
1452
|
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1225
1453
|
if (addsMessage &&
|
|
@@ -1241,7 +1469,7 @@ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, c
|
|
|
1241
1469
|
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1242
1470
|
const reserved = quota?.assistantManaged === true &&
|
|
1243
1471
|
ticket.firstStaffRespondedAt === undefined;
|
|
1244
|
-
enforcePartitionRecordLimit(rows
|
|
1472
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
|
|
1245
1473
|
(reserved ? firstHumanReplyReserveRows : 0) -
|
|
1246
1474
|
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1247
1475
|
if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
|
|
@@ -1352,6 +1580,7 @@ function snapshotStaffReplyCommand(input) {
|
|
|
1352
1580
|
? undefined
|
|
1353
1581
|
: snapshotNotification(raw.notification);
|
|
1354
1582
|
return Object.freeze({
|
|
1583
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
1355
1584
|
...snapshotRevisionHint(source),
|
|
1356
1585
|
commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
|
|
1357
1586
|
correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
|
|
@@ -1371,6 +1600,7 @@ function snapshotAddNoteCommand(input) {
|
|
|
1371
1600
|
body: ownDataProperty(source, "body", "add note command.body"),
|
|
1372
1601
|
};
|
|
1373
1602
|
return Object.freeze({
|
|
1603
|
+
attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
|
|
1374
1604
|
...snapshotRevisionHint(source),
|
|
1375
1605
|
commandId: boundaryString(raw.commandId, "add note command.commandId"),
|
|
1376
1606
|
correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
|
|
@@ -2091,6 +2321,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2091
2321
|
typeof rawMaxConflictAttempts !== "number") {
|
|
2092
2322
|
throw new TypeError("application options.maxConflictAttempts must be a number");
|
|
2093
2323
|
}
|
|
2324
|
+
const attachmentConfig = attachmentConfiguration(ownDataProperty(source, "attachments", "application options.attachments", true));
|
|
2094
2325
|
const records = store.collection(supportRecords);
|
|
2095
2326
|
const index = store.collection(customerTicketIndex);
|
|
2096
2327
|
const numbers = store.collection(ticketNumbers);
|
|
@@ -2381,6 +2612,466 @@ export function createSupportDeskApplication(options) {
|
|
|
2381
2612
|
async function reserveTicketNumber() {
|
|
2382
2613
|
return reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
2383
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
|
+
}
|
|
2384
3075
|
const resolutionLearning = createResolutionLearningServices({
|
|
2385
3076
|
records,
|
|
2386
3077
|
clock,
|
|
@@ -2389,14 +3080,15 @@ export function createSupportDeskApplication(options) {
|
|
|
2389
3080
|
maxConflictAttempts,
|
|
2390
3081
|
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2391
3082
|
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
|
|
2392
|
-
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
3083
|
+
audit: (command, ticket, actorId, now, action, eventId) => ticketAuditAction({
|
|
3084
|
+
...(eventId === undefined ? {} : { eventId }),
|
|
2393
3085
|
commandId: command.commandId,
|
|
2394
3086
|
correlationId: command.correlationId,
|
|
2395
3087
|
ticketId: ticket.id,
|
|
2396
3088
|
revision: ticket.revision,
|
|
2397
3089
|
actorId,
|
|
2398
3090
|
occurredAt: now,
|
|
2399
|
-
action
|
|
3091
|
+
action,
|
|
2400
3092
|
}),
|
|
2401
3093
|
});
|
|
2402
3094
|
const engineeringDependencies = {
|
|
@@ -2415,7 +3107,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2415
3107
|
if (current !== undefined)
|
|
2416
3108
|
enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
|
|
2417
3109
|
else
|
|
2418
|
-
enforcePartitionRecordLimit(rows
|
|
3110
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
|
|
2419
3111
|
},
|
|
2420
3112
|
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2421
3113
|
commandId: command.commandId,
|
|
@@ -2785,7 +3477,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2785
3477
|
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2786
3478
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2787
3479
|
const rows = await records.list(command.ticketId);
|
|
2788
|
-
enforcePartitionRecordLimit(rows
|
|
3480
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket);
|
|
2789
3481
|
const latest = latestVisibleRecord(rows);
|
|
2790
3482
|
if (operation === "assistant_reply" || operation === "assistant_escalate")
|
|
2791
3483
|
requireAssistantTurn(ticket, rows, command);
|
|
@@ -2856,7 +3548,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2856
3548
|
? await planMutation()
|
|
2857
3549
|
: readValue(await mutationRead);
|
|
2858
3550
|
const learningConfirmation = operation === "confirm_customer_success"
|
|
2859
|
-
? 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))
|
|
2860
3552
|
: { actions: [], additionalRows: 0 };
|
|
2861
3553
|
const newRows = (operation === "take_over" ? 2 : 3) +
|
|
2862
3554
|
learningMutation.additionalRows +
|
|
@@ -3018,7 +3710,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3018
3710
|
conversation(ticket, rows) {
|
|
3019
3711
|
return {
|
|
3020
3712
|
ticket: toCustomerTicketSummary(ticket),
|
|
3021
|
-
messages:
|
|
3713
|
+
messages: assistantConversationMessages(rows),
|
|
3022
3714
|
revision: ticket.revision,
|
|
3023
3715
|
handling: { ...effectiveTicketHandling(ticket) },
|
|
3024
3716
|
...(latestVisibleRecord(rows) === undefined
|
|
@@ -3029,6 +3721,82 @@ export function createSupportDeskApplication(options) {
|
|
|
3029
3721
|
mutateHandling,
|
|
3030
3722
|
});
|
|
3031
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
|
+
},
|
|
3032
3800
|
...resolutionLearning.application,
|
|
3033
3801
|
...engineeringEvidence.application,
|
|
3034
3802
|
...engineeringWorkers,
|
|
@@ -3082,7 +3850,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3082
3850
|
const latest = latestVisibleRecord(rows);
|
|
3083
3851
|
return {
|
|
3084
3852
|
ticket: toCustomerTicketSummary(ticket),
|
|
3085
|
-
messages:
|
|
3853
|
+
messages: assistantConversationMessages(rows),
|
|
3086
3854
|
revision: ticket.revision,
|
|
3087
3855
|
handling: { ...effectiveTicketHandling(ticket) },
|
|
3088
3856
|
...(latest?.message.authorKind === "customer"
|
|
@@ -3239,12 +4007,15 @@ export function createSupportDeskApplication(options) {
|
|
|
3239
4007
|
const inputLimits = admitted === undefined ? limits : supportDeskLimitMaximums;
|
|
3240
4008
|
enforceLimit(command.subject, "subject", inputLimits.maxSubjectCharacters);
|
|
3241
4009
|
enforceLimit(command.body, "body", inputLimits.maxMessageCharacters);
|
|
4010
|
+
const attachmentInputs = command.attachments ?? [];
|
|
4011
|
+
const descriptors = await attachmentDescriptors(attachmentInputs);
|
|
3242
4012
|
const fingerprintPayload = {
|
|
3243
4013
|
type: "create_customer_ticket",
|
|
3244
4014
|
ticketId: command.ticketId,
|
|
3245
4015
|
messageId: command.messageId,
|
|
3246
4016
|
subject: command.subject,
|
|
3247
4017
|
body: command.body,
|
|
4018
|
+
...(descriptors.length === 0 ? {} : { attachments: descriptors }),
|
|
3248
4019
|
// Omit absent category so pre-category receipts still match retries.
|
|
3249
4020
|
...(command.category === undefined
|
|
3250
4021
|
? {}
|
|
@@ -3283,6 +4054,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3283
4054
|
let createIsReplay = duplicateMatches(existingCreate, commandType, command.messageId, fingerprint);
|
|
3284
4055
|
if (admitted === undefined &&
|
|
3285
4056
|
!createIsReplay &&
|
|
4057
|
+
descriptors.length === 0 &&
|
|
3286
4058
|
command.initialHandling === undefined &&
|
|
3287
4059
|
existingCreate?.kind === "command" &&
|
|
3288
4060
|
existingCreate.commandType === "create_customer_ticket" &&
|
|
@@ -3328,8 +4100,10 @@ export function createSupportDeskApplication(options) {
|
|
|
3328
4100
|
authorizeAdmitted();
|
|
3329
4101
|
return { view, initialMode };
|
|
3330
4102
|
}
|
|
3331
|
-
if (
|
|
3332
|
-
throw new SupportDeskConflictError(
|
|
4103
|
+
if (existingCreate !== null)
|
|
4104
|
+
throw new SupportDeskConflictError(admitted === undefined
|
|
4105
|
+
? "Command ID reused with different input"
|
|
4106
|
+
: "Admitted create command changed");
|
|
3333
4107
|
if (initialHandling === "assistant" && !assistantHandlingEnabled)
|
|
3334
4108
|
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
3335
4109
|
if (command.category !== undefined) {
|
|
@@ -3361,6 +4135,9 @@ export function createSupportDeskApplication(options) {
|
|
|
3361
4135
|
}
|
|
3362
4136
|
}
|
|
3363
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);
|
|
3364
4141
|
// Fail closed before durable number/index reservation so a too-small
|
|
3365
4142
|
// partition budget cannot exhaust the principal ticket index with
|
|
3366
4143
|
// reserved-but-never-created tickets.
|
|
@@ -3372,6 +4149,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3372
4149
|
? 1
|
|
3373
4150
|
: 0);
|
|
3374
4151
|
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
|
|
4152
|
+
attachmentInputs.length * 6 +
|
|
3375
4153
|
(resolutionLearningOptions.enabled ? 2 : 0) +
|
|
3376
4154
|
(engineeringOptions.enabled ? 2 : 0) +
|
|
3377
4155
|
(initialHandling === "assistant"
|
|
@@ -3545,16 +4323,26 @@ export function createSupportDeskApplication(options) {
|
|
|
3545
4323
|
// Create starts an empty partition; fail closed if the first write would
|
|
3546
4324
|
// exceed the configured physical-record budget.
|
|
3547
4325
|
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
|
|
3548
|
-
if (
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
authorizeAdmitted();
|
|
3552
|
-
if (assistantAccess !== undefined)
|
|
3553
|
-
authorizeAdmitted(assistantAccess, true);
|
|
3554
|
-
if (typeof assertCurrent !== "function" || assertCurrent() !== true)
|
|
3555
|
-
throw new SupportDeskConflictError("Admitted create authority changed");
|
|
4326
|
+
if (attachmentInputs.length > 0) {
|
|
4327
|
+
const initialRows = actions.flatMap((action) => action.action === "insert" ? [action.value] : []);
|
|
4328
|
+
enforceManagedAdmission(ticket, initialRows, limits, attachmentInputs.length * 6);
|
|
3556
4329
|
}
|
|
3557
|
-
const
|
|
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);
|
|
3558
4346
|
if (!outcome.committed) {
|
|
3559
4347
|
const existing = await records.get(commandKey(command.ticketId, command.commandId));
|
|
3560
4348
|
if (!duplicateMatches(existing, commandType, command.messageId, fingerprint)) {
|
|
@@ -3581,11 +4369,17 @@ export function createSupportDeskApplication(options) {
|
|
|
3581
4369
|
requireIdentifier(command.commandId, "commandId");
|
|
3582
4370
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3583
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
|
+
: {};
|
|
3584
4377
|
const fingerprint = await requestFingerprint({
|
|
3585
4378
|
type: "reply_customer_ticket",
|
|
3586
4379
|
ticketId: command.ticketId,
|
|
3587
4380
|
messageId: command.messageId,
|
|
3588
4381
|
body: command.body,
|
|
4382
|
+
...attachmentFingerprint,
|
|
3589
4383
|
notification: stableNotification(command.notification),
|
|
3590
4384
|
});
|
|
3591
4385
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
@@ -3593,6 +4387,8 @@ export function createSupportDeskApplication(options) {
|
|
|
3593
4387
|
if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
3594
4388
|
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
3595
4389
|
}
|
|
4390
|
+
if (duplicate !== null)
|
|
4391
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
3596
4392
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
3597
4393
|
if (versioned?.value.kind !== "ticket" ||
|
|
3598
4394
|
versioned.value.ticket.requester.association !== "authenticated" ||
|
|
@@ -3634,8 +4430,12 @@ export function createSupportDeskApplication(options) {
|
|
|
3634
4430
|
});
|
|
3635
4431
|
}
|
|
3636
4432
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3637
|
-
enforcePartitionRecordLimit(partitionRows
|
|
3638
|
-
|
|
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);
|
|
3639
4439
|
const message = {
|
|
3640
4440
|
id: command.messageId,
|
|
3641
4441
|
ticketId: command.ticketId,
|
|
@@ -3662,7 +4462,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3662
4462
|
const notificationJob = command.notification === undefined
|
|
3663
4463
|
? undefined
|
|
3664
4464
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
3665
|
-
const outcome = await
|
|
4465
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
3666
4466
|
...learningMutation.actions,
|
|
3667
4467
|
{
|
|
3668
4468
|
action: "putIfUnchanged",
|
|
@@ -3711,7 +4511,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3711
4511
|
...(assistantIngress === undefined
|
|
3712
4512
|
? []
|
|
3713
4513
|
: [{ action: "insert", value: assistantIngress }]),
|
|
3714
|
-
]);
|
|
4514
|
+
], attachmentInputs, descriptors);
|
|
3715
4515
|
if (outcome.committed) {
|
|
3716
4516
|
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
3717
4517
|
}
|
|
@@ -3897,12 +4697,18 @@ export function createSupportDeskApplication(options) {
|
|
|
3897
4697
|
requireIdentifier(command.commandId, "commandId");
|
|
3898
4698
|
requireIdentifier(command.correlationId, "correlationId");
|
|
3899
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
|
+
: {};
|
|
3900
4705
|
const fingerprint = await requestFingerprint({
|
|
3901
4706
|
...snapshotRevisionHint(command),
|
|
3902
4707
|
type: "staff_reply",
|
|
3903
4708
|
ticketId: command.ticketId,
|
|
3904
4709
|
messageId: command.messageId,
|
|
3905
4710
|
body: command.body,
|
|
4711
|
+
...attachmentFingerprint,
|
|
3906
4712
|
notification: stableNotification(command.notification),
|
|
3907
4713
|
});
|
|
3908
4714
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
@@ -3933,10 +4739,12 @@ export function createSupportDeskApplication(options) {
|
|
|
3933
4739
|
occurredAt: now,
|
|
3934
4740
|
});
|
|
3935
4741
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3936
|
-
enforcePartitionRecordLimit(partitionRows
|
|
3937
|
-
learningMutation.additionalRows
|
|
4742
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
|
|
4743
|
+
learningMutation.additionalRows +
|
|
4744
|
+
attachmentInputs.length * 6);
|
|
3938
4745
|
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
|
|
3939
|
-
learningMutation.additionalRows
|
|
4746
|
+
learningMutation.additionalRows +
|
|
4747
|
+
attachmentInputs.length * 6, true, true);
|
|
3940
4748
|
const message = {
|
|
3941
4749
|
id: command.messageId,
|
|
3942
4750
|
ticketId: command.ticketId,
|
|
@@ -3952,7 +4760,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3952
4760
|
const notificationJob = command.notification === undefined
|
|
3953
4761
|
? undefined
|
|
3954
4762
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
3955
|
-
const outcome = await
|
|
4763
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
3956
4764
|
...learningMutation.actions,
|
|
3957
4765
|
{
|
|
3958
4766
|
action: "putIfUnchanged",
|
|
@@ -3999,7 +4807,7 @@ export function createSupportDeskApplication(options) {
|
|
|
3999
4807
|
},
|
|
4000
4808
|
},
|
|
4001
4809
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
4002
|
-
]);
|
|
4810
|
+
], attachmentInputs, descriptors);
|
|
4003
4811
|
if (outcome.committed) {
|
|
4004
4812
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
4005
4813
|
}
|
|
@@ -4019,12 +4827,18 @@ export function createSupportDeskApplication(options) {
|
|
|
4019
4827
|
requireIdentifier(command.commandId, "commandId");
|
|
4020
4828
|
requireIdentifier(command.correlationId, "correlationId");
|
|
4021
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
|
+
: {};
|
|
4022
4835
|
const fingerprint = await requestFingerprint({
|
|
4023
4836
|
...snapshotRevisionHint(command),
|
|
4024
4837
|
type: "add_note",
|
|
4025
4838
|
ticketId: command.ticketId,
|
|
4026
4839
|
messageId: command.messageId,
|
|
4027
4840
|
body: command.body,
|
|
4841
|
+
...attachmentFingerprint,
|
|
4028
4842
|
});
|
|
4029
4843
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
4030
4844
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
@@ -4040,8 +4854,8 @@ export function createSupportDeskApplication(options) {
|
|
|
4040
4854
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4041
4855
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
4042
4856
|
const partitionRows = await records.list(command.ticketId);
|
|
4043
|
-
enforcePartitionRecordLimit(partitionRows
|
|
4044
|
-
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);
|
|
4045
4859
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
4046
4860
|
if (quota?.value.kind !== "ticket_quota") {
|
|
4047
4861
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -4073,7 +4887,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4073
4887
|
body: command.body,
|
|
4074
4888
|
createdAt: now,
|
|
4075
4889
|
};
|
|
4076
|
-
const outcome = await
|
|
4890
|
+
const outcome = await transactWithAttachments(command.ticketId, [
|
|
4077
4891
|
{
|
|
4078
4892
|
action: "putIfUnchanged",
|
|
4079
4893
|
version: versioned.version,
|
|
@@ -4120,7 +4934,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4120
4934
|
resultTicket: updated,
|
|
4121
4935
|
},
|
|
4122
4936
|
},
|
|
4123
|
-
]);
|
|
4937
|
+
], attachmentInputs, descriptors);
|
|
4124
4938
|
if (outcome.committed) {
|
|
4125
4939
|
return staffCommandResult(command.ticketId, command.commandId);
|
|
4126
4940
|
}
|
|
@@ -4175,7 +4989,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4175
4989
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4176
4990
|
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
4177
4991
|
const partitionRows = await records.list(command.ticketId);
|
|
4178
|
-
enforcePartitionRecordLimit(partitionRows
|
|
4992
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords);
|
|
4179
4993
|
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
|
|
4180
4994
|
let quotaVersion = null;
|
|
4181
4995
|
if (command.messageId !== undefined) {
|
|
@@ -4324,7 +5138,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4324
5138
|
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4325
5139
|
return staffCommandResult(command.ticketId, command.commandId);
|
|
4326
5140
|
const partitionRows = await records.list(command.ticketId);
|
|
4327
|
-
enforcePartitionRecordLimit(partitionRows
|
|
5141
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 2);
|
|
4328
5142
|
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
|
|
4329
5143
|
const statusBefore = versioned.value.ticket.status;
|
|
4330
5144
|
const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
|
|
@@ -4415,7 +5229,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4415
5229
|
if (command.tags.some((tag) => !allowedTags.has(tag)))
|
|
4416
5230
|
throw new TypeError("tags must be on the host allowlist");
|
|
4417
5231
|
const rows = await records.list(command.ticketId);
|
|
4418
|
-
enforcePartitionRecordLimit(rows
|
|
5232
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, 2);
|
|
4419
5233
|
enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
|
|
4420
5234
|
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
4421
5235
|
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
@@ -4555,7 +5369,7 @@ export function createSupportDeskApplication(options) {
|
|
|
4555
5369
|
occurredAt: now,
|
|
4556
5370
|
});
|
|
4557
5371
|
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
|
|
4558
|
-
enforcePartitionRecordLimit(partitionRows
|
|
5372
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
|
|
4559
5373
|
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
|
|
4560
5374
|
// Optional system message + mail commit with the lifecycle change so a
|
|
4561
5375
|
// resolution notification cannot be lost after the ticket is resolved.
|
|
@@ -6049,7 +6863,7 @@ export function createInboundMailProcessor(options) {
|
|
|
6049
6863
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
6050
6864
|
}
|
|
6051
6865
|
const partitionRows = await records.list(input.ticketId);
|
|
6052
|
-
enforcePartitionRecordLimit(partitionRows
|
|
6866
|
+
enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 1);
|
|
6053
6867
|
enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
|
|
6054
6868
|
const outcome = await records.transact(input.ticketId, [
|
|
6055
6869
|
{
|