@pegma/support-desk-application 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -7
- package/dist/assistant-turn-capacity.d.ts +13 -0
- package/dist/assistant-turn-capacity.d.ts.map +1 -0
- package/dist/assistant-turn-capacity.js +111 -0
- package/dist/assistant-turn-capacity.js.map +1 -0
- package/dist/assistant-turn-records.d.ts +68 -0
- package/dist/assistant-turn-records.d.ts.map +1 -0
- package/dist/assistant-turn-records.js +418 -0
- package/dist/assistant-turn-records.js.map +1 -0
- package/dist/assistant-turn-types.d.ts +133 -0
- package/dist/assistant-turn-types.d.ts.map +1 -0
- package/dist/assistant-turn-types.js +2 -0
- package/dist/assistant-turn-types.js.map +1 -0
- package/dist/assistant-turns.d.ts +42 -0
- package/dist/assistant-turns.d.ts.map +1 -0
- package/dist/assistant-turns.js +660 -0
- package/dist/assistant-turns.js.map +1 -0
- package/dist/engineering-evidence.d.ts +20 -0
- package/dist/engineering-evidence.d.ts.map +1 -0
- package/dist/engineering-evidence.js +899 -0
- package/dist/engineering-evidence.js.map +1 -0
- package/dist/engineering-followup-internal.d.ts +45 -0
- package/dist/engineering-followup-internal.d.ts.map +1 -0
- package/dist/engineering-followup-internal.js +2 -0
- package/dist/engineering-followup-internal.js.map +1 -0
- package/dist/engineering-followup-records.d.ts +209 -0
- package/dist/engineering-followup-records.d.ts.map +1 -0
- package/dist/engineering-followup-records.js +1434 -0
- package/dist/engineering-followup-records.js.map +1 -0
- package/dist/engineering-followup-source.d.ts +168 -0
- package/dist/engineering-followup-source.d.ts.map +1 -0
- package/dist/engineering-followup-source.js +1144 -0
- package/dist/engineering-followup-source.js.map +1 -0
- package/dist/engineering-followup-types.d.ts +364 -0
- package/dist/engineering-followup-types.d.ts.map +1 -0
- package/dist/engineering-followup-types.js +2 -0
- package/dist/engineering-followup-types.js.map +1 -0
- package/dist/engineering-followup-work.d.ts +70 -0
- package/dist/engineering-followup-work.d.ts.map +1 -0
- package/dist/engineering-followup-work.js +1153 -0
- package/dist/engineering-followup-work.js.map +1 -0
- package/dist/engineering-internal.d.ts +38 -0
- package/dist/engineering-internal.d.ts.map +1 -0
- package/dist/engineering-internal.js +11 -0
- package/dist/engineering-internal.js.map +1 -0
- package/dist/engineering-policy.d.ts +47 -0
- package/dist/engineering-policy.d.ts.map +1 -0
- package/dist/engineering-policy.js +579 -0
- package/dist/engineering-policy.js.map +1 -0
- package/dist/engineering-reconciliation.d.ts +6 -0
- package/dist/engineering-reconciliation.d.ts.map +1 -0
- package/dist/engineering-reconciliation.js +464 -0
- package/dist/engineering-reconciliation.js.map +1 -0
- package/dist/engineering-records.d.ts +227 -0
- package/dist/engineering-records.d.ts.map +1 -0
- package/dist/engineering-records.js +1272 -0
- package/dist/engineering-records.js.map +1 -0
- package/dist/engineering-types.d.ts +505 -0
- package/dist/engineering-types.d.ts.map +1 -0
- package/dist/engineering-types.js +2 -0
- package/dist/engineering-types.js.map +1 -0
- package/dist/engineering-work.d.ts +32 -0
- package/dist/engineering-work.d.ts.map +1 -0
- package/dist/engineering-work.js +1757 -0
- package/dist/engineering-work.js.map +1 -0
- package/dist/index.d.ts +609 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3680 -233
- package/dist/index.js.map +1 -1
- package/dist/resolution-learning-records.d.ts +154 -0
- package/dist/resolution-learning-records.d.ts.map +1 -0
- package/dist/resolution-learning-records.js +657 -0
- package/dist/resolution-learning-records.js.map +1 -0
- package/dist/resolution-learning-types.d.ts +356 -0
- package/dist/resolution-learning-types.d.ts.map +1 -0
- package/dist/resolution-learning-types.js +2 -0
- package/dist/resolution-learning-types.js.map +1 -0
- package/dist/resolution-learning-worker.d.ts +21 -0
- package/dist/resolution-learning-worker.d.ts.map +1 -0
- package/dist/resolution-learning-worker.js +1027 -0
- package/dist/resolution-learning-worker.js.map +1 -0
- package/dist/resolution-learning.d.ts +38 -0
- package/dist/resolution-learning.d.ts.map +1 -0
- package/dist/resolution-learning.js +860 -0
- package/dist/resolution-learning.js.map +1 -0
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -2,8 +2,27 @@ import { auditRecordId, defineAudit } from "@pegma/audit";
|
|
|
2
2
|
import { hasPermission } from "@pegma/authorization-core";
|
|
3
3
|
import { defineMail, maxMailAttempts } from "@pegma/mail";
|
|
4
4
|
import { noopLogger } from "@pegma/spine";
|
|
5
|
-
import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
6
|
-
import { applyTicketEvent, createTicket } from "@pegma/support-desk-core";
|
|
5
|
+
import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
6
|
+
import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
|
|
7
|
+
import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
|
|
8
|
+
import { createAssistantTurnServices, } from "./assistant-turns.js";
|
|
9
|
+
import { createEngineeringEvidenceServices, planEngineeringTicketMutation, } from "./engineering-evidence.js";
|
|
10
|
+
import { assertEngineeringRecord, createEngineeringSource, resolveEngineeringOptions, } from "./engineering-records.js";
|
|
11
|
+
import { createEngineeringPolicyServices } from "./engineering-policy.js";
|
|
12
|
+
import { createEngineeringWorkerServices } from "./engineering-work.js";
|
|
13
|
+
import { createEngineeringReconciliationServices } from "./engineering-reconciliation.js";
|
|
14
|
+
import { assertEngineeringFollowupRecord, resolveEngineeringFollowupOptions, } from "./engineering-followup-records.js";
|
|
15
|
+
import { createEngineeringFollowupSourceServices, planEngineeringFollowupSourceChange, planEngineeringFollowupTicketMutation, } from "./engineering-followup-source.js";
|
|
16
|
+
import { createEngineeringFollowupWorkServices } from "./engineering-followup-work.js";
|
|
17
|
+
import { fields as followupFields, integer as followupInteger, text as followupText, } from "./assistant-turn-records.js";
|
|
18
|
+
import { engineeringFollowupCommandKey, engineeringFollowupHeadKey, safeEngineeringFollowupSnapshot as followupSnapshot, checkEngineeringFollowupRecordCapacity, } from "./engineering-followup-records.js";
|
|
19
|
+
import { engineeringFollowupNow, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
|
|
20
|
+
import { assertResolutionLearningRecord, resolveResolutionLearningOptions, } from "./resolution-learning-records.js";
|
|
21
|
+
import { createResolutionLearningServices, createResolutionLearningSource, planResolutionLearningTicketMutation, } from "./resolution-learning.js";
|
|
22
|
+
export { resolutionLearningDeliveryBinding, resolutionLearningManifestHash, } from "./resolution-learning-worker.js";
|
|
23
|
+
export { engineeringFollowupModelManifestHash, engineeringFollowupContextHash, engineeringFollowupPublicFactsHash, engineeringFollowupChecksBinding, engineeringFollowupRetestRequestId, engineeringFollowupRetestProductBinding, engineeringFollowupRetestRequestHash, engineeringFollowupRetestSummary, engineeringFollowupModelAttemptCount, } from "./engineering-followup-records.js";
|
|
24
|
+
export { engineeringPolicyRecords, engineeringPolicyAudit, engineeringPolicyAuditActions, createEngineeringPolicyServices, } from "./engineering-policy.js";
|
|
25
|
+
export { engineeringModelManifestHash, engineeringRoutingBinding, engineeringExportChecksBinding, engineeringRoutingBindingHash, engineeringStaffSelectionHash, } from "./engineering-work.js";
|
|
7
26
|
export const supportPermissions = Object.freeze({
|
|
8
27
|
create: "support.ticket.create",
|
|
9
28
|
readOwn: "support.ticket.read.own",
|
|
@@ -14,6 +33,32 @@ export const supportPermissions = Object.freeze({
|
|
|
14
33
|
assign: "support.ticket.assign",
|
|
15
34
|
manage: "support.ticket.manage",
|
|
16
35
|
auditRead: "support.audit.read",
|
|
36
|
+
assistantStart: "support.ticket.assistant.start",
|
|
37
|
+
assistantRead: "support.ticket.assistant.read",
|
|
38
|
+
assistantReply: "support.ticket.assistant.reply",
|
|
39
|
+
assistantEscalate: "support.ticket.assistant.escalate",
|
|
40
|
+
requestPerson: "support.ticket.request_person.own",
|
|
41
|
+
takeOver: "support.ticket.take_over",
|
|
42
|
+
confirmSuccess: "support.ticket.confirm_success.own",
|
|
43
|
+
learningRead: "support.learning.read",
|
|
44
|
+
learningEvidenceSubmit: "support.learning.evidence.submit",
|
|
45
|
+
learningProcess: "support.learning.process",
|
|
46
|
+
learningReview: "support.learning.review",
|
|
47
|
+
learningWithdraw: "support.learning.withdraw",
|
|
48
|
+
engineeringRead: "support.engineering.read",
|
|
49
|
+
engineeringEvidenceSubmit: "support.engineering.evidence.submit",
|
|
50
|
+
engineeringProcess: "support.engineering.process",
|
|
51
|
+
engineeringReview: "support.engineering.review",
|
|
52
|
+
engineeringWithdraw: "support.engineering.withdraw",
|
|
53
|
+
engineeringRestrictedRead: "support.engineering.restricted.read",
|
|
54
|
+
engineeringSecurityReview: "support.engineering.security.review",
|
|
55
|
+
engineeringReconcile: "support.engineering.reconcile",
|
|
56
|
+
engineeringPolicyRead: "support.engineering.policy.read",
|
|
57
|
+
engineeringPolicyManage: "support.engineering.policy.manage",
|
|
58
|
+
engineeringFollowupRead: "support.engineering.followup.read",
|
|
59
|
+
engineeringFollowupProcess: "support.engineering.followup.process",
|
|
60
|
+
engineeringFollowupReview: "support.engineering.followup.review",
|
|
61
|
+
engineeringFollowupEvidenceSubmit: "support.engineering.followup.evidence.submit",
|
|
17
62
|
});
|
|
18
63
|
export const defaultSupportDeskLimits = Object.freeze({
|
|
19
64
|
maxSubjectCharacters: 200,
|
|
@@ -42,6 +87,17 @@ export class SupportDeskConflictError extends Error {
|
|
|
42
87
|
this.name = "SupportDeskConflictError";
|
|
43
88
|
}
|
|
44
89
|
}
|
|
90
|
+
/** Content-safe collision hint, available only after staff authorization. */
|
|
91
|
+
export class SupportDeskStaleRevisionError extends SupportDeskConflictError {
|
|
92
|
+
currentRevision;
|
|
93
|
+
updatedAt;
|
|
94
|
+
constructor(currentRevision, updatedAt) {
|
|
95
|
+
super("The ticket changed since it was loaded");
|
|
96
|
+
this.currentRevision = currentRevision;
|
|
97
|
+
this.updatedAt = updatedAt;
|
|
98
|
+
this.name = "SupportDeskStaleRevisionError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
45
101
|
export class SupportDeskLimitError extends Error {
|
|
46
102
|
field;
|
|
47
103
|
maximum;
|
|
@@ -79,10 +135,65 @@ export const supportTicketAuditActions = Object.freeze({
|
|
|
79
135
|
assigned: "support.ticket.assigned",
|
|
80
136
|
unassigned: "support.ticket.unassigned",
|
|
81
137
|
priorityChanged: "support.ticket.priority_changed",
|
|
138
|
+
tagsChanged: "support.ticket.tags_changed",
|
|
82
139
|
resolved: "support.ticket.resolved",
|
|
83
140
|
closed: "support.ticket.closed",
|
|
84
141
|
reopened: "support.ticket.reopened",
|
|
142
|
+
assistantReplied: "support.ticket.assistant_replied",
|
|
143
|
+
humanRequested: "support.ticket.human_requested",
|
|
144
|
+
humanEscalated: "support.ticket.human_escalated",
|
|
145
|
+
humanTakenOver: "support.ticket.human_taken_over",
|
|
146
|
+
customerSuccessConfirmed: "support.ticket.customer_success_confirmed",
|
|
147
|
+
learningEnrolled: "support.learning.enrolled",
|
|
148
|
+
learningEvidenceRecorded: "support.learning.evidence_recorded",
|
|
149
|
+
learningEvidenceInvalidated: "support.learning.evidence_invalidated",
|
|
150
|
+
learningWithdrawn: "support.learning.withdrawn",
|
|
151
|
+
learningReviewed: "support.learning.reviewed",
|
|
152
|
+
engineeringEnrolled: "support.engineering.enrolled",
|
|
153
|
+
engineeringEvidenceRecorded: "support.engineering.evidence_recorded",
|
|
154
|
+
engineeringEvidenceInvalidated: "support.engineering.evidence_invalidated",
|
|
155
|
+
engineeringWithdrawn: "support.engineering.withdrawn",
|
|
156
|
+
engineeringRepositorySelected: "support.engineering.repository_selected",
|
|
157
|
+
engineeringProposalPrepared: "support.engineering.proposal_prepared",
|
|
158
|
+
engineeringReviewed: "support.engineering.reviewed",
|
|
159
|
+
engineeringDispatchStarted: "support.engineering.dispatch_started",
|
|
160
|
+
engineeringExportRecorded: "support.engineering.export_recorded",
|
|
161
|
+
engineeringReconciled: "support.engineering.reconciled",
|
|
162
|
+
engineeringFollowupObserved: "support.engineering.followup.observed",
|
|
163
|
+
engineeringFollowupBundlePrepared: "support.engineering.followup.bundle_prepared",
|
|
164
|
+
engineeringFollowupReassessed: "support.engineering.followup.reassessed",
|
|
165
|
+
engineeringFollowupRetried: "support.engineering.followup.retried",
|
|
166
|
+
engineeringFollowupAbandoned: "support.engineering.followup.abandoned",
|
|
167
|
+
engineeringFollowupRejected: "support.engineering.followup.rejected",
|
|
168
|
+
engineeringFollowupNoticePosted: "support.engineering.followup.notice_posted",
|
|
169
|
+
engineeringFollowupRetestRecorded: "support.engineering.followup.retest_recorded",
|
|
85
170
|
});
|
|
171
|
+
function sameInboundReceiptRoute(left, right) {
|
|
172
|
+
if (left?.kind !== right.kind) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
switch (right.kind) {
|
|
176
|
+
case "suppressed":
|
|
177
|
+
return left.kind === "suppressed" && left.reason === right.reason;
|
|
178
|
+
case "duplicate_message":
|
|
179
|
+
return (left.kind === "duplicate_message" &&
|
|
180
|
+
left.ticketId === right.ticketId &&
|
|
181
|
+
left.messageId === right.messageId);
|
|
182
|
+
case "reply":
|
|
183
|
+
return (left.kind === "reply" &&
|
|
184
|
+
left.ticketId === right.ticketId &&
|
|
185
|
+
left.matchMethod === right.matchMethod);
|
|
186
|
+
case "new_conversation":
|
|
187
|
+
return (left.kind === "new_conversation" &&
|
|
188
|
+
left.ticketId === right.ticketId &&
|
|
189
|
+
left.matchMethod === right.matchMethod &&
|
|
190
|
+
left.association === right.association &&
|
|
191
|
+
left.principalId === right.principalId &&
|
|
192
|
+
left.priority === right.priority &&
|
|
193
|
+
left.category === right.category &&
|
|
194
|
+
left.relatedTicketId === right.relatedTicketId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
86
197
|
export const deliveryCallbackDedupeDays = 30;
|
|
87
198
|
export const maxDeliveryCallbacksPerBucket = 256;
|
|
88
199
|
export const inboundReceiptDedupeDays = 30;
|
|
@@ -97,6 +208,36 @@ function jsonCodec(key) {
|
|
|
97
208
|
return {
|
|
98
209
|
encode(value) {
|
|
99
210
|
const location = key(value);
|
|
211
|
+
if ([
|
|
212
|
+
"engineering_followup_head",
|
|
213
|
+
"engineering_followup_work",
|
|
214
|
+
"engineering_followup_receipt",
|
|
215
|
+
].includes(value.kind ?? "") ||
|
|
216
|
+
location.id === "engineering-followup-head" ||
|
|
217
|
+
location.id.startsWith("engineering-followup:") ||
|
|
218
|
+
location.id.startsWith("engineering-followup-command:"))
|
|
219
|
+
assertEngineeringFollowupRecord(value, location);
|
|
220
|
+
if ([
|
|
221
|
+
"engineering_source",
|
|
222
|
+
"engineering_work",
|
|
223
|
+
"engineering_receipt",
|
|
224
|
+
].includes(value.kind ?? "") ||
|
|
225
|
+
location.id === "engineering-source" ||
|
|
226
|
+
location.id.startsWith("engineering-work:") ||
|
|
227
|
+
location.id.startsWith("engineering-command:"))
|
|
228
|
+
assertEngineeringRecord(value, location);
|
|
229
|
+
if ([
|
|
230
|
+
"resolution_learning_source",
|
|
231
|
+
"resolution_learning_work",
|
|
232
|
+
"resolution_learning_review",
|
|
233
|
+
].includes(value.kind ?? "") ||
|
|
234
|
+
location.id === "learning-source" ||
|
|
235
|
+
location.id.startsWith("resolution-learning:") ||
|
|
236
|
+
location.id.startsWith("learning-review:"))
|
|
237
|
+
assertResolutionLearningRecord(value, location);
|
|
238
|
+
if (value.kind === "assistant_turn" ||
|
|
239
|
+
location.id.startsWith("assistant-turn:"))
|
|
240
|
+
assertAssistantTurnRecord(value, location);
|
|
100
241
|
return {
|
|
101
242
|
partition: location.partition,
|
|
102
243
|
id: location.id,
|
|
@@ -107,7 +248,70 @@ function jsonCodec(key) {
|
|
|
107
248
|
if (typeof record.payload !== "string") {
|
|
108
249
|
throw new TypeError("stored Support Desk record has no JSON payload");
|
|
109
250
|
}
|
|
110
|
-
|
|
251
|
+
const decoded = JSON.parse(record.payload);
|
|
252
|
+
if ([
|
|
253
|
+
"engineering_followup_head",
|
|
254
|
+
"engineering_followup_work",
|
|
255
|
+
"engineering_followup_receipt",
|
|
256
|
+
].includes(decoded?.kind ?? "") ||
|
|
257
|
+
record.id === "engineering-followup-head" ||
|
|
258
|
+
(typeof record.id === "string" &&
|
|
259
|
+
(record.id.startsWith("engineering-followup:") ||
|
|
260
|
+
record.id.startsWith("engineering-followup-command:")))) {
|
|
261
|
+
if (typeof record.partition !== "string" ||
|
|
262
|
+
typeof record.id !== "string")
|
|
263
|
+
throw new TypeError("Follow-up record has invalid physical envelope");
|
|
264
|
+
assertEngineeringFollowupRecord(decoded, {
|
|
265
|
+
partition: record.partition,
|
|
266
|
+
id: record.id,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if ([
|
|
270
|
+
"engineering_source",
|
|
271
|
+
"engineering_work",
|
|
272
|
+
"engineering_receipt",
|
|
273
|
+
].includes(decoded?.kind ?? "") ||
|
|
274
|
+
record.id === "engineering-source" ||
|
|
275
|
+
(typeof record.id === "string" &&
|
|
276
|
+
(record.id.startsWith("engineering-work:") ||
|
|
277
|
+
record.id.startsWith("engineering-command:")))) {
|
|
278
|
+
if (typeof record.partition !== "string" ||
|
|
279
|
+
typeof record.id !== "string")
|
|
280
|
+
throw new TypeError("Engineering record has invalid physical envelope");
|
|
281
|
+
assertEngineeringRecord(decoded, {
|
|
282
|
+
partition: record.partition,
|
|
283
|
+
id: record.id,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if ([
|
|
287
|
+
"resolution_learning_source",
|
|
288
|
+
"resolution_learning_work",
|
|
289
|
+
"resolution_learning_review",
|
|
290
|
+
].includes(decoded?.kind ?? "") ||
|
|
291
|
+
record.id === "learning-source" ||
|
|
292
|
+
(typeof record.id === "string" &&
|
|
293
|
+
(record.id.startsWith("resolution-learning:") ||
|
|
294
|
+
record.id.startsWith("learning-review:")))) {
|
|
295
|
+
if (typeof record.partition !== "string" ||
|
|
296
|
+
typeof record.id !== "string")
|
|
297
|
+
throw new TypeError("Learning record has invalid physical envelope");
|
|
298
|
+
assertResolutionLearningRecord(decoded, {
|
|
299
|
+
partition: record.partition,
|
|
300
|
+
id: record.id,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (decoded?.kind === "assistant_turn" ||
|
|
304
|
+
(typeof record.id === "string" &&
|
|
305
|
+
record.id.startsWith("assistant-turn:"))) {
|
|
306
|
+
if (typeof record.partition !== "string" ||
|
|
307
|
+
typeof record.id !== "string")
|
|
308
|
+
throw new TypeError("stored assistant turn has no string partition/id envelope");
|
|
309
|
+
assertAssistantTurnRecord(decoded, {
|
|
310
|
+
partition: record.partition,
|
|
311
|
+
id: record.id,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return decoded;
|
|
111
315
|
},
|
|
112
316
|
};
|
|
113
317
|
}
|
|
@@ -280,6 +484,7 @@ export const defaultQueueScanBudgets = Object.freeze({
|
|
|
280
484
|
maxActiveResults: 1_000,
|
|
281
485
|
scanPageSize: 100,
|
|
282
486
|
});
|
|
487
|
+
const replyRoutingTokenPattern = /^[A-Za-z0-9_-]{22,128}$/;
|
|
283
488
|
export const ticketNumberPartition = "instance";
|
|
284
489
|
export const ticketNumberRecordId = "ticket-number";
|
|
285
490
|
const maxNotificationVariables = 32;
|
|
@@ -341,9 +546,12 @@ function ticketReservationKey(ticketId) {
|
|
|
341
546
|
function commandKey(ticketId, commandId) {
|
|
342
547
|
return { partition: ticketId, id: `command:${commandId}` };
|
|
343
548
|
}
|
|
344
|
-
function ownDataProperty(source, key, field, optional = false) {
|
|
549
|
+
function ownDataProperty(source, key, field, optional = false, rejectInherited = false) {
|
|
345
550
|
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
|
346
551
|
if (descriptor === undefined) {
|
|
552
|
+
if (rejectInherited && Reflect.has(source, key)) {
|
|
553
|
+
throw new TypeError(`${field} must be an own data property`);
|
|
554
|
+
}
|
|
347
555
|
if (optional) {
|
|
348
556
|
return undefined;
|
|
349
557
|
}
|
|
@@ -414,6 +622,12 @@ function snapshotNotification(input) {
|
|
|
414
622
|
const subject = readString("subject");
|
|
415
623
|
const outboundMessageId = readString("outboundMessageId");
|
|
416
624
|
const maxAttempts = ownDataProperty(input, "maxAttempts", "notification.maxAttempts", true);
|
|
625
|
+
const replyRoutingToken = ownDataProperty(input, "replyRoutingToken", "notification.replyRoutingToken", true);
|
|
626
|
+
if (replyRoutingToken !== undefined &&
|
|
627
|
+
(typeof replyRoutingToken !== "string" ||
|
|
628
|
+
!replyRoutingTokenPattern.test(replyRoutingToken))) {
|
|
629
|
+
throw new TypeError("notification.replyRoutingToken must be 22 to 128 URL-safe base64 characters");
|
|
630
|
+
}
|
|
417
631
|
requireIdentifier(id, "notification.id");
|
|
418
632
|
requireIdentifier(recipientRef, "notification.recipientRef");
|
|
419
633
|
requireIdentifier(templateId, "notification.templateId");
|
|
@@ -444,6 +658,9 @@ function snapshotNotification(input) {
|
|
|
444
658
|
...(maxAttempts === undefined
|
|
445
659
|
? {}
|
|
446
660
|
: { maxAttempts: maxAttempts }),
|
|
661
|
+
...(replyRoutingToken === undefined
|
|
662
|
+
? {}
|
|
663
|
+
: { replyRoutingToken: replyRoutingToken }),
|
|
447
664
|
});
|
|
448
665
|
}
|
|
449
666
|
function boundaryObject(input, field) {
|
|
@@ -462,16 +679,16 @@ function boundaryString(value, field) {
|
|
|
462
679
|
const requesterEmailMaximumCharacters = 254;
|
|
463
680
|
const requesterEmailLocalPart = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
|
|
464
681
|
const requesterEmailDomain = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
465
|
-
function normalizeRequesterEmail(value) {
|
|
682
|
+
function normalizeRequesterEmail(value, field = "requesterEmail") {
|
|
466
683
|
if (value === undefined) {
|
|
467
684
|
return undefined;
|
|
468
685
|
}
|
|
469
686
|
if (typeof value !== "string") {
|
|
470
|
-
throw new TypeError(
|
|
687
|
+
throw new TypeError(`${field} must be a string`);
|
|
471
688
|
}
|
|
472
689
|
if (value.length > requesterEmailMaximumCharacters ||
|
|
473
690
|
/[\u0000-\u001F\u007F<>&"\\]/.test(value)) {
|
|
474
|
-
throw new TypeError(
|
|
691
|
+
throw new TypeError(`${field} must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
|
|
475
692
|
}
|
|
476
693
|
const normalized = value.trim();
|
|
477
694
|
const separator = normalized.indexOf("@");
|
|
@@ -488,7 +705,7 @@ function normalizeRequesterEmail(value) {
|
|
|
488
705
|
!requesterEmailLocalPart.test(local) ||
|
|
489
706
|
domain.length > 253 ||
|
|
490
707
|
!requesterEmailDomain.test(domain)) {
|
|
491
|
-
throw new TypeError(
|
|
708
|
+
throw new TypeError(`${field} must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
|
|
492
709
|
}
|
|
493
710
|
return `${local}@${domain.toLowerCase()}`;
|
|
494
711
|
}
|
|
@@ -504,6 +721,111 @@ function snapshotCategory(value, field) {
|
|
|
504
721
|
}
|
|
505
722
|
return value;
|
|
506
723
|
}
|
|
724
|
+
function normalizedTag(value) {
|
|
725
|
+
if (typeof value !== "string" ||
|
|
726
|
+
value.length > 64 ||
|
|
727
|
+
/[\u0000-\u001F\u007F]/.test(value)) {
|
|
728
|
+
throw new TypeError("tag must be a bounded safe identifier");
|
|
729
|
+
}
|
|
730
|
+
const tag = value.trim().toLowerCase();
|
|
731
|
+
if (!categoryPattern.test(tag))
|
|
732
|
+
throw new TypeError("tag must match the category identifier discipline");
|
|
733
|
+
return tag;
|
|
734
|
+
}
|
|
735
|
+
function snapshotTags(input) {
|
|
736
|
+
if (!Array.isArray(input) || input.length > 32) {
|
|
737
|
+
throw new TypeError("tags must be an array of at most 32 values");
|
|
738
|
+
}
|
|
739
|
+
const tags = new Set();
|
|
740
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
741
|
+
tags.add(normalizedTag(ownDataProperty(input, index, `tags[${index}]`)));
|
|
742
|
+
}
|
|
743
|
+
return Object.freeze([...tags].sort());
|
|
744
|
+
}
|
|
745
|
+
function snapshotRevisionHint(source) {
|
|
746
|
+
const value = ownDataProperty(source, "expectedRevision", "expectedRevision", true);
|
|
747
|
+
if (value === undefined)
|
|
748
|
+
return {};
|
|
749
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
750
|
+
throw new TypeError("expectedRevision must be a positive safe integer");
|
|
751
|
+
}
|
|
752
|
+
return { expectedRevision: value };
|
|
753
|
+
}
|
|
754
|
+
function requireExpectedRevision(ticket, command) {
|
|
755
|
+
if (command.expectedRevision !== undefined &&
|
|
756
|
+
ticket.revision !== command.expectedRevision) {
|
|
757
|
+
throw new SupportDeskStaleRevisionError(ticket.revision, ticket.updatedAt);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function parseResponseTargetPolicy(input) {
|
|
761
|
+
if (input === undefined)
|
|
762
|
+
return undefined;
|
|
763
|
+
const source = boundaryObject(input, "responseTargetPolicy");
|
|
764
|
+
const method = ownDataProperty(source, "computeTargets", "responseTargetPolicy.computeTargets");
|
|
765
|
+
if (typeof method !== "function")
|
|
766
|
+
throw new TypeError("responseTargetPolicy must provide computeTargets()");
|
|
767
|
+
return Object.freeze({
|
|
768
|
+
computeTargets: (context) => method.call(source, context),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function staffLifecycleFields(ticket) {
|
|
772
|
+
return {
|
|
773
|
+
...(ticket.tags === undefined
|
|
774
|
+
? {}
|
|
775
|
+
: { tags: Object.freeze([...ticket.tags]) }),
|
|
776
|
+
...(ticket.firstResponseDueAt === undefined
|
|
777
|
+
? {}
|
|
778
|
+
: { firstResponseDueAt: ticket.firstResponseDueAt }),
|
|
779
|
+
...(ticket.resolutionDueAt === undefined
|
|
780
|
+
? {}
|
|
781
|
+
: { resolutionDueAt: ticket.resolutionDueAt }),
|
|
782
|
+
...(ticket.firstStaffRespondedAt === undefined
|
|
783
|
+
? {}
|
|
784
|
+
: { firstStaffRespondedAt: ticket.firstStaffRespondedAt }),
|
|
785
|
+
...(ticket.resolvedAt === undefined
|
|
786
|
+
? {}
|
|
787
|
+
: { resolvedAt: ticket.resolvedAt }),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
async function applyResponseTargets(ticket, policy, reason) {
|
|
791
|
+
if (policy === undefined)
|
|
792
|
+
return ticket;
|
|
793
|
+
const context = Object.freeze({
|
|
794
|
+
reason,
|
|
795
|
+
ticketId: ticket.id,
|
|
796
|
+
revision: ticket.revision,
|
|
797
|
+
priority: ticket.priority,
|
|
798
|
+
channel: ticket.channel,
|
|
799
|
+
createdAt: ticket.createdAt,
|
|
800
|
+
occurredAt: ticket.updatedAt,
|
|
801
|
+
...(ticket.category === undefined ? {} : { category: ticket.category }),
|
|
802
|
+
...(ticket.firstResponseDueAt === undefined
|
|
803
|
+
? {}
|
|
804
|
+
: { firstResponseDueAt: ticket.firstResponseDueAt }),
|
|
805
|
+
...(ticket.resolutionDueAt === undefined
|
|
806
|
+
? {}
|
|
807
|
+
: { resolutionDueAt: ticket.resolutionDueAt }),
|
|
808
|
+
...(ticket.firstStaffRespondedAt === undefined
|
|
809
|
+
? {}
|
|
810
|
+
: { firstStaffRespondedAt: ticket.firstStaffRespondedAt }),
|
|
811
|
+
...(ticket.resolvedAt === undefined
|
|
812
|
+
? {}
|
|
813
|
+
: { resolvedAt: ticket.resolvedAt }),
|
|
814
|
+
});
|
|
815
|
+
const result = boundaryObject(await policy.computeTargets(context), "response targets");
|
|
816
|
+
const targets = {};
|
|
817
|
+
for (const field of ["firstResponseDueAt", "resolutionDueAt"]) {
|
|
818
|
+
const value = ownDataProperty(result, field, `response targets.${field}`, true);
|
|
819
|
+
if (value !== undefined) {
|
|
820
|
+
if (typeof value !== "string")
|
|
821
|
+
throw new TypeError("response target must be an ISO timestamp");
|
|
822
|
+
canonicalTimestamp(value, `response targets.${field}`);
|
|
823
|
+
targets[field] = value;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const { firstResponseDueAt: _first, resolutionDueAt: _resolution, ...rest } = ticket;
|
|
827
|
+
return Object.freeze({ ...rest, ...targets });
|
|
828
|
+
}
|
|
507
829
|
function snapshotCreateCustomerTicketCommand(input) {
|
|
508
830
|
const source = boundaryObject(input, "create command");
|
|
509
831
|
const raw = {
|
|
@@ -518,6 +840,9 @@ function snapshotCreateCustomerTicketCommand(input) {
|
|
|
518
840
|
notification: ownDataProperty(source, "notification", "create command.notification", true),
|
|
519
841
|
};
|
|
520
842
|
const category = snapshotCategory(raw.category, "create command.category");
|
|
843
|
+
const initialHandling = ownDataProperty(source, "initialHandling", "create command.initialHandling", true);
|
|
844
|
+
if (initialHandling !== undefined && initialHandling !== "assistant")
|
|
845
|
+
throw new TypeError("initialHandling must be assistant when supplied");
|
|
521
846
|
const requesterEmail = normalizeRequesterEmail(raw.requesterEmail);
|
|
522
847
|
const notification = raw.notification === undefined
|
|
523
848
|
? undefined
|
|
@@ -532,6 +857,7 @@ function snapshotCreateCustomerTicketCommand(input) {
|
|
|
532
857
|
...(category === undefined ? {} : { category }),
|
|
533
858
|
...(requesterEmail === undefined ? {} : { requesterEmail }),
|
|
534
859
|
...(notification === undefined ? {} : { notification }),
|
|
860
|
+
...(initialHandling === undefined ? {} : { initialHandling }),
|
|
535
861
|
});
|
|
536
862
|
}
|
|
537
863
|
function snapshotReplyToCustomerTicketCommand(input) {
|
|
@@ -577,6 +903,9 @@ function deliveryContent(input) {
|
|
|
577
903
|
variables: input.variables,
|
|
578
904
|
subject: input.subject,
|
|
579
905
|
outboundMessageId: input.outboundMessageId,
|
|
906
|
+
...(input.replyRoutingToken === undefined
|
|
907
|
+
? {}
|
|
908
|
+
: { replyRoutingToken: input.replyRoutingToken }),
|
|
580
909
|
};
|
|
581
910
|
}
|
|
582
911
|
function bindNotificationTicketNumber(input, ticketNumber) {
|
|
@@ -640,6 +969,9 @@ function toCustomerTicketSummary(ticket) {
|
|
|
640
969
|
channel: ticket.channel,
|
|
641
970
|
createdAt: ticket.createdAt,
|
|
642
971
|
customerUpdatedAt,
|
|
972
|
+
...(ticket.handling === undefined
|
|
973
|
+
? {}
|
|
974
|
+
: { handling: ticket.handling.state }),
|
|
643
975
|
});
|
|
644
976
|
}
|
|
645
977
|
async function authoritativeView(records, principalId, ticketId, maxRecordsPerTicket) {
|
|
@@ -661,19 +993,19 @@ async function authoritativeView(records, principalId, ticketId, maxRecordsPerTi
|
|
|
661
993
|
* Validate and copy host-supplied category allowlist into a private Set.
|
|
662
994
|
* The host array is not retained; the Set is application-owned state.
|
|
663
995
|
*/
|
|
664
|
-
function parseAllowedCategories(input) {
|
|
996
|
+
function parseAllowedCategories(input, name = "application options.allowedCategories") {
|
|
665
997
|
if (input === undefined) {
|
|
666
998
|
return undefined;
|
|
667
999
|
}
|
|
668
1000
|
if (!Array.isArray(input)) {
|
|
669
|
-
throw new TypeError(
|
|
1001
|
+
throw new TypeError(`${name} must be an array`);
|
|
670
1002
|
}
|
|
671
1003
|
if (input.length > maxAllowedCategories) {
|
|
672
|
-
throw new TypeError(
|
|
1004
|
+
throw new TypeError(`${name} may contain at most ${maxAllowedCategories} values`);
|
|
673
1005
|
}
|
|
674
1006
|
const values = new Set();
|
|
675
1007
|
for (let index = 0; index < input.length; index += 1) {
|
|
676
|
-
const field =
|
|
1008
|
+
const field = `${name}[${index}]`;
|
|
677
1009
|
const value = ownDataProperty(input, index, field);
|
|
678
1010
|
if (typeof value !== "string") {
|
|
679
1011
|
throw new TypeError(`${field} must be a string`);
|
|
@@ -682,7 +1014,7 @@ function parseAllowedCategories(input) {
|
|
|
682
1014
|
throw new TypeError(`${field} must match ${categoryPattern.source} and contain no controls`);
|
|
683
1015
|
}
|
|
684
1016
|
if (values.has(value)) {
|
|
685
|
-
throw new TypeError(
|
|
1017
|
+
throw new TypeError(`${name} must not contain duplicates`);
|
|
686
1018
|
}
|
|
687
1019
|
values.add(value);
|
|
688
1020
|
}
|
|
@@ -694,6 +1026,18 @@ function duplicateMatches(existing, commandType, messageId, requestFingerprint)
|
|
|
694
1026
|
existing.messageId === messageId &&
|
|
695
1027
|
existing.requestFingerprint === requestFingerprint);
|
|
696
1028
|
}
|
|
1029
|
+
function inboundReplyRerouteFenceMatches(existing, commandId, messageId, requestFingerprint) {
|
|
1030
|
+
return (existing?.kind === "inbound_reply_reroute_fence" &&
|
|
1031
|
+
existing.commandId === commandId &&
|
|
1032
|
+
existing.messageId === messageId &&
|
|
1033
|
+
existing.requestFingerprint === requestFingerprint);
|
|
1034
|
+
}
|
|
1035
|
+
function inboundCreateRerouteFenceMatches(existing, commandId, messageId, requestFingerprint) {
|
|
1036
|
+
return (existing?.kind === "inbound_create_reroute_fence" &&
|
|
1037
|
+
existing.commandId === commandId &&
|
|
1038
|
+
existing.messageId === messageId &&
|
|
1039
|
+
existing.requestFingerprint === requestFingerprint);
|
|
1040
|
+
}
|
|
697
1041
|
const TICKET_PRIORITIES = [
|
|
698
1042
|
"low",
|
|
699
1043
|
"normal",
|
|
@@ -721,6 +1065,197 @@ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
|
|
|
721
1065
|
throw new SupportDeskLimitError("ticket_partition", maximum);
|
|
722
1066
|
}
|
|
723
1067
|
}
|
|
1068
|
+
export const assistantHumanReserveRows = 12;
|
|
1069
|
+
export const assistantHumanReserveMessages = 1;
|
|
1070
|
+
const firstHumanReplyReserveRows = 4;
|
|
1071
|
+
/** Settle unused speculative failures without changing their consumer order. */
|
|
1072
|
+
async function prepareRead(read) {
|
|
1073
|
+
try {
|
|
1074
|
+
return { outcome: "read", value: await read() };
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
return { outcome: "failed", error };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function readValue(observation) {
|
|
1081
|
+
if (observation.outcome === "failed")
|
|
1082
|
+
throw observation.error;
|
|
1083
|
+
return observation.value;
|
|
1084
|
+
}
|
|
1085
|
+
async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice, parallel = false) {
|
|
1086
|
+
const learningRead = planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
|
|
1087
|
+
const engineeringRead = parallel && notice === undefined
|
|
1088
|
+
? prepareRead(() => planEngineeringTicketMutation(records, before, after, {
|
|
1089
|
+
visibleChange,
|
|
1090
|
+
}))
|
|
1091
|
+
: undefined;
|
|
1092
|
+
const learning = await learningRead;
|
|
1093
|
+
const engineering = notice === undefined
|
|
1094
|
+
? engineeringRead === undefined
|
|
1095
|
+
? await planEngineeringTicketMutation(records, before, after, {
|
|
1096
|
+
visibleChange,
|
|
1097
|
+
})
|
|
1098
|
+
: readValue(await engineeringRead)
|
|
1099
|
+
: { actions: [], additionalRows: 0 };
|
|
1100
|
+
const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
|
|
1101
|
+
!["resolved", "closed"].includes(after.status);
|
|
1102
|
+
const followup = visibleChange || reopened
|
|
1103
|
+
? await planEngineeringFollowupTicketMutation(records, rows, after.id, notice !== undefined
|
|
1104
|
+
? "approved_followup_notice"
|
|
1105
|
+
: reopened
|
|
1106
|
+
? "reopened"
|
|
1107
|
+
: "visible_source_changed", notice?.completedFollowupWorkId)
|
|
1108
|
+
: { actions: [], nextWorkRows: [] };
|
|
1109
|
+
return {
|
|
1110
|
+
...learning,
|
|
1111
|
+
actions: [...learning.actions, ...engineering.actions, ...followup.actions],
|
|
1112
|
+
additionalRows: learning.additionalRows + engineering.additionalRows,
|
|
1113
|
+
nextWorkRows: followup.nextWorkRows,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
/** Project reserved work transitions without inventing another quota counter. */
|
|
1117
|
+
function projectFollowupRows(rows, nextWorkRows) {
|
|
1118
|
+
if (nextWorkRows.length > 8)
|
|
1119
|
+
throw new SupportDeskConflictError("Follow-up transition bound exceeded");
|
|
1120
|
+
const updates = new Map(nextWorkRows.map((row) => [row.id, row]));
|
|
1121
|
+
if (updates.size !== nextWorkRows.length)
|
|
1122
|
+
throw new SupportDeskConflictError("Duplicate follow-up transition");
|
|
1123
|
+
const projected = rows.map((row) => {
|
|
1124
|
+
if (row.kind !== "engineering_followup_work")
|
|
1125
|
+
return row;
|
|
1126
|
+
const next = updates.get(row.id);
|
|
1127
|
+
updates.delete(row.id);
|
|
1128
|
+
return next ?? row;
|
|
1129
|
+
});
|
|
1130
|
+
return [...projected, ...updates.values()];
|
|
1131
|
+
}
|
|
1132
|
+
function followupMessageSlots(rows) {
|
|
1133
|
+
return rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.messageSlots : 0), 0);
|
|
1134
|
+
}
|
|
1135
|
+
function humanReserveRows(rows) {
|
|
1136
|
+
return (assistantHumanReserveRows +
|
|
1137
|
+
(rows.some((row) => row.kind === "resolution_learning_source") ? 1 : 0));
|
|
1138
|
+
}
|
|
1139
|
+
function derivationReserveRows(rows, consuming = false) {
|
|
1140
|
+
const learning = consuming !== "learning" &&
|
|
1141
|
+
rows.some((row) => row.kind === "resolution_learning_source" && row.state !== "withdrawn")
|
|
1142
|
+
? 2
|
|
1143
|
+
: 0;
|
|
1144
|
+
const engineering = consuming !== "engineering" &&
|
|
1145
|
+
rows.some((row) => row.kind === "engineering_source" && row.state !== "withdrawn")
|
|
1146
|
+
? 2
|
|
1147
|
+
: 0;
|
|
1148
|
+
const terminalWorkId = typeof consuming === "object" ? consuming.engineeringTerminalWorkId : null;
|
|
1149
|
+
const terminal = rows.reduce((sum, row) => sum +
|
|
1150
|
+
(row.kind === "engineering_work" && row.id !== terminalWorkId
|
|
1151
|
+
? row.terminalReserveRows
|
|
1152
|
+
: 0), 0);
|
|
1153
|
+
const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
|
|
1154
|
+
return learning + engineering + terminal + followup;
|
|
1155
|
+
}
|
|
1156
|
+
/** Reserve is opt-in metadata on the existing quota, never a second counter. */
|
|
1157
|
+
function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
|
|
1158
|
+
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1159
|
+
const reserved = quota?.assistantManaged === true &&
|
|
1160
|
+
ticket.firstStaffRespondedAt === undefined &&
|
|
1161
|
+
!firstHumanReply;
|
|
1162
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
|
|
1163
|
+
(reserved ? humanReserveRows(rows) : 0) -
|
|
1164
|
+
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1165
|
+
if (addsMessage &&
|
|
1166
|
+
quota !== undefined &&
|
|
1167
|
+
quota.messageCount >=
|
|
1168
|
+
limits.maxMessagesPerTicket -
|
|
1169
|
+
(reserved ? assistantHumanReserveMessages : 0) -
|
|
1170
|
+
followupMessageSlots(rows))
|
|
1171
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1172
|
+
}
|
|
1173
|
+
function latestVisibleRecord(rows) {
|
|
1174
|
+
// Reuse the ordinal validation that protects ordinary customer views.
|
|
1175
|
+
customerMessages(rows);
|
|
1176
|
+
return rows
|
|
1177
|
+
.filter((row) => row.kind === "message" && row.message.visibility === "customer")
|
|
1178
|
+
.sort((a, b) => b.ordinal - a.ordinal)[0];
|
|
1179
|
+
}
|
|
1180
|
+
function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, consumesWithdrawalReserve = false) {
|
|
1181
|
+
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1182
|
+
const reserved = quota?.assistantManaged === true &&
|
|
1183
|
+
ticket.firstStaffRespondedAt === undefined;
|
|
1184
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
|
|
1185
|
+
(reserved ? firstHumanReplyReserveRows : 0) -
|
|
1186
|
+
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1187
|
+
if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
|
|
1188
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1189
|
+
}
|
|
1190
|
+
function handlingInput(input, fields, optional = []) {
|
|
1191
|
+
const source = boundaryObject(input, "handling command");
|
|
1192
|
+
if (Object.getOwnPropertySymbols(source).length !== 0)
|
|
1193
|
+
throw new TypeError("Handling commands must contain data fields");
|
|
1194
|
+
const allowed = [
|
|
1195
|
+
"commandId",
|
|
1196
|
+
"correlationId",
|
|
1197
|
+
"ticketId",
|
|
1198
|
+
...fields,
|
|
1199
|
+
...optional,
|
|
1200
|
+
];
|
|
1201
|
+
if (Object.getOwnPropertyNames(source).some((key) => !allowed.includes(key)))
|
|
1202
|
+
throw new TypeError("Handling command contains unsupported fields");
|
|
1203
|
+
const result = {};
|
|
1204
|
+
for (const field of allowed) {
|
|
1205
|
+
const value = ownDataProperty(source, field, "handling command." + field, optional.includes(field));
|
|
1206
|
+
if (value !== undefined)
|
|
1207
|
+
result[field] = value;
|
|
1208
|
+
}
|
|
1209
|
+
for (const field of ["commandId", "correlationId", "ticketId"]) {
|
|
1210
|
+
const value = boundaryString(result[field], field);
|
|
1211
|
+
requireIdentifier(value, field);
|
|
1212
|
+
result[field] = value;
|
|
1213
|
+
}
|
|
1214
|
+
return result;
|
|
1215
|
+
}
|
|
1216
|
+
function handlingFence(input) {
|
|
1217
|
+
for (const field of ["expectedRevision", "expectedHandlingGeneration"]) {
|
|
1218
|
+
if (typeof input[field] !== "number" ||
|
|
1219
|
+
!Number.isSafeInteger(input[field]) ||
|
|
1220
|
+
input[field] < (field === "expectedRevision" ? 1 : 0))
|
|
1221
|
+
throw new TypeError("Handling fence must be a safe integer");
|
|
1222
|
+
}
|
|
1223
|
+
return input;
|
|
1224
|
+
}
|
|
1225
|
+
function requireAssistantTurn(ticket, rows, command) {
|
|
1226
|
+
const handling = effectiveTicketHandling(ticket);
|
|
1227
|
+
const latest = latestVisibleRecord(rows);
|
|
1228
|
+
if (ticket.channel !== "web" ||
|
|
1229
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
1230
|
+
handling.state !== "assistant" ||
|
|
1231
|
+
ticket.revision !== command.expectedRevision ||
|
|
1232
|
+
handling.generation !== command.expectedHandlingGeneration ||
|
|
1233
|
+
latest?.message.authorKind !== "customer" ||
|
|
1234
|
+
latest.message.id !== command.triggerMessageId)
|
|
1235
|
+
throw new SupportDeskConflictError("Assistant turn changed or is no longer available");
|
|
1236
|
+
}
|
|
1237
|
+
function handoffWork(input) {
|
|
1238
|
+
const work = {
|
|
1239
|
+
kind: "handoff_work",
|
|
1240
|
+
partition: input.ticket.id,
|
|
1241
|
+
id: "handoff:" + input.commandId,
|
|
1242
|
+
status: "pending",
|
|
1243
|
+
reason: input.reason,
|
|
1244
|
+
...(input.summary === undefined ? {} : { summary: input.summary }),
|
|
1245
|
+
attemptedGuideIds: input.attemptedGuideIds ?? [],
|
|
1246
|
+
transcript: {
|
|
1247
|
+
ticketId: input.ticket.id,
|
|
1248
|
+
throughRevision: input.ticket.revision,
|
|
1249
|
+
lastVisibleMessageId: input.lastVisibleMessageId,
|
|
1250
|
+
},
|
|
1251
|
+
handlingGeneration: effectiveTicketHandling(input.ticket).generation,
|
|
1252
|
+
createdAt: input.occurredAt,
|
|
1253
|
+
};
|
|
1254
|
+
// Support's existing codec stores one JSON string property, unlike KB chunks.
|
|
1255
|
+
if (JSON.stringify(work).length * 2 > 60_000)
|
|
1256
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
1257
|
+
return work;
|
|
1258
|
+
}
|
|
724
1259
|
async function staffAuthoritativeView(records, ticketId, maxRecordsPerTicket) {
|
|
725
1260
|
const all = await records.list(ticketId);
|
|
726
1261
|
enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
|
|
@@ -757,6 +1292,7 @@ function snapshotStaffReplyCommand(input) {
|
|
|
757
1292
|
? undefined
|
|
758
1293
|
: snapshotNotification(raw.notification);
|
|
759
1294
|
return Object.freeze({
|
|
1295
|
+
...snapshotRevisionHint(source),
|
|
760
1296
|
commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
|
|
761
1297
|
correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
|
|
762
1298
|
ticketId: boundaryString(raw.ticketId, "staff reply command.ticketId"),
|
|
@@ -775,6 +1311,7 @@ function snapshotAddNoteCommand(input) {
|
|
|
775
1311
|
body: ownDataProperty(source, "body", "add note command.body"),
|
|
776
1312
|
};
|
|
777
1313
|
return Object.freeze({
|
|
1314
|
+
...snapshotRevisionHint(source),
|
|
778
1315
|
commandId: boundaryString(raw.commandId, "add note command.commandId"),
|
|
779
1316
|
correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
|
|
780
1317
|
ticketId: boundaryString(raw.ticketId, "add note command.ticketId"),
|
|
@@ -813,6 +1350,7 @@ function snapshotAssignTicketCommand(input) {
|
|
|
813
1350
|
}
|
|
814
1351
|
}
|
|
815
1352
|
return Object.freeze({
|
|
1353
|
+
...snapshotRevisionHint(source),
|
|
816
1354
|
commandId: boundaryString(raw.commandId, "assign command.commandId"),
|
|
817
1355
|
correlationId: boundaryString(raw.correlationId, "assign command.correlationId"),
|
|
818
1356
|
ticketId: boundaryString(raw.ticketId, "assign command.ticketId"),
|
|
@@ -837,6 +1375,7 @@ function snapshotChangePriorityCommand(input) {
|
|
|
837
1375
|
throw new TypeError(`change priority command.priority must be one of: ${TICKET_PRIORITIES.join(", ")}`);
|
|
838
1376
|
}
|
|
839
1377
|
return Object.freeze({
|
|
1378
|
+
...snapshotRevisionHint(source),
|
|
840
1379
|
commandId: boundaryString(raw.commandId, "change priority command.commandId"),
|
|
841
1380
|
correlationId: boundaryString(raw.correlationId, "change priority command.correlationId"),
|
|
842
1381
|
ticketId: boundaryString(raw.ticketId, "change priority command.ticketId"),
|
|
@@ -870,6 +1409,7 @@ function snapshotLifecycleTicketCommand(input, field) {
|
|
|
870
1409
|
}
|
|
871
1410
|
}
|
|
872
1411
|
return Object.freeze({
|
|
1412
|
+
...snapshotRevisionHint(source),
|
|
873
1413
|
commandId: boundaryString(raw.commandId, `${field}.commandId`),
|
|
874
1414
|
correlationId: boundaryString(raw.correlationId, `${field}.correlationId`),
|
|
875
1415
|
ticketId: boundaryString(raw.ticketId, `${field}.ticketId`),
|
|
@@ -910,6 +1450,7 @@ function ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)
|
|
|
910
1450
|
function deriveQueueIndexRecord(ticket) {
|
|
911
1451
|
return {
|
|
912
1452
|
ticketId: ticket.id,
|
|
1453
|
+
...staffLifecycleFields(ticket),
|
|
913
1454
|
id: "queue",
|
|
914
1455
|
projectedRevision: ticket.revision,
|
|
915
1456
|
state: isTerminalTicketStatus(ticket.status) ? "inactive" : "active",
|
|
@@ -922,6 +1463,9 @@ function deriveQueueIndexRecord(ticket) {
|
|
|
922
1463
|
? {}
|
|
923
1464
|
: { assignedTo: ticket.assignedTo }),
|
|
924
1465
|
updatedAt: ticket.updatedAt,
|
|
1466
|
+
...(ticket.handling === undefined
|
|
1467
|
+
? {}
|
|
1468
|
+
: { handling: { ...ticket.handling } }),
|
|
925
1469
|
};
|
|
926
1470
|
}
|
|
927
1471
|
function queueIndexContentEqual(left, right) {
|
|
@@ -932,10 +1476,16 @@ function queueIndexContentEqual(left, right) {
|
|
|
932
1476
|
left.status === right.status &&
|
|
933
1477
|
left.priority === right.priority &&
|
|
934
1478
|
left.category === right.category &&
|
|
1479
|
+
JSON.stringify(left.tags ?? []) === JSON.stringify(right.tags ?? []) &&
|
|
1480
|
+
left.firstResponseDueAt === right.firstResponseDueAt &&
|
|
1481
|
+
left.resolutionDueAt === right.resolutionDueAt &&
|
|
1482
|
+
left.firstStaffRespondedAt === right.firstStaffRespondedAt &&
|
|
1483
|
+
left.resolvedAt === right.resolvedAt &&
|
|
935
1484
|
left.requesterAssociation === right.requesterAssociation &&
|
|
936
1485
|
left.channel === right.channel &&
|
|
937
1486
|
left.assignedTo === right.assignedTo &&
|
|
938
|
-
left.updatedAt === right.updatedAt
|
|
1487
|
+
left.updatedAt === right.updatedAt &&
|
|
1488
|
+
JSON.stringify(left.handling) === JSON.stringify(right.handling));
|
|
939
1489
|
}
|
|
940
1490
|
function queueProjectionKey(ticketId) {
|
|
941
1491
|
return { partition: ticketId, id: "queue" };
|
|
@@ -971,41 +1521,8 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
971
1521
|
}
|
|
972
1522
|
const records = store.collection(supportRecords);
|
|
973
1523
|
const projections = store.collection(queueIndex);
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
const existing = await projections.getVersioned(queueProjectionKey(ticketId));
|
|
977
|
-
if (existing === null) {
|
|
978
|
-
return "absent";
|
|
979
|
-
}
|
|
980
|
-
const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
|
|
981
|
-
return removed ? "deleted" : "kept";
|
|
982
|
-
}
|
|
983
|
-
const ticket = ticketRecord.ticket;
|
|
984
|
-
if (ticket.id !== ticketId) {
|
|
985
|
-
throw new TypeError("stored ticket id does not match its partition");
|
|
986
|
-
}
|
|
987
|
-
// Sample the host clock only for terminal retention. Active tickets never
|
|
988
|
-
// consult wall time, so post-commit projection does not consume sequence
|
|
989
|
-
// clocks used by ticket mutations.
|
|
990
|
-
if (isTerminalTicketStatus(ticket.status)) {
|
|
991
|
-
const now = clock.now();
|
|
992
|
-
if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
|
|
993
|
-
const existing = await projections.getVersioned(queueProjectionKey(ticketId));
|
|
994
|
-
if (existing === null) {
|
|
995
|
-
return "absent";
|
|
996
|
-
}
|
|
997
|
-
// Only prune a projection that still matches this terminal revision.
|
|
998
|
-
// A concurrent reopen can land a newer active row between the ticket
|
|
999
|
-
// read and this delete; that row must be preserved for repair to see.
|
|
1000
|
-
if (existing.value.projectedRevision !== ticket.revision) {
|
|
1001
|
-
return "kept";
|
|
1002
|
-
}
|
|
1003
|
-
const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
|
|
1004
|
-
return removed ? "deleted" : "kept";
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
const derived = deriveQueueIndexRecord(ticket);
|
|
1008
|
-
const result = await projections.update(queueProjectionKey(ticketId), (current) => {
|
|
1524
|
+
const key = queueProjectionKey(ticketId);
|
|
1525
|
+
function decide(current, derived) {
|
|
1009
1526
|
if (current === null) {
|
|
1010
1527
|
return { action: "write", value: derived };
|
|
1011
1528
|
}
|
|
@@ -1021,13 +1538,72 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1021
1538
|
if (queueIndexContentEqual(current, derived)) {
|
|
1022
1539
|
return { action: "keep" };
|
|
1023
1540
|
}
|
|
1541
|
+
// Exact pre-SD-01 rows may already contain Task-13 fields but no handling.
|
|
1542
|
+
const { handling: _newHandling, ...beforeHandling } = derived;
|
|
1543
|
+
if (current.handling === undefined &&
|
|
1544
|
+
queueIndexContentEqual(current, beforeHandling))
|
|
1545
|
+
return { action: "write", value: derived };
|
|
1546
|
+
// Pre-Task-13 rows have none of these additive staff fields. Enrich
|
|
1547
|
+
// only that exact legacy shape from freshly loaded authoritative state.
|
|
1548
|
+
const { tags: _tags, firstResponseDueAt: _first, resolutionDueAt: _due, firstStaffRespondedAt: _responded, resolvedAt: _resolved, handling: _handling, ...legacy } = derived;
|
|
1549
|
+
if (queueIndexContentEqual(current, legacy))
|
|
1550
|
+
return { action: "write", value: derived };
|
|
1024
1551
|
throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1552
|
+
}
|
|
1553
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
1554
|
+
const ticketRead = records.get(ticketKey(ticketId));
|
|
1555
|
+
const projectionRead = prepareRead(() => projections.getVersioned(key));
|
|
1556
|
+
const ticketRecord = await ticketRead;
|
|
1557
|
+
if (ticketRecord?.kind !== "ticket") {
|
|
1558
|
+
const existing = readValue(await projectionRead);
|
|
1559
|
+
if (existing === null)
|
|
1560
|
+
return "absent";
|
|
1561
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1562
|
+
return "deleted";
|
|
1563
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1564
|
+
return "kept";
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
const ticket = ticketRecord.ticket;
|
|
1568
|
+
if (ticket.id !== ticketId)
|
|
1569
|
+
throw new TypeError("stored ticket id does not match its partition");
|
|
1570
|
+
// Active tickets never consult wall time. Retry terminal retention only
|
|
1571
|
+
// with the new authoritative Ticket and projection observations together.
|
|
1572
|
+
if (isTerminalTicketStatus(ticket.status)) {
|
|
1573
|
+
const now = clock.now();
|
|
1574
|
+
if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
|
|
1575
|
+
const existing = readValue(await projectionRead);
|
|
1576
|
+
if (existing === null)
|
|
1577
|
+
return "absent";
|
|
1578
|
+
// A newer active projection from a concurrent reopen must survive.
|
|
1579
|
+
if (existing.value.projectedRevision !== ticket.revision)
|
|
1580
|
+
return "kept";
|
|
1581
|
+
if (await projections.deleteIfUnchanged(key, existing.version))
|
|
1582
|
+
return "deleted";
|
|
1583
|
+
if (attempt + 1 === maxConflictAttempts)
|
|
1584
|
+
return "kept";
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const derived = deriveQueueIndexRecord(ticket);
|
|
1589
|
+
const existing = readValue(await projectionRead);
|
|
1590
|
+
const decision = decide(existing?.value ?? null, derived);
|
|
1591
|
+
if (decision.action !== "write")
|
|
1592
|
+
return "kept";
|
|
1593
|
+
const written = existing === null
|
|
1594
|
+
? (await projections.insertIfAbsent(decision.value)).inserted
|
|
1595
|
+
: await projections.putIfUnchanged(decision.value, existing.version);
|
|
1596
|
+
if (written)
|
|
1597
|
+
return "written";
|
|
1598
|
+
// Rebuild both observations; never keep a stale Ticket across a CAS retry.
|
|
1599
|
+
}
|
|
1600
|
+
throw new ConcurrencyError(queueIndex.name, key, maxConflictAttempts);
|
|
1027
1601
|
}
|
|
1028
1602
|
function toStaffQueueItem(ticket) {
|
|
1029
1603
|
return Object.freeze({
|
|
1604
|
+
handling: effectiveTicketHandling(ticket).state,
|
|
1030
1605
|
ticketId: ticket.id,
|
|
1606
|
+
...staffLifecycleFields(ticket),
|
|
1031
1607
|
revision: ticket.revision,
|
|
1032
1608
|
status: ticket.status,
|
|
1033
1609
|
priority: ticket.priority,
|
|
@@ -1046,7 +1622,23 @@ function normalizeFilterList(value) {
|
|
|
1046
1622
|
}
|
|
1047
1623
|
return new Set(Array.isArray(value) ? value : [value]);
|
|
1048
1624
|
}
|
|
1049
|
-
function staffQueueItemMatches(item, query) {
|
|
1625
|
+
function staffQueueItemMatches(item, query, now) {
|
|
1626
|
+
const handling = query.handling ?? "human";
|
|
1627
|
+
if (handling !== "all" &&
|
|
1628
|
+
(handling === "assistant") !== (item.handling === "assistant"))
|
|
1629
|
+
return false;
|
|
1630
|
+
if (query.tag !== undefined && !(item.tags ?? []).includes(query.tag))
|
|
1631
|
+
return false;
|
|
1632
|
+
if (query.targetState !== undefined) {
|
|
1633
|
+
const due = pendingTargetDue(item, query.target);
|
|
1634
|
+
if (due === undefined || now === undefined)
|
|
1635
|
+
return false;
|
|
1636
|
+
const epoch = canonicalTimestamp(due, "ticket target");
|
|
1637
|
+
if (query.targetState === "overdue"
|
|
1638
|
+
? epoch >= now
|
|
1639
|
+
: epoch < now || epoch - now > query.dueSoonMilliseconds)
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1050
1642
|
const statuses = normalizeFilterList(query.status);
|
|
1051
1643
|
if (statuses !== undefined && !statuses.has(item.status)) {
|
|
1052
1644
|
return false;
|
|
@@ -1073,6 +1665,15 @@ function staffQueueItemMatches(item, query) {
|
|
|
1073
1665
|
}
|
|
1074
1666
|
return true;
|
|
1075
1667
|
}
|
|
1668
|
+
function pendingTargetDue(item, target) {
|
|
1669
|
+
return target === "first_response"
|
|
1670
|
+
? item.firstStaffRespondedAt === undefined
|
|
1671
|
+
? item.firstResponseDueAt
|
|
1672
|
+
: undefined
|
|
1673
|
+
: item.resolvedAt === undefined
|
|
1674
|
+
? item.resolutionDueAt
|
|
1675
|
+
: undefined;
|
|
1676
|
+
}
|
|
1076
1677
|
const QUEUE_FILTER_STATUSES = [
|
|
1077
1678
|
"open",
|
|
1078
1679
|
"waiting_on_support",
|
|
@@ -1121,6 +1722,12 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1121
1722
|
}
|
|
1122
1723
|
const source = boundaryObject(input, "staff queue query");
|
|
1123
1724
|
const readOptional = (key) => ownDataProperty(source, key, `staff queue query.${key}`, true);
|
|
1725
|
+
const handling = readOptional("handling");
|
|
1726
|
+
if (handling !== undefined &&
|
|
1727
|
+
handling !== "human" &&
|
|
1728
|
+
handling !== "assistant" &&
|
|
1729
|
+
handling !== "all")
|
|
1730
|
+
throw new TypeError("invalid queue handling view");
|
|
1124
1731
|
const status = readOptional("status");
|
|
1125
1732
|
const priority = readOptional("priority");
|
|
1126
1733
|
const association = readOptional("association");
|
|
@@ -1128,6 +1735,29 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1128
1735
|
const assignedTo = readOptional("assignedTo");
|
|
1129
1736
|
const unassignedOnly = readOptional("unassignedOnly");
|
|
1130
1737
|
const sort = readOptional("sort");
|
|
1738
|
+
const rawTag = readOptional("tag");
|
|
1739
|
+
const tag = rawTag === undefined ? undefined : normalizedTag(rawTag);
|
|
1740
|
+
const target = readOptional("target");
|
|
1741
|
+
const targetState = readOptional("targetState");
|
|
1742
|
+
const dueSoonMilliseconds = readOptional("dueSoonMilliseconds");
|
|
1743
|
+
if (target !== undefined &&
|
|
1744
|
+
target !== "first_response" &&
|
|
1745
|
+
target !== "resolution")
|
|
1746
|
+
throw new TypeError("invalid queue target");
|
|
1747
|
+
if (targetState !== undefined &&
|
|
1748
|
+
targetState !== "overdue" &&
|
|
1749
|
+
targetState !== "due_soon")
|
|
1750
|
+
throw new TypeError("invalid queue targetState");
|
|
1751
|
+
if (dueSoonMilliseconds !== undefined &&
|
|
1752
|
+
(typeof dueSoonMilliseconds !== "number" ||
|
|
1753
|
+
!Number.isSafeInteger(dueSoonMilliseconds) ||
|
|
1754
|
+
dueSoonMilliseconds < 0 ||
|
|
1755
|
+
dueSoonMilliseconds > 30 * 86_400_000))
|
|
1756
|
+
throw new TypeError("dueSoonMilliseconds must be between 0 and 30 days");
|
|
1757
|
+
if (targetState === "due_soon" && dueSoonMilliseconds === undefined)
|
|
1758
|
+
throw new TypeError("due_soon requires dueSoonMilliseconds");
|
|
1759
|
+
if (targetState !== "due_soon" && dueSoonMilliseconds !== undefined)
|
|
1760
|
+
throw new TypeError("dueSoonMilliseconds requires targetState due_soon");
|
|
1131
1761
|
if (assignedTo !== undefined) {
|
|
1132
1762
|
if (typeof assignedTo !== "string") {
|
|
1133
1763
|
throw new TypeError("staff queue query.assignedTo must be a string");
|
|
@@ -1139,10 +1769,20 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1139
1769
|
}
|
|
1140
1770
|
if (sort !== undefined &&
|
|
1141
1771
|
sort !== "updated_newest" &&
|
|
1142
|
-
sort !== "updated_oldest"
|
|
1143
|
-
|
|
1772
|
+
sort !== "updated_oldest" &&
|
|
1773
|
+
sort !== "due_soonest" &&
|
|
1774
|
+
sort !== "due_latest") {
|
|
1775
|
+
throw new TypeError("invalid staff queue query.sort");
|
|
1144
1776
|
}
|
|
1145
|
-
const query = {
|
|
1777
|
+
const query = {
|
|
1778
|
+
...(handling === undefined ? {} : { handling }),
|
|
1779
|
+
...(tag === undefined ? {} : { tag }),
|
|
1780
|
+
...(target === undefined ? {} : { target }),
|
|
1781
|
+
...(targetState === undefined ? {} : { targetState }),
|
|
1782
|
+
...(dueSoonMilliseconds === undefined
|
|
1783
|
+
? {}
|
|
1784
|
+
: { dueSoonMilliseconds: dueSoonMilliseconds }),
|
|
1785
|
+
};
|
|
1146
1786
|
if (status !== undefined) {
|
|
1147
1787
|
query.status =
|
|
1148
1788
|
snapshotEnumOrEnumArray(status, "staff queue query.status", QUEUE_FILTER_STATUSES);
|
|
@@ -1293,36 +1933,100 @@ export async function sweepInactiveQueueProjections(options) {
|
|
|
1293
1933
|
deleted,
|
|
1294
1934
|
};
|
|
1295
1935
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
|
|
1306
|
-
const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
|
|
1936
|
+
const supportDeskLimitMaximums = Object.freeze({
|
|
1937
|
+
maxSubjectCharacters: 1_000,
|
|
1938
|
+
maxMessageCharacters: 20_000,
|
|
1939
|
+
maxTicketsPerPrincipal: 1_000,
|
|
1940
|
+
maxMessagesPerTicket: 100,
|
|
1941
|
+
maxRecordsPerTicket: 2_000,
|
|
1942
|
+
});
|
|
1943
|
+
/** Merge validated host overrides onto the defaults and enforce maxima. */
|
|
1944
|
+
function resolveSupportDeskLimits(rawLimits, field) {
|
|
1307
1945
|
const limitOverrides = {};
|
|
1308
1946
|
if (rawLimits !== undefined) {
|
|
1309
|
-
const limitSource = boundaryObject(rawLimits,
|
|
1310
|
-
for (const
|
|
1947
|
+
const limitSource = boundaryObject(rawLimits, field);
|
|
1948
|
+
for (const name of [
|
|
1311
1949
|
"maxSubjectCharacters",
|
|
1312
1950
|
"maxMessageCharacters",
|
|
1313
1951
|
"maxTicketsPerPrincipal",
|
|
1314
1952
|
"maxMessagesPerTicket",
|
|
1315
1953
|
"maxRecordsPerTicket",
|
|
1316
1954
|
]) {
|
|
1317
|
-
const value = ownDataProperty(limitSource,
|
|
1955
|
+
const value = ownDataProperty(limitSource, name, `${field}.${name}`, true);
|
|
1318
1956
|
if (value !== undefined) {
|
|
1319
1957
|
if (typeof value !== "number") {
|
|
1320
|
-
throw new TypeError(
|
|
1958
|
+
throw new TypeError(`${field}.${name} must be a number`);
|
|
1321
1959
|
}
|
|
1322
|
-
limitOverrides[
|
|
1960
|
+
limitOverrides[name] = value;
|
|
1323
1961
|
}
|
|
1324
1962
|
}
|
|
1325
1963
|
}
|
|
1964
|
+
const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
|
|
1965
|
+
for (const name of Object.keys(limits)) {
|
|
1966
|
+
const value = limits[name];
|
|
1967
|
+
if (!Number.isSafeInteger(value) ||
|
|
1968
|
+
value <= 0 ||
|
|
1969
|
+
value > supportDeskLimitMaximums[name]) {
|
|
1970
|
+
throw new TypeError(`${name} must be a positive safe integer no greater than ${supportDeskLimitMaximums[name]}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
return Object.freeze(limits);
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Reserve the next instance-scoped ticket number outside any ticket
|
|
1977
|
+
* transaction. A later failure leaves a gap; a duplicate is impossible.
|
|
1978
|
+
*/
|
|
1979
|
+
async function reserveNextTicketNumber(numbers, maxConflictAttempts) {
|
|
1980
|
+
const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
|
|
1981
|
+
const lastIssued = current?.lastIssued ?? 0;
|
|
1982
|
+
if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
|
|
1983
|
+
throw new SupportDeskConflictError("Ticket number counter is corrupt");
|
|
1984
|
+
}
|
|
1985
|
+
if (lastIssued >= Number.MAX_SAFE_INTEGER) {
|
|
1986
|
+
throw new SupportDeskConflictError("Ticket number sequence is exhausted");
|
|
1987
|
+
}
|
|
1988
|
+
return {
|
|
1989
|
+
action: "write",
|
|
1990
|
+
value: { lastIssued: lastIssued + 1 },
|
|
1991
|
+
};
|
|
1992
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
1993
|
+
const issued = result.value?.lastIssued;
|
|
1994
|
+
if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
|
|
1995
|
+
throw new SupportDeskConflictError("Ticket number was not reserved");
|
|
1996
|
+
}
|
|
1997
|
+
return issued;
|
|
1998
|
+
}
|
|
1999
|
+
export function createSupportDeskApplication(options) {
|
|
2000
|
+
const source = boundaryObject(options, "application options");
|
|
2001
|
+
const engineeringFollowupOptions = resolveEngineeringFollowupOptions(ownDataProperty(source, "engineeringFollowup", "application options.engineeringFollowup", true));
|
|
2002
|
+
const engineeringOptions = resolveEngineeringOptions(ownDataProperty(source, "engineering", "application options.engineering", true));
|
|
2003
|
+
const resolutionLearningOptions = resolveResolutionLearningOptions(ownDataProperty(source, "resolutionLearning", "application options.resolutionLearning", true));
|
|
2004
|
+
const assistantTurnOptions = resolveAssistantTurnOptions(ownDataProperty(source, "assistantTurns", "application options.assistantTurns", true));
|
|
2005
|
+
const rawAssistantEnabled = ownDataProperty(source, "assistantHandlingEnabled", "application options.assistantHandlingEnabled", true);
|
|
2006
|
+
if (rawAssistantEnabled !== undefined &&
|
|
2007
|
+
typeof rawAssistantEnabled !== "boolean")
|
|
2008
|
+
throw new TypeError("assistantHandlingEnabled must be boolean");
|
|
2009
|
+
const assistantHandlingEnabled = rawAssistantEnabled === true;
|
|
2010
|
+
const store = ownDataProperty(source, "store", "application options.store");
|
|
2011
|
+
const clock = ownDataProperty(source, "clock", "application options.clock");
|
|
2012
|
+
const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
|
|
2013
|
+
const logger = rawLogger === undefined ? noopLogger : rawLogger;
|
|
2014
|
+
const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
|
|
2015
|
+
const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
|
|
2016
|
+
const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
|
|
2017
|
+
const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
|
|
2018
|
+
const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
|
|
2019
|
+
const rawTags = ownDataProperty(source, "allowedTags", "allowedTags", true);
|
|
2020
|
+
const allowedTags = new Set(rawTags === undefined ? [] : snapshotTags(rawTags));
|
|
2021
|
+
const rawMaxTags = ownDataProperty(source, "maxTagsPerTicket", "maxTagsPerTicket", true);
|
|
2022
|
+
const maxTagsPerTicket = rawMaxTags === undefined ? 8 : rawMaxTags;
|
|
2023
|
+
if (typeof maxTagsPerTicket !== "number" ||
|
|
2024
|
+
!Number.isSafeInteger(maxTagsPerTicket) ||
|
|
2025
|
+
maxTagsPerTicket < 1 ||
|
|
2026
|
+
maxTagsPerTicket > 32) {
|
|
2027
|
+
throw new TypeError("maxTagsPerTicket must be between 1 and 32");
|
|
2028
|
+
}
|
|
2029
|
+
const responseTargetPolicy = parseResponseTargetPolicy(ownDataProperty(source, "responseTargetPolicy", "responseTargetPolicy", true));
|
|
1326
2030
|
if (rawMaxConflictAttempts !== undefined &&
|
|
1327
2031
|
typeof rawMaxConflictAttempts !== "number") {
|
|
1328
2032
|
throw new TypeError("application options.maxConflictAttempts must be a number");
|
|
@@ -1331,13 +2035,36 @@ export function createSupportDeskApplication(options) {
|
|
|
1331
2035
|
const index = store.collection(customerTicketIndex);
|
|
1332
2036
|
const numbers = store.collection(ticketNumbers);
|
|
1333
2037
|
const projections = store.collection(queueIndex);
|
|
1334
|
-
const limits =
|
|
2038
|
+
const limits = resolveSupportDeskLimits(rawLimits, "application options.limits");
|
|
1335
2039
|
const maxConflictAttempts = rawMaxConflictAttempts ?? 4;
|
|
1336
2040
|
if (!Number.isSafeInteger(maxConflictAttempts) ||
|
|
1337
2041
|
maxConflictAttempts <= 0 ||
|
|
1338
2042
|
maxConflictAttempts > 20) {
|
|
1339
2043
|
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
1340
2044
|
}
|
|
2045
|
+
const rawEngineeringPolicy = ownDataProperty(source, "engineeringPolicy", "application options.engineeringPolicy", true);
|
|
2046
|
+
const engineeringPolicyOverrides = {};
|
|
2047
|
+
if (rawEngineeringPolicy !== undefined) {
|
|
2048
|
+
const policyOptions = boundaryObject(rawEngineeringPolicy, "engineeringPolicy options");
|
|
2049
|
+
for (const field of [
|
|
2050
|
+
"maxRepositories",
|
|
2051
|
+
"maxRecords",
|
|
2052
|
+
"historyRetentionMilliseconds",
|
|
2053
|
+
]) {
|
|
2054
|
+
const value = ownDataProperty(policyOptions, field, `engineeringPolicy.${field}`, true);
|
|
2055
|
+
if (value === undefined)
|
|
2056
|
+
continue;
|
|
2057
|
+
if (typeof value !== "number")
|
|
2058
|
+
throw new TypeError(`engineeringPolicy.${field} must be a number`);
|
|
2059
|
+
engineeringPolicyOverrides[field] = value;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const engineeringPolicy = createEngineeringPolicyServices({
|
|
2063
|
+
store,
|
|
2064
|
+
clock,
|
|
2065
|
+
maxConflictAttempts,
|
|
2066
|
+
...engineeringPolicyOverrides,
|
|
2067
|
+
});
|
|
1341
2068
|
const terminalRetentionMilliseconds = rawTerminalRetention === undefined
|
|
1342
2069
|
? defaultQueueTerminalRetentionMilliseconds
|
|
1343
2070
|
: rawTerminalRetention;
|
|
@@ -1382,21 +2109,6 @@ export function createSupportDeskApplication(options) {
|
|
|
1382
2109
|
throw new TypeError(`queueScanBudgets.${field} must be a positive safe integer`);
|
|
1383
2110
|
}
|
|
1384
2111
|
}
|
|
1385
|
-
const limitMaximums = {
|
|
1386
|
-
maxSubjectCharacters: 1_000,
|
|
1387
|
-
maxMessageCharacters: 20_000,
|
|
1388
|
-
maxTicketsPerPrincipal: 1_000,
|
|
1389
|
-
maxMessagesPerTicket: 100,
|
|
1390
|
-
maxRecordsPerTicket: 2_000,
|
|
1391
|
-
};
|
|
1392
|
-
for (const field of Object.keys(limits)) {
|
|
1393
|
-
const value = limits[field];
|
|
1394
|
-
if (!Number.isSafeInteger(value) ||
|
|
1395
|
-
value <= 0 ||
|
|
1396
|
-
value > limitMaximums[field]) {
|
|
1397
|
-
throw new TypeError(`${field} must be a positive safe integer no greater than ${limitMaximums[field]}`);
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
2112
|
let projectionHealth = Object.freeze({
|
|
1401
2113
|
consecutiveFailures: 0,
|
|
1402
2114
|
});
|
|
@@ -1448,13 +2160,65 @@ export function createSupportDeskApplication(options) {
|
|
|
1448
2160
|
}
|
|
1449
2161
|
}
|
|
1450
2162
|
}
|
|
1451
|
-
|
|
2163
|
+
/**
|
|
2164
|
+
* Best-effort post-commit threading index writes. Never fails a committed
|
|
2165
|
+
* command; `repairThreadIndexPage` converges anything missed. Does not
|
|
2166
|
+
* sample the host clock.
|
|
2167
|
+
*/
|
|
2168
|
+
async function safeIndexThreading(ticketId, threading) {
|
|
2169
|
+
if (threading === undefined ||
|
|
2170
|
+
(threading.messageId === undefined && threading.ticketNumber !== true)) {
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
await indexThreadingAfterCommit(store, ticketId, threading, logger);
|
|
2174
|
+
}
|
|
2175
|
+
async function customerViewAfterChange(principalId, ticketId, threading) {
|
|
1452
2176
|
await safeProjectQueue(ticketId);
|
|
2177
|
+
await safeIndexThreading(ticketId, threading);
|
|
1453
2178
|
return authoritativeView(records, principalId, ticketId, limits.maxRecordsPerTicket);
|
|
1454
2179
|
}
|
|
1455
|
-
async function
|
|
2180
|
+
async function staffCommandResult(ticketId, commandId, threading) {
|
|
1456
2181
|
await safeProjectQueue(ticketId);
|
|
1457
|
-
|
|
2182
|
+
await safeIndexThreading(ticketId, threading);
|
|
2183
|
+
const receipt = await records.get(commandKey(ticketId, commandId));
|
|
2184
|
+
if (receipt?.kind !== "command" || receipt.resultTicket === undefined) {
|
|
2185
|
+
// Pre-Task-13 receipts retain their existing replay behavior.
|
|
2186
|
+
return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
|
|
2187
|
+
}
|
|
2188
|
+
const rows = await records.list(ticketId);
|
|
2189
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2190
|
+
return {
|
|
2191
|
+
ticket: receipt.resultTicket,
|
|
2192
|
+
messages: staffMessages(rows.filter((row) => row.kind !== "message" ||
|
|
2193
|
+
row.ordinal <= receipt.resultTicket.revision)),
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
async function checkStaffRevision(ticket, command, fingerprint) {
|
|
2197
|
+
if (command.expectedRevision === undefined ||
|
|
2198
|
+
command.expectedRevision === ticket.revision)
|
|
2199
|
+
return false;
|
|
2200
|
+
// A commit may race the first receipt read. Replay wins over a stale hint.
|
|
2201
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2202
|
+
if (receipt?.kind === "command" &&
|
|
2203
|
+
receipt.requestFingerprint === fingerprint)
|
|
2204
|
+
return true;
|
|
2205
|
+
if (receipt !== null)
|
|
2206
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2207
|
+
requireExpectedRevision(ticket, command);
|
|
2208
|
+
return false;
|
|
2209
|
+
}
|
|
2210
|
+
async function staffConflictResult(command, fingerprint) {
|
|
2211
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2212
|
+
if (receipt?.kind === "command" &&
|
|
2213
|
+
receipt.requestFingerprint === fingerprint)
|
|
2214
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2215
|
+
if (receipt !== null)
|
|
2216
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2217
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
2218
|
+
if (current?.kind === "ticket" &&
|
|
2219
|
+
(await checkStaffRevision(current.ticket, command, fingerprint)))
|
|
2220
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2221
|
+
throw new SupportDeskConflictError();
|
|
1458
2222
|
}
|
|
1459
2223
|
async function reserveCustomerTicket(principalId, ticketId, reservedAt) {
|
|
1460
2224
|
requireIdentifier(principalId, "principalId");
|
|
@@ -1555,80 +2319,863 @@ export function createSupportDeskApplication(options) {
|
|
|
1555
2319
|
}
|
|
1556
2320
|
}
|
|
1557
2321
|
async function reserveTicketNumber() {
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2322
|
+
return reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
2323
|
+
}
|
|
2324
|
+
const resolutionLearning = createResolutionLearningServices({
|
|
2325
|
+
records,
|
|
2326
|
+
clock,
|
|
2327
|
+
limits,
|
|
2328
|
+
options: resolutionLearningOptions,
|
|
2329
|
+
maxConflictAttempts,
|
|
2330
|
+
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2331
|
+
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
|
|
2332
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2333
|
+
commandId: command.commandId,
|
|
2334
|
+
correlationId: command.correlationId,
|
|
2335
|
+
ticketId: ticket.id,
|
|
2336
|
+
revision: ticket.revision,
|
|
2337
|
+
actorId,
|
|
2338
|
+
occurredAt: now,
|
|
2339
|
+
action: action,
|
|
2340
|
+
}),
|
|
2341
|
+
});
|
|
2342
|
+
const engineeringDependencies = {
|
|
2343
|
+
records,
|
|
2344
|
+
clock,
|
|
2345
|
+
limits,
|
|
2346
|
+
options: engineeringOptions,
|
|
2347
|
+
maxConflictAttempts,
|
|
2348
|
+
policy: engineeringPolicy,
|
|
2349
|
+
planFollowupSourceChange: (context) => planEngineeringFollowupSourceChange(records, context),
|
|
2350
|
+
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2351
|
+
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "engineering"),
|
|
2352
|
+
completionAdmission: (ticket, rows, additional, workId) => {
|
|
2353
|
+
const consumption = { engineeringTerminalWorkId: workId };
|
|
2354
|
+
const current = rows.find((row) => row.kind === "ticket" && row.ticket.id === ticket.id);
|
|
2355
|
+
if (current !== undefined)
|
|
2356
|
+
enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
|
|
2357
|
+
else
|
|
2358
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
|
|
2359
|
+
},
|
|
2360
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2361
|
+
commandId: command.commandId,
|
|
2362
|
+
correlationId: command.correlationId,
|
|
2363
|
+
ticketId: ticket.id,
|
|
2364
|
+
revision: ticket.revision,
|
|
2365
|
+
actorId,
|
|
2366
|
+
occurredAt: now,
|
|
2367
|
+
action: action,
|
|
2368
|
+
}),
|
|
2369
|
+
};
|
|
2370
|
+
const engineeringEvidence = createEngineeringEvidenceServices(engineeringDependencies);
|
|
2371
|
+
const engineeringWorkers = createEngineeringWorkerServices(engineeringDependencies, engineeringEvidence.load);
|
|
2372
|
+
const engineeringReconciliation = createEngineeringReconciliationServices(engineeringDependencies);
|
|
2373
|
+
const followupDependencies = {
|
|
2374
|
+
records,
|
|
2375
|
+
clock,
|
|
2376
|
+
limits,
|
|
2377
|
+
options: engineeringFollowupOptions,
|
|
2378
|
+
maxConflictAttempts,
|
|
2379
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2380
|
+
commandId: command.commandId,
|
|
2381
|
+
correlationId: command.correlationId,
|
|
2382
|
+
ticketId: ticket.id,
|
|
2383
|
+
revision: ticket.revision,
|
|
2384
|
+
actorId,
|
|
2385
|
+
occurredAt: now,
|
|
2386
|
+
action: action,
|
|
2387
|
+
}),
|
|
2388
|
+
admission: (ticket, rows, nextWorkRows, addedPhysicalRows, addsMessage = false) => {
|
|
2389
|
+
const projected = projectFollowupRows(rows, nextWorkRows);
|
|
2390
|
+
const newlyProjected = projected.length - rows.length;
|
|
2391
|
+
if (addedPhysicalRows < newlyProjected)
|
|
2392
|
+
throw new SupportDeskConflictError("Follow-up admission uses a stale partition view");
|
|
2393
|
+
const quota = projected.find((row) => row.kind === "ticket_quota");
|
|
2394
|
+
if (followupMessageSlots(projected) > 0) {
|
|
2395
|
+
if (quota === undefined)
|
|
2396
|
+
throw new SupportDeskConflictError("Follow-up quota metadata is missing");
|
|
2397
|
+
const humanSlot = quota.assistantManaged === true &&
|
|
2398
|
+
ticket.firstStaffRespondedAt === undefined
|
|
2399
|
+
? assistantHumanReserveMessages
|
|
2400
|
+
: 0;
|
|
2401
|
+
if (quota.messageCount + followupMessageSlots(projected) + humanSlot >
|
|
2402
|
+
limits.maxMessagesPerTicket)
|
|
2403
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1562
2404
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
2405
|
+
enforceManagedAdmission(ticket, projected, limits, addedPhysicalRows - newlyProjected, addsMessage);
|
|
2406
|
+
},
|
|
2407
|
+
};
|
|
2408
|
+
const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
|
|
2409
|
+
const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
|
|
2410
|
+
const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
|
|
2411
|
+
const actorId = access.principalId;
|
|
2412
|
+
const authorize = () => {
|
|
2413
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2414
|
+
if (access.principalId !== actorId)
|
|
2415
|
+
throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
|
|
2416
|
+
requirePermission(access, supportPermissions.engineeringFollowupReview);
|
|
2417
|
+
requirePermission(access, supportPermissions.queueRead);
|
|
2418
|
+
requirePermission(access, supportPermissions.replyAny);
|
|
2419
|
+
};
|
|
2420
|
+
authorize();
|
|
2421
|
+
const command = followupSnapshot(followupFields(input, "follow-up web update", [
|
|
2422
|
+
"ticketId",
|
|
2423
|
+
"workId",
|
|
2424
|
+
"commandId",
|
|
2425
|
+
"correlationId",
|
|
2426
|
+
"expectedWorkGeneration",
|
|
2427
|
+
"expectedTicketRevision",
|
|
2428
|
+
"bundleHash",
|
|
2429
|
+
"messageId",
|
|
2430
|
+
]));
|
|
2431
|
+
for (const name of [
|
|
2432
|
+
"ticketId",
|
|
2433
|
+
"workId",
|
|
2434
|
+
"commandId",
|
|
2435
|
+
"correlationId",
|
|
2436
|
+
"bundleHash",
|
|
2437
|
+
"messageId",
|
|
2438
|
+
])
|
|
2439
|
+
followupText(command[name], name);
|
|
2440
|
+
followupInteger(command.expectedWorkGeneration, "expectedWorkGeneration", 1);
|
|
2441
|
+
followupInteger(command.expectedTicketRevision, "expectedTicketRevision", 1);
|
|
2442
|
+
if (!/^[0-9a-f]{64}$/.test(command.bundleHash))
|
|
2443
|
+
throw new TypeError("Invalid follow-up bundle hash");
|
|
2444
|
+
if (typeof guard !== "function")
|
|
2445
|
+
throw new TypeError("Follow-up posting requires a current-proof guard");
|
|
2446
|
+
const hash = await requestFingerprint({
|
|
2447
|
+
operation: "engineering_followup_web_update",
|
|
2448
|
+
actorId,
|
|
2449
|
+
command,
|
|
2450
|
+
});
|
|
2451
|
+
const receiptKey = engineeringFollowupCommandKey(command.ticketId, command.commandId);
|
|
2452
|
+
const now = engineeringFollowupNow(followupDependencies);
|
|
2453
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2454
|
+
const prior = await records.get(receiptKey);
|
|
2455
|
+
authorize();
|
|
2456
|
+
if (prior !== null) {
|
|
2457
|
+
if (prior.kind !== "engineering_followup_receipt" ||
|
|
2458
|
+
prior.fingerprint !== hash ||
|
|
2459
|
+
!("messageId" in prior.result) ||
|
|
2460
|
+
prior.result.messageId === undefined ||
|
|
2461
|
+
!("bundleHash" in prior.result) ||
|
|
2462
|
+
prior.result.bundleHash !== command.bundleHash ||
|
|
2463
|
+
!("ticketRevision" in prior.result) ||
|
|
2464
|
+
prior.result.ticketRevision === undefined ||
|
|
2465
|
+
!("workId" in prior.result) ||
|
|
2466
|
+
prior.result.workId !== command.workId)
|
|
2467
|
+
throw new SupportDeskConflictError("Follow-up posting command changed");
|
|
2468
|
+
return followupSnapshot({
|
|
2469
|
+
ticketId: command.ticketId,
|
|
2470
|
+
workId: command.workId,
|
|
2471
|
+
messageId: prior.result.messageId,
|
|
2472
|
+
ticketRevision: prior.result.ticketRevision,
|
|
2473
|
+
bundleHash: prior.result.bundleHash,
|
|
2474
|
+
});
|
|
1565
2475
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2476
|
+
if (!engineeringFollowupOptions.enabled)
|
|
2477
|
+
throw new SupportDeskConflictError("Follow-up is disabled");
|
|
2478
|
+
const current = await engineeringFollowupWork.internal.current({ ticketId: command.ticketId, workId: command.workId }, now);
|
|
2479
|
+
authorize();
|
|
2480
|
+
const work = current.work.value;
|
|
2481
|
+
const prepared = work.preparedBundle;
|
|
2482
|
+
const ticket = current.loaded.ticket.value.ticket;
|
|
2483
|
+
if (prepared === undefined ||
|
|
2484
|
+
current.loaded.head === null ||
|
|
2485
|
+
work.workGeneration !== command.expectedWorkGeneration ||
|
|
2486
|
+
ticket.revision !== command.expectedTicketRevision ||
|
|
2487
|
+
prepared.bundleHash !== command.bundleHash ||
|
|
2488
|
+
work.completionRows !== 3 ||
|
|
2489
|
+
work.messageSlots !== 1 ||
|
|
2490
|
+
now() >= Date.parse(work.deadline))
|
|
2491
|
+
throw new SupportDeskConflictError("Follow-up exact prepared work or ticket changed");
|
|
2492
|
+
const checked = await engineeringFollowupWork.internal.verifyBundle(current, now);
|
|
2493
|
+
const body = prepared.bundle.customerDraft.body;
|
|
2494
|
+
requireNonempty(body, "body");
|
|
2495
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
2496
|
+
const bodyHash = await requestFingerprint({ body });
|
|
2497
|
+
await guard();
|
|
2498
|
+
authorize();
|
|
2499
|
+
const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
2500
|
+
const updated = applyTicketEvent(ticket, {
|
|
2501
|
+
type: "customer_notice_added",
|
|
2502
|
+
actorId,
|
|
2503
|
+
occurredAt,
|
|
2504
|
+
});
|
|
2505
|
+
const derivations = await planTicketDerivationMutation(records, current.loaded.ticket, updated, current.loaded.rows, true, { completedFollowupWorkId: work.workId });
|
|
2506
|
+
const { claim: _claim, leaseUntil: _lease, retryAt: _retry, candidate: _candidate, candidateHash: _candidateHash, preparedBundle: _prepared, ...remaining } = work;
|
|
2507
|
+
const next = {
|
|
2508
|
+
...remaining,
|
|
2509
|
+
stage: "delivered",
|
|
2510
|
+
status: "delivered",
|
|
2511
|
+
workGeneration: work.workGeneration + 1,
|
|
2512
|
+
completionRows: 0,
|
|
2513
|
+
messageSlots: 0,
|
|
2514
|
+
modelAttempts: [],
|
|
2515
|
+
usageTombstones: [
|
|
2516
|
+
...new Map([
|
|
2517
|
+
...(work.usageTombstones ?? []).map((entry) => [entry.stepId, entry]),
|
|
2518
|
+
...work.modelAttempts.map((entry) => [
|
|
2519
|
+
entry.stepId,
|
|
2520
|
+
{
|
|
2521
|
+
stepId: entry.stepId,
|
|
2522
|
+
requestHash: entry.requestHash,
|
|
2523
|
+
usageReservationId: entry.binding.usageReservationId,
|
|
2524
|
+
accounting: entry.accounting,
|
|
2525
|
+
},
|
|
2526
|
+
]),
|
|
2527
|
+
]).values(),
|
|
2528
|
+
],
|
|
2529
|
+
delivered: {
|
|
2530
|
+
messageId: command.messageId,
|
|
2531
|
+
ticketRevision: updated.revision,
|
|
2532
|
+
bodyHash,
|
|
2533
|
+
bundleHash: prepared.bundleHash,
|
|
2534
|
+
commandId: command.commandId,
|
|
2535
|
+
postedBy: actorId,
|
|
2536
|
+
postedAt: occurredAt,
|
|
2537
|
+
},
|
|
2538
|
+
handoff: {
|
|
2539
|
+
kbProposal: prepared.bundle.kbProposal,
|
|
2540
|
+
retestRequests: prepared.bundle.retestRequests,
|
|
2541
|
+
},
|
|
1569
2542
|
};
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
async createCustomerTicket(access, input) {
|
|
1579
|
-
requirePermission(access, supportPermissions.create);
|
|
1580
|
-
requireIdentifier(access.principalId, "principalId");
|
|
1581
|
-
const command = snapshotCreateCustomerTicketCommand(input);
|
|
1582
|
-
requireIdentifier(command.ticketId, "ticketId");
|
|
1583
|
-
requireIdentifier(command.messageId, "messageId");
|
|
1584
|
-
requireIdentifier(command.commandId, "commandId");
|
|
1585
|
-
requireIdentifier(command.correlationId, "correlationId");
|
|
1586
|
-
enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
|
|
1587
|
-
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
1588
|
-
const fingerprintPayload = {
|
|
1589
|
-
type: "create_customer_ticket",
|
|
2543
|
+
checkEngineeringFollowupRecordCapacity(next);
|
|
2544
|
+
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2545
|
+
authorize();
|
|
2546
|
+
if (quota?.value.kind !== "ticket_quota")
|
|
2547
|
+
throw new SupportDeskConflictError("Follow-up quota metadata is missing");
|
|
2548
|
+
const admissionRows = current.loaded.rows.map((row) => row.kind === "ticket_quota" ? quota.value : row);
|
|
2549
|
+
followupDependencies.admission(updated, admissionRows, [...derivations.nextWorkRows, next], 3 + derivations.additionalRows, true);
|
|
2550
|
+
const result = {
|
|
1590
2551
|
ticketId: command.ticketId,
|
|
2552
|
+
workId: command.workId,
|
|
1591
2553
|
messageId: command.messageId,
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
// Omit absent category so pre-category receipts still match retries.
|
|
1595
|
-
...(command.category === undefined
|
|
1596
|
-
? {}
|
|
1597
|
-
: { category: command.category }),
|
|
1598
|
-
requesterEmail: command.requesterEmail ?? null,
|
|
1599
|
-
notification: stableNotification(command.notification),
|
|
2554
|
+
ticketRevision: updated.revision,
|
|
2555
|
+
bundleHash: prepared.bundleHash,
|
|
1600
2556
|
};
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
2557
|
+
const receipt = {
|
|
2558
|
+
kind: "engineering_followup_receipt",
|
|
2559
|
+
partition: command.ticketId,
|
|
2560
|
+
id: receiptKey.id,
|
|
2561
|
+
fingerprint: hash,
|
|
2562
|
+
result,
|
|
2563
|
+
};
|
|
2564
|
+
checkEngineeringFollowupRecordCapacity(receipt);
|
|
2565
|
+
if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
|
|
2566
|
+
continue;
|
|
2567
|
+
authorize();
|
|
2568
|
+
if (now() >=
|
|
2569
|
+
Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil)))
|
|
2570
|
+
throw new SupportDeskConflictError("Follow-up posting proof expired");
|
|
2571
|
+
const outcome = await records.transact(command.ticketId, [
|
|
2572
|
+
...derivations.actions,
|
|
2573
|
+
{
|
|
2574
|
+
action: "putIfUnchanged",
|
|
2575
|
+
version: current.loaded.source.version,
|
|
2576
|
+
value: current.loaded.source.value,
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
action: "putIfUnchanged",
|
|
2580
|
+
version: current.engineeringWork.version,
|
|
2581
|
+
value: current.engineeringWork.value,
|
|
2582
|
+
},
|
|
2583
|
+
{
|
|
2584
|
+
action: "putIfUnchanged",
|
|
2585
|
+
version: current.loaded.head.version,
|
|
2586
|
+
value: current.loaded.head.value,
|
|
2587
|
+
},
|
|
2588
|
+
{
|
|
2589
|
+
action: "putIfUnchanged",
|
|
2590
|
+
version: current.work.version,
|
|
2591
|
+
value: next,
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
action: "putIfUnchanged",
|
|
2595
|
+
version: quota.version,
|
|
2596
|
+
value: {
|
|
2597
|
+
...quota.value,
|
|
2598
|
+
messageCount: quota.value.messageCount + 1,
|
|
2599
|
+
},
|
|
2600
|
+
},
|
|
2601
|
+
{
|
|
2602
|
+
action: "insert",
|
|
2603
|
+
value: {
|
|
2604
|
+
kind: "message",
|
|
2605
|
+
partition: command.ticketId,
|
|
2606
|
+
id: `message:${command.messageId}`,
|
|
2607
|
+
ordinal: updated.revision,
|
|
2608
|
+
message: {
|
|
2609
|
+
id: command.messageId,
|
|
2610
|
+
ticketId: command.ticketId,
|
|
2611
|
+
authorKind: "staff",
|
|
2612
|
+
authorPrincipalId: actorId,
|
|
2613
|
+
channel: "web",
|
|
2614
|
+
visibility: "customer",
|
|
2615
|
+
format: "plain_text",
|
|
2616
|
+
body,
|
|
2617
|
+
createdAt: occurredAt,
|
|
2618
|
+
},
|
|
2619
|
+
},
|
|
2620
|
+
},
|
|
2621
|
+
ticketAuditAction({
|
|
2622
|
+
commandId: command.commandId,
|
|
2623
|
+
correlationId: command.correlationId,
|
|
2624
|
+
ticketId: command.ticketId,
|
|
2625
|
+
revision: updated.revision,
|
|
2626
|
+
actorId,
|
|
2627
|
+
occurredAt,
|
|
2628
|
+
action: supportTicketAuditActions.engineeringFollowupNoticePosted,
|
|
2629
|
+
}),
|
|
2630
|
+
{ action: "insert", value: receipt },
|
|
2631
|
+
]);
|
|
2632
|
+
if (outcome.committed) {
|
|
2633
|
+
authorize();
|
|
2634
|
+
return result;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
throw new SupportDeskConflictError();
|
|
2638
|
+
};
|
|
2639
|
+
function owns(ticket, principalId) {
|
|
2640
|
+
return (ticket.requester.association === "authenticated" &&
|
|
2641
|
+
ticket.requester.principalId === principalId);
|
|
2642
|
+
}
|
|
2643
|
+
function handlingAck(ticket, messageId) {
|
|
2644
|
+
return {
|
|
2645
|
+
ticketId: ticket.id,
|
|
2646
|
+
revision: ticket.revision,
|
|
2647
|
+
handlingGeneration: effectiveTicketHandling(ticket).generation,
|
|
2648
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
/** Only the new handling commands share this small executor; legacy fingerprints stay untouched. */
|
|
2652
|
+
async function mutateHandling(access, command, operation, assistantTurnCommit) {
|
|
2653
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2654
|
+
const customer = operation === "request_person" ||
|
|
2655
|
+
operation === "confirm_customer_success";
|
|
2656
|
+
const fingerprint = await requestFingerprint({
|
|
2657
|
+
type: operation,
|
|
2658
|
+
actorId: access.principalId,
|
|
2659
|
+
...Object.fromEntries(Object.entries(command).filter(([key]) => key !== "commandId" && key !== "correlationId")),
|
|
2660
|
+
...(assistantTurnCommit === undefined
|
|
2661
|
+
? {}
|
|
2662
|
+
: { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
|
|
2663
|
+
});
|
|
2664
|
+
const completion = assistantTurnCommit;
|
|
2665
|
+
const parallel = completion?.kind === "complete";
|
|
2666
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2667
|
+
const ticketRead = records.getVersioned(ticketKey(command.ticketId));
|
|
2668
|
+
const duplicateRead = parallel
|
|
2669
|
+
? prepareRead(() => records.get(commandKey(command.ticketId, command.commandId)))
|
|
2670
|
+
: undefined;
|
|
2671
|
+
const versioned = await ticketRead;
|
|
2672
|
+
if (versioned?.value.kind !== "ticket" ||
|
|
2673
|
+
(customer && !owns(versioned.value.ticket, access.principalId)))
|
|
2674
|
+
throw new SupportDeskNotFoundError();
|
|
2675
|
+
const ticket = versioned.value.ticket;
|
|
2676
|
+
const duplicate = duplicateRead === undefined
|
|
2677
|
+
? await records.get(commandKey(command.ticketId, command.commandId))
|
|
2678
|
+
: readValue(await duplicateRead);
|
|
2679
|
+
if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
|
|
2680
|
+
if (duplicate?.kind !== "command" ||
|
|
2681
|
+
duplicate.resultTicket === undefined)
|
|
2682
|
+
throw new SupportDeskConflictError("Handling receipt is incomplete");
|
|
2683
|
+
return duplicate.resultTicket;
|
|
2684
|
+
}
|
|
2685
|
+
if (duplicate !== null)
|
|
2686
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2687
|
+
if (parallel &&
|
|
2688
|
+
(ticket.channel !== "web" ||
|
|
2689
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
2690
|
+
effectiveTicketHandling(ticket).state !== "assistant" ||
|
|
2691
|
+
ticket.revision !== command.expectedRevision ||
|
|
2692
|
+
effectiveTicketHandling(ticket).generation !==
|
|
2693
|
+
command.expectedHandlingGeneration)) {
|
|
2694
|
+
// The speculative receipt may predate a concurrent exact completion
|
|
2695
|
+
// that this Ticket observation already contains. Recover its receipt
|
|
2696
|
+
// before rejecting the now-stale live fence; ordinary paired reads
|
|
2697
|
+
// retain their existing cost and original whole-attempt CAS versions.
|
|
2698
|
+
const accepted = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2699
|
+
if (duplicateMatches(accepted, operation, command.messageId, fingerprint)) {
|
|
2700
|
+
if (accepted?.kind !== "command" ||
|
|
2701
|
+
accepted.resultTicket === undefined)
|
|
2702
|
+
throw new SupportDeskConflictError("Handling receipt is incomplete");
|
|
2703
|
+
return accepted.resultTicket;
|
|
2704
|
+
}
|
|
2705
|
+
if (accepted !== null)
|
|
2706
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2707
|
+
}
|
|
2708
|
+
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2709
|
+
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2710
|
+
const rows = await records.list(command.ticketId);
|
|
2711
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2712
|
+
const latest = latestVisibleRecord(rows);
|
|
2713
|
+
if (operation === "assistant_reply" || operation === "assistant_escalate")
|
|
2714
|
+
requireAssistantTurn(ticket, rows, command);
|
|
2715
|
+
if (operation === "request_person" &&
|
|
2716
|
+
!isTerminalTicketStatus(ticket.status) &&
|
|
2717
|
+
effectiveTicketHandling(ticket).state !== "assistant")
|
|
2718
|
+
return ticket;
|
|
2719
|
+
if (operation === "confirm_customer_success") {
|
|
2720
|
+
if (latest?.message.id !== command.lastSeenVisibleMessageId)
|
|
2721
|
+
throw new SupportDeskConflictError("Visible conversation changed");
|
|
2722
|
+
if (ticket.status === "resolved" &&
|
|
2723
|
+
rows.some((row) => row.kind === "customer_success" &&
|
|
2724
|
+
row.lastSeenVisibleMessageId ===
|
|
2725
|
+
command.lastSeenVisibleMessageId &&
|
|
2726
|
+
(rows.find((record) => record.kind === "resolution_learning_source") === undefined ||
|
|
2727
|
+
row.resolutionCycle ===
|
|
2728
|
+
rows.find((record) => record.kind === "resolution_learning_source")?.resolutionCycle)))
|
|
2729
|
+
return ticket;
|
|
2730
|
+
}
|
|
2731
|
+
const now = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
2732
|
+
const common = { actorId: access.principalId, occurredAt: now };
|
|
2733
|
+
const fence = {
|
|
2734
|
+
expectedRevision: command.expectedRevision,
|
|
2735
|
+
expectedHandlingGeneration: command.expectedHandlingGeneration,
|
|
2736
|
+
};
|
|
2737
|
+
const updated = operation === "assistant_reply"
|
|
2738
|
+
? applyTicketEvent(ticket, {
|
|
2739
|
+
type: "assistant_replied",
|
|
2740
|
+
...common,
|
|
2741
|
+
...fence,
|
|
2742
|
+
})
|
|
2743
|
+
: operation === "assistant_escalate"
|
|
2744
|
+
? applyTicketEvent(ticket, {
|
|
2745
|
+
type: "human_escalated",
|
|
2746
|
+
...common,
|
|
2747
|
+
...fence,
|
|
2748
|
+
})
|
|
2749
|
+
: operation === "take_over"
|
|
2750
|
+
? applyTicketEvent(ticket, {
|
|
2751
|
+
type: "human_taken_over",
|
|
2752
|
+
...common,
|
|
2753
|
+
...fence,
|
|
2754
|
+
})
|
|
2755
|
+
: operation === "request_person"
|
|
2756
|
+
? applyTicketEvent(ticket, {
|
|
2757
|
+
type: "human_requested",
|
|
2758
|
+
...common,
|
|
2759
|
+
})
|
|
2760
|
+
: applyTicketEvent(ticket, {
|
|
2761
|
+
type: "customer_success_confirmed",
|
|
2762
|
+
...common,
|
|
2763
|
+
});
|
|
2764
|
+
if (updated.revision === ticket.revision)
|
|
2765
|
+
return ticket;
|
|
2766
|
+
const originalTicket = {
|
|
2767
|
+
value: versioned.value,
|
|
2768
|
+
version: versioned.version,
|
|
2769
|
+
};
|
|
2770
|
+
const planMutation = () => planTicketDerivationMutation(records, originalTicket, updated, rows, operation === "assistant_reply", undefined, parallel);
|
|
2771
|
+
const mutationRead = parallel ? prepareRead(planMutation) : undefined;
|
|
2772
|
+
const quotaRead = parallel && operation === "assistant_reply"
|
|
2773
|
+
? prepareRead(() => records.getVersioned(ticketQuotaKey(command.ticketId)))
|
|
2774
|
+
: undefined;
|
|
2775
|
+
const assistantRead = parallel && completion !== undefined
|
|
2776
|
+
? prepareRead(() => assistantTurns.completionAction(ticket, rows, updated, completion))
|
|
2777
|
+
: undefined;
|
|
2778
|
+
const learningMutation = mutationRead === undefined
|
|
2779
|
+
? await planMutation()
|
|
2780
|
+
: readValue(await mutationRead);
|
|
2781
|
+
const learningConfirmation = operation === "confirm_customer_success"
|
|
2782
|
+
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
|
|
2783
|
+
: { actions: [], additionalRows: 0 };
|
|
2784
|
+
const newRows = (operation === "take_over" ? 2 : 3) +
|
|
2785
|
+
learningMutation.additionalRows +
|
|
2786
|
+
learningConfirmation.additionalRows;
|
|
2787
|
+
if (operation === "assistant_reply")
|
|
2788
|
+
enforceManagedAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows, true);
|
|
2789
|
+
else
|
|
2790
|
+
enforceHandlingControlAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows);
|
|
2791
|
+
const actions = [
|
|
2792
|
+
...learningMutation.actions,
|
|
2793
|
+
...learningConfirmation.actions,
|
|
2794
|
+
];
|
|
2795
|
+
if (operation === "assistant_reply") {
|
|
2796
|
+
const quota = quotaRead === undefined
|
|
2797
|
+
? await records.getVersioned(ticketQuotaKey(command.ticketId))
|
|
2798
|
+
: readValue(await quotaRead);
|
|
2799
|
+
if (quota?.value.kind !== "ticket_quota")
|
|
2800
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
2801
|
+
if (quota.value.messageCount >= limits.maxMessagesPerTicket)
|
|
2802
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
2803
|
+
const assistantMessage = {
|
|
2804
|
+
kind: "message",
|
|
2805
|
+
partition: ticket.id,
|
|
2806
|
+
id: "message:" + command.messageId,
|
|
2807
|
+
ordinal: updated.revision,
|
|
2808
|
+
message: {
|
|
2809
|
+
id: command.messageId,
|
|
2810
|
+
ticketId: ticket.id,
|
|
2811
|
+
authorKind: "assistant",
|
|
2812
|
+
authorPrincipalId: access.principalId,
|
|
2813
|
+
channel: "web",
|
|
2814
|
+
visibility: "customer",
|
|
2815
|
+
format: "plain_text",
|
|
2816
|
+
body: command.body,
|
|
2817
|
+
createdAt: now,
|
|
2818
|
+
},
|
|
2819
|
+
};
|
|
2820
|
+
if (JSON.stringify(assistantMessage).length * 2 > 60_000)
|
|
2821
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
2822
|
+
actions.push({
|
|
2823
|
+
action: "putIfUnchanged",
|
|
2824
|
+
version: quota.version,
|
|
2825
|
+
value: {
|
|
2826
|
+
...quota.value,
|
|
2827
|
+
messageCount: quota.value.messageCount + 1,
|
|
2828
|
+
},
|
|
2829
|
+
}, { action: "insert", value: assistantMessage });
|
|
2830
|
+
}
|
|
2831
|
+
else if (operation === "request_person" ||
|
|
2832
|
+
operation === "assistant_escalate") {
|
|
2833
|
+
if (latest === undefined)
|
|
2834
|
+
throw new SupportDeskConflictError("Ticket conversation is unavailable");
|
|
2835
|
+
actions.push({
|
|
2836
|
+
action: "insert",
|
|
2837
|
+
value: handoffWork({
|
|
2838
|
+
ticket: updated,
|
|
2839
|
+
commandId: command.commandId,
|
|
2840
|
+
occurredAt: now,
|
|
2841
|
+
lastVisibleMessageId: latest.message.id,
|
|
2842
|
+
reason: operation === "request_person"
|
|
2843
|
+
? "customer_requested"
|
|
2844
|
+
: (command.reason ?? "other"),
|
|
2845
|
+
...(command.summary === undefined
|
|
2846
|
+
? {}
|
|
2847
|
+
: { summary: command.summary }),
|
|
2848
|
+
...(command.attemptedGuideIds === undefined
|
|
2849
|
+
? {}
|
|
2850
|
+
: { attemptedGuideIds: command.attemptedGuideIds }),
|
|
2851
|
+
}),
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
else if (operation === "confirm_customer_success") {
|
|
2855
|
+
actions.push({
|
|
2856
|
+
action: "insert",
|
|
2857
|
+
value: {
|
|
2858
|
+
kind: "customer_success",
|
|
2859
|
+
partition: ticket.id,
|
|
2860
|
+
id: "success:" + command.commandId,
|
|
2861
|
+
commandId: command.commandId,
|
|
2862
|
+
ticketId: ticket.id,
|
|
2863
|
+
confirmedBy: access.principalId,
|
|
2864
|
+
confirmedAt: now,
|
|
2865
|
+
lastSeenVisibleMessageId: command.lastSeenVisibleMessageId,
|
|
2866
|
+
revision: updated.revision,
|
|
2867
|
+
handlingGeneration: effectiveTicketHandling(updated).generation,
|
|
2868
|
+
...(learningMutation.source === null
|
|
2869
|
+
? {}
|
|
2870
|
+
: {
|
|
2871
|
+
resolutionCycle: learningMutation.source.value.resolutionCycle,
|
|
2872
|
+
}),
|
|
2873
|
+
},
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
const auditAction = operation === "assistant_reply"
|
|
2877
|
+
? supportTicketAuditActions.assistantReplied
|
|
2878
|
+
: operation === "assistant_escalate"
|
|
2879
|
+
? supportTicketAuditActions.humanEscalated
|
|
2880
|
+
: operation === "request_person"
|
|
2881
|
+
? supportTicketAuditActions.humanRequested
|
|
2882
|
+
: operation === "take_over"
|
|
2883
|
+
? supportTicketAuditActions.humanTakenOver
|
|
2884
|
+
: supportTicketAuditActions.customerSuccessConfirmed;
|
|
2885
|
+
actions.push(ticketAuditAction({
|
|
2886
|
+
commandId: command.commandId,
|
|
2887
|
+
correlationId: command.correlationId,
|
|
2888
|
+
ticketId: ticket.id,
|
|
2889
|
+
revision: updated.revision,
|
|
2890
|
+
actorId: access.principalId,
|
|
2891
|
+
occurredAt: now,
|
|
2892
|
+
action: auditAction,
|
|
2893
|
+
}), {
|
|
2894
|
+
action: "insert",
|
|
2895
|
+
value: {
|
|
2896
|
+
kind: "command",
|
|
2897
|
+
partition: ticket.id,
|
|
2898
|
+
id: "command:" + command.commandId,
|
|
2899
|
+
commandId: command.commandId,
|
|
2900
|
+
commandType: operation,
|
|
2901
|
+
...(command.messageId === undefined
|
|
2902
|
+
? {}
|
|
2903
|
+
: { messageId: command.messageId }),
|
|
2904
|
+
requestFingerprint: fingerprint,
|
|
2905
|
+
completedAt: now,
|
|
2906
|
+
resultTicket: updated,
|
|
2907
|
+
},
|
|
2908
|
+
});
|
|
2909
|
+
const assistantCommit = assistantTurnCommit === undefined
|
|
2910
|
+
? undefined
|
|
2911
|
+
: assistantRead === undefined
|
|
2912
|
+
? await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit)
|
|
2913
|
+
: readValue(await assistantRead);
|
|
2914
|
+
if (assistantCommit !== undefined) {
|
|
2915
|
+
actions.push(assistantCommit.action);
|
|
2916
|
+
await assistantCommit.verifyBeforeCommit();
|
|
2917
|
+
}
|
|
2918
|
+
if ((await records.transact(ticket.id, actions)).committed) {
|
|
2919
|
+
await safeProjectQueue(ticket.id);
|
|
2920
|
+
return updated;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
2924
|
+
if (current?.kind !== "ticket" ||
|
|
2925
|
+
(customer && !owns(current.ticket, access.principalId)))
|
|
2926
|
+
throw new SupportDeskNotFoundError();
|
|
2927
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2928
|
+
if (duplicateMatches(receipt, operation, command.messageId, fingerprint) &&
|
|
2929
|
+
receipt?.kind === "command" &&
|
|
2930
|
+
receipt.resultTicket !== undefined)
|
|
2931
|
+
return receipt.resultTicket;
|
|
2932
|
+
throw new SupportDeskConflictError();
|
|
2933
|
+
}
|
|
2934
|
+
const assistantTurns = createAssistantTurnServices({
|
|
2935
|
+
records,
|
|
2936
|
+
clock,
|
|
2937
|
+
options: assistantTurnOptions,
|
|
2938
|
+
assistantHandlingEnabled,
|
|
2939
|
+
limits,
|
|
2940
|
+
maxConflictAttempts,
|
|
2941
|
+
conversation(ticket, rows) {
|
|
2942
|
+
return {
|
|
2943
|
+
ticket: toCustomerTicketSummary(ticket),
|
|
2944
|
+
messages: customerMessages(rows),
|
|
2945
|
+
revision: ticket.revision,
|
|
2946
|
+
handling: { ...effectiveTicketHandling(ticket) },
|
|
2947
|
+
...(latestVisibleRecord(rows) === undefined
|
|
2948
|
+
? {}
|
|
2949
|
+
: { triggerMessageId: latestVisibleRecord(rows).message.id }),
|
|
2950
|
+
};
|
|
2951
|
+
},
|
|
2952
|
+
mutateHandling,
|
|
2953
|
+
});
|
|
2954
|
+
return {
|
|
2955
|
+
...resolutionLearning.application,
|
|
2956
|
+
...engineeringEvidence.application,
|
|
2957
|
+
...engineeringWorkers,
|
|
2958
|
+
...engineeringReconciliation,
|
|
2959
|
+
...engineeringPolicy.application,
|
|
2960
|
+
...engineeringFollowupSource.application,
|
|
2961
|
+
...engineeringFollowupWork.application,
|
|
2962
|
+
approveAndPostEngineeringFollowupWebUpdate,
|
|
2963
|
+
scanAssistantTurnCandidates: assistantTurns.scanAssistantTurnCandidates,
|
|
2964
|
+
claimAssistantTurn: assistantTurns.claimAssistantTurn,
|
|
2965
|
+
prepareAssistantTurnAttempt: assistantTurns.prepareAssistantTurnAttempt,
|
|
2966
|
+
completeAssistantTurn: assistantTurns.completeAssistantTurn,
|
|
2967
|
+
finishAssistantTurnAttempt: assistantTurns.finishAssistantTurnAttempt,
|
|
2968
|
+
async readHandoffWork(access, ticketId) {
|
|
2969
|
+
requirePermission(access, supportPermissions.queueRead);
|
|
2970
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2971
|
+
requireIdentifier(ticketId, "ticketId");
|
|
2972
|
+
const rows = await records.list(ticketId);
|
|
2973
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2974
|
+
if (!rows.some((row) => row.kind === "ticket"))
|
|
2975
|
+
throw new SupportDeskNotFoundError();
|
|
2976
|
+
return rows
|
|
2977
|
+
.filter((row) => row.kind === "handoff_work")
|
|
2978
|
+
.sort((a, b) => a.transcript.throughRevision - b.transcript.throughRevision ||
|
|
2979
|
+
a.id.localeCompare(b.id))
|
|
2980
|
+
.map((row) => Object.freeze({
|
|
2981
|
+
ticketId,
|
|
2982
|
+
workId: row.id,
|
|
2983
|
+
status: row.status,
|
|
2984
|
+
reason: row.reason,
|
|
2985
|
+
...(row.summary === undefined ? {} : { summary: row.summary }),
|
|
2986
|
+
attemptedGuideIds: Object.freeze([...row.attemptedGuideIds]),
|
|
2987
|
+
transcript: Object.freeze({ ...row.transcript }),
|
|
2988
|
+
handlingGeneration: row.handlingGeneration,
|
|
2989
|
+
createdAt: row.createdAt,
|
|
2990
|
+
}));
|
|
2991
|
+
},
|
|
2992
|
+
async readAssistantConversation(access, ticketId) {
|
|
2993
|
+
requirePermission(access, supportPermissions.assistantRead);
|
|
2994
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2995
|
+
requireIdentifier(ticketId, "ticketId");
|
|
2996
|
+
const rows = await records.list(ticketId);
|
|
2997
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2998
|
+
const ticket = rows.find((row) => row.kind === "ticket")?.ticket;
|
|
2999
|
+
if (ticket === undefined)
|
|
3000
|
+
throw new SupportDeskNotFoundError();
|
|
3001
|
+
if (ticket.channel !== "web" ||
|
|
3002
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
3003
|
+
effectiveTicketHandling(ticket).state !== "assistant")
|
|
3004
|
+
throw new SupportDeskConflictError("Assistant conversation is unavailable");
|
|
3005
|
+
const latest = latestVisibleRecord(rows);
|
|
3006
|
+
return {
|
|
3007
|
+
ticket: toCustomerTicketSummary(ticket),
|
|
3008
|
+
messages: customerMessages(rows),
|
|
3009
|
+
revision: ticket.revision,
|
|
3010
|
+
handling: { ...effectiveTicketHandling(ticket) },
|
|
3011
|
+
...(latest?.message.authorKind === "customer"
|
|
3012
|
+
? { triggerMessageId: latest.message.id }
|
|
3013
|
+
: {}),
|
|
3014
|
+
};
|
|
3015
|
+
},
|
|
3016
|
+
async replyAsAssistant(access, input) {
|
|
3017
|
+
requirePermission(access, supportPermissions.assistantReply);
|
|
3018
|
+
const raw = handlingInput(input, [
|
|
3019
|
+
"expectedRevision",
|
|
3020
|
+
"expectedHandlingGeneration",
|
|
3021
|
+
"triggerMessageId",
|
|
3022
|
+
"messageId",
|
|
3023
|
+
"body",
|
|
3024
|
+
]);
|
|
3025
|
+
handlingFence(raw);
|
|
3026
|
+
for (const key of ["triggerMessageId", "messageId"])
|
|
3027
|
+
requireIdentifier(boundaryString(raw[key], key), key);
|
|
3028
|
+
const body = boundaryString(raw.body, "body");
|
|
3029
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
3030
|
+
const command = raw;
|
|
3031
|
+
return handlingAck(await mutateHandling(access, command, "assistant_reply"), command.messageId);
|
|
3032
|
+
},
|
|
3033
|
+
async escalateToHuman(access, input) {
|
|
3034
|
+
requirePermission(access, supportPermissions.assistantEscalate);
|
|
3035
|
+
const raw = handlingInput(input, ["expectedRevision", "expectedHandlingGeneration", "triggerMessageId"], ["reason", "summary", "attemptedGuideIds"]);
|
|
3036
|
+
handlingFence(raw);
|
|
3037
|
+
requireIdentifier(boundaryString(raw.triggerMessageId, "triggerMessageId"), "triggerMessageId");
|
|
3038
|
+
if (raw.reason !== undefined &&
|
|
3039
|
+
![
|
|
3040
|
+
"no_guidance",
|
|
3041
|
+
"unsuccessful",
|
|
3042
|
+
"capacity",
|
|
3043
|
+
"provider_failure",
|
|
3044
|
+
"other",
|
|
3045
|
+
].includes(raw.reason))
|
|
3046
|
+
throw new TypeError("Invalid escalation reason");
|
|
3047
|
+
if (raw.summary !== undefined) {
|
|
3048
|
+
const summary = boundaryString(raw.summary, "summary");
|
|
3049
|
+
requireNonempty(summary, "handoff_summary");
|
|
3050
|
+
if (summary.length > 2_000)
|
|
3051
|
+
throw new SupportDeskLimitError("handoff_summary", 2_000);
|
|
3052
|
+
}
|
|
3053
|
+
if (raw.attemptedGuideIds !== undefined) {
|
|
3054
|
+
if (!Array.isArray(raw.attemptedGuideIds) ||
|
|
3055
|
+
raw.attemptedGuideIds.length > 16)
|
|
3056
|
+
throw new TypeError("Too many attempted guide IDs");
|
|
3057
|
+
const guides = raw.attemptedGuideIds;
|
|
3058
|
+
if (Object.keys(guides).length !== guides.length ||
|
|
3059
|
+
Object.getOwnPropertySymbols(guides).length !== 0)
|
|
3060
|
+
throw new TypeError("Guide IDs must be dense data");
|
|
3061
|
+
raw.attemptedGuideIds = Object.freeze(Array.from({ length: guides.length }, (_, index) => {
|
|
3062
|
+
const value = ownDataProperty(guides, String(index), "attemptedGuideIds");
|
|
3063
|
+
const id = boundaryString(value, "guideId");
|
|
3064
|
+
requireIdentifier(id, "guideId");
|
|
3065
|
+
if (id.length > 200 || id.includes("://"))
|
|
3066
|
+
throw new TypeError("Guide IDs must be bounded opaque references");
|
|
3067
|
+
return id;
|
|
3068
|
+
}));
|
|
3069
|
+
}
|
|
3070
|
+
return handlingAck(await mutateHandling(access, raw, "assistant_escalate"));
|
|
3071
|
+
},
|
|
3072
|
+
async requestPerson(access, input) {
|
|
3073
|
+
requirePermission(access, supportPermissions.requestPerson);
|
|
3074
|
+
const command = handlingInput(input, []);
|
|
3075
|
+
await mutateHandling(access, command, "request_person");
|
|
3076
|
+
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3077
|
+
},
|
|
3078
|
+
async takeOverTicket(access, input) {
|
|
3079
|
+
requireStaffMutationAccess(access, supportPermissions.takeOver);
|
|
3080
|
+
const command = handlingFence(handlingInput(input, [
|
|
3081
|
+
"expectedRevision",
|
|
3082
|
+
"expectedHandlingGeneration",
|
|
3083
|
+
]));
|
|
3084
|
+
await mutateHandling(access, command, "take_over");
|
|
3085
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
3086
|
+
},
|
|
3087
|
+
async confirmCustomerSuccess(access, input) {
|
|
3088
|
+
requirePermission(access, supportPermissions.confirmSuccess);
|
|
3089
|
+
const raw = handlingInput(input, ["lastSeenVisibleMessageId"]);
|
|
3090
|
+
requireIdentifier(boundaryString(raw.lastSeenVisibleMessageId, "lastSeenVisibleMessageId"), "lastSeenVisibleMessageId");
|
|
3091
|
+
const command = raw;
|
|
3092
|
+
await mutateHandling(access, command, "confirm_customer_success");
|
|
3093
|
+
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3094
|
+
},
|
|
3095
|
+
async readCustomerSuccessConfirmations(access, ticketId) {
|
|
3096
|
+
requireStaffMutationAccess(access, supportPermissions.auditRead);
|
|
3097
|
+
requireIdentifier(access.principalId, "principalId");
|
|
3098
|
+
requireIdentifier(ticketId, "ticketId");
|
|
3099
|
+
const rows = await records.list(ticketId);
|
|
3100
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
3101
|
+
if (!rows.some((row) => row.kind === "ticket"))
|
|
3102
|
+
throw new SupportDeskNotFoundError();
|
|
3103
|
+
return rows
|
|
3104
|
+
.filter((row) => row.kind === "customer_success")
|
|
3105
|
+
.sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
|
|
3106
|
+
.map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
|
|
3107
|
+
},
|
|
3108
|
+
async createCustomerTicket(access, input) {
|
|
3109
|
+
requirePermission(access, supportPermissions.create);
|
|
3110
|
+
requireIdentifier(access.principalId, "principalId");
|
|
3111
|
+
const command = snapshotCreateCustomerTicketCommand(input);
|
|
3112
|
+
if (command.initialHandling === "assistant") {
|
|
3113
|
+
requirePermission(access, supportPermissions.assistantStart);
|
|
3114
|
+
}
|
|
3115
|
+
requireIdentifier(command.ticketId, "ticketId");
|
|
3116
|
+
requireIdentifier(command.messageId, "messageId");
|
|
3117
|
+
requireIdentifier(command.commandId, "commandId");
|
|
3118
|
+
requireIdentifier(command.correlationId, "correlationId");
|
|
3119
|
+
enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
|
|
3120
|
+
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
3121
|
+
const fingerprintPayload = {
|
|
3122
|
+
type: "create_customer_ticket",
|
|
3123
|
+
ticketId: command.ticketId,
|
|
3124
|
+
messageId: command.messageId,
|
|
3125
|
+
subject: command.subject,
|
|
3126
|
+
body: command.body,
|
|
3127
|
+
// Omit absent category so pre-category receipts still match retries.
|
|
3128
|
+
...(command.category === undefined
|
|
3129
|
+
? {}
|
|
3130
|
+
: { category: command.category }),
|
|
3131
|
+
requesterEmail: command.requesterEmail ?? null,
|
|
3132
|
+
notification: stableNotification(command.notification),
|
|
3133
|
+
...(command.initialHandling === undefined
|
|
3134
|
+
? {}
|
|
3135
|
+
: { initialHandling: command.initialHandling }),
|
|
3136
|
+
};
|
|
3137
|
+
const fingerprint = await requestFingerprint(fingerprintPayload);
|
|
3138
|
+
const createThreading = {
|
|
3139
|
+
ticketNumber: true,
|
|
3140
|
+
...(command.notification === undefined
|
|
3141
|
+
? {}
|
|
3142
|
+
: { messageId: command.messageId }),
|
|
3143
|
+
};
|
|
3144
|
+
// Replay an already committed create before host allowlist checks so a
|
|
3145
|
+
// later config change cannot break idempotent retries of the same command.
|
|
3146
|
+
// Replay never reserves another instance ticket number.
|
|
3147
|
+
const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
3148
|
+
let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
|
|
3149
|
+
if (!createIsReplay &&
|
|
3150
|
+
command.initialHandling === undefined &&
|
|
3151
|
+
existingCreate?.kind === "command" &&
|
|
3152
|
+
existingCreate.commandType === "create_customer_ticket" &&
|
|
3153
|
+
existingCreate.messageId === command.messageId) {
|
|
3154
|
+
// Pre-Task-3 receipts included ticketNumber in the fingerprint.
|
|
3155
|
+
const existingTicket = await records.get(ticketKey(command.ticketId));
|
|
3156
|
+
if (existingTicket?.kind === "ticket") {
|
|
3157
|
+
// Pre-Task-3 hashed ticketNumber immediately after ticketId.
|
|
3158
|
+
const legacyFingerprint = await requestFingerprint({
|
|
3159
|
+
type: fingerprintPayload.type,
|
|
3160
|
+
ticketId: fingerprintPayload.ticketId,
|
|
3161
|
+
ticketNumber: existingTicket.ticket.number,
|
|
3162
|
+
messageId: fingerprintPayload.messageId,
|
|
3163
|
+
subject: fingerprintPayload.subject,
|
|
3164
|
+
body: fingerprintPayload.body,
|
|
3165
|
+
...(command.category === undefined
|
|
3166
|
+
? {}
|
|
3167
|
+
: { category: command.category }),
|
|
3168
|
+
requesterEmail: fingerprintPayload.requesterEmail,
|
|
3169
|
+
notification: fingerprintPayload.notification,
|
|
3170
|
+
});
|
|
3171
|
+
createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
1631
3174
|
if (createIsReplay) {
|
|
3175
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
3176
|
+
if (current?.kind !== "ticket" ||
|
|
3177
|
+
!owns(current.ticket, access.principalId))
|
|
3178
|
+
throw new SupportDeskNotFoundError();
|
|
1632
3179
|
// A crash after ticket commit and before index confirmation must still
|
|
1633
3180
|
// converge the principal hint on retry.
|
|
1634
3181
|
const fence = await records.get(ticketReservationKey(command.ticketId));
|
|
@@ -1636,8 +3183,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1636
3183
|
fence.state === "committed") {
|
|
1637
3184
|
await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
|
|
1638
3185
|
}
|
|
1639
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3186
|
+
return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
|
|
1640
3187
|
}
|
|
3188
|
+
if (command.initialHandling === "assistant" && !assistantHandlingEnabled)
|
|
3189
|
+
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
1641
3190
|
if (command.category !== undefined) {
|
|
1642
3191
|
if (allowedCategories === undefined ||
|
|
1643
3192
|
!allowedCategories.has(command.category)) {
|
|
@@ -1648,8 +3197,23 @@ export function createSupportDeskApplication(options) {
|
|
|
1648
3197
|
// partition budget cannot exhaust the principal ticket index with
|
|
1649
3198
|
// reserved-but-never-created tickets.
|
|
1650
3199
|
const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
|
|
1651
|
-
(
|
|
1652
|
-
|
|
3200
|
+
(resolutionLearningOptions.enabled ? 1 : 0) +
|
|
3201
|
+
(engineeringOptions.enabled ? 1 : 0) +
|
|
3202
|
+
(command.notification === undefined ? 0 : 1) +
|
|
3203
|
+
(assistantTurnOptions !== undefined &&
|
|
3204
|
+
command.initialHandling === "assistant"
|
|
3205
|
+
? 1
|
|
3206
|
+
: 0);
|
|
3207
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
|
|
3208
|
+
(resolutionLearningOptions.enabled ? 2 : 0) +
|
|
3209
|
+
(engineeringOptions.enabled ? 2 : 0) +
|
|
3210
|
+
(command.initialHandling === "assistant"
|
|
3211
|
+
? assistantHumanReserveRows +
|
|
3212
|
+
(resolutionLearningOptions.enabled ? 1 : 0)
|
|
3213
|
+
: 0));
|
|
3214
|
+
if (command.initialHandling === "assistant" &&
|
|
3215
|
+
limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
|
|
3216
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1653
3217
|
// Reserve outside the ticket transaction; gaps are accepted if create fails.
|
|
1654
3218
|
const ticketNumber = await reserveTicketNumber();
|
|
1655
3219
|
// Bind host-supplied notification content to the reserved number so mail
|
|
@@ -1658,11 +3222,14 @@ export function createSupportDeskApplication(options) {
|
|
|
1658
3222
|
? undefined
|
|
1659
3223
|
: bindNotificationTicketNumber(command.notification, ticketNumber);
|
|
1660
3224
|
const now = clock.now();
|
|
1661
|
-
const ticket = createTicket({
|
|
3225
|
+
const ticket = await applyResponseTargets(createTicket({
|
|
1662
3226
|
id: command.ticketId,
|
|
1663
3227
|
number: ticketNumber,
|
|
1664
3228
|
subject: command.subject,
|
|
1665
3229
|
channel: "web",
|
|
3230
|
+
...(command.initialHandling === undefined
|
|
3231
|
+
? {}
|
|
3232
|
+
: { initialHandling: command.initialHandling }),
|
|
1666
3233
|
...(command.category === undefined
|
|
1667
3234
|
? {}
|
|
1668
3235
|
: { category: command.category }),
|
|
@@ -1674,7 +3241,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1674
3241
|
: { email: command.requesterEmail }),
|
|
1675
3242
|
},
|
|
1676
3243
|
createdAt: now,
|
|
1677
|
-
});
|
|
3244
|
+
}), responseTargetPolicy, "created");
|
|
1678
3245
|
const message = {
|
|
1679
3246
|
id: command.messageId,
|
|
1680
3247
|
ticketId: command.ticketId,
|
|
@@ -1686,6 +3253,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1686
3253
|
body: command.body,
|
|
1687
3254
|
createdAt: now,
|
|
1688
3255
|
};
|
|
3256
|
+
const assistantTurn = assistantTurnOptions !== undefined &&
|
|
3257
|
+
command.initialHandling === "assistant"
|
|
3258
|
+
? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
|
|
3259
|
+
: undefined;
|
|
1689
3260
|
const notificationJob = notification === undefined
|
|
1690
3261
|
? undefined
|
|
1691
3262
|
: deliveryJobAction(command.ticketId, command.messageId, now, notification);
|
|
@@ -1728,6 +3299,22 @@ export function createSupportDeskApplication(options) {
|
|
|
1728
3299
|
]
|
|
1729
3300
|
: [];
|
|
1730
3301
|
const actions = [
|
|
3302
|
+
...(engineeringOptions.enabled
|
|
3303
|
+
? [
|
|
3304
|
+
{
|
|
3305
|
+
action: "insert",
|
|
3306
|
+
value: await createEngineeringSource(ticket),
|
|
3307
|
+
},
|
|
3308
|
+
]
|
|
3309
|
+
: []),
|
|
3310
|
+
...(resolutionLearningOptions.enabled
|
|
3311
|
+
? [
|
|
3312
|
+
{
|
|
3313
|
+
action: "insert",
|
|
3314
|
+
value: await createResolutionLearningSource(ticket),
|
|
3315
|
+
},
|
|
3316
|
+
]
|
|
3317
|
+
: []),
|
|
1731
3318
|
{
|
|
1732
3319
|
action: "insert",
|
|
1733
3320
|
value: {
|
|
@@ -1744,6 +3331,9 @@ export function createSupportDeskApplication(options) {
|
|
|
1744
3331
|
partition: command.ticketId,
|
|
1745
3332
|
id: "quota",
|
|
1746
3333
|
messageCount: 1,
|
|
3334
|
+
...(command.initialHandling === "assistant"
|
|
3335
|
+
? { assistantManaged: true }
|
|
3336
|
+
: {}),
|
|
1747
3337
|
},
|
|
1748
3338
|
},
|
|
1749
3339
|
...reservationActions,
|
|
@@ -1783,6 +3373,9 @@ export function createSupportDeskApplication(options) {
|
|
|
1783
3373
|
},
|
|
1784
3374
|
},
|
|
1785
3375
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
3376
|
+
...(assistantTurn === undefined
|
|
3377
|
+
? []
|
|
3378
|
+
: [{ action: "insert", value: assistantTurn }]),
|
|
1786
3379
|
];
|
|
1787
3380
|
// Create starts an empty partition; fail closed if the first write would
|
|
1788
3381
|
// exceed the configured physical-record budget.
|
|
@@ -1795,7 +3388,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1795
3388
|
}
|
|
1796
3389
|
}
|
|
1797
3390
|
await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
|
|
1798
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3391
|
+
return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
|
|
1799
3392
|
},
|
|
1800
3393
|
async replyToCustomerTicket(access, input) {
|
|
1801
3394
|
requirePermission(access, supportPermissions.replyOwn);
|
|
@@ -1816,7 +3409,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1816
3409
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
1817
3410
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
1818
3411
|
if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
1819
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3412
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1820
3413
|
}
|
|
1821
3414
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
1822
3415
|
if (versioned?.value.kind !== "ticket" ||
|
|
@@ -1825,7 +3418,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1825
3418
|
throw new SupportDeskNotFoundError();
|
|
1826
3419
|
}
|
|
1827
3420
|
const partitionRows = await records.list(command.ticketId);
|
|
1828
|
-
|
|
3421
|
+
const durableAssistantIngress = assistantTurnOptions !== undefined &&
|
|
3422
|
+
effectiveTicketHandling(versioned.value.ticket).state === "assistant";
|
|
3423
|
+
const addedReplyRows = (command.notification === undefined ? 3 : 4) +
|
|
3424
|
+
(durableAssistantIngress ? 1 : 0);
|
|
1829
3425
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
1830
3426
|
if (quota?.value.kind !== "ticket_quota") {
|
|
1831
3427
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -1839,11 +3435,25 @@ export function createSupportDeskApplication(options) {
|
|
|
1839
3435
|
const now = sampledEpoch < storedEpoch
|
|
1840
3436
|
? versioned.value.ticket.updatedAt
|
|
1841
3437
|
: sampledNow;
|
|
1842
|
-
|
|
3438
|
+
let updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
|
|
1843
3439
|
type: "customer_replied",
|
|
1844
3440
|
actorId: access.principalId,
|
|
1845
3441
|
occurredAt: now,
|
|
1846
|
-
});
|
|
3442
|
+
}), responseTargetPolicy, "customer_replied");
|
|
3443
|
+
if (durableAssistantIngress && !assistantHandlingEnabled) {
|
|
3444
|
+
// Configuration can disable generation while an older assistant
|
|
3445
|
+
// conversation exists. The causal reply itself commits human work.
|
|
3446
|
+
updated = applyTicketEvent(updated, {
|
|
3447
|
+
type: "human_escalated",
|
|
3448
|
+
actorId: access.principalId,
|
|
3449
|
+
occurredAt: now,
|
|
3450
|
+
expectedRevision: updated.revision,
|
|
3451
|
+
expectedHandlingGeneration: effectiveTicketHandling(updated).generation,
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3455
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
|
|
3456
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows + learningMutation.additionalRows, true);
|
|
1847
3457
|
const message = {
|
|
1848
3458
|
id: command.messageId,
|
|
1849
3459
|
ticketId: command.ticketId,
|
|
@@ -1856,15 +3466,22 @@ export function createSupportDeskApplication(options) {
|
|
|
1856
3466
|
createdAt: now,
|
|
1857
3467
|
};
|
|
1858
3468
|
const notificationContent = deliveryContent(command.notification);
|
|
3469
|
+
const assistantIngress = !durableAssistantIngress
|
|
3470
|
+
? undefined
|
|
3471
|
+
: assistantHandlingEnabled
|
|
3472
|
+
? await createAssistantTurnRecord(updated, message, command.correlationId, assistantTurnOptions)
|
|
3473
|
+
: handoffWork({
|
|
3474
|
+
ticket: updated,
|
|
3475
|
+
commandId: command.commandId,
|
|
3476
|
+
occurredAt: now,
|
|
3477
|
+
lastVisibleMessageId: command.messageId,
|
|
3478
|
+
reason: "other",
|
|
3479
|
+
});
|
|
1859
3480
|
const notificationJob = command.notification === undefined
|
|
1860
3481
|
? undefined
|
|
1861
3482
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
1862
3483
|
const outcome = await records.transact(command.ticketId, [
|
|
1863
|
-
|
|
1864
|
-
action: "putIfUnchanged",
|
|
1865
|
-
version: versioned.version,
|
|
1866
|
-
value: { ...versioned.value, ticket: updated },
|
|
1867
|
-
},
|
|
3484
|
+
...learningMutation.actions,
|
|
1868
3485
|
{
|
|
1869
3486
|
action: "putIfUnchanged",
|
|
1870
3487
|
version: quota.version,
|
|
@@ -1909,13 +3526,16 @@ export function createSupportDeskApplication(options) {
|
|
|
1909
3526
|
},
|
|
1910
3527
|
},
|
|
1911
3528
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
3529
|
+
...(assistantIngress === undefined
|
|
3530
|
+
? []
|
|
3531
|
+
: [{ action: "insert", value: assistantIngress }]),
|
|
1912
3532
|
]);
|
|
1913
3533
|
if (outcome.committed) {
|
|
1914
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3534
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1915
3535
|
}
|
|
1916
3536
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
1917
3537
|
if (duplicateMatches(nowDuplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
1918
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3538
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1919
3539
|
}
|
|
1920
3540
|
}
|
|
1921
3541
|
throw new SupportDeskConflictError();
|
|
@@ -1956,6 +3576,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1956
3576
|
requirePermission(access, supportPermissions.queueRead);
|
|
1957
3577
|
requireIdentifier(access.principalId, "principalId");
|
|
1958
3578
|
const query = snapshotStaffQueueQuery(input);
|
|
3579
|
+
// One trusted instant for the entire bounded request; no clock read for old queries.
|
|
3580
|
+
const targetNow = query.targetState === undefined
|
|
3581
|
+
? undefined
|
|
3582
|
+
: canonicalTimestamp(clock.now(), "clock.now()");
|
|
1959
3583
|
// Request-local cursor only: every online queue read starts without one
|
|
1960
3584
|
// and consumes a single complete projection scan cycle.
|
|
1961
3585
|
let cursor;
|
|
@@ -2016,10 +3640,17 @@ export function createSupportDeskApplication(options) {
|
|
|
2016
3640
|
}
|
|
2017
3641
|
const ticket = ticketRecord.ticket;
|
|
2018
3642
|
if (ticket.id !== projection.ticketId ||
|
|
2019
|
-
|
|
3643
|
+
!Number.isSafeInteger(projection.projectedRevision) ||
|
|
3644
|
+
projection.projectedRevision <= 0 ||
|
|
3645
|
+
projection.projectedRevision > ticket.revision ||
|
|
2020
3646
|
isTerminalTicketStatus(ticket.status)) {
|
|
2021
3647
|
continue;
|
|
2022
3648
|
}
|
|
3649
|
+
const handlingView = query.handling ?? "human";
|
|
3650
|
+
if (handlingView !== "all" &&
|
|
3651
|
+
(handlingView === "assistant") !==
|
|
3652
|
+
(effectiveTicketHandling(ticket).state === "assistant"))
|
|
3653
|
+
continue;
|
|
2023
3654
|
if (seenTicketIds.has(ticket.id)) {
|
|
2024
3655
|
// Scan pages may repeat rows; de-duplicate after confirmation.
|
|
2025
3656
|
continue;
|
|
@@ -2035,9 +3666,33 @@ export function createSupportDeskApplication(options) {
|
|
|
2035
3666
|
}
|
|
2036
3667
|
cursor = page.nextCursor;
|
|
2037
3668
|
}
|
|
2038
|
-
const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query));
|
|
3669
|
+
const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query, targetNow));
|
|
2039
3670
|
const sort = query.sort ?? "updated_newest";
|
|
3671
|
+
// Parse each pending due date once per read, not once per comparison.
|
|
3672
|
+
const dueEpochs = new Map();
|
|
3673
|
+
if (sort === "due_soonest" || sort === "due_latest") {
|
|
3674
|
+
for (const item of filtered) {
|
|
3675
|
+
const due = pendingTargetDue(item, query.target);
|
|
3676
|
+
if (due !== undefined)
|
|
3677
|
+
dueEpochs.set(item, canonicalTimestamp(due, "ticket target"));
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
2040
3680
|
filtered.sort((left, right) => {
|
|
3681
|
+
if (sort === "due_soonest" || sort === "due_latest") {
|
|
3682
|
+
const leftDue = dueEpochs.get(left);
|
|
3683
|
+
const rightDue = dueEpochs.get(right);
|
|
3684
|
+
// Completed and missing targets sort last in both directions.
|
|
3685
|
+
if (leftDue === undefined && rightDue !== undefined)
|
|
3686
|
+
return 1;
|
|
3687
|
+
if (rightDue === undefined && leftDue !== undefined)
|
|
3688
|
+
return -1;
|
|
3689
|
+
const difference = leftDue === undefined || rightDue === undefined
|
|
3690
|
+
? 0
|
|
3691
|
+
: sort === "due_soonest"
|
|
3692
|
+
? leftDue - rightDue
|
|
3693
|
+
: rightDue - leftDue;
|
|
3694
|
+
return difference || left.ticketId.localeCompare(right.ticketId);
|
|
3695
|
+
}
|
|
2041
3696
|
const byTime = sort === "updated_oldest"
|
|
2042
3697
|
? left.updatedAt.localeCompare(right.updatedAt)
|
|
2043
3698
|
: right.updatedAt.localeCompare(left.updatedAt);
|
|
@@ -2061,6 +3716,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2061
3716
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2062
3717
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2063
3718
|
const fingerprint = await requestFingerprint({
|
|
3719
|
+
...snapshotRevisionHint(command),
|
|
2064
3720
|
type: "staff_reply",
|
|
2065
3721
|
ticketId: command.ticketId,
|
|
2066
3722
|
messageId: command.messageId,
|
|
@@ -2070,14 +3726,17 @@ export function createSupportDeskApplication(options) {
|
|
|
2070
3726
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2071
3727
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2072
3728
|
if (duplicateMatches(duplicate, "staff_reply", command.messageId, fingerprint)) {
|
|
2073
|
-
return
|
|
3729
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2074
3730
|
}
|
|
3731
|
+
if (duplicate !== null)
|
|
3732
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2075
3733
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2076
3734
|
if (versioned?.value.kind !== "ticket") {
|
|
2077
3735
|
throw new SupportDeskNotFoundError();
|
|
2078
3736
|
}
|
|
3737
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3738
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2079
3739
|
const partitionRows = await records.list(command.ticketId);
|
|
2080
|
-
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
|
|
2081
3740
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2082
3741
|
if (quota?.value.kind !== "ticket_quota") {
|
|
2083
3742
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -2091,6 +3750,11 @@ export function createSupportDeskApplication(options) {
|
|
|
2091
3750
|
actorId: access.principalId,
|
|
2092
3751
|
occurredAt: now,
|
|
2093
3752
|
});
|
|
3753
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3754
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
|
|
3755
|
+
learningMutation.additionalRows);
|
|
3756
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
|
|
3757
|
+
learningMutation.additionalRows, true, true);
|
|
2094
3758
|
const message = {
|
|
2095
3759
|
id: command.messageId,
|
|
2096
3760
|
ticketId: command.ticketId,
|
|
@@ -2107,11 +3771,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2107
3771
|
? undefined
|
|
2108
3772
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
2109
3773
|
const outcome = await records.transact(command.ticketId, [
|
|
2110
|
-
|
|
2111
|
-
action: "putIfUnchanged",
|
|
2112
|
-
version: versioned.version,
|
|
2113
|
-
value: { ...versioned.value, ticket: updated },
|
|
2114
|
-
},
|
|
3774
|
+
...learningMutation.actions,
|
|
2115
3775
|
{
|
|
2116
3776
|
action: "putIfUnchanged",
|
|
2117
3777
|
version: quota.version,
|
|
@@ -2153,19 +3813,20 @@ export function createSupportDeskApplication(options) {
|
|
|
2153
3813
|
messageId: command.messageId,
|
|
2154
3814
|
requestFingerprint: fingerprint,
|
|
2155
3815
|
completedAt: now,
|
|
3816
|
+
resultTicket: updated,
|
|
2156
3817
|
},
|
|
2157
3818
|
},
|
|
2158
3819
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
2159
3820
|
]);
|
|
2160
3821
|
if (outcome.committed) {
|
|
2161
|
-
return
|
|
3822
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2162
3823
|
}
|
|
2163
3824
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2164
3825
|
if (duplicateMatches(nowDuplicate, "staff_reply", command.messageId, fingerprint)) {
|
|
2165
|
-
return
|
|
3826
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2166
3827
|
}
|
|
2167
3828
|
}
|
|
2168
|
-
|
|
3829
|
+
return staffConflictResult(command, fingerprint);
|
|
2169
3830
|
},
|
|
2170
3831
|
async addNote(access, input) {
|
|
2171
3832
|
requireStaffMutationAccess(access, supportPermissions.note);
|
|
@@ -2177,6 +3838,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2177
3838
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2178
3839
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2179
3840
|
const fingerprint = await requestFingerprint({
|
|
3841
|
+
...snapshotRevisionHint(command),
|
|
2180
3842
|
type: "add_note",
|
|
2181
3843
|
ticketId: command.ticketId,
|
|
2182
3844
|
messageId: command.messageId,
|
|
@@ -2185,14 +3847,19 @@ export function createSupportDeskApplication(options) {
|
|
|
2185
3847
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2186
3848
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2187
3849
|
if (duplicateMatches(duplicate, "add_note", command.messageId, fingerprint)) {
|
|
2188
|
-
return
|
|
3850
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2189
3851
|
}
|
|
3852
|
+
if (duplicate !== null)
|
|
3853
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2190
3854
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2191
3855
|
if (versioned?.value.kind !== "ticket") {
|
|
2192
3856
|
throw new SupportDeskNotFoundError();
|
|
2193
3857
|
}
|
|
3858
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3859
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2194
3860
|
const partitionRows = await records.list(command.ticketId);
|
|
2195
3861
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
|
|
3862
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
|
|
2196
3863
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2197
3864
|
if (quota?.value.kind !== "ticket_quota") {
|
|
2198
3865
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -2268,18 +3935,19 @@ export function createSupportDeskApplication(options) {
|
|
|
2268
3935
|
messageId: command.messageId,
|
|
2269
3936
|
requestFingerprint: fingerprint,
|
|
2270
3937
|
completedAt: now,
|
|
3938
|
+
resultTicket: updated,
|
|
2271
3939
|
},
|
|
2272
3940
|
},
|
|
2273
3941
|
]);
|
|
2274
3942
|
if (outcome.committed) {
|
|
2275
|
-
return
|
|
3943
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2276
3944
|
}
|
|
2277
3945
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2278
3946
|
if (duplicateMatches(nowDuplicate, "add_note", command.messageId, fingerprint)) {
|
|
2279
|
-
return
|
|
3947
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2280
3948
|
}
|
|
2281
3949
|
}
|
|
2282
|
-
|
|
3950
|
+
return staffConflictResult(command, fingerprint);
|
|
2283
3951
|
},
|
|
2284
3952
|
async assignTicket(access, input) {
|
|
2285
3953
|
requireStaffMutationAccess(access, supportPermissions.assign);
|
|
@@ -2298,6 +3966,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2298
3966
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2299
3967
|
}
|
|
2300
3968
|
const fingerprint = await requestFingerprint({
|
|
3969
|
+
...snapshotRevisionHint(command),
|
|
2301
3970
|
type: "assign_ticket",
|
|
2302
3971
|
ticketId: command.ticketId,
|
|
2303
3972
|
assigneeId: command.assigneeId,
|
|
@@ -2308,8 +3977,10 @@ export function createSupportDeskApplication(options) {
|
|
|
2308
3977
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2309
3978
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2310
3979
|
if (duplicateMatches(duplicate, "assign_ticket", command.messageId, fingerprint)) {
|
|
2311
|
-
return
|
|
3980
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2312
3981
|
}
|
|
3982
|
+
if (duplicate !== null)
|
|
3983
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2313
3984
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2314
3985
|
if (versioned?.value.kind !== "ticket") {
|
|
2315
3986
|
throw new SupportDeskNotFoundError();
|
|
@@ -2319,8 +3990,11 @@ export function createSupportDeskApplication(options) {
|
|
|
2319
3990
|
: command.notification === undefined
|
|
2320
3991
|
? 3
|
|
2321
3992
|
: 4;
|
|
3993
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3994
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2322
3995
|
const partitionRows = await records.list(command.ticketId);
|
|
2323
3996
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
|
|
3997
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
|
|
2324
3998
|
let quotaVersion = null;
|
|
2325
3999
|
if (command.messageId !== undefined) {
|
|
2326
4000
|
quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
@@ -2377,9 +4051,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2377
4051
|
action: "putIfUnchanged",
|
|
2378
4052
|
version: quotaVersion.version,
|
|
2379
4053
|
value: {
|
|
2380
|
-
|
|
2381
|
-
partition: command.ticketId,
|
|
2382
|
-
id: "quota",
|
|
4054
|
+
...quotaVersion.value,
|
|
2383
4055
|
messageCount: quotaVersion.value.messageCount + 1,
|
|
2384
4056
|
},
|
|
2385
4057
|
},
|
|
@@ -2428,19 +4100,20 @@ export function createSupportDeskApplication(options) {
|
|
|
2428
4100
|
: { messageId: command.messageId }),
|
|
2429
4101
|
requestFingerprint: fingerprint,
|
|
2430
4102
|
completedAt: now,
|
|
4103
|
+
resultTicket: updated,
|
|
2431
4104
|
},
|
|
2432
4105
|
},
|
|
2433
4106
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
2434
4107
|
]);
|
|
2435
4108
|
if (outcome.committed) {
|
|
2436
|
-
return
|
|
4109
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2437
4110
|
}
|
|
2438
4111
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2439
4112
|
if (duplicateMatches(nowDuplicate, "assign_ticket", command.messageId, fingerprint)) {
|
|
2440
|
-
return
|
|
4113
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2441
4114
|
}
|
|
2442
4115
|
}
|
|
2443
|
-
|
|
4116
|
+
return staffConflictResult(command, fingerprint);
|
|
2444
4117
|
},
|
|
2445
4118
|
async changePriority(access, input) {
|
|
2446
4119
|
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
@@ -2450,6 +4123,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2450
4123
|
requireIdentifier(command.commandId, "commandId");
|
|
2451
4124
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2452
4125
|
const fingerprint = await requestFingerprint({
|
|
4126
|
+
...snapshotRevisionHint(command),
|
|
2453
4127
|
type: "change_priority",
|
|
2454
4128
|
ticketId: command.ticketId,
|
|
2455
4129
|
priority: command.priority,
|
|
@@ -2457,23 +4131,28 @@ export function createSupportDeskApplication(options) {
|
|
|
2457
4131
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2458
4132
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2459
4133
|
if (duplicateMatches(duplicate, "change_priority", undefined, fingerprint)) {
|
|
2460
|
-
return
|
|
4134
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2461
4135
|
}
|
|
4136
|
+
if (duplicate !== null)
|
|
4137
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2462
4138
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2463
4139
|
if (versioned?.value.kind !== "ticket") {
|
|
2464
4140
|
throw new SupportDeskNotFoundError();
|
|
2465
4141
|
}
|
|
4142
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4143
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2466
4144
|
const partitionRows = await records.list(command.ticketId);
|
|
2467
4145
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
|
|
4146
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
|
|
2468
4147
|
const statusBefore = versioned.value.ticket.status;
|
|
2469
4148
|
const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
|
|
2470
4149
|
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
2471
|
-
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
4150
|
+
const updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
|
|
2472
4151
|
type: "priority_changed",
|
|
2473
4152
|
actorId: access.principalId,
|
|
2474
4153
|
priority: command.priority,
|
|
2475
4154
|
occurredAt: now,
|
|
2476
|
-
});
|
|
4155
|
+
}), responseTargetPolicy, "priority_changed");
|
|
2477
4156
|
if (updated.status !== statusBefore ||
|
|
2478
4157
|
updated.customerUpdatedAt !== customerUpdatedBefore) {
|
|
2479
4158
|
throw new SupportDeskConflictError("Priority change unexpectedly changed customer-visible ticket state");
|
|
@@ -2504,35 +4183,115 @@ export function createSupportDeskApplication(options) {
|
|
|
2504
4183
|
commandType: "change_priority",
|
|
2505
4184
|
requestFingerprint: fingerprint,
|
|
2506
4185
|
completedAt: now,
|
|
4186
|
+
resultTicket: updated,
|
|
2507
4187
|
},
|
|
2508
4188
|
},
|
|
2509
4189
|
]);
|
|
2510
4190
|
if (outcome.committed) {
|
|
2511
|
-
return
|
|
4191
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2512
4192
|
}
|
|
2513
4193
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2514
4194
|
if (duplicateMatches(nowDuplicate, "change_priority", undefined, fingerprint)) {
|
|
2515
|
-
return
|
|
4195
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2516
4196
|
}
|
|
2517
4197
|
}
|
|
2518
|
-
|
|
4198
|
+
return staffConflictResult(command, fingerprint);
|
|
2519
4199
|
},
|
|
2520
|
-
async
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
4200
|
+
async setTags(access, input) {
|
|
4201
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
4202
|
+
requireIdentifier(access.principalId, "principalId");
|
|
4203
|
+
const source = boundaryObject(input, "set tags command");
|
|
4204
|
+
const command = Object.freeze({
|
|
4205
|
+
...snapshotRevisionHint(source),
|
|
4206
|
+
commandId: boundaryString(ownDataProperty(source, "commandId", "commandId"), "commandId"),
|
|
4207
|
+
correlationId: boundaryString(ownDataProperty(source, "correlationId", "correlationId"), "correlationId"),
|
|
4208
|
+
ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
|
|
4209
|
+
tags: snapshotTags(ownDataProperty(source, "tags", "tags")),
|
|
2526
4210
|
});
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
4211
|
+
requireIdentifier(command.ticketId, "ticketId");
|
|
4212
|
+
requireIdentifier(command.commandId, "commandId");
|
|
4213
|
+
requireIdentifier(command.correlationId, "correlationId");
|
|
4214
|
+
const fingerprint = await requestFingerprint({
|
|
4215
|
+
type: "set_tags",
|
|
4216
|
+
ticketId: command.ticketId,
|
|
4217
|
+
tags: command.tags,
|
|
4218
|
+
...snapshotRevisionHint(command),
|
|
2534
4219
|
});
|
|
2535
|
-
|
|
4220
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
4221
|
+
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
4222
|
+
if (duplicateMatches(duplicate, "set_tags", undefined, fingerprint))
|
|
4223
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4224
|
+
if (duplicate !== null)
|
|
4225
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
4226
|
+
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
4227
|
+
if (versioned?.value.kind !== "ticket")
|
|
4228
|
+
throw new SupportDeskNotFoundError();
|
|
4229
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4230
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4231
|
+
if (command.tags.length > maxTagsPerTicket)
|
|
4232
|
+
throw new TypeError("tags exceed maxTagsPerTicket");
|
|
4233
|
+
if (command.tags.some((tag) => !allowedTags.has(tag)))
|
|
4234
|
+
throw new TypeError("tags must be on the host allowlist");
|
|
4235
|
+
const rows = await records.list(command.ticketId);
|
|
4236
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
|
|
4237
|
+
enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
|
|
4238
|
+
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
4239
|
+
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
4240
|
+
type: "tags_changed",
|
|
4241
|
+
actorId: access.principalId,
|
|
4242
|
+
occurredAt: now,
|
|
4243
|
+
tags: command.tags,
|
|
4244
|
+
});
|
|
4245
|
+
const outcome = await records.transact(command.ticketId, [
|
|
4246
|
+
{
|
|
4247
|
+
action: "putIfUnchanged",
|
|
4248
|
+
version: versioned.version,
|
|
4249
|
+
value: { ...versioned.value, ticket: updated },
|
|
4250
|
+
},
|
|
4251
|
+
ticketAuditAction({
|
|
4252
|
+
commandId: command.commandId,
|
|
4253
|
+
correlationId: command.correlationId,
|
|
4254
|
+
ticketId: command.ticketId,
|
|
4255
|
+
revision: updated.revision,
|
|
4256
|
+
actorId: access.principalId,
|
|
4257
|
+
occurredAt: now,
|
|
4258
|
+
action: supportTicketAuditActions.tagsChanged,
|
|
4259
|
+
}),
|
|
4260
|
+
{
|
|
4261
|
+
action: "insert",
|
|
4262
|
+
value: {
|
|
4263
|
+
kind: "command",
|
|
4264
|
+
partition: command.ticketId,
|
|
4265
|
+
id: `command:${command.commandId}`,
|
|
4266
|
+
commandId: command.commandId,
|
|
4267
|
+
commandType: "set_tags",
|
|
4268
|
+
requestFingerprint: fingerprint,
|
|
4269
|
+
completedAt: now,
|
|
4270
|
+
resultTicket: updated,
|
|
4271
|
+
},
|
|
4272
|
+
},
|
|
4273
|
+
]);
|
|
4274
|
+
if (outcome.committed)
|
|
4275
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4276
|
+
}
|
|
4277
|
+
return staffConflictResult(command, fingerprint);
|
|
4278
|
+
},
|
|
4279
|
+
async resolveTicket(access, input) {
|
|
4280
|
+
return mutateLifecycle(access, input, {
|
|
4281
|
+
commandType: "resolve_ticket",
|
|
4282
|
+
eventType: "resolved",
|
|
4283
|
+
auditAction: supportTicketAuditActions.resolved,
|
|
4284
|
+
field: "resolve command",
|
|
4285
|
+
});
|
|
4286
|
+
},
|
|
4287
|
+
async closeTicket(access, input) {
|
|
4288
|
+
return mutateLifecycle(access, input, {
|
|
4289
|
+
commandType: "close_ticket",
|
|
4290
|
+
eventType: "closed",
|
|
4291
|
+
auditAction: supportTicketAuditActions.closed,
|
|
4292
|
+
field: "close command",
|
|
4293
|
+
});
|
|
4294
|
+
},
|
|
2536
4295
|
async reopenTicket(access, input) {
|
|
2537
4296
|
return mutateLifecycle(access, input, {
|
|
2538
4297
|
commandType: "reopen_ticket",
|
|
@@ -2568,6 +4327,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2568
4327
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2569
4328
|
}
|
|
2570
4329
|
const fingerprint = await requestFingerprint({
|
|
4330
|
+
...snapshotRevisionHint(command),
|
|
2571
4331
|
type: spec.commandType,
|
|
2572
4332
|
ticketId: command.ticketId,
|
|
2573
4333
|
messageId: command.messageId ?? null,
|
|
@@ -2577,19 +4337,25 @@ export function createSupportDeskApplication(options) {
|
|
|
2577
4337
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2578
4338
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2579
4339
|
if (duplicateMatches(duplicate, spec.commandType, command.messageId, fingerprint)) {
|
|
2580
|
-
return
|
|
4340
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2581
4341
|
}
|
|
4342
|
+
if (duplicate !== null)
|
|
4343
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2582
4344
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2583
4345
|
if (versioned?.value.kind !== "ticket") {
|
|
2584
4346
|
throw new SupportDeskNotFoundError();
|
|
2585
4347
|
}
|
|
4348
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4349
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2586
4350
|
const partitionRows = await records.list(command.ticketId);
|
|
2587
|
-
const
|
|
4351
|
+
const needsReopenHandoff = assistantTurnOptions !== undefined &&
|
|
4352
|
+
spec.eventType === "reopened" &&
|
|
4353
|
+
effectiveTicketHandling(versioned.value.ticket).state === "assistant";
|
|
4354
|
+
const addedRecords = (command.messageId === undefined
|
|
2588
4355
|
? 2 // audit + command
|
|
2589
4356
|
: command.notification === undefined
|
|
2590
4357
|
? 3 // message + audit + command
|
|
2591
|
-
: 4
|
|
2592
|
-
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
|
|
4358
|
+
: 4) + (needsReopenHandoff ? 1 : 0); // optional atomic human handoff
|
|
2593
4359
|
let quotaVersion = null;
|
|
2594
4360
|
if (command.messageId !== undefined) {
|
|
2595
4361
|
quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
@@ -2606,6 +4372,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2606
4372
|
actorId: access.principalId,
|
|
2607
4373
|
occurredAt: now,
|
|
2608
4374
|
});
|
|
4375
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
|
|
4376
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
|
|
4377
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
|
|
2609
4378
|
// Optional system message + mail commit with the lifecycle change so a
|
|
2610
4379
|
// resolution notification cannot be lost after the ticket is resolved.
|
|
2611
4380
|
const systemMessage = command.messageId === undefined || command.body === undefined
|
|
@@ -2625,11 +4394,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2625
4394
|
? undefined
|
|
2626
4395
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
2627
4396
|
const outcome = await records.transact(command.ticketId, [
|
|
2628
|
-
|
|
2629
|
-
action: "putIfUnchanged",
|
|
2630
|
-
version: versioned.version,
|
|
2631
|
-
value: { ...versioned.value, ticket: updated },
|
|
2632
|
-
},
|
|
4397
|
+
...learningMutation.actions,
|
|
2633
4398
|
...(quotaVersion === null ||
|
|
2634
4399
|
systemMessage === undefined ||
|
|
2635
4400
|
quotaVersion.value.kind !== "ticket_quota"
|
|
@@ -2639,9 +4404,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2639
4404
|
action: "putIfUnchanged",
|
|
2640
4405
|
version: quotaVersion.version,
|
|
2641
4406
|
value: {
|
|
2642
|
-
|
|
2643
|
-
partition: command.ticketId,
|
|
2644
|
-
id: "quota",
|
|
4407
|
+
...quotaVersion.value,
|
|
2645
4408
|
messageCount: quotaVersion.value.messageCount + 1,
|
|
2646
4409
|
},
|
|
2647
4410
|
},
|
|
@@ -2685,19 +4448,35 @@ export function createSupportDeskApplication(options) {
|
|
|
2685
4448
|
: { messageId: command.messageId }),
|
|
2686
4449
|
requestFingerprint: fingerprint,
|
|
2687
4450
|
completedAt: now,
|
|
4451
|
+
resultTicket: updated,
|
|
2688
4452
|
},
|
|
2689
4453
|
},
|
|
2690
4454
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
4455
|
+
...(needsReopenHandoff
|
|
4456
|
+
? [
|
|
4457
|
+
{
|
|
4458
|
+
action: "insert",
|
|
4459
|
+
value: handoffWork({
|
|
4460
|
+
ticket: updated,
|
|
4461
|
+
commandId: command.commandId,
|
|
4462
|
+
occurredAt: now,
|
|
4463
|
+
lastVisibleMessageId: systemMessage?.id ??
|
|
4464
|
+
latestVisibleRecord(partitionRows).message.id,
|
|
4465
|
+
reason: "other",
|
|
4466
|
+
}),
|
|
4467
|
+
},
|
|
4468
|
+
]
|
|
4469
|
+
: []),
|
|
2691
4470
|
]);
|
|
2692
4471
|
if (outcome.committed) {
|
|
2693
|
-
return
|
|
4472
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2694
4473
|
}
|
|
2695
4474
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2696
4475
|
if (duplicateMatches(nowDuplicate, spec.commandType, command.messageId, fingerprint)) {
|
|
2697
|
-
return
|
|
4476
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2698
4477
|
}
|
|
2699
4478
|
}
|
|
2700
|
-
|
|
4479
|
+
return staffConflictResult(command, fingerprint);
|
|
2701
4480
|
}
|
|
2702
4481
|
}
|
|
2703
4482
|
function canonicalTimestamp(value, field) {
|
|
@@ -2922,7 +4701,8 @@ export async function sweepInboundReceipts(store, clock, input) {
|
|
|
2922
4701
|
}
|
|
2923
4702
|
const receipts = store.collection(inboundReceipts);
|
|
2924
4703
|
const candidates = (await receipts.listVersioned(bucket))
|
|
2925
|
-
.filter((versioned) => versioned.value.status
|
|
4704
|
+
.filter((versioned) => (versioned.value.status === "processed" ||
|
|
4705
|
+
versioned.value.status === "rejected") &&
|
|
2926
4706
|
versioned.value.processedAt !== undefined &&
|
|
2927
4707
|
versioned.value.processedAt <= processedBefore &&
|
|
2928
4708
|
versioned.value.processedAt < horizonBefore)
|
|
@@ -3045,4 +4825,1671 @@ export async function pruneCustomerTicketIndex(store, principalId, input) {
|
|
|
3045
4825
|
});
|
|
3046
4826
|
return result.value?.entries.map((entry) => entry.ticketId) ?? [];
|
|
3047
4827
|
}
|
|
4828
|
+
const threadIndexKey = (record) => ({
|
|
4829
|
+
partition: record.key,
|
|
4830
|
+
id: record.id,
|
|
4831
|
+
});
|
|
4832
|
+
export const threadIndex = defineCollection({
|
|
4833
|
+
name: "support-desk.thread-index.v1",
|
|
4834
|
+
key: threadIndexKey,
|
|
4835
|
+
codec: jsonCodec(threadIndexKey),
|
|
4836
|
+
});
|
|
4837
|
+
/**
|
|
4838
|
+
* Default lifetime of `Message-ID` and reply-token threading rows. Lookups,
|
|
4839
|
+
* repair, and sweeping all treat older rows as absent; ticket-number rows
|
|
4840
|
+
* live as long as their ticket.
|
|
4841
|
+
*/
|
|
4842
|
+
export const defaultThreadIndexRetentionMilliseconds = 365 * 86_400_000;
|
|
4843
|
+
/** Actor recorded on Audit events accepted from verified inbound mail. */
|
|
4844
|
+
export const inboundEmailSystemActorId = "support-desk.inbound-email";
|
|
4845
|
+
function notificationThreading(command) {
|
|
4846
|
+
return command.notification === undefined || command.messageId === undefined
|
|
4847
|
+
? undefined
|
|
4848
|
+
: { messageId: command.messageId };
|
|
4849
|
+
}
|
|
4850
|
+
function messageKey(ticketId, messageId) {
|
|
4851
|
+
return { partition: ticketId, id: `message:${messageId}` };
|
|
4852
|
+
}
|
|
4853
|
+
async function sha256Hex(text) {
|
|
4854
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
4855
|
+
return [...new Uint8Array(digest)]
|
|
4856
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
4857
|
+
.join("");
|
|
4858
|
+
}
|
|
4859
|
+
async function messageIdIndexKey(value) {
|
|
4860
|
+
return `mid:${await sha256Hex(`message-id\u0000${value}`)}`;
|
|
4861
|
+
}
|
|
4862
|
+
async function replyTokenIndexKey(token) {
|
|
4863
|
+
return `rt:${await sha256Hex(`reply-token\u0000${token}`)}`;
|
|
4864
|
+
}
|
|
4865
|
+
function ticketNumberIndexKey(ticketNumber) {
|
|
4866
|
+
return `number:${ticketNumber}`;
|
|
4867
|
+
}
|
|
4868
|
+
const inboundMessageIdPattern = /^<[^<>\s@]+@[^<>\s@]+>$/;
|
|
4869
|
+
const maxInboundMessageIdCharacters = 512;
|
|
4870
|
+
/**
|
|
4871
|
+
* Accept a header-shaped `<local@domain>` identifier or nothing. Malformed
|
|
4872
|
+
* sender-controlled identifiers are dropped rather than stored or matched.
|
|
4873
|
+
*/
|
|
4874
|
+
function normalizeInboundMessageId(value) {
|
|
4875
|
+
if (typeof value !== "string") {
|
|
4876
|
+
return undefined;
|
|
4877
|
+
}
|
|
4878
|
+
const trimmed = value.trim();
|
|
4879
|
+
if (trimmed.length === 0 ||
|
|
4880
|
+
trimmed.length > maxInboundMessageIdCharacters ||
|
|
4881
|
+
/[\u0000-\u001F\u007F]/.test(trimmed) ||
|
|
4882
|
+
!inboundMessageIdPattern.test(trimmed)) {
|
|
4883
|
+
return undefined;
|
|
4884
|
+
}
|
|
4885
|
+
return trimmed;
|
|
4886
|
+
}
|
|
4887
|
+
/** Derive every thread-index row an authoritative record implies. */
|
|
4888
|
+
async function threadIndexEntriesFor(record) {
|
|
4889
|
+
if (record.kind === "ticket") {
|
|
4890
|
+
const ticket = record.ticket;
|
|
4891
|
+
if (ticket.id !== record.partition ||
|
|
4892
|
+
!Number.isSafeInteger(ticket.number) ||
|
|
4893
|
+
ticket.number <= 0) {
|
|
4894
|
+
return [];
|
|
4895
|
+
}
|
|
4896
|
+
return [
|
|
4897
|
+
{
|
|
4898
|
+
key: ticketNumberIndexKey(ticket.number),
|
|
4899
|
+
id: "thread",
|
|
4900
|
+
kind: "ticket_number",
|
|
4901
|
+
ticketId: ticket.id,
|
|
4902
|
+
issuedAt: ticket.createdAt,
|
|
4903
|
+
},
|
|
4904
|
+
];
|
|
4905
|
+
}
|
|
4906
|
+
if (record.kind !== "message") {
|
|
4907
|
+
return [];
|
|
4908
|
+
}
|
|
4909
|
+
const message = record.message;
|
|
4910
|
+
if (message.ticketId !== record.partition ||
|
|
4911
|
+
record.id !== `message:${message.id}`) {
|
|
4912
|
+
return [];
|
|
4913
|
+
}
|
|
4914
|
+
const entries = [];
|
|
4915
|
+
const content = record.deliveryContent;
|
|
4916
|
+
if (content !== undefined) {
|
|
4917
|
+
entries.push({
|
|
4918
|
+
key: await messageIdIndexKey(content.outboundMessageId),
|
|
4919
|
+
id: "thread",
|
|
4920
|
+
kind: "message_id",
|
|
4921
|
+
ticketId: message.ticketId,
|
|
4922
|
+
messageId: message.id,
|
|
4923
|
+
source: "outbound",
|
|
4924
|
+
issuedAt: message.createdAt,
|
|
4925
|
+
});
|
|
4926
|
+
if (content.replyRoutingToken !== undefined) {
|
|
4927
|
+
entries.push({
|
|
4928
|
+
key: await replyTokenIndexKey(content.replyRoutingToken),
|
|
4929
|
+
id: "thread",
|
|
4930
|
+
kind: "reply_token",
|
|
4931
|
+
ticketId: message.ticketId,
|
|
4932
|
+
messageId: message.id,
|
|
4933
|
+
issuedAt: message.createdAt,
|
|
4934
|
+
});
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
if (message.channel === "email" &&
|
|
4938
|
+
message.authorKind === "customer" &&
|
|
4939
|
+
message.externalMessageId !== undefined &&
|
|
4940
|
+
message.externalMessageId !== content?.outboundMessageId &&
|
|
4941
|
+
normalizeInboundMessageId(message.externalMessageId) ===
|
|
4942
|
+
message.externalMessageId) {
|
|
4943
|
+
entries.push({
|
|
4944
|
+
key: await messageIdIndexKey(message.externalMessageId),
|
|
4945
|
+
id: "thread",
|
|
4946
|
+
kind: "message_id",
|
|
4947
|
+
ticketId: message.ticketId,
|
|
4948
|
+
messageId: message.id,
|
|
4949
|
+
source: "inbound",
|
|
4950
|
+
issuedAt: message.createdAt,
|
|
4951
|
+
});
|
|
4952
|
+
}
|
|
4953
|
+
return entries;
|
|
4954
|
+
}
|
|
4955
|
+
/** First writer wins; a different mapping for the same key is a collision. */
|
|
4956
|
+
async function putThreadIndexEntry(index, entry) {
|
|
4957
|
+
const result = await index.insertIfAbsent(entry);
|
|
4958
|
+
if (result.inserted) {
|
|
4959
|
+
return "written";
|
|
4960
|
+
}
|
|
4961
|
+
const existing = result.value;
|
|
4962
|
+
return existing.kind === entry.kind &&
|
|
4963
|
+
existing.ticketId === entry.ticketId &&
|
|
4964
|
+
existing.messageId === entry.messageId &&
|
|
4965
|
+
existing.source === entry.source
|
|
4966
|
+
? "kept"
|
|
4967
|
+
: "collision";
|
|
4968
|
+
}
|
|
4969
|
+
function safeLog(logger, level, event, fields) {
|
|
4970
|
+
try {
|
|
4971
|
+
logger.log(level, event, fields);
|
|
4972
|
+
}
|
|
4973
|
+
catch {
|
|
4974
|
+
// ignore logger transport failures
|
|
4975
|
+
}
|
|
4976
|
+
}
|
|
4977
|
+
/**
|
|
4978
|
+
* Best-effort post-commit threading writes for one ticket. Failures are
|
|
4979
|
+
* logged and later converged by {@link repairThreadIndexPage}; they never
|
|
4980
|
+
* fail the command that already committed. Does not sample the clock.
|
|
4981
|
+
*/
|
|
4982
|
+
async function indexThreadingAfterCommit(store, ticketId, hint, logger) {
|
|
4983
|
+
try {
|
|
4984
|
+
const records = store.collection(supportRecords);
|
|
4985
|
+
const index = store.collection(threadIndex);
|
|
4986
|
+
const sources = [];
|
|
4987
|
+
if (hint.ticketNumber === true) {
|
|
4988
|
+
const ticket = await records.get(ticketKey(ticketId));
|
|
4989
|
+
if (ticket !== null) {
|
|
4990
|
+
sources.push(ticket);
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
if (hint.messageId !== undefined) {
|
|
4994
|
+
const message = await records.get(messageKey(ticketId, hint.messageId));
|
|
4995
|
+
if (message !== null) {
|
|
4996
|
+
sources.push(message);
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
for (const source of sources) {
|
|
5000
|
+
for (const entry of await threadIndexEntriesFor(source)) {
|
|
5001
|
+
if ((await putThreadIndexEntry(index, entry)) === "collision") {
|
|
5002
|
+
safeLog(logger, "warn", "support-desk.thread_index.collision", {
|
|
5003
|
+
ticketId,
|
|
5004
|
+
kind: entry.kind,
|
|
5005
|
+
});
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
catch (error) {
|
|
5011
|
+
safeLog(logger, "error", "support-desk.thread_index.write_failed", {
|
|
5012
|
+
ticketId,
|
|
5013
|
+
message: error instanceof Error ? error.message : "thread index write failed",
|
|
5014
|
+
});
|
|
5015
|
+
}
|
|
5016
|
+
}
|
|
5017
|
+
/**
|
|
5018
|
+
* Re-derive a row from its authoritative ticket or message. Returns the
|
|
5019
|
+
* current ticket only when the row still describes that record and, for
|
|
5020
|
+
* message rows, is inside the retention window.
|
|
5021
|
+
*/
|
|
5022
|
+
async function confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds) {
|
|
5023
|
+
const ticketRecord = await records.get(ticketKey(entry.ticketId));
|
|
5024
|
+
if (ticketRecord?.kind !== "ticket" ||
|
|
5025
|
+
ticketRecord.ticket.id !== entry.ticketId) {
|
|
5026
|
+
return null;
|
|
5027
|
+
}
|
|
5028
|
+
let authoritative = ticketRecord;
|
|
5029
|
+
if (entry.kind !== "ticket_number") {
|
|
5030
|
+
if (entry.messageId === undefined) {
|
|
5031
|
+
return null;
|
|
5032
|
+
}
|
|
5033
|
+
const message = await records.get(messageKey(entry.ticketId, entry.messageId));
|
|
5034
|
+
if (message === null) {
|
|
5035
|
+
return null;
|
|
5036
|
+
}
|
|
5037
|
+
authoritative = message;
|
|
5038
|
+
}
|
|
5039
|
+
const derived = (await threadIndexEntriesFor(authoritative)).find((candidate) => candidate.key === entry.key && candidate.kind === entry.kind);
|
|
5040
|
+
if (derived === undefined ||
|
|
5041
|
+
derived.ticketId !== entry.ticketId ||
|
|
5042
|
+
derived.messageId !== entry.messageId ||
|
|
5043
|
+
derived.source !== entry.source) {
|
|
5044
|
+
return null;
|
|
5045
|
+
}
|
|
5046
|
+
if (derived.kind !== "ticket_number" &&
|
|
5047
|
+
nowEpoch - canonicalTimestamp(derived.issuedAt, "thread index issuedAt") >=
|
|
5048
|
+
retentionMilliseconds) {
|
|
5049
|
+
return null;
|
|
5050
|
+
}
|
|
5051
|
+
return ticketRecord.ticket;
|
|
5052
|
+
}
|
|
5053
|
+
async function lookupThreadEntry(records, index, kind, key, nowEpoch, retentionMilliseconds) {
|
|
5054
|
+
const entry = await index.get({ partition: key, id: "thread" });
|
|
5055
|
+
if (entry === null || entry.key !== key || entry.kind !== kind) {
|
|
5056
|
+
return null;
|
|
5057
|
+
}
|
|
5058
|
+
const ticket = await confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds);
|
|
5059
|
+
return ticket === null ? null : { ticket, entry };
|
|
5060
|
+
}
|
|
5061
|
+
function parseRetentionMilliseconds(value, field) {
|
|
5062
|
+
if (typeof value !== "number") {
|
|
5063
|
+
throw new TypeError(`${field} must be a number`);
|
|
5064
|
+
}
|
|
5065
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
5066
|
+
throw new TypeError(`${field} must be a positive safe integer`);
|
|
5067
|
+
}
|
|
5068
|
+
return value;
|
|
5069
|
+
}
|
|
5070
|
+
function parseThreadIndexWorkerOptions(options, field) {
|
|
5071
|
+
const source = boundaryObject(options, field);
|
|
5072
|
+
const store = ownDataProperty(source, "store", `${field}.store`);
|
|
5073
|
+
const clock = ownDataProperty(source, "clock", `${field}.clock`);
|
|
5074
|
+
const retentionMilliseconds = parseRetentionMilliseconds(ownDataProperty(source, "threadIndexRetentionMilliseconds", `${field}.threadIndexRetentionMilliseconds`), `${field}.threadIndexRetentionMilliseconds`);
|
|
5075
|
+
const rawCursor = ownDataProperty(source, "cursor", `${field}.cursor`, true);
|
|
5076
|
+
const rawLimit = ownDataProperty(source, "limit", `${field}.limit`, true);
|
|
5077
|
+
if (rawCursor !== undefined && typeof rawCursor !== "string") {
|
|
5078
|
+
throw new TypeError(`${field}.cursor must be a string`);
|
|
5079
|
+
}
|
|
5080
|
+
if (rawLimit !== undefined && typeof rawLimit !== "number") {
|
|
5081
|
+
throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
|
|
5082
|
+
}
|
|
5083
|
+
const limit = rawLimit ?? 100;
|
|
5084
|
+
if (!Number.isSafeInteger(limit) ||
|
|
5085
|
+
limit <= 0 ||
|
|
5086
|
+
limit > MAX_SCAN_PAGE_SIZE) {
|
|
5087
|
+
throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
|
|
5088
|
+
}
|
|
5089
|
+
return {
|
|
5090
|
+
store,
|
|
5091
|
+
clock,
|
|
5092
|
+
retentionMilliseconds,
|
|
5093
|
+
...(rawCursor === undefined ? {} : { cursor: rawCursor }),
|
|
5094
|
+
limit,
|
|
5095
|
+
};
|
|
5096
|
+
}
|
|
5097
|
+
/**
|
|
5098
|
+
* Scan one page of authoritative `support-desk.records.v1` rows and insert
|
|
5099
|
+
* every thread-index row they imply (ticket numbers, outbound `Message-ID`s,
|
|
5100
|
+
* reply tokens, and inbound `Message-ID`s). Rows past retention are not
|
|
5101
|
+
* recreated. Hosts persist `nextCursor` only after the whole page succeeds
|
|
5102
|
+
* and repeat complete cycles.
|
|
5103
|
+
*/
|
|
5104
|
+
export async function repairThreadIndexPage(options) {
|
|
5105
|
+
const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index repair options");
|
|
5106
|
+
const records = store.collection(supportRecords);
|
|
5107
|
+
const index = store.collection(threadIndex);
|
|
5108
|
+
const page = await records.scan({
|
|
5109
|
+
limit,
|
|
5110
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
5111
|
+
});
|
|
5112
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5113
|
+
let indexed = 0;
|
|
5114
|
+
let collisions = 0;
|
|
5115
|
+
for (const entry of page.records) {
|
|
5116
|
+
if (entry.key.partition !== entry.value.partition ||
|
|
5117
|
+
entry.key.id !== entry.value.id) {
|
|
5118
|
+
continue;
|
|
5119
|
+
}
|
|
5120
|
+
for (const candidate of await threadIndexEntriesFor(entry.value)) {
|
|
5121
|
+
if (candidate.kind !== "ticket_number" &&
|
|
5122
|
+
nowEpoch -
|
|
5123
|
+
canonicalTimestamp(candidate.issuedAt, "thread index issuedAt") >=
|
|
5124
|
+
retentionMilliseconds) {
|
|
5125
|
+
continue;
|
|
5126
|
+
}
|
|
5127
|
+
const outcome = await putThreadIndexEntry(index, candidate);
|
|
5128
|
+
if (outcome === "written") {
|
|
5129
|
+
indexed += 1;
|
|
5130
|
+
}
|
|
5131
|
+
else if (outcome === "collision") {
|
|
5132
|
+
collisions += 1;
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
return {
|
|
5137
|
+
nextCursor: page.nextCursor,
|
|
5138
|
+
physicalRows: page.records.length,
|
|
5139
|
+
indexed,
|
|
5140
|
+
collisions,
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
/**
|
|
5144
|
+
* Scan one page of thread-index rows and conditionally delete every row whose
|
|
5145
|
+
* authoritative target is gone, no longer derives it, or is past retention.
|
|
5146
|
+
* Uses `deleteIfUnchanged` with the scanned version.
|
|
5147
|
+
*/
|
|
5148
|
+
export async function sweepThreadIndexPage(options) {
|
|
5149
|
+
const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index sweep options");
|
|
5150
|
+
const records = store.collection(supportRecords);
|
|
5151
|
+
const index = store.collection(threadIndex);
|
|
5152
|
+
const page = await index.scan({
|
|
5153
|
+
limit,
|
|
5154
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
5155
|
+
});
|
|
5156
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5157
|
+
let deleted = 0;
|
|
5158
|
+
for (const entry of page.records) {
|
|
5159
|
+
if (entry.key.partition !== entry.value.key || entry.key.id !== "thread") {
|
|
5160
|
+
continue;
|
|
5161
|
+
}
|
|
5162
|
+
if ((await confirmThreadEntry(records, entry.value, nowEpoch, retentionMilliseconds)) !== null) {
|
|
5163
|
+
continue;
|
|
5164
|
+
}
|
|
5165
|
+
if (await index.deleteIfUnchanged(entry.key, entry.version)) {
|
|
5166
|
+
deleted += 1;
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
return {
|
|
5170
|
+
nextCursor: page.nextCursor,
|
|
5171
|
+
physicalRows: page.records.length,
|
|
5172
|
+
deleted,
|
|
5173
|
+
};
|
|
5174
|
+
}
|
|
5175
|
+
const maxInboundReferences = 100;
|
|
5176
|
+
const maxThreadingLookups = 20;
|
|
5177
|
+
const maxLoopSignalCharacters = 998;
|
|
5178
|
+
const maxInboundDisplayNameCharacters = 200;
|
|
5179
|
+
const maxSupportAddresses = 32;
|
|
5180
|
+
const subjectMarkerPrefixPattern = /^[A-Z][A-Z0-9]{0,15}$/;
|
|
5181
|
+
const inboundSuppressingPrecedence = new Set([
|
|
5182
|
+
"bulk",
|
|
5183
|
+
"junk",
|
|
5184
|
+
"list",
|
|
5185
|
+
"auto_reply",
|
|
5186
|
+
"auto-reply",
|
|
5187
|
+
]);
|
|
5188
|
+
function collapseHeaderText(value) {
|
|
5189
|
+
return value
|
|
5190
|
+
.replace(/[\u0000-\u001F\u007F]+/g, " ")
|
|
5191
|
+
.replace(/\s+/g, " ")
|
|
5192
|
+
.trim();
|
|
5193
|
+
}
|
|
5194
|
+
function normalizeInboundSubject(value, maximum) {
|
|
5195
|
+
const collapsed = collapseHeaderText(value);
|
|
5196
|
+
const text = collapsed.length === 0 ? "(no subject)" : collapsed;
|
|
5197
|
+
const points = [...text];
|
|
5198
|
+
return points.length > maximum
|
|
5199
|
+
? points.slice(0, maximum).join("").trimEnd()
|
|
5200
|
+
: text;
|
|
5201
|
+
}
|
|
5202
|
+
function normalizeInboundBody(value) {
|
|
5203
|
+
return value
|
|
5204
|
+
.replace(/\r\n?/g, "\n")
|
|
5205
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
5206
|
+
}
|
|
5207
|
+
function normalizeInboundDisplayName(value) {
|
|
5208
|
+
const collapsed = collapseHeaderText(value);
|
|
5209
|
+
if (collapsed.length === 0) {
|
|
5210
|
+
return undefined;
|
|
5211
|
+
}
|
|
5212
|
+
return [...collapsed].slice(0, maxInboundDisplayNameCharacters).join("");
|
|
5213
|
+
}
|
|
5214
|
+
function snapshotLoopSignals(input) {
|
|
5215
|
+
if (input === undefined) {
|
|
5216
|
+
return Object.freeze({});
|
|
5217
|
+
}
|
|
5218
|
+
const source = boundaryObject(input, "inbound email.loopSignals");
|
|
5219
|
+
const signals = {};
|
|
5220
|
+
for (const key of [
|
|
5221
|
+
"autoSubmitted",
|
|
5222
|
+
"precedence",
|
|
5223
|
+
"autoResponseSuppress",
|
|
5224
|
+
"listId",
|
|
5225
|
+
"contentType",
|
|
5226
|
+
"envelopeSender",
|
|
5227
|
+
]) {
|
|
5228
|
+
const value = ownDataProperty(source, key, `inbound email.loopSignals.${key}`, true);
|
|
5229
|
+
if (value === undefined) {
|
|
5230
|
+
continue;
|
|
5231
|
+
}
|
|
5232
|
+
if (typeof value !== "string") {
|
|
5233
|
+
throw new TypeError(`inbound email.loopSignals.${key} must be a string`);
|
|
5234
|
+
}
|
|
5235
|
+
signals[key] = value.slice(0, maxLoopSignalCharacters);
|
|
5236
|
+
}
|
|
5237
|
+
const autoReply = ownDataProperty(source, "autoReplyHeaderPresent", "inbound email.loopSignals.autoReplyHeaderPresent", true);
|
|
5238
|
+
if (autoReply !== undefined) {
|
|
5239
|
+
if (typeof autoReply !== "boolean") {
|
|
5240
|
+
throw new TypeError("inbound email.loopSignals.autoReplyHeaderPresent must be a boolean");
|
|
5241
|
+
}
|
|
5242
|
+
signals.autoReplyHeaderPresent = autoReply;
|
|
5243
|
+
}
|
|
5244
|
+
return Object.freeze(signals);
|
|
5245
|
+
}
|
|
5246
|
+
function snapshotInboundSenderAuthentication(input) {
|
|
5247
|
+
if (input === undefined) {
|
|
5248
|
+
return undefined;
|
|
5249
|
+
}
|
|
5250
|
+
const field = "inbound email.senderAuthentication";
|
|
5251
|
+
const source = boundaryObject(input, field);
|
|
5252
|
+
const method = ownDataProperty(source, "method", `${field}.method`);
|
|
5253
|
+
if (method !== "dmarc" && method !== "host_identity") {
|
|
5254
|
+
throw new TypeError(`${field}.method must be dmarc or host_identity`);
|
|
5255
|
+
}
|
|
5256
|
+
const result = ownDataProperty(source, "result", `${field}.result`);
|
|
5257
|
+
if (result !== "pass" && result !== "fail") {
|
|
5258
|
+
throw new TypeError(`${field}.result must be pass or fail`);
|
|
5259
|
+
}
|
|
5260
|
+
const email = normalizeRequesterEmail(boundaryString(ownDataProperty(source, "email", `${field}.email`), `${field}.email`), `${field}.email`);
|
|
5261
|
+
if (method === "host_identity") {
|
|
5262
|
+
for (const key of ["dkimAligned", "spfAligned"]) {
|
|
5263
|
+
ownDataProperty(source, key, `${field}.${key}`, true, true);
|
|
5264
|
+
if (Object.hasOwn(source, key)) {
|
|
5265
|
+
throw new TypeError(`${field}.${key} is not valid when method is host_identity`);
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
return Object.freeze({ method, result, email });
|
|
5269
|
+
}
|
|
5270
|
+
const dkimAligned = ownDataProperty(source, "dkimAligned", `${field}.dkimAligned`);
|
|
5271
|
+
if (typeof dkimAligned !== "boolean") {
|
|
5272
|
+
throw new TypeError(`${field}.dkimAligned must be a boolean`);
|
|
5273
|
+
}
|
|
5274
|
+
const spfAligned = ownDataProperty(source, "spfAligned", `${field}.spfAligned`);
|
|
5275
|
+
if (typeof spfAligned !== "boolean") {
|
|
5276
|
+
throw new TypeError(`${field}.spfAligned must be a boolean`);
|
|
5277
|
+
}
|
|
5278
|
+
return Object.freeze({
|
|
5279
|
+
method,
|
|
5280
|
+
result,
|
|
5281
|
+
email,
|
|
5282
|
+
dkimAligned,
|
|
5283
|
+
spfAligned,
|
|
5284
|
+
});
|
|
5285
|
+
}
|
|
5286
|
+
function snapshotInboundEmailInput(input, limits) {
|
|
5287
|
+
const source = boundaryObject(input, "inbound email");
|
|
5288
|
+
const field = (key) => `inbound email.${key}`;
|
|
5289
|
+
const readString = (key) => boundaryString(ownDataProperty(source, key, field(key)), field(key));
|
|
5290
|
+
const readIdentifier = (key) => {
|
|
5291
|
+
const value = readString(key);
|
|
5292
|
+
requireIdentifier(value, field(key));
|
|
5293
|
+
return value;
|
|
5294
|
+
};
|
|
5295
|
+
const readOptional = (key) => ownDataProperty(source, key, field(key), true);
|
|
5296
|
+
const channelId = readIdentifier("channelId");
|
|
5297
|
+
const providerEventId = readIdentifier("providerEventId");
|
|
5298
|
+
const ticketId = readIdentifier("ticketId");
|
|
5299
|
+
const messageId = readIdentifier("messageId");
|
|
5300
|
+
const correlationId = readIdentifier("correlationId");
|
|
5301
|
+
const from = normalizeRequesterEmail(readString("from"), field("from"));
|
|
5302
|
+
const rawSenderAuthentication = ownDataProperty(source, "senderAuthentication", field("senderAuthentication"), true, true);
|
|
5303
|
+
const senderAuthentication = snapshotInboundSenderAuthentication(rawSenderAuthentication);
|
|
5304
|
+
const rawName = readOptional("fromName");
|
|
5305
|
+
const fromName = rawName === undefined
|
|
5306
|
+
? undefined
|
|
5307
|
+
: normalizeInboundDisplayName(boundaryString(rawName, field("fromName")));
|
|
5308
|
+
const subject = normalizeInboundSubject(readString("subject"), limits.maxSubjectCharacters);
|
|
5309
|
+
const body = normalizeInboundBody(readString("body"));
|
|
5310
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
5311
|
+
const rawFormat = readOptional("format");
|
|
5312
|
+
if (rawFormat !== undefined &&
|
|
5313
|
+
rawFormat !== "plain_text" &&
|
|
5314
|
+
rawFormat !== "markdown") {
|
|
5315
|
+
throw new TypeError(`${field("format")} must be plain_text or markdown`);
|
|
5316
|
+
}
|
|
5317
|
+
const format = rawFormat ?? "plain_text";
|
|
5318
|
+
const externalMessageId = normalizeInboundMessageId(readOptional("externalMessageId"));
|
|
5319
|
+
const inReplyTo = normalizeInboundMessageId(readOptional("inReplyTo"));
|
|
5320
|
+
const rawReferences = readOptional("references");
|
|
5321
|
+
const references = [];
|
|
5322
|
+
if (rawReferences !== undefined) {
|
|
5323
|
+
if (!Array.isArray(rawReferences)) {
|
|
5324
|
+
throw new TypeError(`${field("references")} must be an array`);
|
|
5325
|
+
}
|
|
5326
|
+
const start = Math.max(0, rawReferences.length - maxInboundReferences);
|
|
5327
|
+
for (let position = start; position < rawReferences.length; position += 1) {
|
|
5328
|
+
const normalized = normalizeInboundMessageId(ownDataProperty(rawReferences, position, `${field("references")}[${position}]`));
|
|
5329
|
+
if (normalized !== undefined && !references.includes(normalized)) {
|
|
5330
|
+
references.push(normalized);
|
|
5331
|
+
}
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
const rawToken = readOptional("replyRoutingToken");
|
|
5335
|
+
if (rawToken !== undefined && typeof rawToken !== "string") {
|
|
5336
|
+
throw new TypeError(`${field("replyRoutingToken")} must be a string`);
|
|
5337
|
+
}
|
|
5338
|
+
const token = rawToken?.trim();
|
|
5339
|
+
const replyRoutingToken = token !== undefined && replyRoutingTokenPattern.test(token)
|
|
5340
|
+
? token
|
|
5341
|
+
: undefined;
|
|
5342
|
+
const loopSignals = snapshotLoopSignals(readOptional("loopSignals"));
|
|
5343
|
+
return Object.freeze({
|
|
5344
|
+
channelId,
|
|
5345
|
+
providerEventId,
|
|
5346
|
+
ticketId,
|
|
5347
|
+
messageId,
|
|
5348
|
+
correlationId,
|
|
5349
|
+
from,
|
|
5350
|
+
...(senderAuthentication === undefined ? {} : { senderAuthentication }),
|
|
5351
|
+
...(fromName === undefined ? {} : { fromName }),
|
|
5352
|
+
subject,
|
|
5353
|
+
body,
|
|
5354
|
+
format,
|
|
5355
|
+
...(externalMessageId === undefined ? {} : { externalMessageId }),
|
|
5356
|
+
...(inReplyTo === undefined ? {} : { inReplyTo }),
|
|
5357
|
+
references: Object.freeze(references),
|
|
5358
|
+
...(replyRoutingToken === undefined ? {} : { replyRoutingToken }),
|
|
5359
|
+
loopSignals,
|
|
5360
|
+
});
|
|
5361
|
+
}
|
|
5362
|
+
/** Content fingerprint for one provider event; excludes host-minted IDs. */
|
|
5363
|
+
function inboundFingerprintPayload(input, format = "current") {
|
|
5364
|
+
const canonicalSender = format === "current";
|
|
5365
|
+
const includeSenderResult = format !== "pre_sender_result";
|
|
5366
|
+
return {
|
|
5367
|
+
type: "inbound_email",
|
|
5368
|
+
channelId: input.channelId,
|
|
5369
|
+
providerEventId: input.providerEventId,
|
|
5370
|
+
from: canonicalSender ? input.from.toLowerCase() : input.from,
|
|
5371
|
+
...(!includeSenderResult || input.senderAuthentication === undefined
|
|
5372
|
+
? {}
|
|
5373
|
+
: {
|
|
5374
|
+
senderAuthentication: canonicalSender
|
|
5375
|
+
? {
|
|
5376
|
+
...input.senderAuthentication,
|
|
5377
|
+
email: input.senderAuthentication.email.toLowerCase(),
|
|
5378
|
+
}
|
|
5379
|
+
: input.senderAuthentication,
|
|
5380
|
+
}),
|
|
5381
|
+
fromName: input.fromName ?? null,
|
|
5382
|
+
subject: input.subject,
|
|
5383
|
+
body: input.body,
|
|
5384
|
+
format: input.format,
|
|
5385
|
+
externalMessageId: input.externalMessageId ?? null,
|
|
5386
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
5387
|
+
references: input.references,
|
|
5388
|
+
replyRoutingToken: input.replyRoutingToken ?? null,
|
|
5389
|
+
loopSignals: input.loopSignals,
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
function inboundSuppressionReason(input, supportAddresses) {
|
|
5393
|
+
const signals = input.loopSignals;
|
|
5394
|
+
const autoSubmitted = signals.autoSubmitted
|
|
5395
|
+
?.split(";")[0]
|
|
5396
|
+
?.trim()
|
|
5397
|
+
.toLowerCase();
|
|
5398
|
+
if (autoSubmitted !== undefined &&
|
|
5399
|
+
autoSubmitted.length > 0 &&
|
|
5400
|
+
autoSubmitted !== "no") {
|
|
5401
|
+
return "auto_submitted";
|
|
5402
|
+
}
|
|
5403
|
+
const precedence = signals.precedence?.trim().toLowerCase();
|
|
5404
|
+
if (precedence !== undefined &&
|
|
5405
|
+
inboundSuppressingPrecedence.has(precedence)) {
|
|
5406
|
+
return "precedence";
|
|
5407
|
+
}
|
|
5408
|
+
if (signals.autoReplyHeaderPresent === true) {
|
|
5409
|
+
return "auto_reply_header";
|
|
5410
|
+
}
|
|
5411
|
+
if (signals.envelopeSender !== undefined) {
|
|
5412
|
+
const envelope = signals.envelopeSender.trim();
|
|
5413
|
+
if (envelope.length === 0 || envelope === "<>") {
|
|
5414
|
+
return "bounce";
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
const localPart = input.from.slice(0, input.from.indexOf("@")).toLowerCase();
|
|
5418
|
+
if (localPart === "mailer-daemon") {
|
|
5419
|
+
return "bounce";
|
|
5420
|
+
}
|
|
5421
|
+
const contentType = signals.contentType?.trim().toLowerCase();
|
|
5422
|
+
if (contentType !== undefined &&
|
|
5423
|
+
(contentType.startsWith("multipart/report") ||
|
|
5424
|
+
contentType.startsWith("message/delivery-status") ||
|
|
5425
|
+
contentType.startsWith("message/disposition-notification"))) {
|
|
5426
|
+
return "delivery_report";
|
|
5427
|
+
}
|
|
5428
|
+
if (supportAddresses.has(input.from.toLowerCase())) {
|
|
5429
|
+
return "support_address_loop";
|
|
5430
|
+
}
|
|
5431
|
+
return undefined;
|
|
5432
|
+
}
|
|
5433
|
+
function inboundSenderAcknowledgementAllowed(input) {
|
|
5434
|
+
const suppress = (input.loopSignals.autoResponseSuppress ?? "")
|
|
5435
|
+
.split(",")
|
|
5436
|
+
.map((value) => value.trim().toLowerCase());
|
|
5437
|
+
if (suppress.some((value) => value === "all" || value === "oof" || value === "autoreply")) {
|
|
5438
|
+
return false;
|
|
5439
|
+
}
|
|
5440
|
+
return (input.loopSignals.listId ?? "").trim().length === 0;
|
|
5441
|
+
}
|
|
5442
|
+
function subjectMarkerNumbers(subject, prefix) {
|
|
5443
|
+
const pattern = new RegExp(`\\[${prefix}-([1-9][0-9]{0,15})\\]`, "g");
|
|
5444
|
+
const found = [];
|
|
5445
|
+
for (const match of subject.matchAll(pattern)) {
|
|
5446
|
+
const value = Number(match[1]);
|
|
5447
|
+
if (Number.isSafeInteger(value) && !found.includes(value)) {
|
|
5448
|
+
found.push(value);
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
return found;
|
|
5452
|
+
}
|
|
5453
|
+
function threadingHeaderCandidates(input) {
|
|
5454
|
+
const ordered = [
|
|
5455
|
+
...(input.inReplyTo === undefined ? [] : [input.inReplyTo]),
|
|
5456
|
+
...[...input.references].reverse(),
|
|
5457
|
+
];
|
|
5458
|
+
const unique = [];
|
|
5459
|
+
for (const candidate of ordered) {
|
|
5460
|
+
if (candidate !== input.externalMessageId && !unique.includes(candidate)) {
|
|
5461
|
+
unique.push(candidate);
|
|
5462
|
+
}
|
|
5463
|
+
if (unique.length >= maxThreadingLookups) {
|
|
5464
|
+
break;
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
return unique;
|
|
5468
|
+
}
|
|
5469
|
+
function sameAddress(left, right) {
|
|
5470
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
5471
|
+
}
|
|
5472
|
+
/**
|
|
5473
|
+
* A thread match with a recorded requester address requires both exact
|
|
5474
|
+
* case-insensitive address equality and a passing host-evaluated sender result
|
|
5475
|
+
* bound to that normalized address. Without a recorded address, only an
|
|
5476
|
+
* opaque routing token can join; a `Message-ID` or subject marker alone is
|
|
5477
|
+
* never enough.
|
|
5478
|
+
*/
|
|
5479
|
+
function threadJoinIsSafe(ticket, method, input) {
|
|
5480
|
+
const email = ticket.requester.email;
|
|
5481
|
+
if (email === undefined) {
|
|
5482
|
+
return method === "routing_token";
|
|
5483
|
+
}
|
|
5484
|
+
const authentication = input.senderAuthentication;
|
|
5485
|
+
if (authentication === undefined ||
|
|
5486
|
+
authentication.result !== "pass" ||
|
|
5487
|
+
!sameAddress(email, input.from) ||
|
|
5488
|
+
!sameAddress(authentication.email, input.from)) {
|
|
5489
|
+
return false;
|
|
5490
|
+
}
|
|
5491
|
+
return (authentication.method === "host_identity" ||
|
|
5492
|
+
authentication.dkimAligned ||
|
|
5493
|
+
authentication.spfAligned);
|
|
5494
|
+
}
|
|
5495
|
+
function snapshotSenderMatch(value) {
|
|
5496
|
+
const source = boundaryObject(value, "account matcher result");
|
|
5497
|
+
const matches = ownDataProperty(source, "matches", "account matcher result.matches");
|
|
5498
|
+
if (matches === "none" || matches === "many") {
|
|
5499
|
+
return Object.freeze({ matches });
|
|
5500
|
+
}
|
|
5501
|
+
if (matches === "one") {
|
|
5502
|
+
const principalId = boundaryString(ownDataProperty(source, "principalId", "account matcher result.principalId"), "account matcher result.principalId");
|
|
5503
|
+
requireIdentifier(principalId, "account matcher result.principalId");
|
|
5504
|
+
return Object.freeze({ matches, principalId });
|
|
5505
|
+
}
|
|
5506
|
+
throw new TypeError("account matcher result.matches must be none, one, or many");
|
|
5507
|
+
}
|
|
5508
|
+
function snapshotRoutingDecision(value, allowedCategories) {
|
|
5509
|
+
const source = boundaryObject(value, "inbound routing decision");
|
|
5510
|
+
const priority = ownDataProperty(source, "priority", "inbound routing decision.priority");
|
|
5511
|
+
if (priority !== "low" && priority !== "normal" && priority !== "high") {
|
|
5512
|
+
throw new TypeError("inbound routing decision.priority must be low, normal, or high; inbound mail is never automatically urgent");
|
|
5513
|
+
}
|
|
5514
|
+
const category = snapshotCategory(ownDataProperty(source, "category", "inbound routing decision.category", true), "inbound routing decision.category");
|
|
5515
|
+
if (category !== undefined &&
|
|
5516
|
+
(allowedCategories === undefined || !allowedCategories.has(category))) {
|
|
5517
|
+
throw new TypeError("inbound routing decision.category is not configured for this Support Desk instance");
|
|
5518
|
+
}
|
|
5519
|
+
return Object.freeze({
|
|
5520
|
+
priority,
|
|
5521
|
+
...(category === undefined ? {} : { category }),
|
|
5522
|
+
});
|
|
5523
|
+
}
|
|
5524
|
+
function inboundAuditAction(input) {
|
|
5525
|
+
return supportAudit.action({
|
|
5526
|
+
id: input.commandId,
|
|
5527
|
+
occurredAt: input.occurredAt,
|
|
5528
|
+
actor: { kind: "system", systemId: inboundEmailSystemActorId },
|
|
5529
|
+
action: input.action,
|
|
5530
|
+
subject: input.ticketId,
|
|
5531
|
+
sequence: input.revision,
|
|
5532
|
+
details: {
|
|
5533
|
+
commandId: input.commandId,
|
|
5534
|
+
correlationId: input.correlationId,
|
|
5535
|
+
channel: "email",
|
|
5536
|
+
matchMethod: input.matchMethod,
|
|
5537
|
+
...(input.association === undefined
|
|
5538
|
+
? {}
|
|
5539
|
+
: { association: input.association }),
|
|
5540
|
+
},
|
|
5541
|
+
});
|
|
5542
|
+
}
|
|
5543
|
+
function inboundMessage(input, ticketId, messageId, now) {
|
|
5544
|
+
// No authorPrincipalId: a sender address never authenticates authorship.
|
|
5545
|
+
return {
|
|
5546
|
+
id: messageId,
|
|
5547
|
+
ticketId,
|
|
5548
|
+
authorKind: "customer",
|
|
5549
|
+
channel: "email",
|
|
5550
|
+
visibility: "customer",
|
|
5551
|
+
format: input.format,
|
|
5552
|
+
body: input.body,
|
|
5553
|
+
createdAt: now,
|
|
5554
|
+
...(input.externalMessageId === undefined
|
|
5555
|
+
? {}
|
|
5556
|
+
: { externalMessageId: input.externalMessageId }),
|
|
5557
|
+
...(input.inReplyTo === undefined
|
|
5558
|
+
? {}
|
|
5559
|
+
: { inReplyToExternalMessageId: input.inReplyTo }),
|
|
5560
|
+
};
|
|
5561
|
+
}
|
|
5562
|
+
function inboundResult(receipt, duplicateDelivery) {
|
|
5563
|
+
const route = receipt.route;
|
|
5564
|
+
return Object.freeze({
|
|
5565
|
+
duplicateDelivery,
|
|
5566
|
+
outcome: receipt.outcome ?? "rejected",
|
|
5567
|
+
...(receipt.ticketId === undefined ? {} : { ticketId: receipt.ticketId }),
|
|
5568
|
+
...(receipt.messageId === undefined
|
|
5569
|
+
? {}
|
|
5570
|
+
: { messageId: receipt.messageId }),
|
|
5571
|
+
...(route?.kind === "reply" || route?.kind === "new_conversation"
|
|
5572
|
+
? { matchMethod: route.matchMethod }
|
|
5573
|
+
: {}),
|
|
5574
|
+
...(route?.kind === "new_conversation"
|
|
5575
|
+
? {
|
|
5576
|
+
association: route.association,
|
|
5577
|
+
...(route.relatedTicketId === undefined
|
|
5578
|
+
? {}
|
|
5579
|
+
: { relatedTicketId: route.relatedTicketId }),
|
|
5580
|
+
}
|
|
5581
|
+
: {}),
|
|
5582
|
+
...(receipt.diagnostic === undefined ? {} : { reason: receipt.diagnostic }),
|
|
5583
|
+
});
|
|
5584
|
+
}
|
|
5585
|
+
function parseSupportAddresses(input) {
|
|
5586
|
+
const field = "inbound mail processor options.supportAddresses";
|
|
5587
|
+
if (input === undefined) {
|
|
5588
|
+
return new Set();
|
|
5589
|
+
}
|
|
5590
|
+
if (!Array.isArray(input)) {
|
|
5591
|
+
throw new TypeError(`${field} must be an array`);
|
|
5592
|
+
}
|
|
5593
|
+
if (input.length > maxSupportAddresses) {
|
|
5594
|
+
throw new TypeError(`${field} may contain at most ${maxSupportAddresses} values`);
|
|
5595
|
+
}
|
|
5596
|
+
const addresses = new Set();
|
|
5597
|
+
for (let position = 0; position < input.length; position += 1) {
|
|
5598
|
+
const entry = `${field}[${position}]`;
|
|
5599
|
+
const value = normalizeRequesterEmail(boundaryString(ownDataProperty(input, position, entry), entry), entry);
|
|
5600
|
+
addresses.add(value.toLowerCase());
|
|
5601
|
+
}
|
|
5602
|
+
return addresses;
|
|
5603
|
+
}
|
|
5604
|
+
/**
|
|
5605
|
+
* Create the inbound mail processor a host's mail worker calls after it has
|
|
5606
|
+
* verified provider authenticity and normalized bounded MIME content.
|
|
5607
|
+
*
|
|
5608
|
+
* Processing order: reserve the provider-event receipt with `insertIfAbsent`
|
|
5609
|
+
* before any ticket work; suppress bounce and auto-reply loops; persist one
|
|
5610
|
+
* routing decision on the receipt (routing token, then `In-Reply-To` /
|
|
5611
|
+
* `References`, then subject marker, each confirmed against authoritative
|
|
5612
|
+
* records); then create or reply in one ticket-partition transaction with
|
|
5613
|
+
* its Audit event, command receipt, and optional notification; finally mark
|
|
5614
|
+
* the receipt terminal and refresh the queue and threading projections.
|
|
5615
|
+
*/
|
|
5616
|
+
export function createInboundMailProcessor(options) {
|
|
5617
|
+
const field = "inbound mail processor options";
|
|
5618
|
+
const source = boundaryObject(options, field);
|
|
5619
|
+
const read = (key, optional = false) => ownDataProperty(source, key, `${field}.${key}`, optional);
|
|
5620
|
+
const store = read("store");
|
|
5621
|
+
const clock = read("clock");
|
|
5622
|
+
const rawLogger = read("logger", true);
|
|
5623
|
+
const logger = rawLogger === undefined ? noopLogger : rawLogger;
|
|
5624
|
+
const accountMatcher = read("accountMatcher");
|
|
5625
|
+
const routingPolicy = read("routingPolicy");
|
|
5626
|
+
const notificationPolicy = read("notificationPolicy", true);
|
|
5627
|
+
if (accountMatcher === undefined ||
|
|
5628
|
+
accountMatcher === null ||
|
|
5629
|
+
typeof accountMatcher.matchSenderEmail !== "function") {
|
|
5630
|
+
throw new TypeError(`${field}.accountMatcher must provide matchSenderEmail()`);
|
|
5631
|
+
}
|
|
5632
|
+
if (routingPolicy === undefined ||
|
|
5633
|
+
routingPolicy === null ||
|
|
5634
|
+
typeof routingPolicy.routeNewConversation !== "function") {
|
|
5635
|
+
throw new TypeError(`${field}.routingPolicy must provide routeNewConversation()`);
|
|
5636
|
+
}
|
|
5637
|
+
if (notificationPolicy !== undefined &&
|
|
5638
|
+
(notificationPolicy === null ||
|
|
5639
|
+
typeof notificationPolicy.notificationFor !== "function")) {
|
|
5640
|
+
throw new TypeError(`${field}.notificationPolicy must provide notificationFor()`);
|
|
5641
|
+
}
|
|
5642
|
+
const limits = resolveSupportDeskLimits(read("limits", true), `${field}.limits`);
|
|
5643
|
+
const rawAttempts = read("maxConflictAttempts", true);
|
|
5644
|
+
if (rawAttempts !== undefined && typeof rawAttempts !== "number") {
|
|
5645
|
+
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
5646
|
+
}
|
|
5647
|
+
const maxConflictAttempts = rawAttempts ?? 4;
|
|
5648
|
+
if (!Number.isSafeInteger(maxConflictAttempts) ||
|
|
5649
|
+
maxConflictAttempts <= 0 ||
|
|
5650
|
+
maxConflictAttempts > 20) {
|
|
5651
|
+
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
5652
|
+
}
|
|
5653
|
+
const rawTerminalRetention = read("queueTerminalRetentionMilliseconds", true);
|
|
5654
|
+
if (rawTerminalRetention !== undefined &&
|
|
5655
|
+
typeof rawTerminalRetention !== "number") {
|
|
5656
|
+
throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
|
|
5657
|
+
}
|
|
5658
|
+
const terminalRetentionMilliseconds = rawTerminalRetention ??
|
|
5659
|
+
defaultQueueTerminalRetentionMilliseconds;
|
|
5660
|
+
if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
|
|
5661
|
+
terminalRetentionMilliseconds < 0) {
|
|
5662
|
+
throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
|
|
5663
|
+
}
|
|
5664
|
+
const rawThreadRetention = read("threadIndexRetentionMilliseconds", true);
|
|
5665
|
+
const threadIndexRetentionMilliseconds = rawThreadRetention === undefined
|
|
5666
|
+
? defaultThreadIndexRetentionMilliseconds
|
|
5667
|
+
: parseRetentionMilliseconds(rawThreadRetention, `${field}.threadIndexRetentionMilliseconds`);
|
|
5668
|
+
const responseTargetPolicy = parseResponseTargetPolicy(read("responseTargetPolicy", true));
|
|
5669
|
+
const allowedCategories = parseAllowedCategories(read("allowedCategories", true), `${field}.allowedCategories`);
|
|
5670
|
+
const rawPrefix = read("subjectMarkerPrefix", true);
|
|
5671
|
+
if (rawPrefix !== undefined &&
|
|
5672
|
+
(typeof rawPrefix !== "string" ||
|
|
5673
|
+
!subjectMarkerPrefixPattern.test(rawPrefix))) {
|
|
5674
|
+
throw new TypeError(`${field}.subjectMarkerPrefix must match ${subjectMarkerPrefixPattern.source}`);
|
|
5675
|
+
}
|
|
5676
|
+
const subjectMarkerPrefix = rawPrefix;
|
|
5677
|
+
const supportAddresses = parseSupportAddresses(read("supportAddresses", true));
|
|
5678
|
+
const records = store.collection(supportRecords);
|
|
5679
|
+
const receipts = store.collection(inboundReceipts);
|
|
5680
|
+
const numbers = store.collection(ticketNumbers);
|
|
5681
|
+
const index = store.collection(threadIndex);
|
|
5682
|
+
async function bindInboundReplayFingerprint(key, stored, input, replayFingerprint) {
|
|
5683
|
+
if (stored.replayFingerprint !== undefined) {
|
|
5684
|
+
if (stored.replayFingerprint !== replayFingerprint) {
|
|
5685
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5686
|
+
}
|
|
5687
|
+
return stored;
|
|
5688
|
+
}
|
|
5689
|
+
const compatibleFingerprints = new Set([replayFingerprint]);
|
|
5690
|
+
if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
|
|
5691
|
+
const priorFingerprints = await Promise.all([
|
|
5692
|
+
requestFingerprint(inboundFingerprintPayload(input, "case_preserving_sender")),
|
|
5693
|
+
requestFingerprint(inboundFingerprintPayload(input, "pre_sender_result")),
|
|
5694
|
+
]);
|
|
5695
|
+
for (const prior of priorFingerprints) {
|
|
5696
|
+
compatibleFingerprints.add(prior);
|
|
5697
|
+
}
|
|
5698
|
+
}
|
|
5699
|
+
if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
|
|
5700
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5701
|
+
}
|
|
5702
|
+
const bound = await receipts.update(key, (current) => {
|
|
5703
|
+
if (current === null) {
|
|
5704
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
|
|
5705
|
+
}
|
|
5706
|
+
if (current.replayFingerprint !== undefined) {
|
|
5707
|
+
return { action: "keep" };
|
|
5708
|
+
}
|
|
5709
|
+
if (!compatibleFingerprints.has(current.payloadFingerprint)) {
|
|
5710
|
+
return { action: "keep" };
|
|
5711
|
+
}
|
|
5712
|
+
return {
|
|
5713
|
+
action: "write",
|
|
5714
|
+
value: { ...current, replayFingerprint },
|
|
5715
|
+
};
|
|
5716
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5717
|
+
if (bound.value === null) {
|
|
5718
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
|
|
5719
|
+
}
|
|
5720
|
+
if (bound.value.replayFingerprint !== replayFingerprint ||
|
|
5721
|
+
!compatibleFingerprints.has(bound.value.payloadFingerprint)) {
|
|
5722
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5723
|
+
}
|
|
5724
|
+
return bound.value;
|
|
5725
|
+
}
|
|
5726
|
+
async function finishReceipt(work, terminal) {
|
|
5727
|
+
const processedAt = clock.now();
|
|
5728
|
+
canonicalTimestamp(processedAt, "clock.now()");
|
|
5729
|
+
const result = await receipts.update(work.key, (current) => {
|
|
5730
|
+
if (current === null) {
|
|
5731
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
|
|
5732
|
+
}
|
|
5733
|
+
if (current.status !== "processing" && current.status !== "rerouting") {
|
|
5734
|
+
return { action: "keep" };
|
|
5735
|
+
}
|
|
5736
|
+
if (!sameInboundReceiptRoute(current.route, work.route)) {
|
|
5737
|
+
throw new SupportDeskConflictError("Inbound route changed before the receipt was finalized");
|
|
5738
|
+
}
|
|
5739
|
+
return {
|
|
5740
|
+
action: "write",
|
|
5741
|
+
value: {
|
|
5742
|
+
...current,
|
|
5743
|
+
status: terminal.status,
|
|
5744
|
+
outcome: terminal.outcome,
|
|
5745
|
+
processedAt,
|
|
5746
|
+
...(terminal.ticketId === undefined
|
|
5747
|
+
? {}
|
|
5748
|
+
: { ticketId: terminal.ticketId }),
|
|
5749
|
+
...(terminal.messageId === undefined
|
|
5750
|
+
? {}
|
|
5751
|
+
: { messageId: terminal.messageId }),
|
|
5752
|
+
...(terminal.diagnostic === undefined
|
|
5753
|
+
? {}
|
|
5754
|
+
: { diagnostic: terminal.diagnostic }),
|
|
5755
|
+
},
|
|
5756
|
+
};
|
|
5757
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5758
|
+
if (result.value === null) {
|
|
5759
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
|
|
5760
|
+
}
|
|
5761
|
+
return inboundResult(result.value, work.duplicateDelivery);
|
|
5762
|
+
}
|
|
5763
|
+
async function afterInboundCommit(ticketId, messageId, ticketNumber) {
|
|
5764
|
+
try {
|
|
5765
|
+
await projectTicketToQueue(ticketId, {
|
|
5766
|
+
store,
|
|
5767
|
+
clock,
|
|
5768
|
+
terminalRetentionMilliseconds,
|
|
5769
|
+
maxConflictAttempts,
|
|
5770
|
+
});
|
|
5771
|
+
}
|
|
5772
|
+
catch (error) {
|
|
5773
|
+
safeLog(logger, "error", "support-desk.queue.projection_failed", {
|
|
5774
|
+
ticketId,
|
|
5775
|
+
message: error instanceof Error ? error.message : "queue projection failed",
|
|
5776
|
+
});
|
|
5777
|
+
}
|
|
5778
|
+
await indexThreadingAfterCommit(store, ticketId, { messageId, ticketNumber }, logger);
|
|
5779
|
+
}
|
|
5780
|
+
async function inboundNotification(context) {
|
|
5781
|
+
if (notificationPolicy === undefined) {
|
|
5782
|
+
return undefined;
|
|
5783
|
+
}
|
|
5784
|
+
const proposed = await notificationPolicy.notificationFor(Object.freeze({ ...context }));
|
|
5785
|
+
if (proposed === undefined) {
|
|
5786
|
+
return undefined;
|
|
5787
|
+
}
|
|
5788
|
+
return bindNotificationTicketNumber(snapshotNotification(proposed), context.ticketNumber);
|
|
5789
|
+
}
|
|
5790
|
+
async function proposeNewConversation(input, reservedTicketId, matchMethod, relatedTicketId, forceUnverified = false) {
|
|
5791
|
+
const match = relatedTicketId === undefined && !forceUnverified
|
|
5792
|
+
? snapshotSenderMatch(await accountMatcher.matchSenderEmail(Object.freeze({ channelId: input.channelId, email: input.from })))
|
|
5793
|
+
: { matches: "none" };
|
|
5794
|
+
const association = match.matches === "one" ? "matched_email" : "unverified";
|
|
5795
|
+
const decision = snapshotRoutingDecision(await routingPolicy.routeNewConversation(Object.freeze({
|
|
5796
|
+
channelId: input.channelId,
|
|
5797
|
+
association,
|
|
5798
|
+
...(match.matches === "one"
|
|
5799
|
+
? { principalId: match.principalId }
|
|
5800
|
+
: {}),
|
|
5801
|
+
...(relatedTicketId === undefined ? {} : { relatedTicketId }),
|
|
5802
|
+
})), allowedCategories);
|
|
5803
|
+
return {
|
|
5804
|
+
kind: "new_conversation",
|
|
5805
|
+
ticketId: reservedTicketId,
|
|
5806
|
+
matchMethod,
|
|
5807
|
+
association,
|
|
5808
|
+
...(match.matches === "one" ? { principalId: match.principalId } : {}),
|
|
5809
|
+
priority: decision.priority,
|
|
5810
|
+
...(decision.category === undefined
|
|
5811
|
+
? {}
|
|
5812
|
+
: { category: decision.category }),
|
|
5813
|
+
...(relatedTicketId === undefined ? {} : { relatedTicketId }),
|
|
5814
|
+
};
|
|
5815
|
+
}
|
|
5816
|
+
async function claimRouteForRerouting(key, expected) {
|
|
5817
|
+
const claimed = await receipts.update(key, (current) => {
|
|
5818
|
+
if (current === null) {
|
|
5819
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5820
|
+
}
|
|
5821
|
+
if (current.status !== "processing" ||
|
|
5822
|
+
!sameInboundReceiptRoute(current.route, expected)) {
|
|
5823
|
+
return { action: "keep" };
|
|
5824
|
+
}
|
|
5825
|
+
return {
|
|
5826
|
+
action: "write",
|
|
5827
|
+
value: { ...current, status: "rerouting" },
|
|
5828
|
+
};
|
|
5829
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5830
|
+
if (claimed.value === null) {
|
|
5831
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5832
|
+
}
|
|
5833
|
+
return claimed.value;
|
|
5834
|
+
}
|
|
5835
|
+
async function replaceReroutingRoute(key, expected, proposed) {
|
|
5836
|
+
const replaced = await receipts.update(key, (current) => {
|
|
5837
|
+
if (current === null) {
|
|
5838
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5839
|
+
}
|
|
5840
|
+
if (current.status !== "rerouting" ||
|
|
5841
|
+
!sameInboundReceiptRoute(current.route, expected)) {
|
|
5842
|
+
return { action: "keep" };
|
|
5843
|
+
}
|
|
5844
|
+
return { action: "write", value: { ...current, route: proposed } };
|
|
5845
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5846
|
+
if (replaced.value === null) {
|
|
5847
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5848
|
+
}
|
|
5849
|
+
return replaced.value;
|
|
5850
|
+
}
|
|
5851
|
+
async function reserveReplyRerouteFence(input) {
|
|
5852
|
+
const location = commandKey(input.ticketId, input.commandId);
|
|
5853
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
5854
|
+
const existing = await records.get(location);
|
|
5855
|
+
if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5856
|
+
return true;
|
|
5857
|
+
}
|
|
5858
|
+
if (existing !== null) {
|
|
5859
|
+
return false;
|
|
5860
|
+
}
|
|
5861
|
+
const ticket = await records.getVersioned(ticketKey(input.ticketId));
|
|
5862
|
+
if (ticket?.value.kind !== "ticket") {
|
|
5863
|
+
return false;
|
|
5864
|
+
}
|
|
5865
|
+
const quota = await records.getVersioned(ticketQuotaKey(input.ticketId));
|
|
5866
|
+
if (quota?.value.kind !== "ticket_quota") {
|
|
5867
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
5868
|
+
}
|
|
5869
|
+
const partitionRows = await records.list(input.ticketId);
|
|
5870
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
|
|
5871
|
+
enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
|
|
5872
|
+
const outcome = await records.transact(input.ticketId, [
|
|
5873
|
+
{
|
|
5874
|
+
action: "putIfUnchanged",
|
|
5875
|
+
version: ticket.version,
|
|
5876
|
+
value: ticket.value,
|
|
5877
|
+
},
|
|
5878
|
+
{
|
|
5879
|
+
action: "putIfUnchanged",
|
|
5880
|
+
version: quota.version,
|
|
5881
|
+
value: quota.value,
|
|
5882
|
+
},
|
|
5883
|
+
{
|
|
5884
|
+
action: "insert",
|
|
5885
|
+
value: {
|
|
5886
|
+
kind: "inbound_reply_reroute_fence",
|
|
5887
|
+
partition: input.ticketId,
|
|
5888
|
+
id: location.id,
|
|
5889
|
+
commandId: input.commandId,
|
|
5890
|
+
messageId: input.messageId,
|
|
5891
|
+
requestFingerprint: input.fingerprint,
|
|
5892
|
+
recordedAt: input.recordedAt,
|
|
5893
|
+
},
|
|
5894
|
+
},
|
|
5895
|
+
]);
|
|
5896
|
+
if (outcome.committed) {
|
|
5897
|
+
return true;
|
|
5898
|
+
}
|
|
5899
|
+
}
|
|
5900
|
+
const existing = await records.get(location);
|
|
5901
|
+
if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5902
|
+
return true;
|
|
5903
|
+
}
|
|
5904
|
+
if (existing !== null) {
|
|
5905
|
+
return false;
|
|
5906
|
+
}
|
|
5907
|
+
throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
|
|
5908
|
+
}
|
|
5909
|
+
async function reserveCreateRerouteFence(input) {
|
|
5910
|
+
const location = commandKey(input.ticketId, input.commandId);
|
|
5911
|
+
const existing = await records.get(location);
|
|
5912
|
+
if (inboundCreateRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5913
|
+
return true;
|
|
5914
|
+
}
|
|
5915
|
+
if (existing !== null ||
|
|
5916
|
+
(await records.get(ticketKey(input.ticketId))) !== null) {
|
|
5917
|
+
return false;
|
|
5918
|
+
}
|
|
5919
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 1);
|
|
5920
|
+
const inserted = await records.insertIfAbsent({
|
|
5921
|
+
kind: "inbound_create_reroute_fence",
|
|
5922
|
+
partition: input.ticketId,
|
|
5923
|
+
id: location.id,
|
|
5924
|
+
commandId: input.commandId,
|
|
5925
|
+
messageId: input.messageId,
|
|
5926
|
+
requestFingerprint: input.fingerprint,
|
|
5927
|
+
recordedAt: input.recordedAt,
|
|
5928
|
+
});
|
|
5929
|
+
return (inserted.inserted ||
|
|
5930
|
+
inboundCreateRerouteFenceMatches(inserted.value, input.commandId, input.messageId, input.fingerprint));
|
|
5931
|
+
}
|
|
5932
|
+
async function proposeRoute(input, reservedTicketId) {
|
|
5933
|
+
const reason = inboundSuppressionReason(input, supportAddresses);
|
|
5934
|
+
if (reason !== undefined) {
|
|
5935
|
+
return { kind: "suppressed", reason };
|
|
5936
|
+
}
|
|
5937
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5938
|
+
const lookup = (kind, key) => lookupThreadEntry(records, index, kind, key, nowEpoch, threadIndexRetentionMilliseconds);
|
|
5939
|
+
// The same Message-ID delivered under another provider event is the same
|
|
5940
|
+
// message only when the sender satisfies the existing-ticket join rule.
|
|
5941
|
+
let repeatedMessageTicket;
|
|
5942
|
+
if (input.externalMessageId !== undefined) {
|
|
5943
|
+
const own = await lookup("message_id", await messageIdIndexKey(input.externalMessageId));
|
|
5944
|
+
if (own !== null &&
|
|
5945
|
+
own.entry.source === "inbound" &&
|
|
5946
|
+
own.entry.messageId !== undefined) {
|
|
5947
|
+
if (threadJoinIsSafe(own.ticket, "message_id", input)) {
|
|
5948
|
+
return {
|
|
5949
|
+
kind: "duplicate_message",
|
|
5950
|
+
ticketId: own.ticket.id,
|
|
5951
|
+
messageId: own.entry.messageId,
|
|
5952
|
+
};
|
|
5953
|
+
}
|
|
5954
|
+
repeatedMessageTicket = own.ticket;
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
let candidate;
|
|
5958
|
+
let ambiguous = false;
|
|
5959
|
+
if (input.replyRoutingToken !== undefined) {
|
|
5960
|
+
const hit = await lookup("reply_token", await replyTokenIndexKey(input.replyRoutingToken));
|
|
5961
|
+
if (hit !== null) {
|
|
5962
|
+
candidate = { ticket: hit.ticket, method: "routing_token" };
|
|
5963
|
+
}
|
|
5964
|
+
}
|
|
5965
|
+
if (candidate === undefined) {
|
|
5966
|
+
const found = [];
|
|
5967
|
+
for (const header of threadingHeaderCandidates(input)) {
|
|
5968
|
+
const hit = await lookup("message_id", await messageIdIndexKey(header));
|
|
5969
|
+
if (hit !== null &&
|
|
5970
|
+
!found.some((ticket) => ticket.id === hit.ticket.id)) {
|
|
5971
|
+
found.push(hit.ticket);
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
const first = found[0];
|
|
5975
|
+
if (first !== undefined) {
|
|
5976
|
+
candidate = { ticket: first, method: "message_id" };
|
|
5977
|
+
ambiguous = found.length > 1;
|
|
5978
|
+
}
|
|
5979
|
+
}
|
|
5980
|
+
// Keep a repeated inbound Message-ID ahead of the display-only subject
|
|
5981
|
+
// marker when a stronger token or threading-header locator did not match.
|
|
5982
|
+
if (candidate === undefined && repeatedMessageTicket !== undefined) {
|
|
5983
|
+
candidate = {
|
|
5984
|
+
ticket: repeatedMessageTicket,
|
|
5985
|
+
method: "message_id",
|
|
5986
|
+
};
|
|
5987
|
+
}
|
|
5988
|
+
if (candidate === undefined && subjectMarkerPrefix !== undefined) {
|
|
5989
|
+
const markers = subjectMarkerNumbers(input.subject, subjectMarkerPrefix);
|
|
5990
|
+
ambiguous = markers.length > 1;
|
|
5991
|
+
for (const marker of markers) {
|
|
5992
|
+
const hit = await lookup("ticket_number", ticketNumberIndexKey(marker));
|
|
5993
|
+
if (hit !== null && candidate === undefined) {
|
|
5994
|
+
candidate = { ticket: hit.ticket, method: "subject_marker" };
|
|
5995
|
+
}
|
|
5996
|
+
}
|
|
5997
|
+
}
|
|
5998
|
+
if (candidate !== undefined &&
|
|
5999
|
+
!ambiguous &&
|
|
6000
|
+
candidate.ticket.id !== reservedTicketId &&
|
|
6001
|
+
threadJoinIsSafe(candidate.ticket, candidate.method, input)) {
|
|
6002
|
+
return {
|
|
6003
|
+
kind: "reply",
|
|
6004
|
+
ticketId: candidate.ticket.id,
|
|
6005
|
+
matchMethod: candidate.method,
|
|
6006
|
+
};
|
|
6007
|
+
}
|
|
6008
|
+
const relatedTicketId = candidate !== undefined && candidate.ticket.id !== reservedTicketId
|
|
6009
|
+
? candidate.ticket.id
|
|
6010
|
+
: undefined;
|
|
6011
|
+
return proposeNewConversation(input, reservedTicketId, candidate?.method ?? "none", relatedTicketId, ambiguous);
|
|
6012
|
+
}
|
|
6013
|
+
async function decideRoute(key, stored, input, reservedTicketId, commandId, messageId, fingerprint) {
|
|
6014
|
+
if (stored.status !== "processing" && stored.status !== "rerouting") {
|
|
6015
|
+
return stored;
|
|
6016
|
+
}
|
|
6017
|
+
if (stored.route !== undefined) {
|
|
6018
|
+
const route = stored.route;
|
|
6019
|
+
if (route.kind === "suppressed") {
|
|
6020
|
+
return stored;
|
|
6021
|
+
}
|
|
6022
|
+
if (route.kind === "duplicate_message") {
|
|
6023
|
+
const target = await records.get(ticketKey(route.ticketId));
|
|
6024
|
+
if (target?.kind === "ticket" &&
|
|
6025
|
+
threadJoinIsSafe(target.ticket, "message_id", input)) {
|
|
6026
|
+
return stored;
|
|
6027
|
+
}
|
|
6028
|
+
if (stored.status === "processing") {
|
|
6029
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
6030
|
+
}
|
|
6031
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, "message_id", route.ticketId, true));
|
|
6032
|
+
}
|
|
6033
|
+
if (route.kind === "new_conversation") {
|
|
6034
|
+
if (route.relatedTicketId === undefined ||
|
|
6035
|
+
(route.association === "unverified" &&
|
|
6036
|
+
route.principalId === undefined)) {
|
|
6037
|
+
return stored;
|
|
6038
|
+
}
|
|
6039
|
+
const existing = await records.get(commandKey(route.ticketId, commandId));
|
|
6040
|
+
const alreadyFenced = inboundCreateRerouteFenceMatches(existing, commandId, messageId, fingerprint);
|
|
6041
|
+
// A committed create remains authoritative for receipt replay.
|
|
6042
|
+
if (existing !== null && !alreadyFenced) {
|
|
6043
|
+
return stored;
|
|
6044
|
+
}
|
|
6045
|
+
if (stored.status === "processing") {
|
|
6046
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
6047
|
+
}
|
|
6048
|
+
if (!alreadyFenced &&
|
|
6049
|
+
!(await reserveCreateRerouteFence({
|
|
6050
|
+
ticketId: route.ticketId,
|
|
6051
|
+
commandId,
|
|
6052
|
+
messageId,
|
|
6053
|
+
fingerprint,
|
|
6054
|
+
recordedAt: stored.receivedAt,
|
|
6055
|
+
}))) {
|
|
6056
|
+
return stored;
|
|
6057
|
+
}
|
|
6058
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.relatedTicketId, true));
|
|
6059
|
+
}
|
|
6060
|
+
const commandLocation = commandKey(route.ticketId, commandId);
|
|
6061
|
+
const existing = await records.get(commandLocation);
|
|
6062
|
+
const alreadyFenced = inboundReplyRerouteFenceMatches(existing, commandId, messageId, fingerprint);
|
|
6063
|
+
if (existing !== null && !alreadyFenced) {
|
|
6064
|
+
return stored;
|
|
6065
|
+
}
|
|
6066
|
+
const target = await records.get(ticketKey(route.ticketId));
|
|
6067
|
+
if (!alreadyFenced &&
|
|
6068
|
+
(target?.kind !== "ticket" ||
|
|
6069
|
+
threadJoinIsSafe(target.ticket, route.matchMethod, input))) {
|
|
6070
|
+
return stored;
|
|
6071
|
+
}
|
|
6072
|
+
if (stored.status === "processing") {
|
|
6073
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
6074
|
+
}
|
|
6075
|
+
if (!alreadyFenced) {
|
|
6076
|
+
const fenced = await reserveReplyRerouteFence({
|
|
6077
|
+
ticketId: route.ticketId,
|
|
6078
|
+
commandId,
|
|
6079
|
+
messageId,
|
|
6080
|
+
fingerprint,
|
|
6081
|
+
recordedAt: stored.receivedAt,
|
|
6082
|
+
});
|
|
6083
|
+
if (!fenced) {
|
|
6084
|
+
return stored;
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.ticketId, true));
|
|
6088
|
+
}
|
|
6089
|
+
const proposed = await proposeRoute(input, reservedTicketId);
|
|
6090
|
+
const result = await receipts.update(key, (current) => {
|
|
6091
|
+
if (current === null) {
|
|
6092
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
6093
|
+
}
|
|
6094
|
+
if (current.status !== "processing" || current.route !== undefined) {
|
|
6095
|
+
return { action: "keep" };
|
|
6096
|
+
}
|
|
6097
|
+
return { action: "write", value: { ...current, route: proposed } };
|
|
6098
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
6099
|
+
if (result.value === null ||
|
|
6100
|
+
((result.value.status === "processing" ||
|
|
6101
|
+
result.value.status === "rerouting") &&
|
|
6102
|
+
result.value.route === undefined)) {
|
|
6103
|
+
throw new SupportDeskConflictError("Inbound routing decision was not stored");
|
|
6104
|
+
}
|
|
6105
|
+
return result.value;
|
|
6106
|
+
}
|
|
6107
|
+
async function createConversation(work, route) {
|
|
6108
|
+
const { input, messageId, commandId, fingerprint } = work;
|
|
6109
|
+
const ticketId = route.ticketId;
|
|
6110
|
+
const commandLocation = commandKey(ticketId, commandId);
|
|
6111
|
+
const existingVersioned = await records.getVersioned(commandLocation);
|
|
6112
|
+
const existing = existingVersioned?.value ?? null;
|
|
6113
|
+
const createFence = existingVersioned !== null &&
|
|
6114
|
+
inboundCreateRerouteFenceMatches(existingVersioned.value, commandId, messageId, fingerprint)
|
|
6115
|
+
? existingVersioned
|
|
6116
|
+
: null;
|
|
6117
|
+
if (existing !== null && createFence === null) {
|
|
6118
|
+
if (!duplicateMatches(existing, "inbound_email_create", messageId, fingerprint)) {
|
|
6119
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
else {
|
|
6123
|
+
// ticket, quota, message, audit, command, and an optional delivery job.
|
|
6124
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 6);
|
|
6125
|
+
// Reserved outside the ticket transaction; a failed create leaves a gap.
|
|
6126
|
+
const ticketNumber = await reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
6127
|
+
const now = clock.now();
|
|
6128
|
+
const displayName = input.fromName === undefined ? {} : { displayName: input.fromName };
|
|
6129
|
+
const ticket = await applyResponseTargets(createTicket({
|
|
6130
|
+
id: ticketId,
|
|
6131
|
+
number: ticketNumber,
|
|
6132
|
+
subject: input.subject,
|
|
6133
|
+
channel: "email",
|
|
6134
|
+
priority: route.priority,
|
|
6135
|
+
...(route.category === undefined ? {} : { category: route.category }),
|
|
6136
|
+
requester: route.association === "matched_email"
|
|
6137
|
+
? {
|
|
6138
|
+
association: "matched_email",
|
|
6139
|
+
principalId: route.principalId,
|
|
6140
|
+
email: input.from,
|
|
6141
|
+
...displayName,
|
|
6142
|
+
}
|
|
6143
|
+
: {
|
|
6144
|
+
association: "unverified",
|
|
6145
|
+
email: input.from,
|
|
6146
|
+
...displayName,
|
|
6147
|
+
},
|
|
6148
|
+
createdAt: now,
|
|
6149
|
+
...(route.relatedTicketId === undefined
|
|
6150
|
+
? {}
|
|
6151
|
+
: { relatedTicketId: route.relatedTicketId }),
|
|
6152
|
+
}), responseTargetPolicy, "created");
|
|
6153
|
+
const notification = await inboundNotification({
|
|
6154
|
+
outcome: "created",
|
|
6155
|
+
channelId: input.channelId,
|
|
6156
|
+
ticketId,
|
|
6157
|
+
ticketNumber,
|
|
6158
|
+
messageId,
|
|
6159
|
+
association: route.association,
|
|
6160
|
+
matchMethod: route.matchMethod,
|
|
6161
|
+
senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
|
|
6162
|
+
});
|
|
6163
|
+
const notificationContent = deliveryContent(notification);
|
|
6164
|
+
const commandRecord = {
|
|
6165
|
+
kind: "command",
|
|
6166
|
+
partition: ticketId,
|
|
6167
|
+
id: commandLocation.id,
|
|
6168
|
+
commandId,
|
|
6169
|
+
commandType: "inbound_email_create",
|
|
6170
|
+
messageId,
|
|
6171
|
+
requestFingerprint: fingerprint,
|
|
6172
|
+
completedAt: now,
|
|
6173
|
+
};
|
|
6174
|
+
const actions = [
|
|
6175
|
+
{
|
|
6176
|
+
action: "insert",
|
|
6177
|
+
value: { kind: "ticket", partition: ticketId, id: "ticket", ticket },
|
|
6178
|
+
},
|
|
6179
|
+
{
|
|
6180
|
+
action: "insert",
|
|
6181
|
+
value: {
|
|
6182
|
+
kind: "ticket_quota",
|
|
6183
|
+
partition: ticketId,
|
|
6184
|
+
id: "quota",
|
|
6185
|
+
messageCount: 1,
|
|
6186
|
+
},
|
|
6187
|
+
},
|
|
6188
|
+
{
|
|
6189
|
+
action: "insert",
|
|
6190
|
+
value: {
|
|
6191
|
+
kind: "message",
|
|
6192
|
+
partition: ticketId,
|
|
6193
|
+
id: `message:${messageId}`,
|
|
6194
|
+
ordinal: ticket.revision,
|
|
6195
|
+
message: inboundMessage(input, ticketId, messageId, now),
|
|
6196
|
+
...(notificationContent === undefined
|
|
6197
|
+
? {}
|
|
6198
|
+
: { deliveryContent: notificationContent }),
|
|
6199
|
+
},
|
|
6200
|
+
},
|
|
6201
|
+
inboundAuditAction({
|
|
6202
|
+
commandId,
|
|
6203
|
+
correlationId: work.correlationId,
|
|
6204
|
+
ticketId,
|
|
6205
|
+
revision: ticket.revision,
|
|
6206
|
+
occurredAt: now,
|
|
6207
|
+
action: supportTicketAuditActions.created,
|
|
6208
|
+
matchMethod: route.matchMethod,
|
|
6209
|
+
association: route.association,
|
|
6210
|
+
}),
|
|
6211
|
+
...(createFence === null
|
|
6212
|
+
? [{ action: "insert", value: commandRecord }]
|
|
6213
|
+
: [
|
|
6214
|
+
{
|
|
6215
|
+
action: "putIfUnchanged",
|
|
6216
|
+
version: createFence.version,
|
|
6217
|
+
value: commandRecord,
|
|
6218
|
+
},
|
|
6219
|
+
]),
|
|
6220
|
+
...(notification === undefined
|
|
6221
|
+
? []
|
|
6222
|
+
: [deliveryJobAction(ticketId, messageId, now, notification)]),
|
|
6223
|
+
];
|
|
6224
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
|
|
6225
|
+
const outcome = await records.transact(ticketId, actions);
|
|
6226
|
+
if (!outcome.committed) {
|
|
6227
|
+
const raced = await records.get(commandKey(ticketId, commandId));
|
|
6228
|
+
if (!duplicateMatches(raced, "inbound_email_create", messageId, fingerprint)) {
|
|
6229
|
+
if (raced !== null &&
|
|
6230
|
+
!inboundCreateRerouteFenceMatches(raced, commandId, messageId, fingerprint)) {
|
|
6231
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6232
|
+
}
|
|
6233
|
+
throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
6237
|
+
const result = await finishReceipt(work, {
|
|
6238
|
+
status: "processed",
|
|
6239
|
+
outcome: "created",
|
|
6240
|
+
ticketId,
|
|
6241
|
+
messageId,
|
|
6242
|
+
});
|
|
6243
|
+
await afterInboundCommit(ticketId, messageId, true);
|
|
6244
|
+
return result;
|
|
6245
|
+
}
|
|
6246
|
+
async function appendReply(work, route) {
|
|
6247
|
+
const { input, messageId, commandId, fingerprint } = work;
|
|
6248
|
+
const ticketId = route.ticketId;
|
|
6249
|
+
const completed = async () => {
|
|
6250
|
+
const result = await finishReceipt(work, {
|
|
6251
|
+
status: "processed",
|
|
6252
|
+
outcome: "replied",
|
|
6253
|
+
ticketId,
|
|
6254
|
+
messageId,
|
|
6255
|
+
});
|
|
6256
|
+
await afterInboundCommit(ticketId, messageId, false);
|
|
6257
|
+
return result;
|
|
6258
|
+
};
|
|
6259
|
+
const rejected = (diagnostic) => finishReceipt(work, {
|
|
6260
|
+
status: "rejected",
|
|
6261
|
+
outcome: "rejected",
|
|
6262
|
+
diagnostic,
|
|
6263
|
+
});
|
|
6264
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
6265
|
+
const duplicate = await records.get(commandKey(ticketId, commandId));
|
|
6266
|
+
if (duplicate !== null) {
|
|
6267
|
+
if (!duplicateMatches(duplicate, "inbound_email_reply", messageId, fingerprint)) {
|
|
6268
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6269
|
+
}
|
|
6270
|
+
return completed();
|
|
6271
|
+
}
|
|
6272
|
+
const versioned = await records.getVersioned(ticketKey(ticketId));
|
|
6273
|
+
if (versioned?.value.kind !== "ticket") {
|
|
6274
|
+
return rejected("ticket_unavailable");
|
|
6275
|
+
}
|
|
6276
|
+
const ticket = versioned.value.ticket;
|
|
6277
|
+
const partitionRows = await records.list(ticketId);
|
|
6278
|
+
if (input.externalMessageId !== undefined) {
|
|
6279
|
+
const prior = partitionRows.find((row) => row.kind === "message" &&
|
|
6280
|
+
row.message.channel === "email" &&
|
|
6281
|
+
row.message.authorKind === "customer" &&
|
|
6282
|
+
row.message.externalMessageId === input.externalMessageId);
|
|
6283
|
+
if (prior !== undefined) {
|
|
6284
|
+
return finishReceipt(work, {
|
|
6285
|
+
status: "processed",
|
|
6286
|
+
outcome: "duplicate_message",
|
|
6287
|
+
ticketId,
|
|
6288
|
+
messageId: prior.message.id,
|
|
6289
|
+
});
|
|
6290
|
+
}
|
|
6291
|
+
}
|
|
6292
|
+
const quota = await records.getVersioned(ticketQuotaKey(ticketId));
|
|
6293
|
+
if (quota?.value.kind !== "ticket_quota") {
|
|
6294
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
6295
|
+
}
|
|
6296
|
+
if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
|
|
6297
|
+
return rejected("ticket_messages_limit");
|
|
6298
|
+
}
|
|
6299
|
+
const notification = await inboundNotification({
|
|
6300
|
+
outcome: "replied",
|
|
6301
|
+
channelId: input.channelId,
|
|
6302
|
+
ticketId,
|
|
6303
|
+
ticketNumber: ticket.number,
|
|
6304
|
+
messageId,
|
|
6305
|
+
association: ticket.requester.association,
|
|
6306
|
+
matchMethod: route.matchMethod,
|
|
6307
|
+
...(ticket.assignedTo === undefined
|
|
6308
|
+
? {}
|
|
6309
|
+
: { assignedTo: ticket.assignedTo }),
|
|
6310
|
+
senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
|
|
6311
|
+
});
|
|
6312
|
+
const requiresHandoff = effectiveTicketHandling(ticket).state === "assistant";
|
|
6313
|
+
const now = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
6314
|
+
// Customer-reply semantics: any status, including resolved or closed,
|
|
6315
|
+
// moves to waiting_on_support. No actor: email is not authentication.
|
|
6316
|
+
const updated = await applyResponseTargets(applyTicketEvent(ticket, {
|
|
6317
|
+
type: "customer_replied",
|
|
6318
|
+
occurredAt: now,
|
|
6319
|
+
channel: "email",
|
|
6320
|
+
}), responseTargetPolicy, "customer_replied");
|
|
6321
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
6322
|
+
try {
|
|
6323
|
+
enforceManagedAdmission(ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (notification === undefined ? 3 : 4) +
|
|
6324
|
+
(requiresHandoff ? 1 : 0) +
|
|
6325
|
+
learningMutation.additionalRows, true);
|
|
6326
|
+
}
|
|
6327
|
+
catch (error) {
|
|
6328
|
+
if (!(error instanceof SupportDeskLimitError))
|
|
6329
|
+
throw error;
|
|
6330
|
+
return rejected(error.field === "ticket_messages"
|
|
6331
|
+
? "ticket_messages_limit"
|
|
6332
|
+
: "ticket_partition_limit");
|
|
6333
|
+
}
|
|
6334
|
+
const notificationContent = deliveryContent(notification);
|
|
6335
|
+
const outcome = await records.transact(ticketId, [
|
|
6336
|
+
...learningMutation.actions,
|
|
6337
|
+
{
|
|
6338
|
+
action: "putIfUnchanged",
|
|
6339
|
+
version: quota.version,
|
|
6340
|
+
value: {
|
|
6341
|
+
...quota.value,
|
|
6342
|
+
messageCount: quota.value.messageCount + 1,
|
|
6343
|
+
},
|
|
6344
|
+
},
|
|
6345
|
+
{
|
|
6346
|
+
action: "insert",
|
|
6347
|
+
value: {
|
|
6348
|
+
kind: "message",
|
|
6349
|
+
partition: ticketId,
|
|
6350
|
+
id: `message:${messageId}`,
|
|
6351
|
+
ordinal: updated.revision,
|
|
6352
|
+
message: inboundMessage(input, ticketId, messageId, now),
|
|
6353
|
+
...(notificationContent === undefined
|
|
6354
|
+
? {}
|
|
6355
|
+
: { deliveryContent: notificationContent }),
|
|
6356
|
+
},
|
|
6357
|
+
},
|
|
6358
|
+
inboundAuditAction({
|
|
6359
|
+
commandId,
|
|
6360
|
+
correlationId: work.correlationId,
|
|
6361
|
+
ticketId,
|
|
6362
|
+
revision: updated.revision,
|
|
6363
|
+
occurredAt: now,
|
|
6364
|
+
action: supportTicketAuditActions.customerReplied,
|
|
6365
|
+
matchMethod: route.matchMethod,
|
|
6366
|
+
}),
|
|
6367
|
+
{
|
|
6368
|
+
action: "insert",
|
|
6369
|
+
value: {
|
|
6370
|
+
kind: "command",
|
|
6371
|
+
partition: ticketId,
|
|
6372
|
+
id: `command:${commandId}`,
|
|
6373
|
+
commandId,
|
|
6374
|
+
commandType: "inbound_email_reply",
|
|
6375
|
+
messageId,
|
|
6376
|
+
requestFingerprint: fingerprint,
|
|
6377
|
+
completedAt: now,
|
|
6378
|
+
},
|
|
6379
|
+
},
|
|
6380
|
+
...(notification === undefined
|
|
6381
|
+
? []
|
|
6382
|
+
: [deliveryJobAction(ticketId, messageId, now, notification)]),
|
|
6383
|
+
...(requiresHandoff
|
|
6384
|
+
? [
|
|
6385
|
+
{
|
|
6386
|
+
action: "insert",
|
|
6387
|
+
value: handoffWork({
|
|
6388
|
+
ticket: updated,
|
|
6389
|
+
commandId,
|
|
6390
|
+
occurredAt: now,
|
|
6391
|
+
lastVisibleMessageId: messageId,
|
|
6392
|
+
reason: "email_reply",
|
|
6393
|
+
}),
|
|
6394
|
+
},
|
|
6395
|
+
]
|
|
6396
|
+
: []),
|
|
6397
|
+
]);
|
|
6398
|
+
if (outcome.committed) {
|
|
6399
|
+
return completed();
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
const last = await records.get(commandKey(ticketId, commandId));
|
|
6403
|
+
if (duplicateMatches(last, "inbound_email_reply", messageId, fingerprint)) {
|
|
6404
|
+
return completed();
|
|
6405
|
+
}
|
|
6406
|
+
if (last !== null) {
|
|
6407
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6408
|
+
}
|
|
6409
|
+
throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
|
|
6410
|
+
}
|
|
6411
|
+
return Object.freeze({
|
|
6412
|
+
async processInboundEmail(rawInput) {
|
|
6413
|
+
const input = snapshotInboundEmailInput(rawInput, limits);
|
|
6414
|
+
const replayFingerprint = await requestFingerprint(inboundFingerprintPayload(input));
|
|
6415
|
+
const location = await inboundReceiptLocation(input.channelId, input.providerEventId);
|
|
6416
|
+
const key = {
|
|
6417
|
+
partition: location.bucket,
|
|
6418
|
+
id: receiptSlotId(location.slot, "inbound receipt slot"),
|
|
6419
|
+
};
|
|
6420
|
+
const receivedAt = new Date(canonicalTimestamp(clock.now(), "clock.now()")).toISOString();
|
|
6421
|
+
const inserted = await receipts.insertIfAbsent({
|
|
6422
|
+
bucket: location.bucket,
|
|
6423
|
+
slot: location.slot,
|
|
6424
|
+
channelId: input.channelId,
|
|
6425
|
+
providerEventId: input.providerEventId,
|
|
6426
|
+
...(input.externalMessageId === undefined
|
|
6427
|
+
? {}
|
|
6428
|
+
: { externalMessageId: input.externalMessageId }),
|
|
6429
|
+
payloadFingerprint: replayFingerprint,
|
|
6430
|
+
replayFingerprint,
|
|
6431
|
+
status: "processing",
|
|
6432
|
+
receivedAt,
|
|
6433
|
+
reservedTicketId: input.ticketId,
|
|
6434
|
+
reservedMessageId: input.messageId,
|
|
6435
|
+
correlationId: input.correlationId,
|
|
6436
|
+
});
|
|
6437
|
+
let stored = inserted.value;
|
|
6438
|
+
if (stored.channelId !== input.channelId ||
|
|
6439
|
+
stored.providerEventId !== input.providerEventId) {
|
|
6440
|
+
throw new SupportDeskConflictError("An inbound receipt hash slot collided with a different provider event");
|
|
6441
|
+
}
|
|
6442
|
+
stored = await bindInboundReplayFingerprint(key, stored, input, replayFingerprint);
|
|
6443
|
+
const fingerprint = stored.payloadFingerprint;
|
|
6444
|
+
const duplicateDelivery = !inserted.inserted;
|
|
6445
|
+
if (stored.status !== "processing" && stored.status !== "rerouting") {
|
|
6446
|
+
return inboundResult(stored, duplicateDelivery);
|
|
6447
|
+
}
|
|
6448
|
+
const reservedTicketId = stored.reservedTicketId;
|
|
6449
|
+
const reservedMessageId = stored.reservedMessageId;
|
|
6450
|
+
const correlationId = stored.correlationId;
|
|
6451
|
+
if (reservedTicketId === undefined ||
|
|
6452
|
+
reservedMessageId === undefined ||
|
|
6453
|
+
correlationId === undefined) {
|
|
6454
|
+
throw new SupportDeskConflictError("Inbound receipt is missing its reserved identifiers");
|
|
6455
|
+
}
|
|
6456
|
+
// Stable per provider event and independent of retry identifiers.
|
|
6457
|
+
const commandId = `inbound:${location.bucket.slice(-30)}${location.slot}`;
|
|
6458
|
+
const decided = await decideRoute(key, stored, input, reservedTicketId, commandId, reservedMessageId, fingerprint);
|
|
6459
|
+
if (decided.status !== "processing" && decided.status !== "rerouting") {
|
|
6460
|
+
return inboundResult(decided, duplicateDelivery);
|
|
6461
|
+
}
|
|
6462
|
+
const route = decided.route;
|
|
6463
|
+
const work = {
|
|
6464
|
+
key,
|
|
6465
|
+
input,
|
|
6466
|
+
route,
|
|
6467
|
+
messageId: reservedMessageId,
|
|
6468
|
+
correlationId,
|
|
6469
|
+
commandId,
|
|
6470
|
+
fingerprint,
|
|
6471
|
+
duplicateDelivery,
|
|
6472
|
+
};
|
|
6473
|
+
switch (route.kind) {
|
|
6474
|
+
case "suppressed":
|
|
6475
|
+
return finishReceipt(work, {
|
|
6476
|
+
status: "rejected",
|
|
6477
|
+
outcome: "suppressed",
|
|
6478
|
+
diagnostic: route.reason,
|
|
6479
|
+
});
|
|
6480
|
+
case "duplicate_message":
|
|
6481
|
+
return finishReceipt(work, {
|
|
6482
|
+
status: "processed",
|
|
6483
|
+
outcome: "duplicate_message",
|
|
6484
|
+
ticketId: route.ticketId,
|
|
6485
|
+
messageId: route.messageId,
|
|
6486
|
+
});
|
|
6487
|
+
case "new_conversation":
|
|
6488
|
+
return createConversation(work, route);
|
|
6489
|
+
case "reply":
|
|
6490
|
+
return appendReply(work, route);
|
|
6491
|
+
}
|
|
6492
|
+
},
|
|
6493
|
+
});
|
|
6494
|
+
}
|
|
3048
6495
|
//# sourceMappingURL=index.js.map
|