@pegma/support-desk-application 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -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 +640 -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 +3568 -204
- 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 +8 -8
package/dist/index.js
CHANGED
|
@@ -3,7 +3,26 @@ import { hasPermission } from "@pegma/authorization-core";
|
|
|
3
3
|
import { defineMail, maxMailAttempts } from "@pegma/mail";
|
|
4
4
|
import { noopLogger } from "@pegma/spine";
|
|
5
5
|
import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
|
|
6
|
-
import { applyTicketEvent, createTicket } from "@pegma/support-desk-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,175 @@ 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
|
+
async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice) {
|
|
1072
|
+
const learning = await planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
|
|
1073
|
+
const engineering = notice === undefined
|
|
1074
|
+
? await planEngineeringTicketMutation(records, before, after, {
|
|
1075
|
+
visibleChange,
|
|
1076
|
+
})
|
|
1077
|
+
: { actions: [], additionalRows: 0 };
|
|
1078
|
+
const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
|
|
1079
|
+
!["resolved", "closed"].includes(after.status);
|
|
1080
|
+
const followup = visibleChange || reopened
|
|
1081
|
+
? await planEngineeringFollowupTicketMutation(records, rows, after.id, notice !== undefined
|
|
1082
|
+
? "approved_followup_notice"
|
|
1083
|
+
: reopened
|
|
1084
|
+
? "reopened"
|
|
1085
|
+
: "visible_source_changed", notice?.completedFollowupWorkId)
|
|
1086
|
+
: { actions: [], nextWorkRows: [] };
|
|
1087
|
+
return {
|
|
1088
|
+
...learning,
|
|
1089
|
+
actions: [...learning.actions, ...engineering.actions, ...followup.actions],
|
|
1090
|
+
additionalRows: learning.additionalRows + engineering.additionalRows,
|
|
1091
|
+
nextWorkRows: followup.nextWorkRows,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
/** Project reserved work transitions without inventing another quota counter. */
|
|
1095
|
+
function projectFollowupRows(rows, nextWorkRows) {
|
|
1096
|
+
if (nextWorkRows.length > 8)
|
|
1097
|
+
throw new SupportDeskConflictError("Follow-up transition bound exceeded");
|
|
1098
|
+
const updates = new Map(nextWorkRows.map((row) => [row.id, row]));
|
|
1099
|
+
if (updates.size !== nextWorkRows.length)
|
|
1100
|
+
throw new SupportDeskConflictError("Duplicate follow-up transition");
|
|
1101
|
+
const projected = rows.map((row) => {
|
|
1102
|
+
if (row.kind !== "engineering_followup_work")
|
|
1103
|
+
return row;
|
|
1104
|
+
const next = updates.get(row.id);
|
|
1105
|
+
updates.delete(row.id);
|
|
1106
|
+
return next ?? row;
|
|
1107
|
+
});
|
|
1108
|
+
return [...projected, ...updates.values()];
|
|
1109
|
+
}
|
|
1110
|
+
function followupMessageSlots(rows) {
|
|
1111
|
+
return rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.messageSlots : 0), 0);
|
|
1112
|
+
}
|
|
1113
|
+
function humanReserveRows(rows) {
|
|
1114
|
+
return (assistantHumanReserveRows +
|
|
1115
|
+
(rows.some((row) => row.kind === "resolution_learning_source") ? 1 : 0));
|
|
1116
|
+
}
|
|
1117
|
+
function derivationReserveRows(rows, consuming = false) {
|
|
1118
|
+
const learning = consuming !== "learning" &&
|
|
1119
|
+
rows.some((row) => row.kind === "resolution_learning_source" && row.state !== "withdrawn")
|
|
1120
|
+
? 2
|
|
1121
|
+
: 0;
|
|
1122
|
+
const engineering = consuming !== "engineering" &&
|
|
1123
|
+
rows.some((row) => row.kind === "engineering_source" && row.state !== "withdrawn")
|
|
1124
|
+
? 2
|
|
1125
|
+
: 0;
|
|
1126
|
+
const terminalWorkId = typeof consuming === "object" ? consuming.engineeringTerminalWorkId : null;
|
|
1127
|
+
const terminal = rows.reduce((sum, row) => sum +
|
|
1128
|
+
(row.kind === "engineering_work" && row.id !== terminalWorkId
|
|
1129
|
+
? row.terminalReserveRows
|
|
1130
|
+
: 0), 0);
|
|
1131
|
+
const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
|
|
1132
|
+
return learning + engineering + terminal + followup;
|
|
1133
|
+
}
|
|
1134
|
+
/** Reserve is opt-in metadata on the existing quota, never a second counter. */
|
|
1135
|
+
function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
|
|
1136
|
+
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1137
|
+
const reserved = quota?.assistantManaged === true &&
|
|
1138
|
+
ticket.firstStaffRespondedAt === undefined &&
|
|
1139
|
+
!firstHumanReply;
|
|
1140
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
|
|
1141
|
+
(reserved ? humanReserveRows(rows) : 0) -
|
|
1142
|
+
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1143
|
+
if (addsMessage &&
|
|
1144
|
+
quota !== undefined &&
|
|
1145
|
+
quota.messageCount >=
|
|
1146
|
+
limits.maxMessagesPerTicket -
|
|
1147
|
+
(reserved ? assistantHumanReserveMessages : 0) -
|
|
1148
|
+
followupMessageSlots(rows))
|
|
1149
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1150
|
+
}
|
|
1151
|
+
function latestVisibleRecord(rows) {
|
|
1152
|
+
// Reuse the ordinal validation that protects ordinary customer views.
|
|
1153
|
+
customerMessages(rows);
|
|
1154
|
+
return rows
|
|
1155
|
+
.filter((row) => row.kind === "message" && row.message.visibility === "customer")
|
|
1156
|
+
.sort((a, b) => b.ordinal - a.ordinal)[0];
|
|
1157
|
+
}
|
|
1158
|
+
function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, consumesWithdrawalReserve = false) {
|
|
1159
|
+
const quota = rows.find((row) => row.kind === "ticket_quota");
|
|
1160
|
+
const reserved = quota?.assistantManaged === true &&
|
|
1161
|
+
ticket.firstStaffRespondedAt === undefined;
|
|
1162
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
|
|
1163
|
+
(reserved ? firstHumanReplyReserveRows : 0) -
|
|
1164
|
+
derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
|
|
1165
|
+
if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
|
|
1166
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1167
|
+
}
|
|
1168
|
+
function handlingInput(input, fields, optional = []) {
|
|
1169
|
+
const source = boundaryObject(input, "handling command");
|
|
1170
|
+
if (Object.getOwnPropertySymbols(source).length !== 0)
|
|
1171
|
+
throw new TypeError("Handling commands must contain data fields");
|
|
1172
|
+
const allowed = [
|
|
1173
|
+
"commandId",
|
|
1174
|
+
"correlationId",
|
|
1175
|
+
"ticketId",
|
|
1176
|
+
...fields,
|
|
1177
|
+
...optional,
|
|
1178
|
+
];
|
|
1179
|
+
if (Object.getOwnPropertyNames(source).some((key) => !allowed.includes(key)))
|
|
1180
|
+
throw new TypeError("Handling command contains unsupported fields");
|
|
1181
|
+
const result = {};
|
|
1182
|
+
for (const field of allowed) {
|
|
1183
|
+
const value = ownDataProperty(source, field, "handling command." + field, optional.includes(field));
|
|
1184
|
+
if (value !== undefined)
|
|
1185
|
+
result[field] = value;
|
|
1186
|
+
}
|
|
1187
|
+
for (const field of ["commandId", "correlationId", "ticketId"]) {
|
|
1188
|
+
const value = boundaryString(result[field], field);
|
|
1189
|
+
requireIdentifier(value, field);
|
|
1190
|
+
result[field] = value;
|
|
1191
|
+
}
|
|
1192
|
+
return result;
|
|
1193
|
+
}
|
|
1194
|
+
function handlingFence(input) {
|
|
1195
|
+
for (const field of ["expectedRevision", "expectedHandlingGeneration"]) {
|
|
1196
|
+
if (typeof input[field] !== "number" ||
|
|
1197
|
+
!Number.isSafeInteger(input[field]) ||
|
|
1198
|
+
input[field] < (field === "expectedRevision" ? 1 : 0))
|
|
1199
|
+
throw new TypeError("Handling fence must be a safe integer");
|
|
1200
|
+
}
|
|
1201
|
+
return input;
|
|
1202
|
+
}
|
|
1203
|
+
function requireAssistantTurn(ticket, rows, command) {
|
|
1204
|
+
const handling = effectiveTicketHandling(ticket);
|
|
1205
|
+
const latest = latestVisibleRecord(rows);
|
|
1206
|
+
if (ticket.channel !== "web" ||
|
|
1207
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
1208
|
+
handling.state !== "assistant" ||
|
|
1209
|
+
ticket.revision !== command.expectedRevision ||
|
|
1210
|
+
handling.generation !== command.expectedHandlingGeneration ||
|
|
1211
|
+
latest?.message.authorKind !== "customer" ||
|
|
1212
|
+
latest.message.id !== command.triggerMessageId)
|
|
1213
|
+
throw new SupportDeskConflictError("Assistant turn changed or is no longer available");
|
|
1214
|
+
}
|
|
1215
|
+
function handoffWork(input) {
|
|
1216
|
+
const work = {
|
|
1217
|
+
kind: "handoff_work",
|
|
1218
|
+
partition: input.ticket.id,
|
|
1219
|
+
id: "handoff:" + input.commandId,
|
|
1220
|
+
status: "pending",
|
|
1221
|
+
reason: input.reason,
|
|
1222
|
+
...(input.summary === undefined ? {} : { summary: input.summary }),
|
|
1223
|
+
attemptedGuideIds: input.attemptedGuideIds ?? [],
|
|
1224
|
+
transcript: {
|
|
1225
|
+
ticketId: input.ticket.id,
|
|
1226
|
+
throughRevision: input.ticket.revision,
|
|
1227
|
+
lastVisibleMessageId: input.lastVisibleMessageId,
|
|
1228
|
+
},
|
|
1229
|
+
handlingGeneration: effectiveTicketHandling(input.ticket).generation,
|
|
1230
|
+
createdAt: input.occurredAt,
|
|
1231
|
+
};
|
|
1232
|
+
// Support's existing codec stores one JSON string property, unlike KB chunks.
|
|
1233
|
+
if (JSON.stringify(work).length * 2 > 60_000)
|
|
1234
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
1235
|
+
return work;
|
|
1236
|
+
}
|
|
724
1237
|
async function staffAuthoritativeView(records, ticketId, maxRecordsPerTicket) {
|
|
725
1238
|
const all = await records.list(ticketId);
|
|
726
1239
|
enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
|
|
@@ -757,6 +1270,7 @@ function snapshotStaffReplyCommand(input) {
|
|
|
757
1270
|
? undefined
|
|
758
1271
|
: snapshotNotification(raw.notification);
|
|
759
1272
|
return Object.freeze({
|
|
1273
|
+
...snapshotRevisionHint(source),
|
|
760
1274
|
commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
|
|
761
1275
|
correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
|
|
762
1276
|
ticketId: boundaryString(raw.ticketId, "staff reply command.ticketId"),
|
|
@@ -775,6 +1289,7 @@ function snapshotAddNoteCommand(input) {
|
|
|
775
1289
|
body: ownDataProperty(source, "body", "add note command.body"),
|
|
776
1290
|
};
|
|
777
1291
|
return Object.freeze({
|
|
1292
|
+
...snapshotRevisionHint(source),
|
|
778
1293
|
commandId: boundaryString(raw.commandId, "add note command.commandId"),
|
|
779
1294
|
correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
|
|
780
1295
|
ticketId: boundaryString(raw.ticketId, "add note command.ticketId"),
|
|
@@ -813,6 +1328,7 @@ function snapshotAssignTicketCommand(input) {
|
|
|
813
1328
|
}
|
|
814
1329
|
}
|
|
815
1330
|
return Object.freeze({
|
|
1331
|
+
...snapshotRevisionHint(source),
|
|
816
1332
|
commandId: boundaryString(raw.commandId, "assign command.commandId"),
|
|
817
1333
|
correlationId: boundaryString(raw.correlationId, "assign command.correlationId"),
|
|
818
1334
|
ticketId: boundaryString(raw.ticketId, "assign command.ticketId"),
|
|
@@ -837,6 +1353,7 @@ function snapshotChangePriorityCommand(input) {
|
|
|
837
1353
|
throw new TypeError(`change priority command.priority must be one of: ${TICKET_PRIORITIES.join(", ")}`);
|
|
838
1354
|
}
|
|
839
1355
|
return Object.freeze({
|
|
1356
|
+
...snapshotRevisionHint(source),
|
|
840
1357
|
commandId: boundaryString(raw.commandId, "change priority command.commandId"),
|
|
841
1358
|
correlationId: boundaryString(raw.correlationId, "change priority command.correlationId"),
|
|
842
1359
|
ticketId: boundaryString(raw.ticketId, "change priority command.ticketId"),
|
|
@@ -870,6 +1387,7 @@ function snapshotLifecycleTicketCommand(input, field) {
|
|
|
870
1387
|
}
|
|
871
1388
|
}
|
|
872
1389
|
return Object.freeze({
|
|
1390
|
+
...snapshotRevisionHint(source),
|
|
873
1391
|
commandId: boundaryString(raw.commandId, `${field}.commandId`),
|
|
874
1392
|
correlationId: boundaryString(raw.correlationId, `${field}.correlationId`),
|
|
875
1393
|
ticketId: boundaryString(raw.ticketId, `${field}.ticketId`),
|
|
@@ -910,6 +1428,7 @@ function ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)
|
|
|
910
1428
|
function deriveQueueIndexRecord(ticket) {
|
|
911
1429
|
return {
|
|
912
1430
|
ticketId: ticket.id,
|
|
1431
|
+
...staffLifecycleFields(ticket),
|
|
913
1432
|
id: "queue",
|
|
914
1433
|
projectedRevision: ticket.revision,
|
|
915
1434
|
state: isTerminalTicketStatus(ticket.status) ? "inactive" : "active",
|
|
@@ -922,6 +1441,9 @@ function deriveQueueIndexRecord(ticket) {
|
|
|
922
1441
|
? {}
|
|
923
1442
|
: { assignedTo: ticket.assignedTo }),
|
|
924
1443
|
updatedAt: ticket.updatedAt,
|
|
1444
|
+
...(ticket.handling === undefined
|
|
1445
|
+
? {}
|
|
1446
|
+
: { handling: { ...ticket.handling } }),
|
|
925
1447
|
};
|
|
926
1448
|
}
|
|
927
1449
|
function queueIndexContentEqual(left, right) {
|
|
@@ -932,10 +1454,16 @@ function queueIndexContentEqual(left, right) {
|
|
|
932
1454
|
left.status === right.status &&
|
|
933
1455
|
left.priority === right.priority &&
|
|
934
1456
|
left.category === right.category &&
|
|
1457
|
+
JSON.stringify(left.tags ?? []) === JSON.stringify(right.tags ?? []) &&
|
|
1458
|
+
left.firstResponseDueAt === right.firstResponseDueAt &&
|
|
1459
|
+
left.resolutionDueAt === right.resolutionDueAt &&
|
|
1460
|
+
left.firstStaffRespondedAt === right.firstStaffRespondedAt &&
|
|
1461
|
+
left.resolvedAt === right.resolvedAt &&
|
|
935
1462
|
left.requesterAssociation === right.requesterAssociation &&
|
|
936
1463
|
left.channel === right.channel &&
|
|
937
1464
|
left.assignedTo === right.assignedTo &&
|
|
938
|
-
left.updatedAt === right.updatedAt
|
|
1465
|
+
left.updatedAt === right.updatedAt &&
|
|
1466
|
+
JSON.stringify(left.handling) === JSON.stringify(right.handling));
|
|
939
1467
|
}
|
|
940
1468
|
function queueProjectionKey(ticketId) {
|
|
941
1469
|
return { partition: ticketId, id: "queue" };
|
|
@@ -1021,13 +1549,25 @@ export async function projectTicketToQueue(ticketId, options) {
|
|
|
1021
1549
|
if (queueIndexContentEqual(current, derived)) {
|
|
1022
1550
|
return { action: "keep" };
|
|
1023
1551
|
}
|
|
1552
|
+
// Exact pre-SD-01 rows may already contain Task-13 fields but no handling.
|
|
1553
|
+
const { handling: _newHandling, ...beforeHandling } = derived;
|
|
1554
|
+
if (current.handling === undefined &&
|
|
1555
|
+
queueIndexContentEqual(current, beforeHandling))
|
|
1556
|
+
return { action: "write", value: derived };
|
|
1557
|
+
// Pre-Task-13 rows have none of these additive staff fields. Enrich
|
|
1558
|
+
// only that exact legacy shape from freshly loaded authoritative state.
|
|
1559
|
+
const { tags: _tags, firstResponseDueAt: _first, resolutionDueAt: _due, firstStaffRespondedAt: _responded, resolvedAt: _resolved, handling: _handling, ...legacy } = derived;
|
|
1560
|
+
if (queueIndexContentEqual(current, legacy))
|
|
1561
|
+
return { action: "write", value: derived };
|
|
1024
1562
|
throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
|
|
1025
1563
|
}, { maxAttempts: maxConflictAttempts });
|
|
1026
1564
|
return result.written ? "written" : "kept";
|
|
1027
1565
|
}
|
|
1028
1566
|
function toStaffQueueItem(ticket) {
|
|
1029
1567
|
return Object.freeze({
|
|
1568
|
+
handling: effectiveTicketHandling(ticket).state,
|
|
1030
1569
|
ticketId: ticket.id,
|
|
1570
|
+
...staffLifecycleFields(ticket),
|
|
1031
1571
|
revision: ticket.revision,
|
|
1032
1572
|
status: ticket.status,
|
|
1033
1573
|
priority: ticket.priority,
|
|
@@ -1046,7 +1586,23 @@ function normalizeFilterList(value) {
|
|
|
1046
1586
|
}
|
|
1047
1587
|
return new Set(Array.isArray(value) ? value : [value]);
|
|
1048
1588
|
}
|
|
1049
|
-
function staffQueueItemMatches(item, query) {
|
|
1589
|
+
function staffQueueItemMatches(item, query, now) {
|
|
1590
|
+
const handling = query.handling ?? "human";
|
|
1591
|
+
if (handling !== "all" &&
|
|
1592
|
+
(handling === "assistant") !== (item.handling === "assistant"))
|
|
1593
|
+
return false;
|
|
1594
|
+
if (query.tag !== undefined && !(item.tags ?? []).includes(query.tag))
|
|
1595
|
+
return false;
|
|
1596
|
+
if (query.targetState !== undefined) {
|
|
1597
|
+
const due = pendingTargetDue(item, query.target);
|
|
1598
|
+
if (due === undefined || now === undefined)
|
|
1599
|
+
return false;
|
|
1600
|
+
const epoch = canonicalTimestamp(due, "ticket target");
|
|
1601
|
+
if (query.targetState === "overdue"
|
|
1602
|
+
? epoch >= now
|
|
1603
|
+
: epoch < now || epoch - now > query.dueSoonMilliseconds)
|
|
1604
|
+
return false;
|
|
1605
|
+
}
|
|
1050
1606
|
const statuses = normalizeFilterList(query.status);
|
|
1051
1607
|
if (statuses !== undefined && !statuses.has(item.status)) {
|
|
1052
1608
|
return false;
|
|
@@ -1073,6 +1629,15 @@ function staffQueueItemMatches(item, query) {
|
|
|
1073
1629
|
}
|
|
1074
1630
|
return true;
|
|
1075
1631
|
}
|
|
1632
|
+
function pendingTargetDue(item, target) {
|
|
1633
|
+
return target === "first_response"
|
|
1634
|
+
? item.firstStaffRespondedAt === undefined
|
|
1635
|
+
? item.firstResponseDueAt
|
|
1636
|
+
: undefined
|
|
1637
|
+
: item.resolvedAt === undefined
|
|
1638
|
+
? item.resolutionDueAt
|
|
1639
|
+
: undefined;
|
|
1640
|
+
}
|
|
1076
1641
|
const QUEUE_FILTER_STATUSES = [
|
|
1077
1642
|
"open",
|
|
1078
1643
|
"waiting_on_support",
|
|
@@ -1121,6 +1686,12 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1121
1686
|
}
|
|
1122
1687
|
const source = boundaryObject(input, "staff queue query");
|
|
1123
1688
|
const readOptional = (key) => ownDataProperty(source, key, `staff queue query.${key}`, true);
|
|
1689
|
+
const handling = readOptional("handling");
|
|
1690
|
+
if (handling !== undefined &&
|
|
1691
|
+
handling !== "human" &&
|
|
1692
|
+
handling !== "assistant" &&
|
|
1693
|
+
handling !== "all")
|
|
1694
|
+
throw new TypeError("invalid queue handling view");
|
|
1124
1695
|
const status = readOptional("status");
|
|
1125
1696
|
const priority = readOptional("priority");
|
|
1126
1697
|
const association = readOptional("association");
|
|
@@ -1128,6 +1699,29 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1128
1699
|
const assignedTo = readOptional("assignedTo");
|
|
1129
1700
|
const unassignedOnly = readOptional("unassignedOnly");
|
|
1130
1701
|
const sort = readOptional("sort");
|
|
1702
|
+
const rawTag = readOptional("tag");
|
|
1703
|
+
const tag = rawTag === undefined ? undefined : normalizedTag(rawTag);
|
|
1704
|
+
const target = readOptional("target");
|
|
1705
|
+
const targetState = readOptional("targetState");
|
|
1706
|
+
const dueSoonMilliseconds = readOptional("dueSoonMilliseconds");
|
|
1707
|
+
if (target !== undefined &&
|
|
1708
|
+
target !== "first_response" &&
|
|
1709
|
+
target !== "resolution")
|
|
1710
|
+
throw new TypeError("invalid queue target");
|
|
1711
|
+
if (targetState !== undefined &&
|
|
1712
|
+
targetState !== "overdue" &&
|
|
1713
|
+
targetState !== "due_soon")
|
|
1714
|
+
throw new TypeError("invalid queue targetState");
|
|
1715
|
+
if (dueSoonMilliseconds !== undefined &&
|
|
1716
|
+
(typeof dueSoonMilliseconds !== "number" ||
|
|
1717
|
+
!Number.isSafeInteger(dueSoonMilliseconds) ||
|
|
1718
|
+
dueSoonMilliseconds < 0 ||
|
|
1719
|
+
dueSoonMilliseconds > 30 * 86_400_000))
|
|
1720
|
+
throw new TypeError("dueSoonMilliseconds must be between 0 and 30 days");
|
|
1721
|
+
if (targetState === "due_soon" && dueSoonMilliseconds === undefined)
|
|
1722
|
+
throw new TypeError("due_soon requires dueSoonMilliseconds");
|
|
1723
|
+
if (targetState !== "due_soon" && dueSoonMilliseconds !== undefined)
|
|
1724
|
+
throw new TypeError("dueSoonMilliseconds requires targetState due_soon");
|
|
1131
1725
|
if (assignedTo !== undefined) {
|
|
1132
1726
|
if (typeof assignedTo !== "string") {
|
|
1133
1727
|
throw new TypeError("staff queue query.assignedTo must be a string");
|
|
@@ -1139,10 +1733,20 @@ function snapshotStaffQueueQuery(input) {
|
|
|
1139
1733
|
}
|
|
1140
1734
|
if (sort !== undefined &&
|
|
1141
1735
|
sort !== "updated_newest" &&
|
|
1142
|
-
sort !== "updated_oldest"
|
|
1143
|
-
|
|
1736
|
+
sort !== "updated_oldest" &&
|
|
1737
|
+
sort !== "due_soonest" &&
|
|
1738
|
+
sort !== "due_latest") {
|
|
1739
|
+
throw new TypeError("invalid staff queue query.sort");
|
|
1144
1740
|
}
|
|
1145
|
-
const query = {
|
|
1741
|
+
const query = {
|
|
1742
|
+
...(handling === undefined ? {} : { handling }),
|
|
1743
|
+
...(tag === undefined ? {} : { tag }),
|
|
1744
|
+
...(target === undefined ? {} : { target }),
|
|
1745
|
+
...(targetState === undefined ? {} : { targetState }),
|
|
1746
|
+
...(dueSoonMilliseconds === undefined
|
|
1747
|
+
? {}
|
|
1748
|
+
: { dueSoonMilliseconds: dueSoonMilliseconds }),
|
|
1749
|
+
};
|
|
1146
1750
|
if (status !== undefined) {
|
|
1147
1751
|
query.status =
|
|
1148
1752
|
snapshotEnumOrEnumArray(status, "staff queue query.status", QUEUE_FILTER_STATUSES);
|
|
@@ -1293,36 +1897,100 @@ export async function sweepInactiveQueueProjections(options) {
|
|
|
1293
1897
|
deleted,
|
|
1294
1898
|
};
|
|
1295
1899
|
}
|
|
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));
|
|
1900
|
+
const supportDeskLimitMaximums = Object.freeze({
|
|
1901
|
+
maxSubjectCharacters: 1_000,
|
|
1902
|
+
maxMessageCharacters: 20_000,
|
|
1903
|
+
maxTicketsPerPrincipal: 1_000,
|
|
1904
|
+
maxMessagesPerTicket: 100,
|
|
1905
|
+
maxRecordsPerTicket: 2_000,
|
|
1906
|
+
});
|
|
1907
|
+
/** Merge validated host overrides onto the defaults and enforce maxima. */
|
|
1908
|
+
function resolveSupportDeskLimits(rawLimits, field) {
|
|
1307
1909
|
const limitOverrides = {};
|
|
1308
1910
|
if (rawLimits !== undefined) {
|
|
1309
|
-
const limitSource = boundaryObject(rawLimits,
|
|
1310
|
-
for (const
|
|
1911
|
+
const limitSource = boundaryObject(rawLimits, field);
|
|
1912
|
+
for (const name of [
|
|
1311
1913
|
"maxSubjectCharacters",
|
|
1312
1914
|
"maxMessageCharacters",
|
|
1313
1915
|
"maxTicketsPerPrincipal",
|
|
1314
1916
|
"maxMessagesPerTicket",
|
|
1315
1917
|
"maxRecordsPerTicket",
|
|
1316
1918
|
]) {
|
|
1317
|
-
const value = ownDataProperty(limitSource,
|
|
1919
|
+
const value = ownDataProperty(limitSource, name, `${field}.${name}`, true);
|
|
1318
1920
|
if (value !== undefined) {
|
|
1319
1921
|
if (typeof value !== "number") {
|
|
1320
|
-
throw new TypeError(
|
|
1922
|
+
throw new TypeError(`${field}.${name} must be a number`);
|
|
1321
1923
|
}
|
|
1322
|
-
limitOverrides[
|
|
1924
|
+
limitOverrides[name] = value;
|
|
1323
1925
|
}
|
|
1324
1926
|
}
|
|
1325
1927
|
}
|
|
1928
|
+
const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
|
|
1929
|
+
for (const name of Object.keys(limits)) {
|
|
1930
|
+
const value = limits[name];
|
|
1931
|
+
if (!Number.isSafeInteger(value) ||
|
|
1932
|
+
value <= 0 ||
|
|
1933
|
+
value > supportDeskLimitMaximums[name]) {
|
|
1934
|
+
throw new TypeError(`${name} must be a positive safe integer no greater than ${supportDeskLimitMaximums[name]}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return Object.freeze(limits);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Reserve the next instance-scoped ticket number outside any ticket
|
|
1941
|
+
* transaction. A later failure leaves a gap; a duplicate is impossible.
|
|
1942
|
+
*/
|
|
1943
|
+
async function reserveNextTicketNumber(numbers, maxConflictAttempts) {
|
|
1944
|
+
const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
|
|
1945
|
+
const lastIssued = current?.lastIssued ?? 0;
|
|
1946
|
+
if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
|
|
1947
|
+
throw new SupportDeskConflictError("Ticket number counter is corrupt");
|
|
1948
|
+
}
|
|
1949
|
+
if (lastIssued >= Number.MAX_SAFE_INTEGER) {
|
|
1950
|
+
throw new SupportDeskConflictError("Ticket number sequence is exhausted");
|
|
1951
|
+
}
|
|
1952
|
+
return {
|
|
1953
|
+
action: "write",
|
|
1954
|
+
value: { lastIssued: lastIssued + 1 },
|
|
1955
|
+
};
|
|
1956
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
1957
|
+
const issued = result.value?.lastIssued;
|
|
1958
|
+
if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
|
|
1959
|
+
throw new SupportDeskConflictError("Ticket number was not reserved");
|
|
1960
|
+
}
|
|
1961
|
+
return issued;
|
|
1962
|
+
}
|
|
1963
|
+
export function createSupportDeskApplication(options) {
|
|
1964
|
+
const source = boundaryObject(options, "application options");
|
|
1965
|
+
const engineeringFollowupOptions = resolveEngineeringFollowupOptions(ownDataProperty(source, "engineeringFollowup", "application options.engineeringFollowup", true));
|
|
1966
|
+
const engineeringOptions = resolveEngineeringOptions(ownDataProperty(source, "engineering", "application options.engineering", true));
|
|
1967
|
+
const resolutionLearningOptions = resolveResolutionLearningOptions(ownDataProperty(source, "resolutionLearning", "application options.resolutionLearning", true));
|
|
1968
|
+
const assistantTurnOptions = resolveAssistantTurnOptions(ownDataProperty(source, "assistantTurns", "application options.assistantTurns", true));
|
|
1969
|
+
const rawAssistantEnabled = ownDataProperty(source, "assistantHandlingEnabled", "application options.assistantHandlingEnabled", true);
|
|
1970
|
+
if (rawAssistantEnabled !== undefined &&
|
|
1971
|
+
typeof rawAssistantEnabled !== "boolean")
|
|
1972
|
+
throw new TypeError("assistantHandlingEnabled must be boolean");
|
|
1973
|
+
const assistantHandlingEnabled = rawAssistantEnabled === true;
|
|
1974
|
+
const store = ownDataProperty(source, "store", "application options.store");
|
|
1975
|
+
const clock = ownDataProperty(source, "clock", "application options.clock");
|
|
1976
|
+
const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
|
|
1977
|
+
const logger = rawLogger === undefined ? noopLogger : rawLogger;
|
|
1978
|
+
const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
|
|
1979
|
+
const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
|
|
1980
|
+
const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
|
|
1981
|
+
const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
|
|
1982
|
+
const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
|
|
1983
|
+
const rawTags = ownDataProperty(source, "allowedTags", "allowedTags", true);
|
|
1984
|
+
const allowedTags = new Set(rawTags === undefined ? [] : snapshotTags(rawTags));
|
|
1985
|
+
const rawMaxTags = ownDataProperty(source, "maxTagsPerTicket", "maxTagsPerTicket", true);
|
|
1986
|
+
const maxTagsPerTicket = rawMaxTags === undefined ? 8 : rawMaxTags;
|
|
1987
|
+
if (typeof maxTagsPerTicket !== "number" ||
|
|
1988
|
+
!Number.isSafeInteger(maxTagsPerTicket) ||
|
|
1989
|
+
maxTagsPerTicket < 1 ||
|
|
1990
|
+
maxTagsPerTicket > 32) {
|
|
1991
|
+
throw new TypeError("maxTagsPerTicket must be between 1 and 32");
|
|
1992
|
+
}
|
|
1993
|
+
const responseTargetPolicy = parseResponseTargetPolicy(ownDataProperty(source, "responseTargetPolicy", "responseTargetPolicy", true));
|
|
1326
1994
|
if (rawMaxConflictAttempts !== undefined &&
|
|
1327
1995
|
typeof rawMaxConflictAttempts !== "number") {
|
|
1328
1996
|
throw new TypeError("application options.maxConflictAttempts must be a number");
|
|
@@ -1331,13 +1999,36 @@ export function createSupportDeskApplication(options) {
|
|
|
1331
1999
|
const index = store.collection(customerTicketIndex);
|
|
1332
2000
|
const numbers = store.collection(ticketNumbers);
|
|
1333
2001
|
const projections = store.collection(queueIndex);
|
|
1334
|
-
const limits =
|
|
2002
|
+
const limits = resolveSupportDeskLimits(rawLimits, "application options.limits");
|
|
1335
2003
|
const maxConflictAttempts = rawMaxConflictAttempts ?? 4;
|
|
1336
2004
|
if (!Number.isSafeInteger(maxConflictAttempts) ||
|
|
1337
2005
|
maxConflictAttempts <= 0 ||
|
|
1338
2006
|
maxConflictAttempts > 20) {
|
|
1339
2007
|
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
1340
2008
|
}
|
|
2009
|
+
const rawEngineeringPolicy = ownDataProperty(source, "engineeringPolicy", "application options.engineeringPolicy", true);
|
|
2010
|
+
const engineeringPolicyOverrides = {};
|
|
2011
|
+
if (rawEngineeringPolicy !== undefined) {
|
|
2012
|
+
const policyOptions = boundaryObject(rawEngineeringPolicy, "engineeringPolicy options");
|
|
2013
|
+
for (const field of [
|
|
2014
|
+
"maxRepositories",
|
|
2015
|
+
"maxRecords",
|
|
2016
|
+
"historyRetentionMilliseconds",
|
|
2017
|
+
]) {
|
|
2018
|
+
const value = ownDataProperty(policyOptions, field, `engineeringPolicy.${field}`, true);
|
|
2019
|
+
if (value === undefined)
|
|
2020
|
+
continue;
|
|
2021
|
+
if (typeof value !== "number")
|
|
2022
|
+
throw new TypeError(`engineeringPolicy.${field} must be a number`);
|
|
2023
|
+
engineeringPolicyOverrides[field] = value;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const engineeringPolicy = createEngineeringPolicyServices({
|
|
2027
|
+
store,
|
|
2028
|
+
clock,
|
|
2029
|
+
maxConflictAttempts,
|
|
2030
|
+
...engineeringPolicyOverrides,
|
|
2031
|
+
});
|
|
1341
2032
|
const terminalRetentionMilliseconds = rawTerminalRetention === undefined
|
|
1342
2033
|
? defaultQueueTerminalRetentionMilliseconds
|
|
1343
2034
|
: rawTerminalRetention;
|
|
@@ -1382,21 +2073,6 @@ export function createSupportDeskApplication(options) {
|
|
|
1382
2073
|
throw new TypeError(`queueScanBudgets.${field} must be a positive safe integer`);
|
|
1383
2074
|
}
|
|
1384
2075
|
}
|
|
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
2076
|
let projectionHealth = Object.freeze({
|
|
1401
2077
|
consecutiveFailures: 0,
|
|
1402
2078
|
});
|
|
@@ -1448,13 +2124,65 @@ export function createSupportDeskApplication(options) {
|
|
|
1448
2124
|
}
|
|
1449
2125
|
}
|
|
1450
2126
|
}
|
|
1451
|
-
|
|
2127
|
+
/**
|
|
2128
|
+
* Best-effort post-commit threading index writes. Never fails a committed
|
|
2129
|
+
* command; `repairThreadIndexPage` converges anything missed. Does not
|
|
2130
|
+
* sample the host clock.
|
|
2131
|
+
*/
|
|
2132
|
+
async function safeIndexThreading(ticketId, threading) {
|
|
2133
|
+
if (threading === undefined ||
|
|
2134
|
+
(threading.messageId === undefined && threading.ticketNumber !== true)) {
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
await indexThreadingAfterCommit(store, ticketId, threading, logger);
|
|
2138
|
+
}
|
|
2139
|
+
async function customerViewAfterChange(principalId, ticketId, threading) {
|
|
1452
2140
|
await safeProjectQueue(ticketId);
|
|
2141
|
+
await safeIndexThreading(ticketId, threading);
|
|
1453
2142
|
return authoritativeView(records, principalId, ticketId, limits.maxRecordsPerTicket);
|
|
1454
2143
|
}
|
|
1455
|
-
async function
|
|
2144
|
+
async function staffCommandResult(ticketId, commandId, threading) {
|
|
1456
2145
|
await safeProjectQueue(ticketId);
|
|
1457
|
-
|
|
2146
|
+
await safeIndexThreading(ticketId, threading);
|
|
2147
|
+
const receipt = await records.get(commandKey(ticketId, commandId));
|
|
2148
|
+
if (receipt?.kind !== "command" || receipt.resultTicket === undefined) {
|
|
2149
|
+
// Pre-Task-13 receipts retain their existing replay behavior.
|
|
2150
|
+
return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
|
|
2151
|
+
}
|
|
2152
|
+
const rows = await records.list(ticketId);
|
|
2153
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2154
|
+
return {
|
|
2155
|
+
ticket: receipt.resultTicket,
|
|
2156
|
+
messages: staffMessages(rows.filter((row) => row.kind !== "message" ||
|
|
2157
|
+
row.ordinal <= receipt.resultTicket.revision)),
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
async function checkStaffRevision(ticket, command, fingerprint) {
|
|
2161
|
+
if (command.expectedRevision === undefined ||
|
|
2162
|
+
command.expectedRevision === ticket.revision)
|
|
2163
|
+
return false;
|
|
2164
|
+
// A commit may race the first receipt read. Replay wins over a stale hint.
|
|
2165
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2166
|
+
if (receipt?.kind === "command" &&
|
|
2167
|
+
receipt.requestFingerprint === fingerprint)
|
|
2168
|
+
return true;
|
|
2169
|
+
if (receipt !== null)
|
|
2170
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2171
|
+
requireExpectedRevision(ticket, command);
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
async function staffConflictResult(command, fingerprint) {
|
|
2175
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2176
|
+
if (receipt?.kind === "command" &&
|
|
2177
|
+
receipt.requestFingerprint === fingerprint)
|
|
2178
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2179
|
+
if (receipt !== null)
|
|
2180
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2181
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
2182
|
+
if (current?.kind === "ticket" &&
|
|
2183
|
+
(await checkStaffRevision(current.ticket, command, fingerprint)))
|
|
2184
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2185
|
+
throw new SupportDeskConflictError();
|
|
1458
2186
|
}
|
|
1459
2187
|
async function reserveCustomerTicket(principalId, ticketId, reservedAt) {
|
|
1460
2188
|
requireIdentifier(principalId, "principalId");
|
|
@@ -1555,114 +2283,870 @@ export function createSupportDeskApplication(options) {
|
|
|
1555
2283
|
}
|
|
1556
2284
|
}
|
|
1557
2285
|
async function reserveTicketNumber() {
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2286
|
+
return reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
2287
|
+
}
|
|
2288
|
+
const resolutionLearning = createResolutionLearningServices({
|
|
2289
|
+
records,
|
|
2290
|
+
clock,
|
|
2291
|
+
limits,
|
|
2292
|
+
options: resolutionLearningOptions,
|
|
2293
|
+
maxConflictAttempts,
|
|
2294
|
+
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2295
|
+
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
|
|
2296
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2297
|
+
commandId: command.commandId,
|
|
2298
|
+
correlationId: command.correlationId,
|
|
2299
|
+
ticketId: ticket.id,
|
|
2300
|
+
revision: ticket.revision,
|
|
2301
|
+
actorId,
|
|
2302
|
+
occurredAt: now,
|
|
2303
|
+
action: action,
|
|
2304
|
+
}),
|
|
2305
|
+
});
|
|
2306
|
+
const engineeringDependencies = {
|
|
2307
|
+
records,
|
|
2308
|
+
clock,
|
|
2309
|
+
limits,
|
|
2310
|
+
options: engineeringOptions,
|
|
2311
|
+
maxConflictAttempts,
|
|
2312
|
+
policy: engineeringPolicy,
|
|
2313
|
+
planFollowupSourceChange: (context) => planEngineeringFollowupSourceChange(records, context),
|
|
2314
|
+
admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
|
|
2315
|
+
withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "engineering"),
|
|
2316
|
+
completionAdmission: (ticket, rows, additional, workId) => {
|
|
2317
|
+
const consumption = { engineeringTerminalWorkId: workId };
|
|
2318
|
+
const current = rows.find((row) => row.kind === "ticket" && row.ticket.id === ticket.id);
|
|
2319
|
+
if (current !== undefined)
|
|
2320
|
+
enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
|
|
2321
|
+
else
|
|
2322
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
|
|
2323
|
+
},
|
|
2324
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2325
|
+
commandId: command.commandId,
|
|
2326
|
+
correlationId: command.correlationId,
|
|
2327
|
+
ticketId: ticket.id,
|
|
2328
|
+
revision: ticket.revision,
|
|
2329
|
+
actorId,
|
|
2330
|
+
occurredAt: now,
|
|
2331
|
+
action: action,
|
|
2332
|
+
}),
|
|
2333
|
+
};
|
|
2334
|
+
const engineeringEvidence = createEngineeringEvidenceServices(engineeringDependencies);
|
|
2335
|
+
const engineeringWorkers = createEngineeringWorkerServices(engineeringDependencies, engineeringEvidence.load);
|
|
2336
|
+
const engineeringReconciliation = createEngineeringReconciliationServices(engineeringDependencies);
|
|
2337
|
+
const followupDependencies = {
|
|
2338
|
+
records,
|
|
2339
|
+
clock,
|
|
2340
|
+
limits,
|
|
2341
|
+
options: engineeringFollowupOptions,
|
|
2342
|
+
maxConflictAttempts,
|
|
2343
|
+
audit: (command, ticket, actorId, now, action) => ticketAuditAction({
|
|
2344
|
+
commandId: command.commandId,
|
|
2345
|
+
correlationId: command.correlationId,
|
|
2346
|
+
ticketId: ticket.id,
|
|
2347
|
+
revision: ticket.revision,
|
|
2348
|
+
actorId,
|
|
2349
|
+
occurredAt: now,
|
|
2350
|
+
action: action,
|
|
2351
|
+
}),
|
|
2352
|
+
admission: (ticket, rows, nextWorkRows, addedPhysicalRows, addsMessage = false) => {
|
|
2353
|
+
const projected = projectFollowupRows(rows, nextWorkRows);
|
|
2354
|
+
const newlyProjected = projected.length - rows.length;
|
|
2355
|
+
if (addedPhysicalRows < newlyProjected)
|
|
2356
|
+
throw new SupportDeskConflictError("Follow-up admission uses a stale partition view");
|
|
2357
|
+
const quota = projected.find((row) => row.kind === "ticket_quota");
|
|
2358
|
+
if (followupMessageSlots(projected) > 0) {
|
|
2359
|
+
if (quota === undefined)
|
|
2360
|
+
throw new SupportDeskConflictError("Follow-up quota metadata is missing");
|
|
2361
|
+
const humanSlot = quota.assistantManaged === true &&
|
|
2362
|
+
ticket.firstStaffRespondedAt === undefined
|
|
2363
|
+
? assistantHumanReserveMessages
|
|
2364
|
+
: 0;
|
|
2365
|
+
if (quota.messageCount + followupMessageSlots(projected) + humanSlot >
|
|
2366
|
+
limits.maxMessagesPerTicket)
|
|
2367
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
1562
2368
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
2369
|
+
enforceManagedAdmission(ticket, projected, limits, addedPhysicalRows - newlyProjected, addsMessage);
|
|
2370
|
+
},
|
|
2371
|
+
};
|
|
2372
|
+
const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
|
|
2373
|
+
const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
|
|
2374
|
+
const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
|
|
2375
|
+
const actorId = access.principalId;
|
|
2376
|
+
const authorize = () => {
|
|
2377
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2378
|
+
if (access.principalId !== actorId)
|
|
2379
|
+
throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
|
|
2380
|
+
requirePermission(access, supportPermissions.engineeringFollowupReview);
|
|
2381
|
+
requirePermission(access, supportPermissions.queueRead);
|
|
2382
|
+
requirePermission(access, supportPermissions.replyAny);
|
|
2383
|
+
};
|
|
2384
|
+
authorize();
|
|
2385
|
+
const command = followupSnapshot(followupFields(input, "follow-up web update", [
|
|
2386
|
+
"ticketId",
|
|
2387
|
+
"workId",
|
|
2388
|
+
"commandId",
|
|
2389
|
+
"correlationId",
|
|
2390
|
+
"expectedWorkGeneration",
|
|
2391
|
+
"expectedTicketRevision",
|
|
2392
|
+
"bundleHash",
|
|
2393
|
+
"messageId",
|
|
2394
|
+
]));
|
|
2395
|
+
for (const name of [
|
|
2396
|
+
"ticketId",
|
|
2397
|
+
"workId",
|
|
2398
|
+
"commandId",
|
|
2399
|
+
"correlationId",
|
|
2400
|
+
"bundleHash",
|
|
2401
|
+
"messageId",
|
|
2402
|
+
])
|
|
2403
|
+
followupText(command[name], name);
|
|
2404
|
+
followupInteger(command.expectedWorkGeneration, "expectedWorkGeneration", 1);
|
|
2405
|
+
followupInteger(command.expectedTicketRevision, "expectedTicketRevision", 1);
|
|
2406
|
+
if (!/^[0-9a-f]{64}$/.test(command.bundleHash))
|
|
2407
|
+
throw new TypeError("Invalid follow-up bundle hash");
|
|
2408
|
+
if (typeof guard !== "function")
|
|
2409
|
+
throw new TypeError("Follow-up posting requires a current-proof guard");
|
|
2410
|
+
const hash = await requestFingerprint({
|
|
2411
|
+
operation: "engineering_followup_web_update",
|
|
2412
|
+
actorId,
|
|
2413
|
+
command,
|
|
2414
|
+
});
|
|
2415
|
+
const receiptKey = engineeringFollowupCommandKey(command.ticketId, command.commandId);
|
|
2416
|
+
const now = engineeringFollowupNow(followupDependencies);
|
|
2417
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2418
|
+
const prior = await records.get(receiptKey);
|
|
2419
|
+
authorize();
|
|
2420
|
+
if (prior !== null) {
|
|
2421
|
+
if (prior.kind !== "engineering_followup_receipt" ||
|
|
2422
|
+
prior.fingerprint !== hash ||
|
|
2423
|
+
!("messageId" in prior.result) ||
|
|
2424
|
+
prior.result.messageId === undefined ||
|
|
2425
|
+
!("bundleHash" in prior.result) ||
|
|
2426
|
+
prior.result.bundleHash !== command.bundleHash ||
|
|
2427
|
+
!("ticketRevision" in prior.result) ||
|
|
2428
|
+
prior.result.ticketRevision === undefined ||
|
|
2429
|
+
!("workId" in prior.result) ||
|
|
2430
|
+
prior.result.workId !== command.workId)
|
|
2431
|
+
throw new SupportDeskConflictError("Follow-up posting command changed");
|
|
2432
|
+
return followupSnapshot({
|
|
2433
|
+
ticketId: command.ticketId,
|
|
2434
|
+
workId: command.workId,
|
|
2435
|
+
messageId: prior.result.messageId,
|
|
2436
|
+
ticketRevision: prior.result.ticketRevision,
|
|
2437
|
+
bundleHash: prior.result.bundleHash,
|
|
2438
|
+
});
|
|
1565
2439
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2440
|
+
if (!engineeringFollowupOptions.enabled)
|
|
2441
|
+
throw new SupportDeskConflictError("Follow-up is disabled");
|
|
2442
|
+
const current = await engineeringFollowupWork.internal.current({ ticketId: command.ticketId, workId: command.workId }, now);
|
|
2443
|
+
authorize();
|
|
2444
|
+
const work = current.work.value;
|
|
2445
|
+
const prepared = work.preparedBundle;
|
|
2446
|
+
const ticket = current.loaded.ticket.value.ticket;
|
|
2447
|
+
if (prepared === undefined ||
|
|
2448
|
+
current.loaded.head === null ||
|
|
2449
|
+
work.workGeneration !== command.expectedWorkGeneration ||
|
|
2450
|
+
ticket.revision !== command.expectedTicketRevision ||
|
|
2451
|
+
prepared.bundleHash !== command.bundleHash ||
|
|
2452
|
+
work.completionRows !== 3 ||
|
|
2453
|
+
work.messageSlots !== 1 ||
|
|
2454
|
+
now() >= Date.parse(work.deadline))
|
|
2455
|
+
throw new SupportDeskConflictError("Follow-up exact prepared work or ticket changed");
|
|
2456
|
+
const checked = await engineeringFollowupWork.internal.verifyBundle(current, now);
|
|
2457
|
+
const body = prepared.bundle.customerDraft.body;
|
|
2458
|
+
requireNonempty(body, "body");
|
|
2459
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
2460
|
+
const bodyHash = await requestFingerprint({ body });
|
|
2461
|
+
await guard();
|
|
2462
|
+
authorize();
|
|
2463
|
+
const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
2464
|
+
const updated = applyTicketEvent(ticket, {
|
|
2465
|
+
type: "customer_notice_added",
|
|
2466
|
+
actorId,
|
|
2467
|
+
occurredAt,
|
|
2468
|
+
});
|
|
2469
|
+
const derivations = await planTicketDerivationMutation(records, current.loaded.ticket, updated, current.loaded.rows, true, { completedFollowupWorkId: work.workId });
|
|
2470
|
+
const { claim: _claim, leaseUntil: _lease, retryAt: _retry, candidate: _candidate, candidateHash: _candidateHash, preparedBundle: _prepared, ...remaining } = work;
|
|
2471
|
+
const next = {
|
|
2472
|
+
...remaining,
|
|
2473
|
+
stage: "delivered",
|
|
2474
|
+
status: "delivered",
|
|
2475
|
+
workGeneration: work.workGeneration + 1,
|
|
2476
|
+
completionRows: 0,
|
|
2477
|
+
messageSlots: 0,
|
|
2478
|
+
modelAttempts: [],
|
|
2479
|
+
usageTombstones: [
|
|
2480
|
+
...new Map([
|
|
2481
|
+
...(work.usageTombstones ?? []).map((entry) => [entry.stepId, entry]),
|
|
2482
|
+
...work.modelAttempts.map((entry) => [
|
|
2483
|
+
entry.stepId,
|
|
2484
|
+
{
|
|
2485
|
+
stepId: entry.stepId,
|
|
2486
|
+
requestHash: entry.requestHash,
|
|
2487
|
+
usageReservationId: entry.binding.usageReservationId,
|
|
2488
|
+
accounting: entry.accounting,
|
|
2489
|
+
},
|
|
2490
|
+
]),
|
|
2491
|
+
]).values(),
|
|
2492
|
+
],
|
|
2493
|
+
delivered: {
|
|
2494
|
+
messageId: command.messageId,
|
|
2495
|
+
ticketRevision: updated.revision,
|
|
2496
|
+
bodyHash,
|
|
2497
|
+
bundleHash: prepared.bundleHash,
|
|
2498
|
+
commandId: command.commandId,
|
|
2499
|
+
postedBy: actorId,
|
|
2500
|
+
postedAt: occurredAt,
|
|
2501
|
+
},
|
|
2502
|
+
handoff: {
|
|
2503
|
+
kbProposal: prepared.bundle.kbProposal,
|
|
2504
|
+
retestRequests: prepared.bundle.retestRequests,
|
|
2505
|
+
},
|
|
1569
2506
|
};
|
|
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",
|
|
2507
|
+
checkEngineeringFollowupRecordCapacity(next);
|
|
2508
|
+
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2509
|
+
authorize();
|
|
2510
|
+
if (quota?.value.kind !== "ticket_quota")
|
|
2511
|
+
throw new SupportDeskConflictError("Follow-up quota metadata is missing");
|
|
2512
|
+
const admissionRows = current.loaded.rows.map((row) => row.kind === "ticket_quota" ? quota.value : row);
|
|
2513
|
+
followupDependencies.admission(updated, admissionRows, [...derivations.nextWorkRows, next], 3 + derivations.additionalRows, true);
|
|
2514
|
+
const result = {
|
|
1590
2515
|
ticketId: command.ticketId,
|
|
2516
|
+
workId: command.workId,
|
|
1591
2517
|
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),
|
|
2518
|
+
ticketRevision: updated.revision,
|
|
2519
|
+
bundleHash: prepared.bundleHash,
|
|
1600
2520
|
};
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2521
|
+
const receipt = {
|
|
2522
|
+
kind: "engineering_followup_receipt",
|
|
2523
|
+
partition: command.ticketId,
|
|
2524
|
+
id: receiptKey.id,
|
|
2525
|
+
fingerprint: hash,
|
|
2526
|
+
result,
|
|
2527
|
+
};
|
|
2528
|
+
checkEngineeringFollowupRecordCapacity(receipt);
|
|
2529
|
+
if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
|
|
2530
|
+
continue;
|
|
2531
|
+
authorize();
|
|
2532
|
+
if (now() >=
|
|
2533
|
+
Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil)))
|
|
2534
|
+
throw new SupportDeskConflictError("Follow-up posting proof expired");
|
|
2535
|
+
const outcome = await records.transact(command.ticketId, [
|
|
2536
|
+
...derivations.actions,
|
|
2537
|
+
{
|
|
2538
|
+
action: "putIfUnchanged",
|
|
2539
|
+
version: current.loaded.source.version,
|
|
2540
|
+
value: current.loaded.source.value,
|
|
2541
|
+
},
|
|
2542
|
+
{
|
|
2543
|
+
action: "putIfUnchanged",
|
|
2544
|
+
version: current.engineeringWork.version,
|
|
2545
|
+
value: current.engineeringWork.value,
|
|
2546
|
+
},
|
|
2547
|
+
{
|
|
2548
|
+
action: "putIfUnchanged",
|
|
2549
|
+
version: current.loaded.head.version,
|
|
2550
|
+
value: current.loaded.head.value,
|
|
2551
|
+
},
|
|
2552
|
+
{
|
|
2553
|
+
action: "putIfUnchanged",
|
|
2554
|
+
version: current.work.version,
|
|
2555
|
+
value: next,
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
action: "putIfUnchanged",
|
|
2559
|
+
version: quota.version,
|
|
2560
|
+
value: {
|
|
2561
|
+
...quota.value,
|
|
2562
|
+
messageCount: quota.value.messageCount + 1,
|
|
2563
|
+
},
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
action: "insert",
|
|
2567
|
+
value: {
|
|
2568
|
+
kind: "message",
|
|
2569
|
+
partition: command.ticketId,
|
|
2570
|
+
id: `message:${command.messageId}`,
|
|
2571
|
+
ordinal: updated.revision,
|
|
2572
|
+
message: {
|
|
2573
|
+
id: command.messageId,
|
|
2574
|
+
ticketId: command.ticketId,
|
|
2575
|
+
authorKind: "staff",
|
|
2576
|
+
authorPrincipalId: actorId,
|
|
2577
|
+
channel: "web",
|
|
2578
|
+
visibility: "customer",
|
|
2579
|
+
format: "plain_text",
|
|
2580
|
+
body,
|
|
2581
|
+
createdAt: occurredAt,
|
|
2582
|
+
},
|
|
2583
|
+
},
|
|
2584
|
+
},
|
|
2585
|
+
ticketAuditAction({
|
|
2586
|
+
commandId: command.commandId,
|
|
2587
|
+
correlationId: command.correlationId,
|
|
2588
|
+
ticketId: command.ticketId,
|
|
2589
|
+
revision: updated.revision,
|
|
2590
|
+
actorId,
|
|
2591
|
+
occurredAt,
|
|
2592
|
+
action: supportTicketAuditActions.engineeringFollowupNoticePosted,
|
|
2593
|
+
}),
|
|
2594
|
+
{ action: "insert", value: receipt },
|
|
2595
|
+
]);
|
|
2596
|
+
if (outcome.committed) {
|
|
2597
|
+
authorize();
|
|
2598
|
+
return result;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
throw new SupportDeskConflictError();
|
|
2602
|
+
};
|
|
2603
|
+
function owns(ticket, principalId) {
|
|
2604
|
+
return (ticket.requester.association === "authenticated" &&
|
|
2605
|
+
ticket.requester.principalId === principalId);
|
|
2606
|
+
}
|
|
2607
|
+
function handlingAck(ticket, messageId) {
|
|
2608
|
+
return {
|
|
2609
|
+
ticketId: ticket.id,
|
|
2610
|
+
revision: ticket.revision,
|
|
2611
|
+
handlingGeneration: effectiveTicketHandling(ticket).generation,
|
|
2612
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
/** Only the new handling commands share this small executor; legacy fingerprints stay untouched. */
|
|
2616
|
+
async function mutateHandling(access, command, operation, assistantTurnCommit) {
|
|
2617
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2618
|
+
const customer = operation === "request_person" ||
|
|
2619
|
+
operation === "confirm_customer_success";
|
|
2620
|
+
const fingerprint = await requestFingerprint({
|
|
2621
|
+
type: operation,
|
|
2622
|
+
actorId: access.principalId,
|
|
2623
|
+
...Object.fromEntries(Object.entries(command).filter(([key]) => key !== "commandId" && key !== "correlationId")),
|
|
2624
|
+
...(assistantTurnCommit === undefined
|
|
2625
|
+
? {}
|
|
2626
|
+
: { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
|
|
2627
|
+
});
|
|
2628
|
+
for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
|
|
2629
|
+
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2630
|
+
if (versioned?.value.kind !== "ticket" ||
|
|
2631
|
+
(customer && !owns(versioned.value.ticket, access.principalId)))
|
|
2632
|
+
throw new SupportDeskNotFoundError();
|
|
2633
|
+
const ticket = versioned.value.ticket;
|
|
2634
|
+
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2635
|
+
if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
|
|
2636
|
+
if (duplicate?.kind !== "command" ||
|
|
2637
|
+
duplicate.resultTicket === undefined)
|
|
2638
|
+
throw new SupportDeskConflictError("Handling receipt is incomplete");
|
|
2639
|
+
return duplicate.resultTicket;
|
|
2640
|
+
}
|
|
2641
|
+
if (duplicate !== null)
|
|
2642
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2643
|
+
if (operation === "assistant_reply" && !assistantHandlingEnabled)
|
|
2644
|
+
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
2645
|
+
const rows = await records.list(command.ticketId);
|
|
2646
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2647
|
+
const latest = latestVisibleRecord(rows);
|
|
2648
|
+
if (operation === "assistant_reply" || operation === "assistant_escalate")
|
|
2649
|
+
requireAssistantTurn(ticket, rows, command);
|
|
2650
|
+
if (operation === "request_person" &&
|
|
2651
|
+
!isTerminalTicketStatus(ticket.status) &&
|
|
2652
|
+
effectiveTicketHandling(ticket).state !== "assistant")
|
|
2653
|
+
return ticket;
|
|
2654
|
+
if (operation === "confirm_customer_success") {
|
|
2655
|
+
if (latest?.message.id !== command.lastSeenVisibleMessageId)
|
|
2656
|
+
throw new SupportDeskConflictError("Visible conversation changed");
|
|
2657
|
+
if (ticket.status === "resolved" &&
|
|
2658
|
+
rows.some((row) => row.kind === "customer_success" &&
|
|
2659
|
+
row.lastSeenVisibleMessageId ===
|
|
2660
|
+
command.lastSeenVisibleMessageId &&
|
|
2661
|
+
(rows.find((record) => record.kind === "resolution_learning_source") === undefined ||
|
|
2662
|
+
row.resolutionCycle ===
|
|
2663
|
+
rows.find((record) => record.kind === "resolution_learning_source")?.resolutionCycle)))
|
|
2664
|
+
return ticket;
|
|
2665
|
+
}
|
|
2666
|
+
const now = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
2667
|
+
const common = { actorId: access.principalId, occurredAt: now };
|
|
2668
|
+
const fence = {
|
|
2669
|
+
expectedRevision: command.expectedRevision,
|
|
2670
|
+
expectedHandlingGeneration: command.expectedHandlingGeneration,
|
|
2671
|
+
};
|
|
2672
|
+
const updated = operation === "assistant_reply"
|
|
2673
|
+
? applyTicketEvent(ticket, {
|
|
2674
|
+
type: "assistant_replied",
|
|
2675
|
+
...common,
|
|
2676
|
+
...fence,
|
|
2677
|
+
})
|
|
2678
|
+
: operation === "assistant_escalate"
|
|
2679
|
+
? applyTicketEvent(ticket, {
|
|
2680
|
+
type: "human_escalated",
|
|
2681
|
+
...common,
|
|
2682
|
+
...fence,
|
|
2683
|
+
})
|
|
2684
|
+
: operation === "take_over"
|
|
2685
|
+
? applyTicketEvent(ticket, {
|
|
2686
|
+
type: "human_taken_over",
|
|
2687
|
+
...common,
|
|
2688
|
+
...fence,
|
|
2689
|
+
})
|
|
2690
|
+
: operation === "request_person"
|
|
2691
|
+
? applyTicketEvent(ticket, {
|
|
2692
|
+
type: "human_requested",
|
|
2693
|
+
...common,
|
|
2694
|
+
})
|
|
2695
|
+
: applyTicketEvent(ticket, {
|
|
2696
|
+
type: "customer_success_confirmed",
|
|
2697
|
+
...common,
|
|
2698
|
+
});
|
|
2699
|
+
if (updated.revision === ticket.revision)
|
|
2700
|
+
return ticket;
|
|
2701
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, rows, operation === "assistant_reply");
|
|
2702
|
+
const learningConfirmation = operation === "confirm_customer_success"
|
|
2703
|
+
? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
|
|
2704
|
+
: { actions: [], additionalRows: 0 };
|
|
2705
|
+
const newRows = (operation === "take_over" ? 2 : 3) +
|
|
2706
|
+
learningMutation.additionalRows +
|
|
2707
|
+
learningConfirmation.additionalRows;
|
|
2708
|
+
if (operation === "assistant_reply")
|
|
2709
|
+
enforceManagedAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows, true);
|
|
2710
|
+
else
|
|
2711
|
+
enforceHandlingControlAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows);
|
|
2712
|
+
const actions = [
|
|
2713
|
+
...learningMutation.actions,
|
|
2714
|
+
...learningConfirmation.actions,
|
|
2715
|
+
];
|
|
2716
|
+
if (operation === "assistant_reply") {
|
|
2717
|
+
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2718
|
+
if (quota?.value.kind !== "ticket_quota")
|
|
2719
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
2720
|
+
if (quota.value.messageCount >= limits.maxMessagesPerTicket)
|
|
2721
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
2722
|
+
const assistantMessage = {
|
|
2723
|
+
kind: "message",
|
|
2724
|
+
partition: ticket.id,
|
|
2725
|
+
id: "message:" + command.messageId,
|
|
2726
|
+
ordinal: updated.revision,
|
|
2727
|
+
message: {
|
|
2728
|
+
id: command.messageId,
|
|
2729
|
+
ticketId: ticket.id,
|
|
2730
|
+
authorKind: "assistant",
|
|
2731
|
+
authorPrincipalId: access.principalId,
|
|
2732
|
+
channel: "web",
|
|
2733
|
+
visibility: "customer",
|
|
2734
|
+
format: "plain_text",
|
|
2735
|
+
body: command.body,
|
|
2736
|
+
createdAt: now,
|
|
2737
|
+
},
|
|
2738
|
+
};
|
|
2739
|
+
if (JSON.stringify(assistantMessage).length * 2 > 60_000)
|
|
2740
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
2741
|
+
actions.push({
|
|
2742
|
+
action: "putIfUnchanged",
|
|
2743
|
+
version: quota.version,
|
|
2744
|
+
value: {
|
|
2745
|
+
...quota.value,
|
|
2746
|
+
messageCount: quota.value.messageCount + 1,
|
|
2747
|
+
},
|
|
2748
|
+
}, { action: "insert", value: assistantMessage });
|
|
2749
|
+
}
|
|
2750
|
+
else if (operation === "request_person" ||
|
|
2751
|
+
operation === "assistant_escalate") {
|
|
2752
|
+
if (latest === undefined)
|
|
2753
|
+
throw new SupportDeskConflictError("Ticket conversation is unavailable");
|
|
2754
|
+
actions.push({
|
|
2755
|
+
action: "insert",
|
|
2756
|
+
value: handoffWork({
|
|
2757
|
+
ticket: updated,
|
|
2758
|
+
commandId: command.commandId,
|
|
2759
|
+
occurredAt: now,
|
|
2760
|
+
lastVisibleMessageId: latest.message.id,
|
|
2761
|
+
reason: operation === "request_person"
|
|
2762
|
+
? "customer_requested"
|
|
2763
|
+
: (command.reason ?? "other"),
|
|
2764
|
+
...(command.summary === undefined
|
|
2765
|
+
? {}
|
|
2766
|
+
: { summary: command.summary }),
|
|
2767
|
+
...(command.attemptedGuideIds === undefined
|
|
2768
|
+
? {}
|
|
2769
|
+
: { attemptedGuideIds: command.attemptedGuideIds }),
|
|
2770
|
+
}),
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
else if (operation === "confirm_customer_success") {
|
|
2774
|
+
actions.push({
|
|
2775
|
+
action: "insert",
|
|
2776
|
+
value: {
|
|
2777
|
+
kind: "customer_success",
|
|
2778
|
+
partition: ticket.id,
|
|
2779
|
+
id: "success:" + command.commandId,
|
|
2780
|
+
commandId: command.commandId,
|
|
2781
|
+
ticketId: ticket.id,
|
|
2782
|
+
confirmedBy: access.principalId,
|
|
2783
|
+
confirmedAt: now,
|
|
2784
|
+
lastSeenVisibleMessageId: command.lastSeenVisibleMessageId,
|
|
2785
|
+
revision: updated.revision,
|
|
2786
|
+
handlingGeneration: effectiveTicketHandling(updated).generation,
|
|
2787
|
+
...(learningMutation.source === null
|
|
2788
|
+
? {}
|
|
2789
|
+
: {
|
|
2790
|
+
resolutionCycle: learningMutation.source.value.resolutionCycle,
|
|
2791
|
+
}),
|
|
2792
|
+
},
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
const auditAction = operation === "assistant_reply"
|
|
2796
|
+
? supportTicketAuditActions.assistantReplied
|
|
2797
|
+
: operation === "assistant_escalate"
|
|
2798
|
+
? supportTicketAuditActions.humanEscalated
|
|
2799
|
+
: operation === "request_person"
|
|
2800
|
+
? supportTicketAuditActions.humanRequested
|
|
2801
|
+
: operation === "take_over"
|
|
2802
|
+
? supportTicketAuditActions.humanTakenOver
|
|
2803
|
+
: supportTicketAuditActions.customerSuccessConfirmed;
|
|
2804
|
+
actions.push(ticketAuditAction({
|
|
2805
|
+
commandId: command.commandId,
|
|
2806
|
+
correlationId: command.correlationId,
|
|
2807
|
+
ticketId: ticket.id,
|
|
2808
|
+
revision: updated.revision,
|
|
2809
|
+
actorId: access.principalId,
|
|
2810
|
+
occurredAt: now,
|
|
2811
|
+
action: auditAction,
|
|
2812
|
+
}), {
|
|
2813
|
+
action: "insert",
|
|
2814
|
+
value: {
|
|
2815
|
+
kind: "command",
|
|
2816
|
+
partition: ticket.id,
|
|
2817
|
+
id: "command:" + command.commandId,
|
|
2818
|
+
commandId: command.commandId,
|
|
2819
|
+
commandType: operation,
|
|
2820
|
+
...(command.messageId === undefined
|
|
2821
|
+
? {}
|
|
2822
|
+
: { messageId: command.messageId }),
|
|
2823
|
+
requestFingerprint: fingerprint,
|
|
2824
|
+
completedAt: now,
|
|
2825
|
+
resultTicket: updated,
|
|
2826
|
+
},
|
|
2827
|
+
});
|
|
2828
|
+
const assistantCommit = assistantTurnCommit === undefined
|
|
2829
|
+
? undefined
|
|
2830
|
+
: await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit);
|
|
2831
|
+
if (assistantCommit !== undefined) {
|
|
2832
|
+
actions.push(assistantCommit.action);
|
|
2833
|
+
await assistantCommit.verifyBeforeCommit();
|
|
2834
|
+
}
|
|
2835
|
+
if ((await records.transact(ticket.id, actions)).committed) {
|
|
2836
|
+
await safeProjectQueue(ticket.id);
|
|
2837
|
+
return updated;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
2841
|
+
if (current?.kind !== "ticket" ||
|
|
2842
|
+
(customer && !owns(current.ticket, access.principalId)))
|
|
2843
|
+
throw new SupportDeskNotFoundError();
|
|
2844
|
+
const receipt = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2845
|
+
if (duplicateMatches(receipt, operation, command.messageId, fingerprint) &&
|
|
2846
|
+
receipt?.kind === "command" &&
|
|
2847
|
+
receipt.resultTicket !== undefined)
|
|
2848
|
+
return receipt.resultTicket;
|
|
2849
|
+
throw new SupportDeskConflictError();
|
|
2850
|
+
}
|
|
2851
|
+
const assistantTurns = createAssistantTurnServices({
|
|
2852
|
+
records,
|
|
2853
|
+
clock,
|
|
2854
|
+
options: assistantTurnOptions,
|
|
2855
|
+
assistantHandlingEnabled,
|
|
2856
|
+
limits,
|
|
2857
|
+
maxConflictAttempts,
|
|
2858
|
+
conversation(ticket, rows) {
|
|
2859
|
+
return {
|
|
2860
|
+
ticket: toCustomerTicketSummary(ticket),
|
|
2861
|
+
messages: customerMessages(rows),
|
|
2862
|
+
revision: ticket.revision,
|
|
2863
|
+
handling: { ...effectiveTicketHandling(ticket) },
|
|
2864
|
+
...(latestVisibleRecord(rows) === undefined
|
|
2865
|
+
? {}
|
|
2866
|
+
: { triggerMessageId: latestVisibleRecord(rows).message.id }),
|
|
2867
|
+
};
|
|
2868
|
+
},
|
|
2869
|
+
mutateHandling,
|
|
2870
|
+
});
|
|
2871
|
+
return {
|
|
2872
|
+
...resolutionLearning.application,
|
|
2873
|
+
...engineeringEvidence.application,
|
|
2874
|
+
...engineeringWorkers,
|
|
2875
|
+
...engineeringReconciliation,
|
|
2876
|
+
...engineeringPolicy.application,
|
|
2877
|
+
...engineeringFollowupSource.application,
|
|
2878
|
+
...engineeringFollowupWork.application,
|
|
2879
|
+
approveAndPostEngineeringFollowupWebUpdate,
|
|
2880
|
+
scanAssistantTurnCandidates: assistantTurns.scanAssistantTurnCandidates,
|
|
2881
|
+
claimAssistantTurn: assistantTurns.claimAssistantTurn,
|
|
2882
|
+
prepareAssistantTurnAttempt: assistantTurns.prepareAssistantTurnAttempt,
|
|
2883
|
+
completeAssistantTurn: assistantTurns.completeAssistantTurn,
|
|
2884
|
+
finishAssistantTurnAttempt: assistantTurns.finishAssistantTurnAttempt,
|
|
2885
|
+
async readHandoffWork(access, ticketId) {
|
|
2886
|
+
requirePermission(access, supportPermissions.queueRead);
|
|
2887
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2888
|
+
requireIdentifier(ticketId, "ticketId");
|
|
2889
|
+
const rows = await records.list(ticketId);
|
|
2890
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2891
|
+
if (!rows.some((row) => row.kind === "ticket"))
|
|
2892
|
+
throw new SupportDeskNotFoundError();
|
|
2893
|
+
return rows
|
|
2894
|
+
.filter((row) => row.kind === "handoff_work")
|
|
2895
|
+
.sort((a, b) => a.transcript.throughRevision - b.transcript.throughRevision ||
|
|
2896
|
+
a.id.localeCompare(b.id))
|
|
2897
|
+
.map((row) => Object.freeze({
|
|
2898
|
+
ticketId,
|
|
2899
|
+
workId: row.id,
|
|
2900
|
+
status: row.status,
|
|
2901
|
+
reason: row.reason,
|
|
2902
|
+
...(row.summary === undefined ? {} : { summary: row.summary }),
|
|
2903
|
+
attemptedGuideIds: Object.freeze([...row.attemptedGuideIds]),
|
|
2904
|
+
transcript: Object.freeze({ ...row.transcript }),
|
|
2905
|
+
handlingGeneration: row.handlingGeneration,
|
|
2906
|
+
createdAt: row.createdAt,
|
|
2907
|
+
}));
|
|
2908
|
+
},
|
|
2909
|
+
async readAssistantConversation(access, ticketId) {
|
|
2910
|
+
requirePermission(access, supportPermissions.assistantRead);
|
|
2911
|
+
requireIdentifier(access.principalId, "principalId");
|
|
2912
|
+
requireIdentifier(ticketId, "ticketId");
|
|
2913
|
+
const rows = await records.list(ticketId);
|
|
2914
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
2915
|
+
const ticket = rows.find((row) => row.kind === "ticket")?.ticket;
|
|
2916
|
+
if (ticket === undefined)
|
|
2917
|
+
throw new SupportDeskNotFoundError();
|
|
2918
|
+
if (ticket.channel !== "web" ||
|
|
2919
|
+
isTerminalTicketStatus(ticket.status) ||
|
|
2920
|
+
effectiveTicketHandling(ticket).state !== "assistant")
|
|
2921
|
+
throw new SupportDeskConflictError("Assistant conversation is unavailable");
|
|
2922
|
+
const latest = latestVisibleRecord(rows);
|
|
2923
|
+
return {
|
|
2924
|
+
ticket: toCustomerTicketSummary(ticket),
|
|
2925
|
+
messages: customerMessages(rows),
|
|
2926
|
+
revision: ticket.revision,
|
|
2927
|
+
handling: { ...effectiveTicketHandling(ticket) },
|
|
2928
|
+
...(latest?.message.authorKind === "customer"
|
|
2929
|
+
? { triggerMessageId: latest.message.id }
|
|
2930
|
+
: {}),
|
|
2931
|
+
};
|
|
2932
|
+
},
|
|
2933
|
+
async replyAsAssistant(access, input) {
|
|
2934
|
+
requirePermission(access, supportPermissions.assistantReply);
|
|
2935
|
+
const raw = handlingInput(input, [
|
|
2936
|
+
"expectedRevision",
|
|
2937
|
+
"expectedHandlingGeneration",
|
|
2938
|
+
"triggerMessageId",
|
|
2939
|
+
"messageId",
|
|
2940
|
+
"body",
|
|
2941
|
+
]);
|
|
2942
|
+
handlingFence(raw);
|
|
2943
|
+
for (const key of ["triggerMessageId", "messageId"])
|
|
2944
|
+
requireIdentifier(boundaryString(raw[key], key), key);
|
|
2945
|
+
const body = boundaryString(raw.body, "body");
|
|
2946
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
2947
|
+
const command = raw;
|
|
2948
|
+
return handlingAck(await mutateHandling(access, command, "assistant_reply"), command.messageId);
|
|
2949
|
+
},
|
|
2950
|
+
async escalateToHuman(access, input) {
|
|
2951
|
+
requirePermission(access, supportPermissions.assistantEscalate);
|
|
2952
|
+
const raw = handlingInput(input, ["expectedRevision", "expectedHandlingGeneration", "triggerMessageId"], ["reason", "summary", "attemptedGuideIds"]);
|
|
2953
|
+
handlingFence(raw);
|
|
2954
|
+
requireIdentifier(boundaryString(raw.triggerMessageId, "triggerMessageId"), "triggerMessageId");
|
|
2955
|
+
if (raw.reason !== undefined &&
|
|
2956
|
+
![
|
|
2957
|
+
"no_guidance",
|
|
2958
|
+
"unsuccessful",
|
|
2959
|
+
"capacity",
|
|
2960
|
+
"provider_failure",
|
|
2961
|
+
"other",
|
|
2962
|
+
].includes(raw.reason))
|
|
2963
|
+
throw new TypeError("Invalid escalation reason");
|
|
2964
|
+
if (raw.summary !== undefined) {
|
|
2965
|
+
const summary = boundaryString(raw.summary, "summary");
|
|
2966
|
+
requireNonempty(summary, "handoff_summary");
|
|
2967
|
+
if (summary.length > 2_000)
|
|
2968
|
+
throw new SupportDeskLimitError("handoff_summary", 2_000);
|
|
2969
|
+
}
|
|
2970
|
+
if (raw.attemptedGuideIds !== undefined) {
|
|
2971
|
+
if (!Array.isArray(raw.attemptedGuideIds) ||
|
|
2972
|
+
raw.attemptedGuideIds.length > 16)
|
|
2973
|
+
throw new TypeError("Too many attempted guide IDs");
|
|
2974
|
+
const guides = raw.attemptedGuideIds;
|
|
2975
|
+
if (Object.keys(guides).length !== guides.length ||
|
|
2976
|
+
Object.getOwnPropertySymbols(guides).length !== 0)
|
|
2977
|
+
throw new TypeError("Guide IDs must be dense data");
|
|
2978
|
+
raw.attemptedGuideIds = Object.freeze(Array.from({ length: guides.length }, (_, index) => {
|
|
2979
|
+
const value = ownDataProperty(guides, String(index), "attemptedGuideIds");
|
|
2980
|
+
const id = boundaryString(value, "guideId");
|
|
2981
|
+
requireIdentifier(id, "guideId");
|
|
2982
|
+
if (id.length > 200 || id.includes("://"))
|
|
2983
|
+
throw new TypeError("Guide IDs must be bounded opaque references");
|
|
2984
|
+
return id;
|
|
2985
|
+
}));
|
|
2986
|
+
}
|
|
2987
|
+
return handlingAck(await mutateHandling(access, raw, "assistant_escalate"));
|
|
2988
|
+
},
|
|
2989
|
+
async requestPerson(access, input) {
|
|
2990
|
+
requirePermission(access, supportPermissions.requestPerson);
|
|
2991
|
+
const command = handlingInput(input, []);
|
|
2992
|
+
await mutateHandling(access, command, "request_person");
|
|
2993
|
+
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
2994
|
+
},
|
|
2995
|
+
async takeOverTicket(access, input) {
|
|
2996
|
+
requireStaffMutationAccess(access, supportPermissions.takeOver);
|
|
2997
|
+
const command = handlingFence(handlingInput(input, [
|
|
2998
|
+
"expectedRevision",
|
|
2999
|
+
"expectedHandlingGeneration",
|
|
3000
|
+
]));
|
|
3001
|
+
await mutateHandling(access, command, "take_over");
|
|
3002
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
3003
|
+
},
|
|
3004
|
+
async confirmCustomerSuccess(access, input) {
|
|
3005
|
+
requirePermission(access, supportPermissions.confirmSuccess);
|
|
3006
|
+
const raw = handlingInput(input, ["lastSeenVisibleMessageId"]);
|
|
3007
|
+
requireIdentifier(boundaryString(raw.lastSeenVisibleMessageId, "lastSeenVisibleMessageId"), "lastSeenVisibleMessageId");
|
|
3008
|
+
const command = raw;
|
|
3009
|
+
await mutateHandling(access, command, "confirm_customer_success");
|
|
3010
|
+
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3011
|
+
},
|
|
3012
|
+
async readCustomerSuccessConfirmations(access, ticketId) {
|
|
3013
|
+
requireStaffMutationAccess(access, supportPermissions.auditRead);
|
|
3014
|
+
requireIdentifier(access.principalId, "principalId");
|
|
3015
|
+
requireIdentifier(ticketId, "ticketId");
|
|
3016
|
+
const rows = await records.list(ticketId);
|
|
3017
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
|
|
3018
|
+
if (!rows.some((row) => row.kind === "ticket"))
|
|
3019
|
+
throw new SupportDeskNotFoundError();
|
|
3020
|
+
return rows
|
|
3021
|
+
.filter((row) => row.kind === "customer_success")
|
|
3022
|
+
.sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
|
|
3023
|
+
.map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
|
|
3024
|
+
},
|
|
3025
|
+
async createCustomerTicket(access, input) {
|
|
3026
|
+
requirePermission(access, supportPermissions.create);
|
|
3027
|
+
requireIdentifier(access.principalId, "principalId");
|
|
3028
|
+
const command = snapshotCreateCustomerTicketCommand(input);
|
|
3029
|
+
if (command.initialHandling === "assistant") {
|
|
3030
|
+
requirePermission(access, supportPermissions.assistantStart);
|
|
3031
|
+
}
|
|
3032
|
+
requireIdentifier(command.ticketId, "ticketId");
|
|
3033
|
+
requireIdentifier(command.messageId, "messageId");
|
|
3034
|
+
requireIdentifier(command.commandId, "commandId");
|
|
3035
|
+
requireIdentifier(command.correlationId, "correlationId");
|
|
3036
|
+
enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
|
|
3037
|
+
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
3038
|
+
const fingerprintPayload = {
|
|
3039
|
+
type: "create_customer_ticket",
|
|
3040
|
+
ticketId: command.ticketId,
|
|
3041
|
+
messageId: command.messageId,
|
|
3042
|
+
subject: command.subject,
|
|
3043
|
+
body: command.body,
|
|
3044
|
+
// Omit absent category so pre-category receipts still match retries.
|
|
3045
|
+
...(command.category === undefined
|
|
3046
|
+
? {}
|
|
3047
|
+
: { category: command.category }),
|
|
3048
|
+
requesterEmail: command.requesterEmail ?? null,
|
|
3049
|
+
notification: stableNotification(command.notification),
|
|
3050
|
+
...(command.initialHandling === undefined
|
|
3051
|
+
? {}
|
|
3052
|
+
: { initialHandling: command.initialHandling }),
|
|
3053
|
+
};
|
|
3054
|
+
const fingerprint = await requestFingerprint(fingerprintPayload);
|
|
3055
|
+
const createThreading = {
|
|
3056
|
+
ticketNumber: true,
|
|
3057
|
+
...(command.notification === undefined
|
|
3058
|
+
? {}
|
|
3059
|
+
: { messageId: command.messageId }),
|
|
3060
|
+
};
|
|
3061
|
+
// Replay an already committed create before host allowlist checks so a
|
|
3062
|
+
// later config change cannot break idempotent retries of the same command.
|
|
3063
|
+
// Replay never reserves another instance ticket number.
|
|
3064
|
+
const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
3065
|
+
let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
|
|
3066
|
+
if (!createIsReplay &&
|
|
3067
|
+
command.initialHandling === undefined &&
|
|
3068
|
+
existingCreate?.kind === "command" &&
|
|
3069
|
+
existingCreate.commandType === "create_customer_ticket" &&
|
|
3070
|
+
existingCreate.messageId === command.messageId) {
|
|
3071
|
+
// Pre-Task-3 receipts included ticketNumber in the fingerprint.
|
|
3072
|
+
const existingTicket = await records.get(ticketKey(command.ticketId));
|
|
3073
|
+
if (existingTicket?.kind === "ticket") {
|
|
3074
|
+
// Pre-Task-3 hashed ticketNumber immediately after ticketId.
|
|
3075
|
+
const legacyFingerprint = await requestFingerprint({
|
|
3076
|
+
type: fingerprintPayload.type,
|
|
3077
|
+
ticketId: fingerprintPayload.ticketId,
|
|
3078
|
+
ticketNumber: existingTicket.ticket.number,
|
|
3079
|
+
messageId: fingerprintPayload.messageId,
|
|
3080
|
+
subject: fingerprintPayload.subject,
|
|
3081
|
+
body: fingerprintPayload.body,
|
|
3082
|
+
...(command.category === undefined
|
|
3083
|
+
? {}
|
|
3084
|
+
: { category: command.category }),
|
|
3085
|
+
requesterEmail: fingerprintPayload.requesterEmail,
|
|
3086
|
+
notification: fingerprintPayload.notification,
|
|
3087
|
+
});
|
|
3088
|
+
createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
if (createIsReplay) {
|
|
3092
|
+
const current = await records.get(ticketKey(command.ticketId));
|
|
3093
|
+
if (current?.kind !== "ticket" ||
|
|
3094
|
+
!owns(current.ticket, access.principalId))
|
|
3095
|
+
throw new SupportDeskNotFoundError();
|
|
3096
|
+
// A crash after ticket commit and before index confirmation must still
|
|
3097
|
+
// converge the principal hint on retry.
|
|
3098
|
+
const fence = await records.get(ticketReservationKey(command.ticketId));
|
|
3099
|
+
if (fence?.kind === "ticket_reservation" &&
|
|
3100
|
+
fence.state === "committed") {
|
|
3101
|
+
await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
|
|
3102
|
+
}
|
|
3103
|
+
return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
|
|
3104
|
+
}
|
|
3105
|
+
if (command.initialHandling === "assistant" && !assistantHandlingEnabled)
|
|
3106
|
+
throw new SupportDeskConflictError("Assistant handling is disabled");
|
|
3107
|
+
if (command.category !== undefined) {
|
|
3108
|
+
if (allowedCategories === undefined ||
|
|
3109
|
+
!allowedCategories.has(command.category)) {
|
|
3110
|
+
throw new TypeError("create command.category is not configured for this Support Desk instance");
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
// Fail closed before durable number/index reservation so a too-small
|
|
3114
|
+
// partition budget cannot exhaust the principal ticket index with
|
|
3115
|
+
// reserved-but-never-created tickets.
|
|
3116
|
+
const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
|
|
3117
|
+
(resolutionLearningOptions.enabled ? 1 : 0) +
|
|
3118
|
+
(engineeringOptions.enabled ? 1 : 0) +
|
|
3119
|
+
(command.notification === undefined ? 0 : 1) +
|
|
3120
|
+
(assistantTurnOptions !== undefined &&
|
|
3121
|
+
command.initialHandling === "assistant"
|
|
3122
|
+
? 1
|
|
3123
|
+
: 0);
|
|
3124
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
|
|
3125
|
+
(resolutionLearningOptions.enabled ? 2 : 0) +
|
|
3126
|
+
(engineeringOptions.enabled ? 2 : 0) +
|
|
3127
|
+
(command.initialHandling === "assistant"
|
|
3128
|
+
? assistantHumanReserveRows +
|
|
3129
|
+
(resolutionLearningOptions.enabled ? 1 : 0)
|
|
3130
|
+
: 0));
|
|
3131
|
+
if (command.initialHandling === "assistant" &&
|
|
3132
|
+
limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
|
|
3133
|
+
throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
|
|
3134
|
+
// Reserve outside the ticket transaction; gaps are accepted if create fails.
|
|
3135
|
+
const ticketNumber = await reserveTicketNumber();
|
|
3136
|
+
// Bind host-supplied notification content to the reserved number so mail
|
|
3137
|
+
// cannot advertise a guessed ticket number.
|
|
1657
3138
|
const notification = command.notification === undefined
|
|
1658
3139
|
? undefined
|
|
1659
3140
|
: bindNotificationTicketNumber(command.notification, ticketNumber);
|
|
1660
3141
|
const now = clock.now();
|
|
1661
|
-
const ticket = createTicket({
|
|
3142
|
+
const ticket = await applyResponseTargets(createTicket({
|
|
1662
3143
|
id: command.ticketId,
|
|
1663
3144
|
number: ticketNumber,
|
|
1664
3145
|
subject: command.subject,
|
|
1665
3146
|
channel: "web",
|
|
3147
|
+
...(command.initialHandling === undefined
|
|
3148
|
+
? {}
|
|
3149
|
+
: { initialHandling: command.initialHandling }),
|
|
1666
3150
|
...(command.category === undefined
|
|
1667
3151
|
? {}
|
|
1668
3152
|
: { category: command.category }),
|
|
@@ -1674,7 +3158,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1674
3158
|
: { email: command.requesterEmail }),
|
|
1675
3159
|
},
|
|
1676
3160
|
createdAt: now,
|
|
1677
|
-
});
|
|
3161
|
+
}), responseTargetPolicy, "created");
|
|
1678
3162
|
const message = {
|
|
1679
3163
|
id: command.messageId,
|
|
1680
3164
|
ticketId: command.ticketId,
|
|
@@ -1686,6 +3170,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1686
3170
|
body: command.body,
|
|
1687
3171
|
createdAt: now,
|
|
1688
3172
|
};
|
|
3173
|
+
const assistantTurn = assistantTurnOptions !== undefined &&
|
|
3174
|
+
command.initialHandling === "assistant"
|
|
3175
|
+
? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
|
|
3176
|
+
: undefined;
|
|
1689
3177
|
const notificationJob = notification === undefined
|
|
1690
3178
|
? undefined
|
|
1691
3179
|
: deliveryJobAction(command.ticketId, command.messageId, now, notification);
|
|
@@ -1728,6 +3216,22 @@ export function createSupportDeskApplication(options) {
|
|
|
1728
3216
|
]
|
|
1729
3217
|
: [];
|
|
1730
3218
|
const actions = [
|
|
3219
|
+
...(engineeringOptions.enabled
|
|
3220
|
+
? [
|
|
3221
|
+
{
|
|
3222
|
+
action: "insert",
|
|
3223
|
+
value: await createEngineeringSource(ticket),
|
|
3224
|
+
},
|
|
3225
|
+
]
|
|
3226
|
+
: []),
|
|
3227
|
+
...(resolutionLearningOptions.enabled
|
|
3228
|
+
? [
|
|
3229
|
+
{
|
|
3230
|
+
action: "insert",
|
|
3231
|
+
value: await createResolutionLearningSource(ticket),
|
|
3232
|
+
},
|
|
3233
|
+
]
|
|
3234
|
+
: []),
|
|
1731
3235
|
{
|
|
1732
3236
|
action: "insert",
|
|
1733
3237
|
value: {
|
|
@@ -1744,6 +3248,9 @@ export function createSupportDeskApplication(options) {
|
|
|
1744
3248
|
partition: command.ticketId,
|
|
1745
3249
|
id: "quota",
|
|
1746
3250
|
messageCount: 1,
|
|
3251
|
+
...(command.initialHandling === "assistant"
|
|
3252
|
+
? { assistantManaged: true }
|
|
3253
|
+
: {}),
|
|
1747
3254
|
},
|
|
1748
3255
|
},
|
|
1749
3256
|
...reservationActions,
|
|
@@ -1783,6 +3290,9 @@ export function createSupportDeskApplication(options) {
|
|
|
1783
3290
|
},
|
|
1784
3291
|
},
|
|
1785
3292
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
3293
|
+
...(assistantTurn === undefined
|
|
3294
|
+
? []
|
|
3295
|
+
: [{ action: "insert", value: assistantTurn }]),
|
|
1786
3296
|
];
|
|
1787
3297
|
// Create starts an empty partition; fail closed if the first write would
|
|
1788
3298
|
// exceed the configured physical-record budget.
|
|
@@ -1795,7 +3305,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1795
3305
|
}
|
|
1796
3306
|
}
|
|
1797
3307
|
await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
|
|
1798
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3308
|
+
return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
|
|
1799
3309
|
},
|
|
1800
3310
|
async replyToCustomerTicket(access, input) {
|
|
1801
3311
|
requirePermission(access, supportPermissions.replyOwn);
|
|
@@ -1816,7 +3326,7 @@ export function createSupportDeskApplication(options) {
|
|
|
1816
3326
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
1817
3327
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
1818
3328
|
if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
1819
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3329
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1820
3330
|
}
|
|
1821
3331
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
1822
3332
|
if (versioned?.value.kind !== "ticket" ||
|
|
@@ -1825,7 +3335,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1825
3335
|
throw new SupportDeskNotFoundError();
|
|
1826
3336
|
}
|
|
1827
3337
|
const partitionRows = await records.list(command.ticketId);
|
|
1828
|
-
|
|
3338
|
+
const durableAssistantIngress = assistantTurnOptions !== undefined &&
|
|
3339
|
+
effectiveTicketHandling(versioned.value.ticket).state === "assistant";
|
|
3340
|
+
const addedReplyRows = (command.notification === undefined ? 3 : 4) +
|
|
3341
|
+
(durableAssistantIngress ? 1 : 0);
|
|
1829
3342
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
1830
3343
|
if (quota?.value.kind !== "ticket_quota") {
|
|
1831
3344
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -1839,11 +3352,25 @@ export function createSupportDeskApplication(options) {
|
|
|
1839
3352
|
const now = sampledEpoch < storedEpoch
|
|
1840
3353
|
? versioned.value.ticket.updatedAt
|
|
1841
3354
|
: sampledNow;
|
|
1842
|
-
|
|
3355
|
+
let updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
|
|
1843
3356
|
type: "customer_replied",
|
|
1844
3357
|
actorId: access.principalId,
|
|
1845
3358
|
occurredAt: now,
|
|
1846
|
-
});
|
|
3359
|
+
}), responseTargetPolicy, "customer_replied");
|
|
3360
|
+
if (durableAssistantIngress && !assistantHandlingEnabled) {
|
|
3361
|
+
// Configuration can disable generation while an older assistant
|
|
3362
|
+
// conversation exists. The causal reply itself commits human work.
|
|
3363
|
+
updated = applyTicketEvent(updated, {
|
|
3364
|
+
type: "human_escalated",
|
|
3365
|
+
actorId: access.principalId,
|
|
3366
|
+
occurredAt: now,
|
|
3367
|
+
expectedRevision: updated.revision,
|
|
3368
|
+
expectedHandlingGeneration: effectiveTicketHandling(updated).generation,
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3372
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
|
|
3373
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows + learningMutation.additionalRows, true);
|
|
1847
3374
|
const message = {
|
|
1848
3375
|
id: command.messageId,
|
|
1849
3376
|
ticketId: command.ticketId,
|
|
@@ -1856,15 +3383,22 @@ export function createSupportDeskApplication(options) {
|
|
|
1856
3383
|
createdAt: now,
|
|
1857
3384
|
};
|
|
1858
3385
|
const notificationContent = deliveryContent(command.notification);
|
|
3386
|
+
const assistantIngress = !durableAssistantIngress
|
|
3387
|
+
? undefined
|
|
3388
|
+
: assistantHandlingEnabled
|
|
3389
|
+
? await createAssistantTurnRecord(updated, message, command.correlationId, assistantTurnOptions)
|
|
3390
|
+
: handoffWork({
|
|
3391
|
+
ticket: updated,
|
|
3392
|
+
commandId: command.commandId,
|
|
3393
|
+
occurredAt: now,
|
|
3394
|
+
lastVisibleMessageId: command.messageId,
|
|
3395
|
+
reason: "other",
|
|
3396
|
+
});
|
|
1859
3397
|
const notificationJob = command.notification === undefined
|
|
1860
3398
|
? undefined
|
|
1861
3399
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
1862
3400
|
const outcome = await records.transact(command.ticketId, [
|
|
1863
|
-
|
|
1864
|
-
action: "putIfUnchanged",
|
|
1865
|
-
version: versioned.version,
|
|
1866
|
-
value: { ...versioned.value, ticket: updated },
|
|
1867
|
-
},
|
|
3401
|
+
...learningMutation.actions,
|
|
1868
3402
|
{
|
|
1869
3403
|
action: "putIfUnchanged",
|
|
1870
3404
|
version: quota.version,
|
|
@@ -1909,13 +3443,16 @@ export function createSupportDeskApplication(options) {
|
|
|
1909
3443
|
},
|
|
1910
3444
|
},
|
|
1911
3445
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
3446
|
+
...(assistantIngress === undefined
|
|
3447
|
+
? []
|
|
3448
|
+
: [{ action: "insert", value: assistantIngress }]),
|
|
1912
3449
|
]);
|
|
1913
3450
|
if (outcome.committed) {
|
|
1914
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3451
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1915
3452
|
}
|
|
1916
3453
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
1917
3454
|
if (duplicateMatches(nowDuplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
|
|
1918
|
-
return customerViewAfterChange(access.principalId, command.ticketId);
|
|
3455
|
+
return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
|
|
1919
3456
|
}
|
|
1920
3457
|
}
|
|
1921
3458
|
throw new SupportDeskConflictError();
|
|
@@ -1956,6 +3493,10 @@ export function createSupportDeskApplication(options) {
|
|
|
1956
3493
|
requirePermission(access, supportPermissions.queueRead);
|
|
1957
3494
|
requireIdentifier(access.principalId, "principalId");
|
|
1958
3495
|
const query = snapshotStaffQueueQuery(input);
|
|
3496
|
+
// One trusted instant for the entire bounded request; no clock read for old queries.
|
|
3497
|
+
const targetNow = query.targetState === undefined
|
|
3498
|
+
? undefined
|
|
3499
|
+
: canonicalTimestamp(clock.now(), "clock.now()");
|
|
1959
3500
|
// Request-local cursor only: every online queue read starts without one
|
|
1960
3501
|
// and consumes a single complete projection scan cycle.
|
|
1961
3502
|
let cursor;
|
|
@@ -2016,10 +3557,17 @@ export function createSupportDeskApplication(options) {
|
|
|
2016
3557
|
}
|
|
2017
3558
|
const ticket = ticketRecord.ticket;
|
|
2018
3559
|
if (ticket.id !== projection.ticketId ||
|
|
2019
|
-
|
|
3560
|
+
!Number.isSafeInteger(projection.projectedRevision) ||
|
|
3561
|
+
projection.projectedRevision <= 0 ||
|
|
3562
|
+
projection.projectedRevision > ticket.revision ||
|
|
2020
3563
|
isTerminalTicketStatus(ticket.status)) {
|
|
2021
3564
|
continue;
|
|
2022
3565
|
}
|
|
3566
|
+
const handlingView = query.handling ?? "human";
|
|
3567
|
+
if (handlingView !== "all" &&
|
|
3568
|
+
(handlingView === "assistant") !==
|
|
3569
|
+
(effectiveTicketHandling(ticket).state === "assistant"))
|
|
3570
|
+
continue;
|
|
2023
3571
|
if (seenTicketIds.has(ticket.id)) {
|
|
2024
3572
|
// Scan pages may repeat rows; de-duplicate after confirmation.
|
|
2025
3573
|
continue;
|
|
@@ -2035,9 +3583,33 @@ export function createSupportDeskApplication(options) {
|
|
|
2035
3583
|
}
|
|
2036
3584
|
cursor = page.nextCursor;
|
|
2037
3585
|
}
|
|
2038
|
-
const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query));
|
|
3586
|
+
const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query, targetNow));
|
|
2039
3587
|
const sort = query.sort ?? "updated_newest";
|
|
3588
|
+
// Parse each pending due date once per read, not once per comparison.
|
|
3589
|
+
const dueEpochs = new Map();
|
|
3590
|
+
if (sort === "due_soonest" || sort === "due_latest") {
|
|
3591
|
+
for (const item of filtered) {
|
|
3592
|
+
const due = pendingTargetDue(item, query.target);
|
|
3593
|
+
if (due !== undefined)
|
|
3594
|
+
dueEpochs.set(item, canonicalTimestamp(due, "ticket target"));
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
2040
3597
|
filtered.sort((left, right) => {
|
|
3598
|
+
if (sort === "due_soonest" || sort === "due_latest") {
|
|
3599
|
+
const leftDue = dueEpochs.get(left);
|
|
3600
|
+
const rightDue = dueEpochs.get(right);
|
|
3601
|
+
// Completed and missing targets sort last in both directions.
|
|
3602
|
+
if (leftDue === undefined && rightDue !== undefined)
|
|
3603
|
+
return 1;
|
|
3604
|
+
if (rightDue === undefined && leftDue !== undefined)
|
|
3605
|
+
return -1;
|
|
3606
|
+
const difference = leftDue === undefined || rightDue === undefined
|
|
3607
|
+
? 0
|
|
3608
|
+
: sort === "due_soonest"
|
|
3609
|
+
? leftDue - rightDue
|
|
3610
|
+
: rightDue - leftDue;
|
|
3611
|
+
return difference || left.ticketId.localeCompare(right.ticketId);
|
|
3612
|
+
}
|
|
2041
3613
|
const byTime = sort === "updated_oldest"
|
|
2042
3614
|
? left.updatedAt.localeCompare(right.updatedAt)
|
|
2043
3615
|
: right.updatedAt.localeCompare(left.updatedAt);
|
|
@@ -2061,6 +3633,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2061
3633
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2062
3634
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2063
3635
|
const fingerprint = await requestFingerprint({
|
|
3636
|
+
...snapshotRevisionHint(command),
|
|
2064
3637
|
type: "staff_reply",
|
|
2065
3638
|
ticketId: command.ticketId,
|
|
2066
3639
|
messageId: command.messageId,
|
|
@@ -2070,14 +3643,17 @@ export function createSupportDeskApplication(options) {
|
|
|
2070
3643
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2071
3644
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2072
3645
|
if (duplicateMatches(duplicate, "staff_reply", command.messageId, fingerprint)) {
|
|
2073
|
-
return
|
|
3646
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2074
3647
|
}
|
|
3648
|
+
if (duplicate !== null)
|
|
3649
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2075
3650
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2076
3651
|
if (versioned?.value.kind !== "ticket") {
|
|
2077
3652
|
throw new SupportDeskNotFoundError();
|
|
2078
3653
|
}
|
|
3654
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3655
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2079
3656
|
const partitionRows = await records.list(command.ticketId);
|
|
2080
|
-
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
|
|
2081
3657
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2082
3658
|
if (quota?.value.kind !== "ticket_quota") {
|
|
2083
3659
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -2091,6 +3667,11 @@ export function createSupportDeskApplication(options) {
|
|
|
2091
3667
|
actorId: access.principalId,
|
|
2092
3668
|
occurredAt: now,
|
|
2093
3669
|
});
|
|
3670
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
3671
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
|
|
3672
|
+
learningMutation.additionalRows);
|
|
3673
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
|
|
3674
|
+
learningMutation.additionalRows, true, true);
|
|
2094
3675
|
const message = {
|
|
2095
3676
|
id: command.messageId,
|
|
2096
3677
|
ticketId: command.ticketId,
|
|
@@ -2107,11 +3688,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2107
3688
|
? undefined
|
|
2108
3689
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
2109
3690
|
const outcome = await records.transact(command.ticketId, [
|
|
2110
|
-
|
|
2111
|
-
action: "putIfUnchanged",
|
|
2112
|
-
version: versioned.version,
|
|
2113
|
-
value: { ...versioned.value, ticket: updated },
|
|
2114
|
-
},
|
|
3691
|
+
...learningMutation.actions,
|
|
2115
3692
|
{
|
|
2116
3693
|
action: "putIfUnchanged",
|
|
2117
3694
|
version: quota.version,
|
|
@@ -2153,19 +3730,20 @@ export function createSupportDeskApplication(options) {
|
|
|
2153
3730
|
messageId: command.messageId,
|
|
2154
3731
|
requestFingerprint: fingerprint,
|
|
2155
3732
|
completedAt: now,
|
|
3733
|
+
resultTicket: updated,
|
|
2156
3734
|
},
|
|
2157
3735
|
},
|
|
2158
3736
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
2159
3737
|
]);
|
|
2160
3738
|
if (outcome.committed) {
|
|
2161
|
-
return
|
|
3739
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2162
3740
|
}
|
|
2163
3741
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2164
3742
|
if (duplicateMatches(nowDuplicate, "staff_reply", command.messageId, fingerprint)) {
|
|
2165
|
-
return
|
|
3743
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2166
3744
|
}
|
|
2167
3745
|
}
|
|
2168
|
-
|
|
3746
|
+
return staffConflictResult(command, fingerprint);
|
|
2169
3747
|
},
|
|
2170
3748
|
async addNote(access, input) {
|
|
2171
3749
|
requireStaffMutationAccess(access, supportPermissions.note);
|
|
@@ -2177,6 +3755,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2177
3755
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2178
3756
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2179
3757
|
const fingerprint = await requestFingerprint({
|
|
3758
|
+
...snapshotRevisionHint(command),
|
|
2180
3759
|
type: "add_note",
|
|
2181
3760
|
ticketId: command.ticketId,
|
|
2182
3761
|
messageId: command.messageId,
|
|
@@ -2185,14 +3764,19 @@ export function createSupportDeskApplication(options) {
|
|
|
2185
3764
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2186
3765
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2187
3766
|
if (duplicateMatches(duplicate, "add_note", command.messageId, fingerprint)) {
|
|
2188
|
-
return
|
|
3767
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2189
3768
|
}
|
|
3769
|
+
if (duplicate !== null)
|
|
3770
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2190
3771
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2191
3772
|
if (versioned?.value.kind !== "ticket") {
|
|
2192
3773
|
throw new SupportDeskNotFoundError();
|
|
2193
3774
|
}
|
|
3775
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3776
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2194
3777
|
const partitionRows = await records.list(command.ticketId);
|
|
2195
3778
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
|
|
3779
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
|
|
2196
3780
|
const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
2197
3781
|
if (quota?.value.kind !== "ticket_quota") {
|
|
2198
3782
|
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
@@ -2268,18 +3852,19 @@ export function createSupportDeskApplication(options) {
|
|
|
2268
3852
|
messageId: command.messageId,
|
|
2269
3853
|
requestFingerprint: fingerprint,
|
|
2270
3854
|
completedAt: now,
|
|
3855
|
+
resultTicket: updated,
|
|
2271
3856
|
},
|
|
2272
3857
|
},
|
|
2273
3858
|
]);
|
|
2274
3859
|
if (outcome.committed) {
|
|
2275
|
-
return
|
|
3860
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2276
3861
|
}
|
|
2277
3862
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2278
3863
|
if (duplicateMatches(nowDuplicate, "add_note", command.messageId, fingerprint)) {
|
|
2279
|
-
return
|
|
3864
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2280
3865
|
}
|
|
2281
3866
|
}
|
|
2282
|
-
|
|
3867
|
+
return staffConflictResult(command, fingerprint);
|
|
2283
3868
|
},
|
|
2284
3869
|
async assignTicket(access, input) {
|
|
2285
3870
|
requireStaffMutationAccess(access, supportPermissions.assign);
|
|
@@ -2298,6 +3883,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2298
3883
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2299
3884
|
}
|
|
2300
3885
|
const fingerprint = await requestFingerprint({
|
|
3886
|
+
...snapshotRevisionHint(command),
|
|
2301
3887
|
type: "assign_ticket",
|
|
2302
3888
|
ticketId: command.ticketId,
|
|
2303
3889
|
assigneeId: command.assigneeId,
|
|
@@ -2308,8 +3894,10 @@ export function createSupportDeskApplication(options) {
|
|
|
2308
3894
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2309
3895
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2310
3896
|
if (duplicateMatches(duplicate, "assign_ticket", command.messageId, fingerprint)) {
|
|
2311
|
-
return
|
|
3897
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2312
3898
|
}
|
|
3899
|
+
if (duplicate !== null)
|
|
3900
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2313
3901
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2314
3902
|
if (versioned?.value.kind !== "ticket") {
|
|
2315
3903
|
throw new SupportDeskNotFoundError();
|
|
@@ -2319,8 +3907,11 @@ export function createSupportDeskApplication(options) {
|
|
|
2319
3907
|
: command.notification === undefined
|
|
2320
3908
|
? 3
|
|
2321
3909
|
: 4;
|
|
3910
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
3911
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2322
3912
|
const partitionRows = await records.list(command.ticketId);
|
|
2323
3913
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
|
|
3914
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
|
|
2324
3915
|
let quotaVersion = null;
|
|
2325
3916
|
if (command.messageId !== undefined) {
|
|
2326
3917
|
quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
@@ -2377,9 +3968,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2377
3968
|
action: "putIfUnchanged",
|
|
2378
3969
|
version: quotaVersion.version,
|
|
2379
3970
|
value: {
|
|
2380
|
-
|
|
2381
|
-
partition: command.ticketId,
|
|
2382
|
-
id: "quota",
|
|
3971
|
+
...quotaVersion.value,
|
|
2383
3972
|
messageCount: quotaVersion.value.messageCount + 1,
|
|
2384
3973
|
},
|
|
2385
3974
|
},
|
|
@@ -2428,19 +4017,20 @@ export function createSupportDeskApplication(options) {
|
|
|
2428
4017
|
: { messageId: command.messageId }),
|
|
2429
4018
|
requestFingerprint: fingerprint,
|
|
2430
4019
|
completedAt: now,
|
|
4020
|
+
resultTicket: updated,
|
|
2431
4021
|
},
|
|
2432
4022
|
},
|
|
2433
4023
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
2434
4024
|
]);
|
|
2435
4025
|
if (outcome.committed) {
|
|
2436
|
-
return
|
|
4026
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2437
4027
|
}
|
|
2438
4028
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2439
4029
|
if (duplicateMatches(nowDuplicate, "assign_ticket", command.messageId, fingerprint)) {
|
|
2440
|
-
return
|
|
4030
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2441
4031
|
}
|
|
2442
4032
|
}
|
|
2443
|
-
|
|
4033
|
+
return staffConflictResult(command, fingerprint);
|
|
2444
4034
|
},
|
|
2445
4035
|
async changePriority(access, input) {
|
|
2446
4036
|
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
@@ -2450,6 +4040,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2450
4040
|
requireIdentifier(command.commandId, "commandId");
|
|
2451
4041
|
requireIdentifier(command.correlationId, "correlationId");
|
|
2452
4042
|
const fingerprint = await requestFingerprint({
|
|
4043
|
+
...snapshotRevisionHint(command),
|
|
2453
4044
|
type: "change_priority",
|
|
2454
4045
|
ticketId: command.ticketId,
|
|
2455
4046
|
priority: command.priority,
|
|
@@ -2457,23 +4048,28 @@ export function createSupportDeskApplication(options) {
|
|
|
2457
4048
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2458
4049
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2459
4050
|
if (duplicateMatches(duplicate, "change_priority", undefined, fingerprint)) {
|
|
2460
|
-
return
|
|
4051
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2461
4052
|
}
|
|
4053
|
+
if (duplicate !== null)
|
|
4054
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2462
4055
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2463
4056
|
if (versioned?.value.kind !== "ticket") {
|
|
2464
4057
|
throw new SupportDeskNotFoundError();
|
|
2465
4058
|
}
|
|
4059
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4060
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2466
4061
|
const partitionRows = await records.list(command.ticketId);
|
|
2467
4062
|
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
|
|
4063
|
+
enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
|
|
2468
4064
|
const statusBefore = versioned.value.ticket.status;
|
|
2469
4065
|
const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
|
|
2470
4066
|
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
2471
|
-
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
4067
|
+
const updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
|
|
2472
4068
|
type: "priority_changed",
|
|
2473
4069
|
actorId: access.principalId,
|
|
2474
4070
|
priority: command.priority,
|
|
2475
4071
|
occurredAt: now,
|
|
2476
|
-
});
|
|
4072
|
+
}), responseTargetPolicy, "priority_changed");
|
|
2477
4073
|
if (updated.status !== statusBefore ||
|
|
2478
4074
|
updated.customerUpdatedAt !== customerUpdatedBefore) {
|
|
2479
4075
|
throw new SupportDeskConflictError("Priority change unexpectedly changed customer-visible ticket state");
|
|
@@ -2504,18 +4100,98 @@ export function createSupportDeskApplication(options) {
|
|
|
2504
4100
|
commandType: "change_priority",
|
|
2505
4101
|
requestFingerprint: fingerprint,
|
|
2506
4102
|
completedAt: now,
|
|
4103
|
+
resultTicket: updated,
|
|
2507
4104
|
},
|
|
2508
4105
|
},
|
|
2509
4106
|
]);
|
|
2510
4107
|
if (outcome.committed) {
|
|
2511
|
-
return
|
|
4108
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2512
4109
|
}
|
|
2513
4110
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2514
4111
|
if (duplicateMatches(nowDuplicate, "change_priority", undefined, fingerprint)) {
|
|
2515
|
-
return
|
|
4112
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
2516
4113
|
}
|
|
2517
4114
|
}
|
|
2518
|
-
|
|
4115
|
+
return staffConflictResult(command, fingerprint);
|
|
4116
|
+
},
|
|
4117
|
+
async setTags(access, input) {
|
|
4118
|
+
requireStaffMutationAccess(access, supportPermissions.manage);
|
|
4119
|
+
requireIdentifier(access.principalId, "principalId");
|
|
4120
|
+
const source = boundaryObject(input, "set tags command");
|
|
4121
|
+
const command = Object.freeze({
|
|
4122
|
+
...snapshotRevisionHint(source),
|
|
4123
|
+
commandId: boundaryString(ownDataProperty(source, "commandId", "commandId"), "commandId"),
|
|
4124
|
+
correlationId: boundaryString(ownDataProperty(source, "correlationId", "correlationId"), "correlationId"),
|
|
4125
|
+
ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
|
|
4126
|
+
tags: snapshotTags(ownDataProperty(source, "tags", "tags")),
|
|
4127
|
+
});
|
|
4128
|
+
requireIdentifier(command.ticketId, "ticketId");
|
|
4129
|
+
requireIdentifier(command.commandId, "commandId");
|
|
4130
|
+
requireIdentifier(command.correlationId, "correlationId");
|
|
4131
|
+
const fingerprint = await requestFingerprint({
|
|
4132
|
+
type: "set_tags",
|
|
4133
|
+
ticketId: command.ticketId,
|
|
4134
|
+
tags: command.tags,
|
|
4135
|
+
...snapshotRevisionHint(command),
|
|
4136
|
+
});
|
|
4137
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
4138
|
+
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
4139
|
+
if (duplicateMatches(duplicate, "set_tags", undefined, fingerprint))
|
|
4140
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4141
|
+
if (duplicate !== null)
|
|
4142
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
4143
|
+
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
4144
|
+
if (versioned?.value.kind !== "ticket")
|
|
4145
|
+
throw new SupportDeskNotFoundError();
|
|
4146
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4147
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4148
|
+
if (command.tags.length > maxTagsPerTicket)
|
|
4149
|
+
throw new TypeError("tags exceed maxTagsPerTicket");
|
|
4150
|
+
if (command.tags.some((tag) => !allowedTags.has(tag)))
|
|
4151
|
+
throw new TypeError("tags must be on the host allowlist");
|
|
4152
|
+
const rows = await records.list(command.ticketId);
|
|
4153
|
+
enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
|
|
4154
|
+
enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
|
|
4155
|
+
const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
|
|
4156
|
+
const updated = applyTicketEvent(versioned.value.ticket, {
|
|
4157
|
+
type: "tags_changed",
|
|
4158
|
+
actorId: access.principalId,
|
|
4159
|
+
occurredAt: now,
|
|
4160
|
+
tags: command.tags,
|
|
4161
|
+
});
|
|
4162
|
+
const outcome = await records.transact(command.ticketId, [
|
|
4163
|
+
{
|
|
4164
|
+
action: "putIfUnchanged",
|
|
4165
|
+
version: versioned.version,
|
|
4166
|
+
value: { ...versioned.value, ticket: updated },
|
|
4167
|
+
},
|
|
4168
|
+
ticketAuditAction({
|
|
4169
|
+
commandId: command.commandId,
|
|
4170
|
+
correlationId: command.correlationId,
|
|
4171
|
+
ticketId: command.ticketId,
|
|
4172
|
+
revision: updated.revision,
|
|
4173
|
+
actorId: access.principalId,
|
|
4174
|
+
occurredAt: now,
|
|
4175
|
+
action: supportTicketAuditActions.tagsChanged,
|
|
4176
|
+
}),
|
|
4177
|
+
{
|
|
4178
|
+
action: "insert",
|
|
4179
|
+
value: {
|
|
4180
|
+
kind: "command",
|
|
4181
|
+
partition: command.ticketId,
|
|
4182
|
+
id: `command:${command.commandId}`,
|
|
4183
|
+
commandId: command.commandId,
|
|
4184
|
+
commandType: "set_tags",
|
|
4185
|
+
requestFingerprint: fingerprint,
|
|
4186
|
+
completedAt: now,
|
|
4187
|
+
resultTicket: updated,
|
|
4188
|
+
},
|
|
4189
|
+
},
|
|
4190
|
+
]);
|
|
4191
|
+
if (outcome.committed)
|
|
4192
|
+
return staffCommandResult(command.ticketId, command.commandId);
|
|
4193
|
+
}
|
|
4194
|
+
return staffConflictResult(command, fingerprint);
|
|
2519
4195
|
},
|
|
2520
4196
|
async resolveTicket(access, input) {
|
|
2521
4197
|
return mutateLifecycle(access, input, {
|
|
@@ -2568,6 +4244,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2568
4244
|
enforceLimit(command.body, "body", limits.maxMessageCharacters);
|
|
2569
4245
|
}
|
|
2570
4246
|
const fingerprint = await requestFingerprint({
|
|
4247
|
+
...snapshotRevisionHint(command),
|
|
2571
4248
|
type: spec.commandType,
|
|
2572
4249
|
ticketId: command.ticketId,
|
|
2573
4250
|
messageId: command.messageId ?? null,
|
|
@@ -2577,19 +4254,25 @@ export function createSupportDeskApplication(options) {
|
|
|
2577
4254
|
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
2578
4255
|
const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2579
4256
|
if (duplicateMatches(duplicate, spec.commandType, command.messageId, fingerprint)) {
|
|
2580
|
-
return
|
|
4257
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2581
4258
|
}
|
|
4259
|
+
if (duplicate !== null)
|
|
4260
|
+
throw new SupportDeskConflictError("Command ID reused with different input");
|
|
2582
4261
|
const versioned = await records.getVersioned(ticketKey(command.ticketId));
|
|
2583
4262
|
if (versioned?.value.kind !== "ticket") {
|
|
2584
4263
|
throw new SupportDeskNotFoundError();
|
|
2585
4264
|
}
|
|
4265
|
+
if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
|
|
4266
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2586
4267
|
const partitionRows = await records.list(command.ticketId);
|
|
2587
|
-
const
|
|
4268
|
+
const needsReopenHandoff = assistantTurnOptions !== undefined &&
|
|
4269
|
+
spec.eventType === "reopened" &&
|
|
4270
|
+
effectiveTicketHandling(versioned.value.ticket).state === "assistant";
|
|
4271
|
+
const addedRecords = (command.messageId === undefined
|
|
2588
4272
|
? 2 // audit + command
|
|
2589
4273
|
: command.notification === undefined
|
|
2590
4274
|
? 3 // message + audit + command
|
|
2591
|
-
: 4
|
|
2592
|
-
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
|
|
4275
|
+
: 4) + (needsReopenHandoff ? 1 : 0); // optional atomic human handoff
|
|
2593
4276
|
let quotaVersion = null;
|
|
2594
4277
|
if (command.messageId !== undefined) {
|
|
2595
4278
|
quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
|
|
@@ -2606,6 +4289,9 @@ export function createSupportDeskApplication(options) {
|
|
|
2606
4289
|
actorId: access.principalId,
|
|
2607
4290
|
occurredAt: now,
|
|
2608
4291
|
});
|
|
4292
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
|
|
4293
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
|
|
4294
|
+
enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
|
|
2609
4295
|
// Optional system message + mail commit with the lifecycle change so a
|
|
2610
4296
|
// resolution notification cannot be lost after the ticket is resolved.
|
|
2611
4297
|
const systemMessage = command.messageId === undefined || command.body === undefined
|
|
@@ -2625,11 +4311,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2625
4311
|
? undefined
|
|
2626
4312
|
: deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
|
|
2627
4313
|
const outcome = await records.transact(command.ticketId, [
|
|
2628
|
-
|
|
2629
|
-
action: "putIfUnchanged",
|
|
2630
|
-
version: versioned.version,
|
|
2631
|
-
value: { ...versioned.value, ticket: updated },
|
|
2632
|
-
},
|
|
4314
|
+
...learningMutation.actions,
|
|
2633
4315
|
...(quotaVersion === null ||
|
|
2634
4316
|
systemMessage === undefined ||
|
|
2635
4317
|
quotaVersion.value.kind !== "ticket_quota"
|
|
@@ -2639,9 +4321,7 @@ export function createSupportDeskApplication(options) {
|
|
|
2639
4321
|
action: "putIfUnchanged",
|
|
2640
4322
|
version: quotaVersion.version,
|
|
2641
4323
|
value: {
|
|
2642
|
-
|
|
2643
|
-
partition: command.ticketId,
|
|
2644
|
-
id: "quota",
|
|
4324
|
+
...quotaVersion.value,
|
|
2645
4325
|
messageCount: quotaVersion.value.messageCount + 1,
|
|
2646
4326
|
},
|
|
2647
4327
|
},
|
|
@@ -2685,19 +4365,35 @@ export function createSupportDeskApplication(options) {
|
|
|
2685
4365
|
: { messageId: command.messageId }),
|
|
2686
4366
|
requestFingerprint: fingerprint,
|
|
2687
4367
|
completedAt: now,
|
|
4368
|
+
resultTicket: updated,
|
|
2688
4369
|
},
|
|
2689
4370
|
},
|
|
2690
4371
|
...(notificationJob === undefined ? [] : [notificationJob]),
|
|
4372
|
+
...(needsReopenHandoff
|
|
4373
|
+
? [
|
|
4374
|
+
{
|
|
4375
|
+
action: "insert",
|
|
4376
|
+
value: handoffWork({
|
|
4377
|
+
ticket: updated,
|
|
4378
|
+
commandId: command.commandId,
|
|
4379
|
+
occurredAt: now,
|
|
4380
|
+
lastVisibleMessageId: systemMessage?.id ??
|
|
4381
|
+
latestVisibleRecord(partitionRows).message.id,
|
|
4382
|
+
reason: "other",
|
|
4383
|
+
}),
|
|
4384
|
+
},
|
|
4385
|
+
]
|
|
4386
|
+
: []),
|
|
2691
4387
|
]);
|
|
2692
4388
|
if (outcome.committed) {
|
|
2693
|
-
return
|
|
4389
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2694
4390
|
}
|
|
2695
4391
|
const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
|
|
2696
4392
|
if (duplicateMatches(nowDuplicate, spec.commandType, command.messageId, fingerprint)) {
|
|
2697
|
-
return
|
|
4393
|
+
return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
|
|
2698
4394
|
}
|
|
2699
4395
|
}
|
|
2700
|
-
|
|
4396
|
+
return staffConflictResult(command, fingerprint);
|
|
2701
4397
|
}
|
|
2702
4398
|
}
|
|
2703
4399
|
function canonicalTimestamp(value, field) {
|
|
@@ -2922,7 +4618,8 @@ export async function sweepInboundReceipts(store, clock, input) {
|
|
|
2922
4618
|
}
|
|
2923
4619
|
const receipts = store.collection(inboundReceipts);
|
|
2924
4620
|
const candidates = (await receipts.listVersioned(bucket))
|
|
2925
|
-
.filter((versioned) => versioned.value.status
|
|
4621
|
+
.filter((versioned) => (versioned.value.status === "processed" ||
|
|
4622
|
+
versioned.value.status === "rejected") &&
|
|
2926
4623
|
versioned.value.processedAt !== undefined &&
|
|
2927
4624
|
versioned.value.processedAt <= processedBefore &&
|
|
2928
4625
|
versioned.value.processedAt < horizonBefore)
|
|
@@ -3045,4 +4742,1671 @@ export async function pruneCustomerTicketIndex(store, principalId, input) {
|
|
|
3045
4742
|
});
|
|
3046
4743
|
return result.value?.entries.map((entry) => entry.ticketId) ?? [];
|
|
3047
4744
|
}
|
|
4745
|
+
const threadIndexKey = (record) => ({
|
|
4746
|
+
partition: record.key,
|
|
4747
|
+
id: record.id,
|
|
4748
|
+
});
|
|
4749
|
+
export const threadIndex = defineCollection({
|
|
4750
|
+
name: "support-desk.thread-index.v1",
|
|
4751
|
+
key: threadIndexKey,
|
|
4752
|
+
codec: jsonCodec(threadIndexKey),
|
|
4753
|
+
});
|
|
4754
|
+
/**
|
|
4755
|
+
* Default lifetime of `Message-ID` and reply-token threading rows. Lookups,
|
|
4756
|
+
* repair, and sweeping all treat older rows as absent; ticket-number rows
|
|
4757
|
+
* live as long as their ticket.
|
|
4758
|
+
*/
|
|
4759
|
+
export const defaultThreadIndexRetentionMilliseconds = 365 * 86_400_000;
|
|
4760
|
+
/** Actor recorded on Audit events accepted from verified inbound mail. */
|
|
4761
|
+
export const inboundEmailSystemActorId = "support-desk.inbound-email";
|
|
4762
|
+
function notificationThreading(command) {
|
|
4763
|
+
return command.notification === undefined || command.messageId === undefined
|
|
4764
|
+
? undefined
|
|
4765
|
+
: { messageId: command.messageId };
|
|
4766
|
+
}
|
|
4767
|
+
function messageKey(ticketId, messageId) {
|
|
4768
|
+
return { partition: ticketId, id: `message:${messageId}` };
|
|
4769
|
+
}
|
|
4770
|
+
async function sha256Hex(text) {
|
|
4771
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
4772
|
+
return [...new Uint8Array(digest)]
|
|
4773
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
4774
|
+
.join("");
|
|
4775
|
+
}
|
|
4776
|
+
async function messageIdIndexKey(value) {
|
|
4777
|
+
return `mid:${await sha256Hex(`message-id\u0000${value}`)}`;
|
|
4778
|
+
}
|
|
4779
|
+
async function replyTokenIndexKey(token) {
|
|
4780
|
+
return `rt:${await sha256Hex(`reply-token\u0000${token}`)}`;
|
|
4781
|
+
}
|
|
4782
|
+
function ticketNumberIndexKey(ticketNumber) {
|
|
4783
|
+
return `number:${ticketNumber}`;
|
|
4784
|
+
}
|
|
4785
|
+
const inboundMessageIdPattern = /^<[^<>\s@]+@[^<>\s@]+>$/;
|
|
4786
|
+
const maxInboundMessageIdCharacters = 512;
|
|
4787
|
+
/**
|
|
4788
|
+
* Accept a header-shaped `<local@domain>` identifier or nothing. Malformed
|
|
4789
|
+
* sender-controlled identifiers are dropped rather than stored or matched.
|
|
4790
|
+
*/
|
|
4791
|
+
function normalizeInboundMessageId(value) {
|
|
4792
|
+
if (typeof value !== "string") {
|
|
4793
|
+
return undefined;
|
|
4794
|
+
}
|
|
4795
|
+
const trimmed = value.trim();
|
|
4796
|
+
if (trimmed.length === 0 ||
|
|
4797
|
+
trimmed.length > maxInboundMessageIdCharacters ||
|
|
4798
|
+
/[\u0000-\u001F\u007F]/.test(trimmed) ||
|
|
4799
|
+
!inboundMessageIdPattern.test(trimmed)) {
|
|
4800
|
+
return undefined;
|
|
4801
|
+
}
|
|
4802
|
+
return trimmed;
|
|
4803
|
+
}
|
|
4804
|
+
/** Derive every thread-index row an authoritative record implies. */
|
|
4805
|
+
async function threadIndexEntriesFor(record) {
|
|
4806
|
+
if (record.kind === "ticket") {
|
|
4807
|
+
const ticket = record.ticket;
|
|
4808
|
+
if (ticket.id !== record.partition ||
|
|
4809
|
+
!Number.isSafeInteger(ticket.number) ||
|
|
4810
|
+
ticket.number <= 0) {
|
|
4811
|
+
return [];
|
|
4812
|
+
}
|
|
4813
|
+
return [
|
|
4814
|
+
{
|
|
4815
|
+
key: ticketNumberIndexKey(ticket.number),
|
|
4816
|
+
id: "thread",
|
|
4817
|
+
kind: "ticket_number",
|
|
4818
|
+
ticketId: ticket.id,
|
|
4819
|
+
issuedAt: ticket.createdAt,
|
|
4820
|
+
},
|
|
4821
|
+
];
|
|
4822
|
+
}
|
|
4823
|
+
if (record.kind !== "message") {
|
|
4824
|
+
return [];
|
|
4825
|
+
}
|
|
4826
|
+
const message = record.message;
|
|
4827
|
+
if (message.ticketId !== record.partition ||
|
|
4828
|
+
record.id !== `message:${message.id}`) {
|
|
4829
|
+
return [];
|
|
4830
|
+
}
|
|
4831
|
+
const entries = [];
|
|
4832
|
+
const content = record.deliveryContent;
|
|
4833
|
+
if (content !== undefined) {
|
|
4834
|
+
entries.push({
|
|
4835
|
+
key: await messageIdIndexKey(content.outboundMessageId),
|
|
4836
|
+
id: "thread",
|
|
4837
|
+
kind: "message_id",
|
|
4838
|
+
ticketId: message.ticketId,
|
|
4839
|
+
messageId: message.id,
|
|
4840
|
+
source: "outbound",
|
|
4841
|
+
issuedAt: message.createdAt,
|
|
4842
|
+
});
|
|
4843
|
+
if (content.replyRoutingToken !== undefined) {
|
|
4844
|
+
entries.push({
|
|
4845
|
+
key: await replyTokenIndexKey(content.replyRoutingToken),
|
|
4846
|
+
id: "thread",
|
|
4847
|
+
kind: "reply_token",
|
|
4848
|
+
ticketId: message.ticketId,
|
|
4849
|
+
messageId: message.id,
|
|
4850
|
+
issuedAt: message.createdAt,
|
|
4851
|
+
});
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
if (message.channel === "email" &&
|
|
4855
|
+
message.authorKind === "customer" &&
|
|
4856
|
+
message.externalMessageId !== undefined &&
|
|
4857
|
+
message.externalMessageId !== content?.outboundMessageId &&
|
|
4858
|
+
normalizeInboundMessageId(message.externalMessageId) ===
|
|
4859
|
+
message.externalMessageId) {
|
|
4860
|
+
entries.push({
|
|
4861
|
+
key: await messageIdIndexKey(message.externalMessageId),
|
|
4862
|
+
id: "thread",
|
|
4863
|
+
kind: "message_id",
|
|
4864
|
+
ticketId: message.ticketId,
|
|
4865
|
+
messageId: message.id,
|
|
4866
|
+
source: "inbound",
|
|
4867
|
+
issuedAt: message.createdAt,
|
|
4868
|
+
});
|
|
4869
|
+
}
|
|
4870
|
+
return entries;
|
|
4871
|
+
}
|
|
4872
|
+
/** First writer wins; a different mapping for the same key is a collision. */
|
|
4873
|
+
async function putThreadIndexEntry(index, entry) {
|
|
4874
|
+
const result = await index.insertIfAbsent(entry);
|
|
4875
|
+
if (result.inserted) {
|
|
4876
|
+
return "written";
|
|
4877
|
+
}
|
|
4878
|
+
const existing = result.value;
|
|
4879
|
+
return existing.kind === entry.kind &&
|
|
4880
|
+
existing.ticketId === entry.ticketId &&
|
|
4881
|
+
existing.messageId === entry.messageId &&
|
|
4882
|
+
existing.source === entry.source
|
|
4883
|
+
? "kept"
|
|
4884
|
+
: "collision";
|
|
4885
|
+
}
|
|
4886
|
+
function safeLog(logger, level, event, fields) {
|
|
4887
|
+
try {
|
|
4888
|
+
logger.log(level, event, fields);
|
|
4889
|
+
}
|
|
4890
|
+
catch {
|
|
4891
|
+
// ignore logger transport failures
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4894
|
+
/**
|
|
4895
|
+
* Best-effort post-commit threading writes for one ticket. Failures are
|
|
4896
|
+
* logged and later converged by {@link repairThreadIndexPage}; they never
|
|
4897
|
+
* fail the command that already committed. Does not sample the clock.
|
|
4898
|
+
*/
|
|
4899
|
+
async function indexThreadingAfterCommit(store, ticketId, hint, logger) {
|
|
4900
|
+
try {
|
|
4901
|
+
const records = store.collection(supportRecords);
|
|
4902
|
+
const index = store.collection(threadIndex);
|
|
4903
|
+
const sources = [];
|
|
4904
|
+
if (hint.ticketNumber === true) {
|
|
4905
|
+
const ticket = await records.get(ticketKey(ticketId));
|
|
4906
|
+
if (ticket !== null) {
|
|
4907
|
+
sources.push(ticket);
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
if (hint.messageId !== undefined) {
|
|
4911
|
+
const message = await records.get(messageKey(ticketId, hint.messageId));
|
|
4912
|
+
if (message !== null) {
|
|
4913
|
+
sources.push(message);
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
for (const source of sources) {
|
|
4917
|
+
for (const entry of await threadIndexEntriesFor(source)) {
|
|
4918
|
+
if ((await putThreadIndexEntry(index, entry)) === "collision") {
|
|
4919
|
+
safeLog(logger, "warn", "support-desk.thread_index.collision", {
|
|
4920
|
+
ticketId,
|
|
4921
|
+
kind: entry.kind,
|
|
4922
|
+
});
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
catch (error) {
|
|
4928
|
+
safeLog(logger, "error", "support-desk.thread_index.write_failed", {
|
|
4929
|
+
ticketId,
|
|
4930
|
+
message: error instanceof Error ? error.message : "thread index write failed",
|
|
4931
|
+
});
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
/**
|
|
4935
|
+
* Re-derive a row from its authoritative ticket or message. Returns the
|
|
4936
|
+
* current ticket only when the row still describes that record and, for
|
|
4937
|
+
* message rows, is inside the retention window.
|
|
4938
|
+
*/
|
|
4939
|
+
async function confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds) {
|
|
4940
|
+
const ticketRecord = await records.get(ticketKey(entry.ticketId));
|
|
4941
|
+
if (ticketRecord?.kind !== "ticket" ||
|
|
4942
|
+
ticketRecord.ticket.id !== entry.ticketId) {
|
|
4943
|
+
return null;
|
|
4944
|
+
}
|
|
4945
|
+
let authoritative = ticketRecord;
|
|
4946
|
+
if (entry.kind !== "ticket_number") {
|
|
4947
|
+
if (entry.messageId === undefined) {
|
|
4948
|
+
return null;
|
|
4949
|
+
}
|
|
4950
|
+
const message = await records.get(messageKey(entry.ticketId, entry.messageId));
|
|
4951
|
+
if (message === null) {
|
|
4952
|
+
return null;
|
|
4953
|
+
}
|
|
4954
|
+
authoritative = message;
|
|
4955
|
+
}
|
|
4956
|
+
const derived = (await threadIndexEntriesFor(authoritative)).find((candidate) => candidate.key === entry.key && candidate.kind === entry.kind);
|
|
4957
|
+
if (derived === undefined ||
|
|
4958
|
+
derived.ticketId !== entry.ticketId ||
|
|
4959
|
+
derived.messageId !== entry.messageId ||
|
|
4960
|
+
derived.source !== entry.source) {
|
|
4961
|
+
return null;
|
|
4962
|
+
}
|
|
4963
|
+
if (derived.kind !== "ticket_number" &&
|
|
4964
|
+
nowEpoch - canonicalTimestamp(derived.issuedAt, "thread index issuedAt") >=
|
|
4965
|
+
retentionMilliseconds) {
|
|
4966
|
+
return null;
|
|
4967
|
+
}
|
|
4968
|
+
return ticketRecord.ticket;
|
|
4969
|
+
}
|
|
4970
|
+
async function lookupThreadEntry(records, index, kind, key, nowEpoch, retentionMilliseconds) {
|
|
4971
|
+
const entry = await index.get({ partition: key, id: "thread" });
|
|
4972
|
+
if (entry === null || entry.key !== key || entry.kind !== kind) {
|
|
4973
|
+
return null;
|
|
4974
|
+
}
|
|
4975
|
+
const ticket = await confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds);
|
|
4976
|
+
return ticket === null ? null : { ticket, entry };
|
|
4977
|
+
}
|
|
4978
|
+
function parseRetentionMilliseconds(value, field) {
|
|
4979
|
+
if (typeof value !== "number") {
|
|
4980
|
+
throw new TypeError(`${field} must be a number`);
|
|
4981
|
+
}
|
|
4982
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
4983
|
+
throw new TypeError(`${field} must be a positive safe integer`);
|
|
4984
|
+
}
|
|
4985
|
+
return value;
|
|
4986
|
+
}
|
|
4987
|
+
function parseThreadIndexWorkerOptions(options, field) {
|
|
4988
|
+
const source = boundaryObject(options, field);
|
|
4989
|
+
const store = ownDataProperty(source, "store", `${field}.store`);
|
|
4990
|
+
const clock = ownDataProperty(source, "clock", `${field}.clock`);
|
|
4991
|
+
const retentionMilliseconds = parseRetentionMilliseconds(ownDataProperty(source, "threadIndexRetentionMilliseconds", `${field}.threadIndexRetentionMilliseconds`), `${field}.threadIndexRetentionMilliseconds`);
|
|
4992
|
+
const rawCursor = ownDataProperty(source, "cursor", `${field}.cursor`, true);
|
|
4993
|
+
const rawLimit = ownDataProperty(source, "limit", `${field}.limit`, true);
|
|
4994
|
+
if (rawCursor !== undefined && typeof rawCursor !== "string") {
|
|
4995
|
+
throw new TypeError(`${field}.cursor must be a string`);
|
|
4996
|
+
}
|
|
4997
|
+
if (rawLimit !== undefined && typeof rawLimit !== "number") {
|
|
4998
|
+
throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
|
|
4999
|
+
}
|
|
5000
|
+
const limit = rawLimit ?? 100;
|
|
5001
|
+
if (!Number.isSafeInteger(limit) ||
|
|
5002
|
+
limit <= 0 ||
|
|
5003
|
+
limit > MAX_SCAN_PAGE_SIZE) {
|
|
5004
|
+
throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
|
|
5005
|
+
}
|
|
5006
|
+
return {
|
|
5007
|
+
store,
|
|
5008
|
+
clock,
|
|
5009
|
+
retentionMilliseconds,
|
|
5010
|
+
...(rawCursor === undefined ? {} : { cursor: rawCursor }),
|
|
5011
|
+
limit,
|
|
5012
|
+
};
|
|
5013
|
+
}
|
|
5014
|
+
/**
|
|
5015
|
+
* Scan one page of authoritative `support-desk.records.v1` rows and insert
|
|
5016
|
+
* every thread-index row they imply (ticket numbers, outbound `Message-ID`s,
|
|
5017
|
+
* reply tokens, and inbound `Message-ID`s). Rows past retention are not
|
|
5018
|
+
* recreated. Hosts persist `nextCursor` only after the whole page succeeds
|
|
5019
|
+
* and repeat complete cycles.
|
|
5020
|
+
*/
|
|
5021
|
+
export async function repairThreadIndexPage(options) {
|
|
5022
|
+
const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index repair options");
|
|
5023
|
+
const records = store.collection(supportRecords);
|
|
5024
|
+
const index = store.collection(threadIndex);
|
|
5025
|
+
const page = await records.scan({
|
|
5026
|
+
limit,
|
|
5027
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
5028
|
+
});
|
|
5029
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5030
|
+
let indexed = 0;
|
|
5031
|
+
let collisions = 0;
|
|
5032
|
+
for (const entry of page.records) {
|
|
5033
|
+
if (entry.key.partition !== entry.value.partition ||
|
|
5034
|
+
entry.key.id !== entry.value.id) {
|
|
5035
|
+
continue;
|
|
5036
|
+
}
|
|
5037
|
+
for (const candidate of await threadIndexEntriesFor(entry.value)) {
|
|
5038
|
+
if (candidate.kind !== "ticket_number" &&
|
|
5039
|
+
nowEpoch -
|
|
5040
|
+
canonicalTimestamp(candidate.issuedAt, "thread index issuedAt") >=
|
|
5041
|
+
retentionMilliseconds) {
|
|
5042
|
+
continue;
|
|
5043
|
+
}
|
|
5044
|
+
const outcome = await putThreadIndexEntry(index, candidate);
|
|
5045
|
+
if (outcome === "written") {
|
|
5046
|
+
indexed += 1;
|
|
5047
|
+
}
|
|
5048
|
+
else if (outcome === "collision") {
|
|
5049
|
+
collisions += 1;
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
return {
|
|
5054
|
+
nextCursor: page.nextCursor,
|
|
5055
|
+
physicalRows: page.records.length,
|
|
5056
|
+
indexed,
|
|
5057
|
+
collisions,
|
|
5058
|
+
};
|
|
5059
|
+
}
|
|
5060
|
+
/**
|
|
5061
|
+
* Scan one page of thread-index rows and conditionally delete every row whose
|
|
5062
|
+
* authoritative target is gone, no longer derives it, or is past retention.
|
|
5063
|
+
* Uses `deleteIfUnchanged` with the scanned version.
|
|
5064
|
+
*/
|
|
5065
|
+
export async function sweepThreadIndexPage(options) {
|
|
5066
|
+
const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index sweep options");
|
|
5067
|
+
const records = store.collection(supportRecords);
|
|
5068
|
+
const index = store.collection(threadIndex);
|
|
5069
|
+
const page = await index.scan({
|
|
5070
|
+
limit,
|
|
5071
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
5072
|
+
});
|
|
5073
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5074
|
+
let deleted = 0;
|
|
5075
|
+
for (const entry of page.records) {
|
|
5076
|
+
if (entry.key.partition !== entry.value.key || entry.key.id !== "thread") {
|
|
5077
|
+
continue;
|
|
5078
|
+
}
|
|
5079
|
+
if ((await confirmThreadEntry(records, entry.value, nowEpoch, retentionMilliseconds)) !== null) {
|
|
5080
|
+
continue;
|
|
5081
|
+
}
|
|
5082
|
+
if (await index.deleteIfUnchanged(entry.key, entry.version)) {
|
|
5083
|
+
deleted += 1;
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
return {
|
|
5087
|
+
nextCursor: page.nextCursor,
|
|
5088
|
+
physicalRows: page.records.length,
|
|
5089
|
+
deleted,
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
const maxInboundReferences = 100;
|
|
5093
|
+
const maxThreadingLookups = 20;
|
|
5094
|
+
const maxLoopSignalCharacters = 998;
|
|
5095
|
+
const maxInboundDisplayNameCharacters = 200;
|
|
5096
|
+
const maxSupportAddresses = 32;
|
|
5097
|
+
const subjectMarkerPrefixPattern = /^[A-Z][A-Z0-9]{0,15}$/;
|
|
5098
|
+
const inboundSuppressingPrecedence = new Set([
|
|
5099
|
+
"bulk",
|
|
5100
|
+
"junk",
|
|
5101
|
+
"list",
|
|
5102
|
+
"auto_reply",
|
|
5103
|
+
"auto-reply",
|
|
5104
|
+
]);
|
|
5105
|
+
function collapseHeaderText(value) {
|
|
5106
|
+
return value
|
|
5107
|
+
.replace(/[\u0000-\u001F\u007F]+/g, " ")
|
|
5108
|
+
.replace(/\s+/g, " ")
|
|
5109
|
+
.trim();
|
|
5110
|
+
}
|
|
5111
|
+
function normalizeInboundSubject(value, maximum) {
|
|
5112
|
+
const collapsed = collapseHeaderText(value);
|
|
5113
|
+
const text = collapsed.length === 0 ? "(no subject)" : collapsed;
|
|
5114
|
+
const points = [...text];
|
|
5115
|
+
return points.length > maximum
|
|
5116
|
+
? points.slice(0, maximum).join("").trimEnd()
|
|
5117
|
+
: text;
|
|
5118
|
+
}
|
|
5119
|
+
function normalizeInboundBody(value) {
|
|
5120
|
+
return value
|
|
5121
|
+
.replace(/\r\n?/g, "\n")
|
|
5122
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
5123
|
+
}
|
|
5124
|
+
function normalizeInboundDisplayName(value) {
|
|
5125
|
+
const collapsed = collapseHeaderText(value);
|
|
5126
|
+
if (collapsed.length === 0) {
|
|
5127
|
+
return undefined;
|
|
5128
|
+
}
|
|
5129
|
+
return [...collapsed].slice(0, maxInboundDisplayNameCharacters).join("");
|
|
5130
|
+
}
|
|
5131
|
+
function snapshotLoopSignals(input) {
|
|
5132
|
+
if (input === undefined) {
|
|
5133
|
+
return Object.freeze({});
|
|
5134
|
+
}
|
|
5135
|
+
const source = boundaryObject(input, "inbound email.loopSignals");
|
|
5136
|
+
const signals = {};
|
|
5137
|
+
for (const key of [
|
|
5138
|
+
"autoSubmitted",
|
|
5139
|
+
"precedence",
|
|
5140
|
+
"autoResponseSuppress",
|
|
5141
|
+
"listId",
|
|
5142
|
+
"contentType",
|
|
5143
|
+
"envelopeSender",
|
|
5144
|
+
]) {
|
|
5145
|
+
const value = ownDataProperty(source, key, `inbound email.loopSignals.${key}`, true);
|
|
5146
|
+
if (value === undefined) {
|
|
5147
|
+
continue;
|
|
5148
|
+
}
|
|
5149
|
+
if (typeof value !== "string") {
|
|
5150
|
+
throw new TypeError(`inbound email.loopSignals.${key} must be a string`);
|
|
5151
|
+
}
|
|
5152
|
+
signals[key] = value.slice(0, maxLoopSignalCharacters);
|
|
5153
|
+
}
|
|
5154
|
+
const autoReply = ownDataProperty(source, "autoReplyHeaderPresent", "inbound email.loopSignals.autoReplyHeaderPresent", true);
|
|
5155
|
+
if (autoReply !== undefined) {
|
|
5156
|
+
if (typeof autoReply !== "boolean") {
|
|
5157
|
+
throw new TypeError("inbound email.loopSignals.autoReplyHeaderPresent must be a boolean");
|
|
5158
|
+
}
|
|
5159
|
+
signals.autoReplyHeaderPresent = autoReply;
|
|
5160
|
+
}
|
|
5161
|
+
return Object.freeze(signals);
|
|
5162
|
+
}
|
|
5163
|
+
function snapshotInboundSenderAuthentication(input) {
|
|
5164
|
+
if (input === undefined) {
|
|
5165
|
+
return undefined;
|
|
5166
|
+
}
|
|
5167
|
+
const field = "inbound email.senderAuthentication";
|
|
5168
|
+
const source = boundaryObject(input, field);
|
|
5169
|
+
const method = ownDataProperty(source, "method", `${field}.method`);
|
|
5170
|
+
if (method !== "dmarc" && method !== "host_identity") {
|
|
5171
|
+
throw new TypeError(`${field}.method must be dmarc or host_identity`);
|
|
5172
|
+
}
|
|
5173
|
+
const result = ownDataProperty(source, "result", `${field}.result`);
|
|
5174
|
+
if (result !== "pass" && result !== "fail") {
|
|
5175
|
+
throw new TypeError(`${field}.result must be pass or fail`);
|
|
5176
|
+
}
|
|
5177
|
+
const email = normalizeRequesterEmail(boundaryString(ownDataProperty(source, "email", `${field}.email`), `${field}.email`), `${field}.email`);
|
|
5178
|
+
if (method === "host_identity") {
|
|
5179
|
+
for (const key of ["dkimAligned", "spfAligned"]) {
|
|
5180
|
+
ownDataProperty(source, key, `${field}.${key}`, true, true);
|
|
5181
|
+
if (Object.hasOwn(source, key)) {
|
|
5182
|
+
throw new TypeError(`${field}.${key} is not valid when method is host_identity`);
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
return Object.freeze({ method, result, email });
|
|
5186
|
+
}
|
|
5187
|
+
const dkimAligned = ownDataProperty(source, "dkimAligned", `${field}.dkimAligned`);
|
|
5188
|
+
if (typeof dkimAligned !== "boolean") {
|
|
5189
|
+
throw new TypeError(`${field}.dkimAligned must be a boolean`);
|
|
5190
|
+
}
|
|
5191
|
+
const spfAligned = ownDataProperty(source, "spfAligned", `${field}.spfAligned`);
|
|
5192
|
+
if (typeof spfAligned !== "boolean") {
|
|
5193
|
+
throw new TypeError(`${field}.spfAligned must be a boolean`);
|
|
5194
|
+
}
|
|
5195
|
+
return Object.freeze({
|
|
5196
|
+
method,
|
|
5197
|
+
result,
|
|
5198
|
+
email,
|
|
5199
|
+
dkimAligned,
|
|
5200
|
+
spfAligned,
|
|
5201
|
+
});
|
|
5202
|
+
}
|
|
5203
|
+
function snapshotInboundEmailInput(input, limits) {
|
|
5204
|
+
const source = boundaryObject(input, "inbound email");
|
|
5205
|
+
const field = (key) => `inbound email.${key}`;
|
|
5206
|
+
const readString = (key) => boundaryString(ownDataProperty(source, key, field(key)), field(key));
|
|
5207
|
+
const readIdentifier = (key) => {
|
|
5208
|
+
const value = readString(key);
|
|
5209
|
+
requireIdentifier(value, field(key));
|
|
5210
|
+
return value;
|
|
5211
|
+
};
|
|
5212
|
+
const readOptional = (key) => ownDataProperty(source, key, field(key), true);
|
|
5213
|
+
const channelId = readIdentifier("channelId");
|
|
5214
|
+
const providerEventId = readIdentifier("providerEventId");
|
|
5215
|
+
const ticketId = readIdentifier("ticketId");
|
|
5216
|
+
const messageId = readIdentifier("messageId");
|
|
5217
|
+
const correlationId = readIdentifier("correlationId");
|
|
5218
|
+
const from = normalizeRequesterEmail(readString("from"), field("from"));
|
|
5219
|
+
const rawSenderAuthentication = ownDataProperty(source, "senderAuthentication", field("senderAuthentication"), true, true);
|
|
5220
|
+
const senderAuthentication = snapshotInboundSenderAuthentication(rawSenderAuthentication);
|
|
5221
|
+
const rawName = readOptional("fromName");
|
|
5222
|
+
const fromName = rawName === undefined
|
|
5223
|
+
? undefined
|
|
5224
|
+
: normalizeInboundDisplayName(boundaryString(rawName, field("fromName")));
|
|
5225
|
+
const subject = normalizeInboundSubject(readString("subject"), limits.maxSubjectCharacters);
|
|
5226
|
+
const body = normalizeInboundBody(readString("body"));
|
|
5227
|
+
enforceLimit(body, "body", limits.maxMessageCharacters);
|
|
5228
|
+
const rawFormat = readOptional("format");
|
|
5229
|
+
if (rawFormat !== undefined &&
|
|
5230
|
+
rawFormat !== "plain_text" &&
|
|
5231
|
+
rawFormat !== "markdown") {
|
|
5232
|
+
throw new TypeError(`${field("format")} must be plain_text or markdown`);
|
|
5233
|
+
}
|
|
5234
|
+
const format = rawFormat ?? "plain_text";
|
|
5235
|
+
const externalMessageId = normalizeInboundMessageId(readOptional("externalMessageId"));
|
|
5236
|
+
const inReplyTo = normalizeInboundMessageId(readOptional("inReplyTo"));
|
|
5237
|
+
const rawReferences = readOptional("references");
|
|
5238
|
+
const references = [];
|
|
5239
|
+
if (rawReferences !== undefined) {
|
|
5240
|
+
if (!Array.isArray(rawReferences)) {
|
|
5241
|
+
throw new TypeError(`${field("references")} must be an array`);
|
|
5242
|
+
}
|
|
5243
|
+
const start = Math.max(0, rawReferences.length - maxInboundReferences);
|
|
5244
|
+
for (let position = start; position < rawReferences.length; position += 1) {
|
|
5245
|
+
const normalized = normalizeInboundMessageId(ownDataProperty(rawReferences, position, `${field("references")}[${position}]`));
|
|
5246
|
+
if (normalized !== undefined && !references.includes(normalized)) {
|
|
5247
|
+
references.push(normalized);
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
const rawToken = readOptional("replyRoutingToken");
|
|
5252
|
+
if (rawToken !== undefined && typeof rawToken !== "string") {
|
|
5253
|
+
throw new TypeError(`${field("replyRoutingToken")} must be a string`);
|
|
5254
|
+
}
|
|
5255
|
+
const token = rawToken?.trim();
|
|
5256
|
+
const replyRoutingToken = token !== undefined && replyRoutingTokenPattern.test(token)
|
|
5257
|
+
? token
|
|
5258
|
+
: undefined;
|
|
5259
|
+
const loopSignals = snapshotLoopSignals(readOptional("loopSignals"));
|
|
5260
|
+
return Object.freeze({
|
|
5261
|
+
channelId,
|
|
5262
|
+
providerEventId,
|
|
5263
|
+
ticketId,
|
|
5264
|
+
messageId,
|
|
5265
|
+
correlationId,
|
|
5266
|
+
from,
|
|
5267
|
+
...(senderAuthentication === undefined ? {} : { senderAuthentication }),
|
|
5268
|
+
...(fromName === undefined ? {} : { fromName }),
|
|
5269
|
+
subject,
|
|
5270
|
+
body,
|
|
5271
|
+
format,
|
|
5272
|
+
...(externalMessageId === undefined ? {} : { externalMessageId }),
|
|
5273
|
+
...(inReplyTo === undefined ? {} : { inReplyTo }),
|
|
5274
|
+
references: Object.freeze(references),
|
|
5275
|
+
...(replyRoutingToken === undefined ? {} : { replyRoutingToken }),
|
|
5276
|
+
loopSignals,
|
|
5277
|
+
});
|
|
5278
|
+
}
|
|
5279
|
+
/** Content fingerprint for one provider event; excludes host-minted IDs. */
|
|
5280
|
+
function inboundFingerprintPayload(input, format = "current") {
|
|
5281
|
+
const canonicalSender = format === "current";
|
|
5282
|
+
const includeSenderResult = format !== "pre_sender_result";
|
|
5283
|
+
return {
|
|
5284
|
+
type: "inbound_email",
|
|
5285
|
+
channelId: input.channelId,
|
|
5286
|
+
providerEventId: input.providerEventId,
|
|
5287
|
+
from: canonicalSender ? input.from.toLowerCase() : input.from,
|
|
5288
|
+
...(!includeSenderResult || input.senderAuthentication === undefined
|
|
5289
|
+
? {}
|
|
5290
|
+
: {
|
|
5291
|
+
senderAuthentication: canonicalSender
|
|
5292
|
+
? {
|
|
5293
|
+
...input.senderAuthentication,
|
|
5294
|
+
email: input.senderAuthentication.email.toLowerCase(),
|
|
5295
|
+
}
|
|
5296
|
+
: input.senderAuthentication,
|
|
5297
|
+
}),
|
|
5298
|
+
fromName: input.fromName ?? null,
|
|
5299
|
+
subject: input.subject,
|
|
5300
|
+
body: input.body,
|
|
5301
|
+
format: input.format,
|
|
5302
|
+
externalMessageId: input.externalMessageId ?? null,
|
|
5303
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
5304
|
+
references: input.references,
|
|
5305
|
+
replyRoutingToken: input.replyRoutingToken ?? null,
|
|
5306
|
+
loopSignals: input.loopSignals,
|
|
5307
|
+
};
|
|
5308
|
+
}
|
|
5309
|
+
function inboundSuppressionReason(input, supportAddresses) {
|
|
5310
|
+
const signals = input.loopSignals;
|
|
5311
|
+
const autoSubmitted = signals.autoSubmitted
|
|
5312
|
+
?.split(";")[0]
|
|
5313
|
+
?.trim()
|
|
5314
|
+
.toLowerCase();
|
|
5315
|
+
if (autoSubmitted !== undefined &&
|
|
5316
|
+
autoSubmitted.length > 0 &&
|
|
5317
|
+
autoSubmitted !== "no") {
|
|
5318
|
+
return "auto_submitted";
|
|
5319
|
+
}
|
|
5320
|
+
const precedence = signals.precedence?.trim().toLowerCase();
|
|
5321
|
+
if (precedence !== undefined &&
|
|
5322
|
+
inboundSuppressingPrecedence.has(precedence)) {
|
|
5323
|
+
return "precedence";
|
|
5324
|
+
}
|
|
5325
|
+
if (signals.autoReplyHeaderPresent === true) {
|
|
5326
|
+
return "auto_reply_header";
|
|
5327
|
+
}
|
|
5328
|
+
if (signals.envelopeSender !== undefined) {
|
|
5329
|
+
const envelope = signals.envelopeSender.trim();
|
|
5330
|
+
if (envelope.length === 0 || envelope === "<>") {
|
|
5331
|
+
return "bounce";
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
const localPart = input.from.slice(0, input.from.indexOf("@")).toLowerCase();
|
|
5335
|
+
if (localPart === "mailer-daemon") {
|
|
5336
|
+
return "bounce";
|
|
5337
|
+
}
|
|
5338
|
+
const contentType = signals.contentType?.trim().toLowerCase();
|
|
5339
|
+
if (contentType !== undefined &&
|
|
5340
|
+
(contentType.startsWith("multipart/report") ||
|
|
5341
|
+
contentType.startsWith("message/delivery-status") ||
|
|
5342
|
+
contentType.startsWith("message/disposition-notification"))) {
|
|
5343
|
+
return "delivery_report";
|
|
5344
|
+
}
|
|
5345
|
+
if (supportAddresses.has(input.from.toLowerCase())) {
|
|
5346
|
+
return "support_address_loop";
|
|
5347
|
+
}
|
|
5348
|
+
return undefined;
|
|
5349
|
+
}
|
|
5350
|
+
function inboundSenderAcknowledgementAllowed(input) {
|
|
5351
|
+
const suppress = (input.loopSignals.autoResponseSuppress ?? "")
|
|
5352
|
+
.split(",")
|
|
5353
|
+
.map((value) => value.trim().toLowerCase());
|
|
5354
|
+
if (suppress.some((value) => value === "all" || value === "oof" || value === "autoreply")) {
|
|
5355
|
+
return false;
|
|
5356
|
+
}
|
|
5357
|
+
return (input.loopSignals.listId ?? "").trim().length === 0;
|
|
5358
|
+
}
|
|
5359
|
+
function subjectMarkerNumbers(subject, prefix) {
|
|
5360
|
+
const pattern = new RegExp(`\\[${prefix}-([1-9][0-9]{0,15})\\]`, "g");
|
|
5361
|
+
const found = [];
|
|
5362
|
+
for (const match of subject.matchAll(pattern)) {
|
|
5363
|
+
const value = Number(match[1]);
|
|
5364
|
+
if (Number.isSafeInteger(value) && !found.includes(value)) {
|
|
5365
|
+
found.push(value);
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
return found;
|
|
5369
|
+
}
|
|
5370
|
+
function threadingHeaderCandidates(input) {
|
|
5371
|
+
const ordered = [
|
|
5372
|
+
...(input.inReplyTo === undefined ? [] : [input.inReplyTo]),
|
|
5373
|
+
...[...input.references].reverse(),
|
|
5374
|
+
];
|
|
5375
|
+
const unique = [];
|
|
5376
|
+
for (const candidate of ordered) {
|
|
5377
|
+
if (candidate !== input.externalMessageId && !unique.includes(candidate)) {
|
|
5378
|
+
unique.push(candidate);
|
|
5379
|
+
}
|
|
5380
|
+
if (unique.length >= maxThreadingLookups) {
|
|
5381
|
+
break;
|
|
5382
|
+
}
|
|
5383
|
+
}
|
|
5384
|
+
return unique;
|
|
5385
|
+
}
|
|
5386
|
+
function sameAddress(left, right) {
|
|
5387
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
5388
|
+
}
|
|
5389
|
+
/**
|
|
5390
|
+
* A thread match with a recorded requester address requires both exact
|
|
5391
|
+
* case-insensitive address equality and a passing host-evaluated sender result
|
|
5392
|
+
* bound to that normalized address. Without a recorded address, only an
|
|
5393
|
+
* opaque routing token can join; a `Message-ID` or subject marker alone is
|
|
5394
|
+
* never enough.
|
|
5395
|
+
*/
|
|
5396
|
+
function threadJoinIsSafe(ticket, method, input) {
|
|
5397
|
+
const email = ticket.requester.email;
|
|
5398
|
+
if (email === undefined) {
|
|
5399
|
+
return method === "routing_token";
|
|
5400
|
+
}
|
|
5401
|
+
const authentication = input.senderAuthentication;
|
|
5402
|
+
if (authentication === undefined ||
|
|
5403
|
+
authentication.result !== "pass" ||
|
|
5404
|
+
!sameAddress(email, input.from) ||
|
|
5405
|
+
!sameAddress(authentication.email, input.from)) {
|
|
5406
|
+
return false;
|
|
5407
|
+
}
|
|
5408
|
+
return (authentication.method === "host_identity" ||
|
|
5409
|
+
authentication.dkimAligned ||
|
|
5410
|
+
authentication.spfAligned);
|
|
5411
|
+
}
|
|
5412
|
+
function snapshotSenderMatch(value) {
|
|
5413
|
+
const source = boundaryObject(value, "account matcher result");
|
|
5414
|
+
const matches = ownDataProperty(source, "matches", "account matcher result.matches");
|
|
5415
|
+
if (matches === "none" || matches === "many") {
|
|
5416
|
+
return Object.freeze({ matches });
|
|
5417
|
+
}
|
|
5418
|
+
if (matches === "one") {
|
|
5419
|
+
const principalId = boundaryString(ownDataProperty(source, "principalId", "account matcher result.principalId"), "account matcher result.principalId");
|
|
5420
|
+
requireIdentifier(principalId, "account matcher result.principalId");
|
|
5421
|
+
return Object.freeze({ matches, principalId });
|
|
5422
|
+
}
|
|
5423
|
+
throw new TypeError("account matcher result.matches must be none, one, or many");
|
|
5424
|
+
}
|
|
5425
|
+
function snapshotRoutingDecision(value, allowedCategories) {
|
|
5426
|
+
const source = boundaryObject(value, "inbound routing decision");
|
|
5427
|
+
const priority = ownDataProperty(source, "priority", "inbound routing decision.priority");
|
|
5428
|
+
if (priority !== "low" && priority !== "normal" && priority !== "high") {
|
|
5429
|
+
throw new TypeError("inbound routing decision.priority must be low, normal, or high; inbound mail is never automatically urgent");
|
|
5430
|
+
}
|
|
5431
|
+
const category = snapshotCategory(ownDataProperty(source, "category", "inbound routing decision.category", true), "inbound routing decision.category");
|
|
5432
|
+
if (category !== undefined &&
|
|
5433
|
+
(allowedCategories === undefined || !allowedCategories.has(category))) {
|
|
5434
|
+
throw new TypeError("inbound routing decision.category is not configured for this Support Desk instance");
|
|
5435
|
+
}
|
|
5436
|
+
return Object.freeze({
|
|
5437
|
+
priority,
|
|
5438
|
+
...(category === undefined ? {} : { category }),
|
|
5439
|
+
});
|
|
5440
|
+
}
|
|
5441
|
+
function inboundAuditAction(input) {
|
|
5442
|
+
return supportAudit.action({
|
|
5443
|
+
id: input.commandId,
|
|
5444
|
+
occurredAt: input.occurredAt,
|
|
5445
|
+
actor: { kind: "system", systemId: inboundEmailSystemActorId },
|
|
5446
|
+
action: input.action,
|
|
5447
|
+
subject: input.ticketId,
|
|
5448
|
+
sequence: input.revision,
|
|
5449
|
+
details: {
|
|
5450
|
+
commandId: input.commandId,
|
|
5451
|
+
correlationId: input.correlationId,
|
|
5452
|
+
channel: "email",
|
|
5453
|
+
matchMethod: input.matchMethod,
|
|
5454
|
+
...(input.association === undefined
|
|
5455
|
+
? {}
|
|
5456
|
+
: { association: input.association }),
|
|
5457
|
+
},
|
|
5458
|
+
});
|
|
5459
|
+
}
|
|
5460
|
+
function inboundMessage(input, ticketId, messageId, now) {
|
|
5461
|
+
// No authorPrincipalId: a sender address never authenticates authorship.
|
|
5462
|
+
return {
|
|
5463
|
+
id: messageId,
|
|
5464
|
+
ticketId,
|
|
5465
|
+
authorKind: "customer",
|
|
5466
|
+
channel: "email",
|
|
5467
|
+
visibility: "customer",
|
|
5468
|
+
format: input.format,
|
|
5469
|
+
body: input.body,
|
|
5470
|
+
createdAt: now,
|
|
5471
|
+
...(input.externalMessageId === undefined
|
|
5472
|
+
? {}
|
|
5473
|
+
: { externalMessageId: input.externalMessageId }),
|
|
5474
|
+
...(input.inReplyTo === undefined
|
|
5475
|
+
? {}
|
|
5476
|
+
: { inReplyToExternalMessageId: input.inReplyTo }),
|
|
5477
|
+
};
|
|
5478
|
+
}
|
|
5479
|
+
function inboundResult(receipt, duplicateDelivery) {
|
|
5480
|
+
const route = receipt.route;
|
|
5481
|
+
return Object.freeze({
|
|
5482
|
+
duplicateDelivery,
|
|
5483
|
+
outcome: receipt.outcome ?? "rejected",
|
|
5484
|
+
...(receipt.ticketId === undefined ? {} : { ticketId: receipt.ticketId }),
|
|
5485
|
+
...(receipt.messageId === undefined
|
|
5486
|
+
? {}
|
|
5487
|
+
: { messageId: receipt.messageId }),
|
|
5488
|
+
...(route?.kind === "reply" || route?.kind === "new_conversation"
|
|
5489
|
+
? { matchMethod: route.matchMethod }
|
|
5490
|
+
: {}),
|
|
5491
|
+
...(route?.kind === "new_conversation"
|
|
5492
|
+
? {
|
|
5493
|
+
association: route.association,
|
|
5494
|
+
...(route.relatedTicketId === undefined
|
|
5495
|
+
? {}
|
|
5496
|
+
: { relatedTicketId: route.relatedTicketId }),
|
|
5497
|
+
}
|
|
5498
|
+
: {}),
|
|
5499
|
+
...(receipt.diagnostic === undefined ? {} : { reason: receipt.diagnostic }),
|
|
5500
|
+
});
|
|
5501
|
+
}
|
|
5502
|
+
function parseSupportAddresses(input) {
|
|
5503
|
+
const field = "inbound mail processor options.supportAddresses";
|
|
5504
|
+
if (input === undefined) {
|
|
5505
|
+
return new Set();
|
|
5506
|
+
}
|
|
5507
|
+
if (!Array.isArray(input)) {
|
|
5508
|
+
throw new TypeError(`${field} must be an array`);
|
|
5509
|
+
}
|
|
5510
|
+
if (input.length > maxSupportAddresses) {
|
|
5511
|
+
throw new TypeError(`${field} may contain at most ${maxSupportAddresses} values`);
|
|
5512
|
+
}
|
|
5513
|
+
const addresses = new Set();
|
|
5514
|
+
for (let position = 0; position < input.length; position += 1) {
|
|
5515
|
+
const entry = `${field}[${position}]`;
|
|
5516
|
+
const value = normalizeRequesterEmail(boundaryString(ownDataProperty(input, position, entry), entry), entry);
|
|
5517
|
+
addresses.add(value.toLowerCase());
|
|
5518
|
+
}
|
|
5519
|
+
return addresses;
|
|
5520
|
+
}
|
|
5521
|
+
/**
|
|
5522
|
+
* Create the inbound mail processor a host's mail worker calls after it has
|
|
5523
|
+
* verified provider authenticity and normalized bounded MIME content.
|
|
5524
|
+
*
|
|
5525
|
+
* Processing order: reserve the provider-event receipt with `insertIfAbsent`
|
|
5526
|
+
* before any ticket work; suppress bounce and auto-reply loops; persist one
|
|
5527
|
+
* routing decision on the receipt (routing token, then `In-Reply-To` /
|
|
5528
|
+
* `References`, then subject marker, each confirmed against authoritative
|
|
5529
|
+
* records); then create or reply in one ticket-partition transaction with
|
|
5530
|
+
* its Audit event, command receipt, and optional notification; finally mark
|
|
5531
|
+
* the receipt terminal and refresh the queue and threading projections.
|
|
5532
|
+
*/
|
|
5533
|
+
export function createInboundMailProcessor(options) {
|
|
5534
|
+
const field = "inbound mail processor options";
|
|
5535
|
+
const source = boundaryObject(options, field);
|
|
5536
|
+
const read = (key, optional = false) => ownDataProperty(source, key, `${field}.${key}`, optional);
|
|
5537
|
+
const store = read("store");
|
|
5538
|
+
const clock = read("clock");
|
|
5539
|
+
const rawLogger = read("logger", true);
|
|
5540
|
+
const logger = rawLogger === undefined ? noopLogger : rawLogger;
|
|
5541
|
+
const accountMatcher = read("accountMatcher");
|
|
5542
|
+
const routingPolicy = read("routingPolicy");
|
|
5543
|
+
const notificationPolicy = read("notificationPolicy", true);
|
|
5544
|
+
if (accountMatcher === undefined ||
|
|
5545
|
+
accountMatcher === null ||
|
|
5546
|
+
typeof accountMatcher.matchSenderEmail !== "function") {
|
|
5547
|
+
throw new TypeError(`${field}.accountMatcher must provide matchSenderEmail()`);
|
|
5548
|
+
}
|
|
5549
|
+
if (routingPolicy === undefined ||
|
|
5550
|
+
routingPolicy === null ||
|
|
5551
|
+
typeof routingPolicy.routeNewConversation !== "function") {
|
|
5552
|
+
throw new TypeError(`${field}.routingPolicy must provide routeNewConversation()`);
|
|
5553
|
+
}
|
|
5554
|
+
if (notificationPolicy !== undefined &&
|
|
5555
|
+
(notificationPolicy === null ||
|
|
5556
|
+
typeof notificationPolicy.notificationFor !== "function")) {
|
|
5557
|
+
throw new TypeError(`${field}.notificationPolicy must provide notificationFor()`);
|
|
5558
|
+
}
|
|
5559
|
+
const limits = resolveSupportDeskLimits(read("limits", true), `${field}.limits`);
|
|
5560
|
+
const rawAttempts = read("maxConflictAttempts", true);
|
|
5561
|
+
if (rawAttempts !== undefined && typeof rawAttempts !== "number") {
|
|
5562
|
+
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
5563
|
+
}
|
|
5564
|
+
const maxConflictAttempts = rawAttempts ?? 4;
|
|
5565
|
+
if (!Number.isSafeInteger(maxConflictAttempts) ||
|
|
5566
|
+
maxConflictAttempts <= 0 ||
|
|
5567
|
+
maxConflictAttempts > 20) {
|
|
5568
|
+
throw new TypeError("maxConflictAttempts must be between 1 and 20");
|
|
5569
|
+
}
|
|
5570
|
+
const rawTerminalRetention = read("queueTerminalRetentionMilliseconds", true);
|
|
5571
|
+
if (rawTerminalRetention !== undefined &&
|
|
5572
|
+
typeof rawTerminalRetention !== "number") {
|
|
5573
|
+
throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
|
|
5574
|
+
}
|
|
5575
|
+
const terminalRetentionMilliseconds = rawTerminalRetention ??
|
|
5576
|
+
defaultQueueTerminalRetentionMilliseconds;
|
|
5577
|
+
if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
|
|
5578
|
+
terminalRetentionMilliseconds < 0) {
|
|
5579
|
+
throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
|
|
5580
|
+
}
|
|
5581
|
+
const rawThreadRetention = read("threadIndexRetentionMilliseconds", true);
|
|
5582
|
+
const threadIndexRetentionMilliseconds = rawThreadRetention === undefined
|
|
5583
|
+
? defaultThreadIndexRetentionMilliseconds
|
|
5584
|
+
: parseRetentionMilliseconds(rawThreadRetention, `${field}.threadIndexRetentionMilliseconds`);
|
|
5585
|
+
const responseTargetPolicy = parseResponseTargetPolicy(read("responseTargetPolicy", true));
|
|
5586
|
+
const allowedCategories = parseAllowedCategories(read("allowedCategories", true), `${field}.allowedCategories`);
|
|
5587
|
+
const rawPrefix = read("subjectMarkerPrefix", true);
|
|
5588
|
+
if (rawPrefix !== undefined &&
|
|
5589
|
+
(typeof rawPrefix !== "string" ||
|
|
5590
|
+
!subjectMarkerPrefixPattern.test(rawPrefix))) {
|
|
5591
|
+
throw new TypeError(`${field}.subjectMarkerPrefix must match ${subjectMarkerPrefixPattern.source}`);
|
|
5592
|
+
}
|
|
5593
|
+
const subjectMarkerPrefix = rawPrefix;
|
|
5594
|
+
const supportAddresses = parseSupportAddresses(read("supportAddresses", true));
|
|
5595
|
+
const records = store.collection(supportRecords);
|
|
5596
|
+
const receipts = store.collection(inboundReceipts);
|
|
5597
|
+
const numbers = store.collection(ticketNumbers);
|
|
5598
|
+
const index = store.collection(threadIndex);
|
|
5599
|
+
async function bindInboundReplayFingerprint(key, stored, input, replayFingerprint) {
|
|
5600
|
+
if (stored.replayFingerprint !== undefined) {
|
|
5601
|
+
if (stored.replayFingerprint !== replayFingerprint) {
|
|
5602
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5603
|
+
}
|
|
5604
|
+
return stored;
|
|
5605
|
+
}
|
|
5606
|
+
const compatibleFingerprints = new Set([replayFingerprint]);
|
|
5607
|
+
if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
|
|
5608
|
+
const priorFingerprints = await Promise.all([
|
|
5609
|
+
requestFingerprint(inboundFingerprintPayload(input, "case_preserving_sender")),
|
|
5610
|
+
requestFingerprint(inboundFingerprintPayload(input, "pre_sender_result")),
|
|
5611
|
+
]);
|
|
5612
|
+
for (const prior of priorFingerprints) {
|
|
5613
|
+
compatibleFingerprints.add(prior);
|
|
5614
|
+
}
|
|
5615
|
+
}
|
|
5616
|
+
if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
|
|
5617
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5618
|
+
}
|
|
5619
|
+
const bound = await receipts.update(key, (current) => {
|
|
5620
|
+
if (current === null) {
|
|
5621
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
|
|
5622
|
+
}
|
|
5623
|
+
if (current.replayFingerprint !== undefined) {
|
|
5624
|
+
return { action: "keep" };
|
|
5625
|
+
}
|
|
5626
|
+
if (!compatibleFingerprints.has(current.payloadFingerprint)) {
|
|
5627
|
+
return { action: "keep" };
|
|
5628
|
+
}
|
|
5629
|
+
return {
|
|
5630
|
+
action: "write",
|
|
5631
|
+
value: { ...current, replayFingerprint },
|
|
5632
|
+
};
|
|
5633
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5634
|
+
if (bound.value === null) {
|
|
5635
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
|
|
5636
|
+
}
|
|
5637
|
+
if (bound.value.replayFingerprint !== replayFingerprint ||
|
|
5638
|
+
!compatibleFingerprints.has(bound.value.payloadFingerprint)) {
|
|
5639
|
+
throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
|
|
5640
|
+
}
|
|
5641
|
+
return bound.value;
|
|
5642
|
+
}
|
|
5643
|
+
async function finishReceipt(work, terminal) {
|
|
5644
|
+
const processedAt = clock.now();
|
|
5645
|
+
canonicalTimestamp(processedAt, "clock.now()");
|
|
5646
|
+
const result = await receipts.update(work.key, (current) => {
|
|
5647
|
+
if (current === null) {
|
|
5648
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
|
|
5649
|
+
}
|
|
5650
|
+
if (current.status !== "processing" && current.status !== "rerouting") {
|
|
5651
|
+
return { action: "keep" };
|
|
5652
|
+
}
|
|
5653
|
+
if (!sameInboundReceiptRoute(current.route, work.route)) {
|
|
5654
|
+
throw new SupportDeskConflictError("Inbound route changed before the receipt was finalized");
|
|
5655
|
+
}
|
|
5656
|
+
return {
|
|
5657
|
+
action: "write",
|
|
5658
|
+
value: {
|
|
5659
|
+
...current,
|
|
5660
|
+
status: terminal.status,
|
|
5661
|
+
outcome: terminal.outcome,
|
|
5662
|
+
processedAt,
|
|
5663
|
+
...(terminal.ticketId === undefined
|
|
5664
|
+
? {}
|
|
5665
|
+
: { ticketId: terminal.ticketId }),
|
|
5666
|
+
...(terminal.messageId === undefined
|
|
5667
|
+
? {}
|
|
5668
|
+
: { messageId: terminal.messageId }),
|
|
5669
|
+
...(terminal.diagnostic === undefined
|
|
5670
|
+
? {}
|
|
5671
|
+
: { diagnostic: terminal.diagnostic }),
|
|
5672
|
+
},
|
|
5673
|
+
};
|
|
5674
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5675
|
+
if (result.value === null) {
|
|
5676
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
|
|
5677
|
+
}
|
|
5678
|
+
return inboundResult(result.value, work.duplicateDelivery);
|
|
5679
|
+
}
|
|
5680
|
+
async function afterInboundCommit(ticketId, messageId, ticketNumber) {
|
|
5681
|
+
try {
|
|
5682
|
+
await projectTicketToQueue(ticketId, {
|
|
5683
|
+
store,
|
|
5684
|
+
clock,
|
|
5685
|
+
terminalRetentionMilliseconds,
|
|
5686
|
+
maxConflictAttempts,
|
|
5687
|
+
});
|
|
5688
|
+
}
|
|
5689
|
+
catch (error) {
|
|
5690
|
+
safeLog(logger, "error", "support-desk.queue.projection_failed", {
|
|
5691
|
+
ticketId,
|
|
5692
|
+
message: error instanceof Error ? error.message : "queue projection failed",
|
|
5693
|
+
});
|
|
5694
|
+
}
|
|
5695
|
+
await indexThreadingAfterCommit(store, ticketId, { messageId, ticketNumber }, logger);
|
|
5696
|
+
}
|
|
5697
|
+
async function inboundNotification(context) {
|
|
5698
|
+
if (notificationPolicy === undefined) {
|
|
5699
|
+
return undefined;
|
|
5700
|
+
}
|
|
5701
|
+
const proposed = await notificationPolicy.notificationFor(Object.freeze({ ...context }));
|
|
5702
|
+
if (proposed === undefined) {
|
|
5703
|
+
return undefined;
|
|
5704
|
+
}
|
|
5705
|
+
return bindNotificationTicketNumber(snapshotNotification(proposed), context.ticketNumber);
|
|
5706
|
+
}
|
|
5707
|
+
async function proposeNewConversation(input, reservedTicketId, matchMethod, relatedTicketId, forceUnverified = false) {
|
|
5708
|
+
const match = relatedTicketId === undefined && !forceUnverified
|
|
5709
|
+
? snapshotSenderMatch(await accountMatcher.matchSenderEmail(Object.freeze({ channelId: input.channelId, email: input.from })))
|
|
5710
|
+
: { matches: "none" };
|
|
5711
|
+
const association = match.matches === "one" ? "matched_email" : "unverified";
|
|
5712
|
+
const decision = snapshotRoutingDecision(await routingPolicy.routeNewConversation(Object.freeze({
|
|
5713
|
+
channelId: input.channelId,
|
|
5714
|
+
association,
|
|
5715
|
+
...(match.matches === "one"
|
|
5716
|
+
? { principalId: match.principalId }
|
|
5717
|
+
: {}),
|
|
5718
|
+
...(relatedTicketId === undefined ? {} : { relatedTicketId }),
|
|
5719
|
+
})), allowedCategories);
|
|
5720
|
+
return {
|
|
5721
|
+
kind: "new_conversation",
|
|
5722
|
+
ticketId: reservedTicketId,
|
|
5723
|
+
matchMethod,
|
|
5724
|
+
association,
|
|
5725
|
+
...(match.matches === "one" ? { principalId: match.principalId } : {}),
|
|
5726
|
+
priority: decision.priority,
|
|
5727
|
+
...(decision.category === undefined
|
|
5728
|
+
? {}
|
|
5729
|
+
: { category: decision.category }),
|
|
5730
|
+
...(relatedTicketId === undefined ? {} : { relatedTicketId }),
|
|
5731
|
+
};
|
|
5732
|
+
}
|
|
5733
|
+
async function claimRouteForRerouting(key, expected) {
|
|
5734
|
+
const claimed = await receipts.update(key, (current) => {
|
|
5735
|
+
if (current === null) {
|
|
5736
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5737
|
+
}
|
|
5738
|
+
if (current.status !== "processing" ||
|
|
5739
|
+
!sameInboundReceiptRoute(current.route, expected)) {
|
|
5740
|
+
return { action: "keep" };
|
|
5741
|
+
}
|
|
5742
|
+
return {
|
|
5743
|
+
action: "write",
|
|
5744
|
+
value: { ...current, status: "rerouting" },
|
|
5745
|
+
};
|
|
5746
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5747
|
+
if (claimed.value === null) {
|
|
5748
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5749
|
+
}
|
|
5750
|
+
return claimed.value;
|
|
5751
|
+
}
|
|
5752
|
+
async function replaceReroutingRoute(key, expected, proposed) {
|
|
5753
|
+
const replaced = await receipts.update(key, (current) => {
|
|
5754
|
+
if (current === null) {
|
|
5755
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5756
|
+
}
|
|
5757
|
+
if (current.status !== "rerouting" ||
|
|
5758
|
+
!sameInboundReceiptRoute(current.route, expected)) {
|
|
5759
|
+
return { action: "keep" };
|
|
5760
|
+
}
|
|
5761
|
+
return { action: "write", value: { ...current, route: proposed } };
|
|
5762
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
5763
|
+
if (replaced.value === null) {
|
|
5764
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
5765
|
+
}
|
|
5766
|
+
return replaced.value;
|
|
5767
|
+
}
|
|
5768
|
+
async function reserveReplyRerouteFence(input) {
|
|
5769
|
+
const location = commandKey(input.ticketId, input.commandId);
|
|
5770
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
5771
|
+
const existing = await records.get(location);
|
|
5772
|
+
if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5773
|
+
return true;
|
|
5774
|
+
}
|
|
5775
|
+
if (existing !== null) {
|
|
5776
|
+
return false;
|
|
5777
|
+
}
|
|
5778
|
+
const ticket = await records.getVersioned(ticketKey(input.ticketId));
|
|
5779
|
+
if (ticket?.value.kind !== "ticket") {
|
|
5780
|
+
return false;
|
|
5781
|
+
}
|
|
5782
|
+
const quota = await records.getVersioned(ticketQuotaKey(input.ticketId));
|
|
5783
|
+
if (quota?.value.kind !== "ticket_quota") {
|
|
5784
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
5785
|
+
}
|
|
5786
|
+
const partitionRows = await records.list(input.ticketId);
|
|
5787
|
+
enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
|
|
5788
|
+
enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
|
|
5789
|
+
const outcome = await records.transact(input.ticketId, [
|
|
5790
|
+
{
|
|
5791
|
+
action: "putIfUnchanged",
|
|
5792
|
+
version: ticket.version,
|
|
5793
|
+
value: ticket.value,
|
|
5794
|
+
},
|
|
5795
|
+
{
|
|
5796
|
+
action: "putIfUnchanged",
|
|
5797
|
+
version: quota.version,
|
|
5798
|
+
value: quota.value,
|
|
5799
|
+
},
|
|
5800
|
+
{
|
|
5801
|
+
action: "insert",
|
|
5802
|
+
value: {
|
|
5803
|
+
kind: "inbound_reply_reroute_fence",
|
|
5804
|
+
partition: input.ticketId,
|
|
5805
|
+
id: location.id,
|
|
5806
|
+
commandId: input.commandId,
|
|
5807
|
+
messageId: input.messageId,
|
|
5808
|
+
requestFingerprint: input.fingerprint,
|
|
5809
|
+
recordedAt: input.recordedAt,
|
|
5810
|
+
},
|
|
5811
|
+
},
|
|
5812
|
+
]);
|
|
5813
|
+
if (outcome.committed) {
|
|
5814
|
+
return true;
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
const existing = await records.get(location);
|
|
5818
|
+
if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5819
|
+
return true;
|
|
5820
|
+
}
|
|
5821
|
+
if (existing !== null) {
|
|
5822
|
+
return false;
|
|
5823
|
+
}
|
|
5824
|
+
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.");
|
|
5825
|
+
}
|
|
5826
|
+
async function reserveCreateRerouteFence(input) {
|
|
5827
|
+
const location = commandKey(input.ticketId, input.commandId);
|
|
5828
|
+
const existing = await records.get(location);
|
|
5829
|
+
if (inboundCreateRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
|
|
5830
|
+
return true;
|
|
5831
|
+
}
|
|
5832
|
+
if (existing !== null ||
|
|
5833
|
+
(await records.get(ticketKey(input.ticketId))) !== null) {
|
|
5834
|
+
return false;
|
|
5835
|
+
}
|
|
5836
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 1);
|
|
5837
|
+
const inserted = await records.insertIfAbsent({
|
|
5838
|
+
kind: "inbound_create_reroute_fence",
|
|
5839
|
+
partition: input.ticketId,
|
|
5840
|
+
id: location.id,
|
|
5841
|
+
commandId: input.commandId,
|
|
5842
|
+
messageId: input.messageId,
|
|
5843
|
+
requestFingerprint: input.fingerprint,
|
|
5844
|
+
recordedAt: input.recordedAt,
|
|
5845
|
+
});
|
|
5846
|
+
return (inserted.inserted ||
|
|
5847
|
+
inboundCreateRerouteFenceMatches(inserted.value, input.commandId, input.messageId, input.fingerprint));
|
|
5848
|
+
}
|
|
5849
|
+
async function proposeRoute(input, reservedTicketId) {
|
|
5850
|
+
const reason = inboundSuppressionReason(input, supportAddresses);
|
|
5851
|
+
if (reason !== undefined) {
|
|
5852
|
+
return { kind: "suppressed", reason };
|
|
5853
|
+
}
|
|
5854
|
+
const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
|
|
5855
|
+
const lookup = (kind, key) => lookupThreadEntry(records, index, kind, key, nowEpoch, threadIndexRetentionMilliseconds);
|
|
5856
|
+
// The same Message-ID delivered under another provider event is the same
|
|
5857
|
+
// message only when the sender satisfies the existing-ticket join rule.
|
|
5858
|
+
let repeatedMessageTicket;
|
|
5859
|
+
if (input.externalMessageId !== undefined) {
|
|
5860
|
+
const own = await lookup("message_id", await messageIdIndexKey(input.externalMessageId));
|
|
5861
|
+
if (own !== null &&
|
|
5862
|
+
own.entry.source === "inbound" &&
|
|
5863
|
+
own.entry.messageId !== undefined) {
|
|
5864
|
+
if (threadJoinIsSafe(own.ticket, "message_id", input)) {
|
|
5865
|
+
return {
|
|
5866
|
+
kind: "duplicate_message",
|
|
5867
|
+
ticketId: own.ticket.id,
|
|
5868
|
+
messageId: own.entry.messageId,
|
|
5869
|
+
};
|
|
5870
|
+
}
|
|
5871
|
+
repeatedMessageTicket = own.ticket;
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
let candidate;
|
|
5875
|
+
let ambiguous = false;
|
|
5876
|
+
if (input.replyRoutingToken !== undefined) {
|
|
5877
|
+
const hit = await lookup("reply_token", await replyTokenIndexKey(input.replyRoutingToken));
|
|
5878
|
+
if (hit !== null) {
|
|
5879
|
+
candidate = { ticket: hit.ticket, method: "routing_token" };
|
|
5880
|
+
}
|
|
5881
|
+
}
|
|
5882
|
+
if (candidate === undefined) {
|
|
5883
|
+
const found = [];
|
|
5884
|
+
for (const header of threadingHeaderCandidates(input)) {
|
|
5885
|
+
const hit = await lookup("message_id", await messageIdIndexKey(header));
|
|
5886
|
+
if (hit !== null &&
|
|
5887
|
+
!found.some((ticket) => ticket.id === hit.ticket.id)) {
|
|
5888
|
+
found.push(hit.ticket);
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
const first = found[0];
|
|
5892
|
+
if (first !== undefined) {
|
|
5893
|
+
candidate = { ticket: first, method: "message_id" };
|
|
5894
|
+
ambiguous = found.length > 1;
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
// Keep a repeated inbound Message-ID ahead of the display-only subject
|
|
5898
|
+
// marker when a stronger token or threading-header locator did not match.
|
|
5899
|
+
if (candidate === undefined && repeatedMessageTicket !== undefined) {
|
|
5900
|
+
candidate = {
|
|
5901
|
+
ticket: repeatedMessageTicket,
|
|
5902
|
+
method: "message_id",
|
|
5903
|
+
};
|
|
5904
|
+
}
|
|
5905
|
+
if (candidate === undefined && subjectMarkerPrefix !== undefined) {
|
|
5906
|
+
const markers = subjectMarkerNumbers(input.subject, subjectMarkerPrefix);
|
|
5907
|
+
ambiguous = markers.length > 1;
|
|
5908
|
+
for (const marker of markers) {
|
|
5909
|
+
const hit = await lookup("ticket_number", ticketNumberIndexKey(marker));
|
|
5910
|
+
if (hit !== null && candidate === undefined) {
|
|
5911
|
+
candidate = { ticket: hit.ticket, method: "subject_marker" };
|
|
5912
|
+
}
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
if (candidate !== undefined &&
|
|
5916
|
+
!ambiguous &&
|
|
5917
|
+
candidate.ticket.id !== reservedTicketId &&
|
|
5918
|
+
threadJoinIsSafe(candidate.ticket, candidate.method, input)) {
|
|
5919
|
+
return {
|
|
5920
|
+
kind: "reply",
|
|
5921
|
+
ticketId: candidate.ticket.id,
|
|
5922
|
+
matchMethod: candidate.method,
|
|
5923
|
+
};
|
|
5924
|
+
}
|
|
5925
|
+
const relatedTicketId = candidate !== undefined && candidate.ticket.id !== reservedTicketId
|
|
5926
|
+
? candidate.ticket.id
|
|
5927
|
+
: undefined;
|
|
5928
|
+
return proposeNewConversation(input, reservedTicketId, candidate?.method ?? "none", relatedTicketId, ambiguous);
|
|
5929
|
+
}
|
|
5930
|
+
async function decideRoute(key, stored, input, reservedTicketId, commandId, messageId, fingerprint) {
|
|
5931
|
+
if (stored.status !== "processing" && stored.status !== "rerouting") {
|
|
5932
|
+
return stored;
|
|
5933
|
+
}
|
|
5934
|
+
if (stored.route !== undefined) {
|
|
5935
|
+
const route = stored.route;
|
|
5936
|
+
if (route.kind === "suppressed") {
|
|
5937
|
+
return stored;
|
|
5938
|
+
}
|
|
5939
|
+
if (route.kind === "duplicate_message") {
|
|
5940
|
+
const target = await records.get(ticketKey(route.ticketId));
|
|
5941
|
+
if (target?.kind === "ticket" &&
|
|
5942
|
+
threadJoinIsSafe(target.ticket, "message_id", input)) {
|
|
5943
|
+
return stored;
|
|
5944
|
+
}
|
|
5945
|
+
if (stored.status === "processing") {
|
|
5946
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
5947
|
+
}
|
|
5948
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, "message_id", route.ticketId, true));
|
|
5949
|
+
}
|
|
5950
|
+
if (route.kind === "new_conversation") {
|
|
5951
|
+
if (route.relatedTicketId === undefined ||
|
|
5952
|
+
(route.association === "unverified" &&
|
|
5953
|
+
route.principalId === undefined)) {
|
|
5954
|
+
return stored;
|
|
5955
|
+
}
|
|
5956
|
+
const existing = await records.get(commandKey(route.ticketId, commandId));
|
|
5957
|
+
const alreadyFenced = inboundCreateRerouteFenceMatches(existing, commandId, messageId, fingerprint);
|
|
5958
|
+
// A committed create remains authoritative for receipt replay.
|
|
5959
|
+
if (existing !== null && !alreadyFenced) {
|
|
5960
|
+
return stored;
|
|
5961
|
+
}
|
|
5962
|
+
if (stored.status === "processing") {
|
|
5963
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
5964
|
+
}
|
|
5965
|
+
if (!alreadyFenced &&
|
|
5966
|
+
!(await reserveCreateRerouteFence({
|
|
5967
|
+
ticketId: route.ticketId,
|
|
5968
|
+
commandId,
|
|
5969
|
+
messageId,
|
|
5970
|
+
fingerprint,
|
|
5971
|
+
recordedAt: stored.receivedAt,
|
|
5972
|
+
}))) {
|
|
5973
|
+
return stored;
|
|
5974
|
+
}
|
|
5975
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.relatedTicketId, true));
|
|
5976
|
+
}
|
|
5977
|
+
const commandLocation = commandKey(route.ticketId, commandId);
|
|
5978
|
+
const existing = await records.get(commandLocation);
|
|
5979
|
+
const alreadyFenced = inboundReplyRerouteFenceMatches(existing, commandId, messageId, fingerprint);
|
|
5980
|
+
if (existing !== null && !alreadyFenced) {
|
|
5981
|
+
return stored;
|
|
5982
|
+
}
|
|
5983
|
+
const target = await records.get(ticketKey(route.ticketId));
|
|
5984
|
+
if (!alreadyFenced &&
|
|
5985
|
+
(target?.kind !== "ticket" ||
|
|
5986
|
+
threadJoinIsSafe(target.ticket, route.matchMethod, input))) {
|
|
5987
|
+
return stored;
|
|
5988
|
+
}
|
|
5989
|
+
if (stored.status === "processing") {
|
|
5990
|
+
return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
|
|
5991
|
+
}
|
|
5992
|
+
if (!alreadyFenced) {
|
|
5993
|
+
const fenced = await reserveReplyRerouteFence({
|
|
5994
|
+
ticketId: route.ticketId,
|
|
5995
|
+
commandId,
|
|
5996
|
+
messageId,
|
|
5997
|
+
fingerprint,
|
|
5998
|
+
recordedAt: stored.receivedAt,
|
|
5999
|
+
});
|
|
6000
|
+
if (!fenced) {
|
|
6001
|
+
return stored;
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.ticketId, true));
|
|
6005
|
+
}
|
|
6006
|
+
const proposed = await proposeRoute(input, reservedTicketId);
|
|
6007
|
+
const result = await receipts.update(key, (current) => {
|
|
6008
|
+
if (current === null) {
|
|
6009
|
+
throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
|
|
6010
|
+
}
|
|
6011
|
+
if (current.status !== "processing" || current.route !== undefined) {
|
|
6012
|
+
return { action: "keep" };
|
|
6013
|
+
}
|
|
6014
|
+
return { action: "write", value: { ...current, route: proposed } };
|
|
6015
|
+
}, { maxAttempts: maxConflictAttempts });
|
|
6016
|
+
if (result.value === null ||
|
|
6017
|
+
((result.value.status === "processing" ||
|
|
6018
|
+
result.value.status === "rerouting") &&
|
|
6019
|
+
result.value.route === undefined)) {
|
|
6020
|
+
throw new SupportDeskConflictError("Inbound routing decision was not stored");
|
|
6021
|
+
}
|
|
6022
|
+
return result.value;
|
|
6023
|
+
}
|
|
6024
|
+
async function createConversation(work, route) {
|
|
6025
|
+
const { input, messageId, commandId, fingerprint } = work;
|
|
6026
|
+
const ticketId = route.ticketId;
|
|
6027
|
+
const commandLocation = commandKey(ticketId, commandId);
|
|
6028
|
+
const existingVersioned = await records.getVersioned(commandLocation);
|
|
6029
|
+
const existing = existingVersioned?.value ?? null;
|
|
6030
|
+
const createFence = existingVersioned !== null &&
|
|
6031
|
+
inboundCreateRerouteFenceMatches(existingVersioned.value, commandId, messageId, fingerprint)
|
|
6032
|
+
? existingVersioned
|
|
6033
|
+
: null;
|
|
6034
|
+
if (existing !== null && createFence === null) {
|
|
6035
|
+
if (!duplicateMatches(existing, "inbound_email_create", messageId, fingerprint)) {
|
|
6036
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
else {
|
|
6040
|
+
// ticket, quota, message, audit, command, and an optional delivery job.
|
|
6041
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 6);
|
|
6042
|
+
// Reserved outside the ticket transaction; a failed create leaves a gap.
|
|
6043
|
+
const ticketNumber = await reserveNextTicketNumber(numbers, maxConflictAttempts);
|
|
6044
|
+
const now = clock.now();
|
|
6045
|
+
const displayName = input.fromName === undefined ? {} : { displayName: input.fromName };
|
|
6046
|
+
const ticket = await applyResponseTargets(createTicket({
|
|
6047
|
+
id: ticketId,
|
|
6048
|
+
number: ticketNumber,
|
|
6049
|
+
subject: input.subject,
|
|
6050
|
+
channel: "email",
|
|
6051
|
+
priority: route.priority,
|
|
6052
|
+
...(route.category === undefined ? {} : { category: route.category }),
|
|
6053
|
+
requester: route.association === "matched_email"
|
|
6054
|
+
? {
|
|
6055
|
+
association: "matched_email",
|
|
6056
|
+
principalId: route.principalId,
|
|
6057
|
+
email: input.from,
|
|
6058
|
+
...displayName,
|
|
6059
|
+
}
|
|
6060
|
+
: {
|
|
6061
|
+
association: "unverified",
|
|
6062
|
+
email: input.from,
|
|
6063
|
+
...displayName,
|
|
6064
|
+
},
|
|
6065
|
+
createdAt: now,
|
|
6066
|
+
...(route.relatedTicketId === undefined
|
|
6067
|
+
? {}
|
|
6068
|
+
: { relatedTicketId: route.relatedTicketId }),
|
|
6069
|
+
}), responseTargetPolicy, "created");
|
|
6070
|
+
const notification = await inboundNotification({
|
|
6071
|
+
outcome: "created",
|
|
6072
|
+
channelId: input.channelId,
|
|
6073
|
+
ticketId,
|
|
6074
|
+
ticketNumber,
|
|
6075
|
+
messageId,
|
|
6076
|
+
association: route.association,
|
|
6077
|
+
matchMethod: route.matchMethod,
|
|
6078
|
+
senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
|
|
6079
|
+
});
|
|
6080
|
+
const notificationContent = deliveryContent(notification);
|
|
6081
|
+
const commandRecord = {
|
|
6082
|
+
kind: "command",
|
|
6083
|
+
partition: ticketId,
|
|
6084
|
+
id: commandLocation.id,
|
|
6085
|
+
commandId,
|
|
6086
|
+
commandType: "inbound_email_create",
|
|
6087
|
+
messageId,
|
|
6088
|
+
requestFingerprint: fingerprint,
|
|
6089
|
+
completedAt: now,
|
|
6090
|
+
};
|
|
6091
|
+
const actions = [
|
|
6092
|
+
{
|
|
6093
|
+
action: "insert",
|
|
6094
|
+
value: { kind: "ticket", partition: ticketId, id: "ticket", ticket },
|
|
6095
|
+
},
|
|
6096
|
+
{
|
|
6097
|
+
action: "insert",
|
|
6098
|
+
value: {
|
|
6099
|
+
kind: "ticket_quota",
|
|
6100
|
+
partition: ticketId,
|
|
6101
|
+
id: "quota",
|
|
6102
|
+
messageCount: 1,
|
|
6103
|
+
},
|
|
6104
|
+
},
|
|
6105
|
+
{
|
|
6106
|
+
action: "insert",
|
|
6107
|
+
value: {
|
|
6108
|
+
kind: "message",
|
|
6109
|
+
partition: ticketId,
|
|
6110
|
+
id: `message:${messageId}`,
|
|
6111
|
+
ordinal: ticket.revision,
|
|
6112
|
+
message: inboundMessage(input, ticketId, messageId, now),
|
|
6113
|
+
...(notificationContent === undefined
|
|
6114
|
+
? {}
|
|
6115
|
+
: { deliveryContent: notificationContent }),
|
|
6116
|
+
},
|
|
6117
|
+
},
|
|
6118
|
+
inboundAuditAction({
|
|
6119
|
+
commandId,
|
|
6120
|
+
correlationId: work.correlationId,
|
|
6121
|
+
ticketId,
|
|
6122
|
+
revision: ticket.revision,
|
|
6123
|
+
occurredAt: now,
|
|
6124
|
+
action: supportTicketAuditActions.created,
|
|
6125
|
+
matchMethod: route.matchMethod,
|
|
6126
|
+
association: route.association,
|
|
6127
|
+
}),
|
|
6128
|
+
...(createFence === null
|
|
6129
|
+
? [{ action: "insert", value: commandRecord }]
|
|
6130
|
+
: [
|
|
6131
|
+
{
|
|
6132
|
+
action: "putIfUnchanged",
|
|
6133
|
+
version: createFence.version,
|
|
6134
|
+
value: commandRecord,
|
|
6135
|
+
},
|
|
6136
|
+
]),
|
|
6137
|
+
...(notification === undefined
|
|
6138
|
+
? []
|
|
6139
|
+
: [deliveryJobAction(ticketId, messageId, now, notification)]),
|
|
6140
|
+
];
|
|
6141
|
+
enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
|
|
6142
|
+
const outcome = await records.transact(ticketId, actions);
|
|
6143
|
+
if (!outcome.committed) {
|
|
6144
|
+
const raced = await records.get(commandKey(ticketId, commandId));
|
|
6145
|
+
if (!duplicateMatches(raced, "inbound_email_create", messageId, fingerprint)) {
|
|
6146
|
+
if (raced !== null &&
|
|
6147
|
+
!inboundCreateRerouteFenceMatches(raced, commandId, messageId, fingerprint)) {
|
|
6148
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6149
|
+
}
|
|
6150
|
+
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.");
|
|
6151
|
+
}
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
const result = await finishReceipt(work, {
|
|
6155
|
+
status: "processed",
|
|
6156
|
+
outcome: "created",
|
|
6157
|
+
ticketId,
|
|
6158
|
+
messageId,
|
|
6159
|
+
});
|
|
6160
|
+
await afterInboundCommit(ticketId, messageId, true);
|
|
6161
|
+
return result;
|
|
6162
|
+
}
|
|
6163
|
+
async function appendReply(work, route) {
|
|
6164
|
+
const { input, messageId, commandId, fingerprint } = work;
|
|
6165
|
+
const ticketId = route.ticketId;
|
|
6166
|
+
const completed = async () => {
|
|
6167
|
+
const result = await finishReceipt(work, {
|
|
6168
|
+
status: "processed",
|
|
6169
|
+
outcome: "replied",
|
|
6170
|
+
ticketId,
|
|
6171
|
+
messageId,
|
|
6172
|
+
});
|
|
6173
|
+
await afterInboundCommit(ticketId, messageId, false);
|
|
6174
|
+
return result;
|
|
6175
|
+
};
|
|
6176
|
+
const rejected = (diagnostic) => finishReceipt(work, {
|
|
6177
|
+
status: "rejected",
|
|
6178
|
+
outcome: "rejected",
|
|
6179
|
+
diagnostic,
|
|
6180
|
+
});
|
|
6181
|
+
for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
|
|
6182
|
+
const duplicate = await records.get(commandKey(ticketId, commandId));
|
|
6183
|
+
if (duplicate !== null) {
|
|
6184
|
+
if (!duplicateMatches(duplicate, "inbound_email_reply", messageId, fingerprint)) {
|
|
6185
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6186
|
+
}
|
|
6187
|
+
return completed();
|
|
6188
|
+
}
|
|
6189
|
+
const versioned = await records.getVersioned(ticketKey(ticketId));
|
|
6190
|
+
if (versioned?.value.kind !== "ticket") {
|
|
6191
|
+
return rejected("ticket_unavailable");
|
|
6192
|
+
}
|
|
6193
|
+
const ticket = versioned.value.ticket;
|
|
6194
|
+
const partitionRows = await records.list(ticketId);
|
|
6195
|
+
if (input.externalMessageId !== undefined) {
|
|
6196
|
+
const prior = partitionRows.find((row) => row.kind === "message" &&
|
|
6197
|
+
row.message.channel === "email" &&
|
|
6198
|
+
row.message.authorKind === "customer" &&
|
|
6199
|
+
row.message.externalMessageId === input.externalMessageId);
|
|
6200
|
+
if (prior !== undefined) {
|
|
6201
|
+
return finishReceipt(work, {
|
|
6202
|
+
status: "processed",
|
|
6203
|
+
outcome: "duplicate_message",
|
|
6204
|
+
ticketId,
|
|
6205
|
+
messageId: prior.message.id,
|
|
6206
|
+
});
|
|
6207
|
+
}
|
|
6208
|
+
}
|
|
6209
|
+
const quota = await records.getVersioned(ticketQuotaKey(ticketId));
|
|
6210
|
+
if (quota?.value.kind !== "ticket_quota") {
|
|
6211
|
+
throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
|
|
6212
|
+
}
|
|
6213
|
+
if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
|
|
6214
|
+
return rejected("ticket_messages_limit");
|
|
6215
|
+
}
|
|
6216
|
+
const notification = await inboundNotification({
|
|
6217
|
+
outcome: "replied",
|
|
6218
|
+
channelId: input.channelId,
|
|
6219
|
+
ticketId,
|
|
6220
|
+
ticketNumber: ticket.number,
|
|
6221
|
+
messageId,
|
|
6222
|
+
association: ticket.requester.association,
|
|
6223
|
+
matchMethod: route.matchMethod,
|
|
6224
|
+
...(ticket.assignedTo === undefined
|
|
6225
|
+
? {}
|
|
6226
|
+
: { assignedTo: ticket.assignedTo }),
|
|
6227
|
+
senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
|
|
6228
|
+
});
|
|
6229
|
+
const requiresHandoff = effectiveTicketHandling(ticket).state === "assistant";
|
|
6230
|
+
const now = clampTicketTime(clock.now(), ticket.updatedAt);
|
|
6231
|
+
// Customer-reply semantics: any status, including resolved or closed,
|
|
6232
|
+
// moves to waiting_on_support. No actor: email is not authentication.
|
|
6233
|
+
const updated = await applyResponseTargets(applyTicketEvent(ticket, {
|
|
6234
|
+
type: "customer_replied",
|
|
6235
|
+
occurredAt: now,
|
|
6236
|
+
channel: "email",
|
|
6237
|
+
}), responseTargetPolicy, "customer_replied");
|
|
6238
|
+
const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
|
|
6239
|
+
try {
|
|
6240
|
+
enforceManagedAdmission(ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (notification === undefined ? 3 : 4) +
|
|
6241
|
+
(requiresHandoff ? 1 : 0) +
|
|
6242
|
+
learningMutation.additionalRows, true);
|
|
6243
|
+
}
|
|
6244
|
+
catch (error) {
|
|
6245
|
+
if (!(error instanceof SupportDeskLimitError))
|
|
6246
|
+
throw error;
|
|
6247
|
+
return rejected(error.field === "ticket_messages"
|
|
6248
|
+
? "ticket_messages_limit"
|
|
6249
|
+
: "ticket_partition_limit");
|
|
6250
|
+
}
|
|
6251
|
+
const notificationContent = deliveryContent(notification);
|
|
6252
|
+
const outcome = await records.transact(ticketId, [
|
|
6253
|
+
...learningMutation.actions,
|
|
6254
|
+
{
|
|
6255
|
+
action: "putIfUnchanged",
|
|
6256
|
+
version: quota.version,
|
|
6257
|
+
value: {
|
|
6258
|
+
...quota.value,
|
|
6259
|
+
messageCount: quota.value.messageCount + 1,
|
|
6260
|
+
},
|
|
6261
|
+
},
|
|
6262
|
+
{
|
|
6263
|
+
action: "insert",
|
|
6264
|
+
value: {
|
|
6265
|
+
kind: "message",
|
|
6266
|
+
partition: ticketId,
|
|
6267
|
+
id: `message:${messageId}`,
|
|
6268
|
+
ordinal: updated.revision,
|
|
6269
|
+
message: inboundMessage(input, ticketId, messageId, now),
|
|
6270
|
+
...(notificationContent === undefined
|
|
6271
|
+
? {}
|
|
6272
|
+
: { deliveryContent: notificationContent }),
|
|
6273
|
+
},
|
|
6274
|
+
},
|
|
6275
|
+
inboundAuditAction({
|
|
6276
|
+
commandId,
|
|
6277
|
+
correlationId: work.correlationId,
|
|
6278
|
+
ticketId,
|
|
6279
|
+
revision: updated.revision,
|
|
6280
|
+
occurredAt: now,
|
|
6281
|
+
action: supportTicketAuditActions.customerReplied,
|
|
6282
|
+
matchMethod: route.matchMethod,
|
|
6283
|
+
}),
|
|
6284
|
+
{
|
|
6285
|
+
action: "insert",
|
|
6286
|
+
value: {
|
|
6287
|
+
kind: "command",
|
|
6288
|
+
partition: ticketId,
|
|
6289
|
+
id: `command:${commandId}`,
|
|
6290
|
+
commandId,
|
|
6291
|
+
commandType: "inbound_email_reply",
|
|
6292
|
+
messageId,
|
|
6293
|
+
requestFingerprint: fingerprint,
|
|
6294
|
+
completedAt: now,
|
|
6295
|
+
},
|
|
6296
|
+
},
|
|
6297
|
+
...(notification === undefined
|
|
6298
|
+
? []
|
|
6299
|
+
: [deliveryJobAction(ticketId, messageId, now, notification)]),
|
|
6300
|
+
...(requiresHandoff
|
|
6301
|
+
? [
|
|
6302
|
+
{
|
|
6303
|
+
action: "insert",
|
|
6304
|
+
value: handoffWork({
|
|
6305
|
+
ticket: updated,
|
|
6306
|
+
commandId,
|
|
6307
|
+
occurredAt: now,
|
|
6308
|
+
lastVisibleMessageId: messageId,
|
|
6309
|
+
reason: "email_reply",
|
|
6310
|
+
}),
|
|
6311
|
+
},
|
|
6312
|
+
]
|
|
6313
|
+
: []),
|
|
6314
|
+
]);
|
|
6315
|
+
if (outcome.committed) {
|
|
6316
|
+
return completed();
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
const last = await records.get(commandKey(ticketId, commandId));
|
|
6320
|
+
if (duplicateMatches(last, "inbound_email_reply", messageId, fingerprint)) {
|
|
6321
|
+
return completed();
|
|
6322
|
+
}
|
|
6323
|
+
if (last !== null) {
|
|
6324
|
+
throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
|
|
6325
|
+
}
|
|
6326
|
+
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.");
|
|
6327
|
+
}
|
|
6328
|
+
return Object.freeze({
|
|
6329
|
+
async processInboundEmail(rawInput) {
|
|
6330
|
+
const input = snapshotInboundEmailInput(rawInput, limits);
|
|
6331
|
+
const replayFingerprint = await requestFingerprint(inboundFingerprintPayload(input));
|
|
6332
|
+
const location = await inboundReceiptLocation(input.channelId, input.providerEventId);
|
|
6333
|
+
const key = {
|
|
6334
|
+
partition: location.bucket,
|
|
6335
|
+
id: receiptSlotId(location.slot, "inbound receipt slot"),
|
|
6336
|
+
};
|
|
6337
|
+
const receivedAt = new Date(canonicalTimestamp(clock.now(), "clock.now()")).toISOString();
|
|
6338
|
+
const inserted = await receipts.insertIfAbsent({
|
|
6339
|
+
bucket: location.bucket,
|
|
6340
|
+
slot: location.slot,
|
|
6341
|
+
channelId: input.channelId,
|
|
6342
|
+
providerEventId: input.providerEventId,
|
|
6343
|
+
...(input.externalMessageId === undefined
|
|
6344
|
+
? {}
|
|
6345
|
+
: { externalMessageId: input.externalMessageId }),
|
|
6346
|
+
payloadFingerprint: replayFingerprint,
|
|
6347
|
+
replayFingerprint,
|
|
6348
|
+
status: "processing",
|
|
6349
|
+
receivedAt,
|
|
6350
|
+
reservedTicketId: input.ticketId,
|
|
6351
|
+
reservedMessageId: input.messageId,
|
|
6352
|
+
correlationId: input.correlationId,
|
|
6353
|
+
});
|
|
6354
|
+
let stored = inserted.value;
|
|
6355
|
+
if (stored.channelId !== input.channelId ||
|
|
6356
|
+
stored.providerEventId !== input.providerEventId) {
|
|
6357
|
+
throw new SupportDeskConflictError("An inbound receipt hash slot collided with a different provider event");
|
|
6358
|
+
}
|
|
6359
|
+
stored = await bindInboundReplayFingerprint(key, stored, input, replayFingerprint);
|
|
6360
|
+
const fingerprint = stored.payloadFingerprint;
|
|
6361
|
+
const duplicateDelivery = !inserted.inserted;
|
|
6362
|
+
if (stored.status !== "processing" && stored.status !== "rerouting") {
|
|
6363
|
+
return inboundResult(stored, duplicateDelivery);
|
|
6364
|
+
}
|
|
6365
|
+
const reservedTicketId = stored.reservedTicketId;
|
|
6366
|
+
const reservedMessageId = stored.reservedMessageId;
|
|
6367
|
+
const correlationId = stored.correlationId;
|
|
6368
|
+
if (reservedTicketId === undefined ||
|
|
6369
|
+
reservedMessageId === undefined ||
|
|
6370
|
+
correlationId === undefined) {
|
|
6371
|
+
throw new SupportDeskConflictError("Inbound receipt is missing its reserved identifiers");
|
|
6372
|
+
}
|
|
6373
|
+
// Stable per provider event and independent of retry identifiers.
|
|
6374
|
+
const commandId = `inbound:${location.bucket.slice(-30)}${location.slot}`;
|
|
6375
|
+
const decided = await decideRoute(key, stored, input, reservedTicketId, commandId, reservedMessageId, fingerprint);
|
|
6376
|
+
if (decided.status !== "processing" && decided.status !== "rerouting") {
|
|
6377
|
+
return inboundResult(decided, duplicateDelivery);
|
|
6378
|
+
}
|
|
6379
|
+
const route = decided.route;
|
|
6380
|
+
const work = {
|
|
6381
|
+
key,
|
|
6382
|
+
input,
|
|
6383
|
+
route,
|
|
6384
|
+
messageId: reservedMessageId,
|
|
6385
|
+
correlationId,
|
|
6386
|
+
commandId,
|
|
6387
|
+
fingerprint,
|
|
6388
|
+
duplicateDelivery,
|
|
6389
|
+
};
|
|
6390
|
+
switch (route.kind) {
|
|
6391
|
+
case "suppressed":
|
|
6392
|
+
return finishReceipt(work, {
|
|
6393
|
+
status: "rejected",
|
|
6394
|
+
outcome: "suppressed",
|
|
6395
|
+
diagnostic: route.reason,
|
|
6396
|
+
});
|
|
6397
|
+
case "duplicate_message":
|
|
6398
|
+
return finishReceipt(work, {
|
|
6399
|
+
status: "processed",
|
|
6400
|
+
outcome: "duplicate_message",
|
|
6401
|
+
ticketId: route.ticketId,
|
|
6402
|
+
messageId: route.messageId,
|
|
6403
|
+
});
|
|
6404
|
+
case "new_conversation":
|
|
6405
|
+
return createConversation(work, route);
|
|
6406
|
+
case "reply":
|
|
6407
|
+
return appendReply(work, route);
|
|
6408
|
+
}
|
|
6409
|
+
},
|
|
6410
|
+
});
|
|
6411
|
+
}
|
|
3048
6412
|
//# sourceMappingURL=index.js.map
|