@dxv-systems/helpdesk 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 +273 -279
- package/dist/bounce.d.ts +6 -0
- package/dist/bounce.js +38 -0
- package/dist/controls.js +1 -6
- package/dist/draft-body.d.ts +0 -1
- package/dist/draft-body.js +0 -14
- package/dist/drafting.d.ts +1 -2
- package/dist/drafting.js +12 -80
- package/dist/errors.d.ts +0 -3
- package/dist/errors.js +1 -7
- package/dist/inbox/handlers.js +23 -35
- package/dist/inbox/messages.d.ts +4 -5
- package/dist/inbox/messages.js +23 -14
- package/dist/inbox/types.d.ts +10 -2
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -6
- package/dist/react.d.ts +42 -2
- package/dist/react.js +239 -94
- package/dist/sending.d.ts +2 -1
- package/dist/sending.js +35 -58
- package/dist/states.d.ts +1 -1
- package/dist/states.js +1 -2
- package/dist/testing/index.d.ts +0 -2
- package/dist/testing/index.js +1 -18
- package/dist/types.d.ts +2 -8
- package/package.json +73 -73
- package/dist/autosend.d.ts +0 -43
- package/dist/autosend.js +0 -83
- package/dist/cleanup.d.ts +0 -7
- package/dist/cleanup.js +0 -39
package/dist/sending.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.classifySendError = classifySendError;
|
|
4
|
+
exports.sendIntentId = sendIntentId;
|
|
4
5
|
exports.approveAndSend = approveAndSend;
|
|
5
6
|
exports.reconcileUnknownSends = reconcileUnknownSends;
|
|
6
7
|
exports.resolveUnknownSend = resolveUnknownSend;
|
|
@@ -8,7 +9,6 @@ exports.markRepliedExternally = markRepliedExternally;
|
|
|
8
9
|
exports.dismissThread = dismissThread;
|
|
9
10
|
exports.markWaiting = markWaiting;
|
|
10
11
|
exports.closeThread = closeThread;
|
|
11
|
-
const cleanup_1 = require("./cleanup");
|
|
12
12
|
const controls_1 = require("./controls");
|
|
13
13
|
const draft_body_1 = require("./draft-body");
|
|
14
14
|
const errors_1 = require("./errors");
|
|
@@ -22,6 +22,9 @@ function classifySendError(err) {
|
|
|
22
22
|
return typeof status === "number" && NOT_SENT_STATUSES.has(status) ? "not_sent" : "unknown";
|
|
23
23
|
}
|
|
24
24
|
const SEND_IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
25
|
+
function sendIntentId(run) {
|
|
26
|
+
return `send:${run.mailbox}:${run.idempotencyKey}`;
|
|
27
|
+
}
|
|
25
28
|
async function markSent(deps, thread, intent) {
|
|
26
29
|
const fresh = await (0, internal_1.loadThread)(deps, thread.id);
|
|
27
30
|
const sent = await deps.store.transitionSendIntent(intent.id, ["sending", "unknown"], {
|
|
@@ -58,7 +61,7 @@ async function sendingElsewhereOrLost(deps, thread, intent) {
|
|
|
58
61
|
}
|
|
59
62
|
return surfaceUnknown(deps, thread, intent);
|
|
60
63
|
}
|
|
61
|
-
async function
|
|
64
|
+
async function prepareDraft(deps, access, run, intent) {
|
|
62
65
|
const gateway = access.gateway();
|
|
63
66
|
let current = run;
|
|
64
67
|
if (!intent.draftId || run.placedDraftId !== intent.draftId || run.placedBodyHtml === null) {
|
|
@@ -66,7 +69,6 @@ async function prepareAppDraft(deps, access, run, intent) {
|
|
|
66
69
|
current = await deps.store.updateDraftRun(run.idempotencyKey, {
|
|
67
70
|
placedDraftId: draft.id,
|
|
68
71
|
placedBodyHtml: draft.body.content,
|
|
69
|
-
placementError: null,
|
|
70
72
|
});
|
|
71
73
|
await deps.store.transitionSendIntent(intent.id, ["sending"], { draftId: draft.id });
|
|
72
74
|
}
|
|
@@ -84,21 +86,17 @@ async function approveAndSend(deps, input) {
|
|
|
84
86
|
const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
|
|
85
87
|
if (access.mode === "read_only")
|
|
86
88
|
return { status: "write_blocked", mode: access.mode };
|
|
87
|
-
const inApp = access.mode === "drafts_in_app";
|
|
88
89
|
const run = (0, drafting_1.currentReplyRun)(deps, thread, await deps.store.getLatestDraftRunForThread(thread.id));
|
|
89
|
-
if (!run ||
|
|
90
|
-
throw new errors_1.DraftNotReadyError(thread.id,
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (prior?.status === "sending")
|
|
100
|
-
return sendingElsewhereOrLost(deps, thread, prior);
|
|
101
|
-
}
|
|
90
|
+
if (!run || run.status !== "succeeded")
|
|
91
|
+
throw new errors_1.DraftNotReadyError(thread.id, "no reply text to approve");
|
|
92
|
+
const intentId = sendIntentId(run);
|
|
93
|
+
const prior = await deps.store.getSendIntent(intentId);
|
|
94
|
+
if (prior?.status === "sent")
|
|
95
|
+
return { status: "already_sent" };
|
|
96
|
+
if (prior?.status === "unknown")
|
|
97
|
+
return surfaceUnknown(deps, thread, prior);
|
|
98
|
+
if (prior?.status === "sending")
|
|
99
|
+
return sendingElsewhereOrLost(deps, thread, prior);
|
|
102
100
|
(0, states_1.transition)(thread.state, "approve");
|
|
103
101
|
if ((run.commentText ?? run.text ?? "").trim() === "")
|
|
104
102
|
throw new errors_1.EmptyReplyError(thread.id);
|
|
@@ -107,10 +105,10 @@ async function approveAndSend(deps, input) {
|
|
|
107
105
|
}
|
|
108
106
|
const now = (0, internal_1.nowIso)(deps);
|
|
109
107
|
const { intent } = await deps.store.createSendIntentIfAbsent({
|
|
110
|
-
id:
|
|
108
|
+
id: intentId,
|
|
111
109
|
mailbox: thread.mailbox,
|
|
112
110
|
threadId: thread.id,
|
|
113
|
-
draftId:
|
|
111
|
+
draftId: null,
|
|
114
112
|
approvedBy: input.approvedBy,
|
|
115
113
|
status: "intended",
|
|
116
114
|
createdAt: now,
|
|
@@ -134,10 +132,9 @@ async function approveAndSend(deps, input) {
|
|
|
134
132
|
await failNotSent(deps, intent.id, new Error("ticket changed before sending"));
|
|
135
133
|
throw new errors_1.StaleThreadError(thread.id);
|
|
136
134
|
}
|
|
137
|
-
let draftId
|
|
135
|
+
let draftId;
|
|
138
136
|
try {
|
|
139
|
-
|
|
140
|
-
draftId = await prepareAppDraft(deps, access, run, claimed);
|
|
137
|
+
draftId = await prepareDraft(deps, access, run, claimed);
|
|
141
138
|
await (0, reply_attachments_1.placeReplyAttachments)(deps, access, thread.id, draftId);
|
|
142
139
|
}
|
|
143
140
|
catch (err) {
|
|
@@ -202,14 +199,8 @@ async function resolveUnknownSend(deps, input) {
|
|
|
202
199
|
const run = await deps.store.getLatestDraftRunForThread(thread.id);
|
|
203
200
|
if (!run)
|
|
204
201
|
throw new errors_1.DraftNotReadyError(thread.id, "no draft run");
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
for (const id of [ids.perRun, ids.perDraft]) {
|
|
208
|
-
const found = id ? await deps.store.getSendIntent(id) : null;
|
|
209
|
-
if (found?.status === "unknown")
|
|
210
|
-
current = found;
|
|
211
|
-
}
|
|
212
|
-
if (!current)
|
|
202
|
+
const current = await deps.store.getSendIntent(sendIntentId(run));
|
|
203
|
+
if (current?.status !== "unknown")
|
|
213
204
|
throw new errors_1.DraftNotReadyError(thread.id, "no unknown send to resolve");
|
|
214
205
|
const id = current.id;
|
|
215
206
|
if (input.outcome === "not_sent") {
|
|
@@ -229,56 +220,42 @@ async function markRepliedExternally(deps, threadId) {
|
|
|
229
220
|
if (controls.paused)
|
|
230
221
|
return (0, controls_1.paused)();
|
|
231
222
|
const thread = await (0, internal_1.loadThread)(deps, threadId);
|
|
232
|
-
|
|
223
|
+
(0, controls_1.assertInScope)(deps.config, thread.mailbox);
|
|
233
224
|
const updated = await (0, internal_1.applyEvents)(deps, thread, ["approve"], { reviewReason: null });
|
|
234
225
|
await (0, reply_attachments_1.discardPendingReplyAttachments)(deps, thread.id);
|
|
235
|
-
const run = await deps.store.getLatestDraftRunForThread(thread.id).catch(() => null);
|
|
236
|
-
if (run)
|
|
237
|
-
await (0, cleanup_1.discardPlacedDraft)(deps, run, access, thread.id);
|
|
238
226
|
return updated;
|
|
239
227
|
}
|
|
240
228
|
async function dismissThread(deps, input) {
|
|
241
|
-
|
|
242
|
-
if (controls.paused)
|
|
229
|
+
if ((await (0, controls_1.loadControls)(deps)).paused)
|
|
243
230
|
return (0, controls_1.paused)();
|
|
244
231
|
const thread = await (0, internal_1.loadThread)(deps, input.threadId);
|
|
245
|
-
|
|
232
|
+
(0, controls_1.assertInScope)(deps.config, thread.mailbox);
|
|
246
233
|
(0, states_1.transition)(thread.state, "dismiss");
|
|
247
234
|
const run = await deps.store.getLatestDraftRunForThread(thread.id);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
throw new errors_1.SendPendingError(thread.id, seen.status === "unknown_outcome" ? "unknown" : "sending");
|
|
253
|
-
}
|
|
254
|
-
if (intent?.status === "unknown")
|
|
255
|
-
throw new errors_1.SendPendingError(thread.id, "unknown");
|
|
235
|
+
const intent = run ? await deps.store.getSendIntent(sendIntentId(run)) : null;
|
|
236
|
+
if (intent?.status === "sending") {
|
|
237
|
+
const seen = await sendingElsewhereOrLost(deps, thread, intent);
|
|
238
|
+
throw new errors_1.SendPendingError(thread.id, seen.status === "unknown_outcome" ? "unknown" : "sending");
|
|
256
239
|
}
|
|
240
|
+
if (intent?.status === "unknown")
|
|
241
|
+
throw new errors_1.SendPendingError(thread.id, "unknown");
|
|
257
242
|
const updated = await (0, internal_1.applyEvents)(deps, thread, ["dismiss"], {
|
|
258
243
|
reviewReason: null,
|
|
259
244
|
dismissedBy: input.dismissedBy,
|
|
260
245
|
});
|
|
261
|
-
await stopDrafting(deps,
|
|
246
|
+
await stopDrafting(deps, thread.id);
|
|
262
247
|
await (0, reply_attachments_1.discardPendingReplyAttachments)(deps, thread.id);
|
|
263
248
|
return updated;
|
|
264
249
|
}
|
|
265
|
-
async function stopDrafting(deps,
|
|
250
|
+
async function stopDrafting(deps, threadId) {
|
|
266
251
|
try {
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
if (latest.status === "requested" || latest.status === "started") {
|
|
271
|
-
latest = await deps.store.updateDraftRun(latest.idempotencyKey, {
|
|
252
|
+
const latest = await deps.store.getLatestDraftRunForThread(threadId);
|
|
253
|
+
if (latest?.status === "requested" || latest?.status === "started") {
|
|
254
|
+
await deps.store.updateDraftRun(latest.idempotencyKey, {
|
|
272
255
|
status: "failed",
|
|
273
256
|
error: "stopped: staff marked the ticket as needing no reply",
|
|
274
257
|
});
|
|
275
258
|
}
|
|
276
|
-
const previous = latest.generation > 1
|
|
277
|
-
? await deps.store.getDraftRunGeneration(latest.mailbox, latest.inboundMessageId, latest.generation - 1)
|
|
278
|
-
: null;
|
|
279
|
-
for (const run of previous ? [latest, previous] : [latest]) {
|
|
280
|
-
await (0, cleanup_1.discardPlacedDraft)(deps, run, access, threadId);
|
|
281
|
-
}
|
|
282
259
|
}
|
|
283
260
|
catch {
|
|
284
261
|
}
|
package/dist/states.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TicketState } from "./types";
|
|
2
2
|
export declare const TICKET_STATES: readonly TicketState[];
|
|
3
|
-
export type TicketEvent = "ignore" | "draft" | "needs_review" | "
|
|
3
|
+
export type TicketEvent = "ignore" | "draft" | "needs_review" | "approve" | "redraft" | "settle" | "customer_reply" | "close" | "unignore" | "dismiss" | "bounce";
|
|
4
4
|
export declare const TICKET_EVENTS: readonly TicketEvent[];
|
|
5
5
|
export declare function transition(from: TicketState, event: TicketEvent): TicketState;
|
|
6
6
|
export declare function canTransition(from: TicketState, event: TicketEvent): boolean;
|
package/dist/states.js
CHANGED
|
@@ -18,7 +18,6 @@ exports.TICKET_EVENTS = [
|
|
|
18
18
|
"ignore",
|
|
19
19
|
"draft",
|
|
20
20
|
"needs_review",
|
|
21
|
-
"auto_send",
|
|
22
21
|
"approve",
|
|
23
22
|
"redraft",
|
|
24
23
|
"settle",
|
|
@@ -31,7 +30,7 @@ exports.TICKET_EVENTS = [
|
|
|
31
30
|
const TABLE = {
|
|
32
31
|
new: { ignore: "ignored", draft: "drafted", dismiss: "ignored" },
|
|
33
32
|
ignored: { unignore: "new" },
|
|
34
|
-
drafted: { needs_review: "review",
|
|
33
|
+
drafted: { needs_review: "review", dismiss: "ignored" },
|
|
35
34
|
review: { approve: "sent", redraft: "drafted", dismiss: "ignored" },
|
|
36
35
|
sent: { settle: "waiting", bounce: "review" },
|
|
37
36
|
waiting: { customer_reply: "new", close: "closed", bounce: "review" },
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -53,14 +53,12 @@ export interface FakeMailbox extends MailboxGateway {
|
|
|
53
53
|
sendBehaviour: SendBehaviour;
|
|
54
54
|
sent: string[];
|
|
55
55
|
drafts: Map<string, Message>;
|
|
56
|
-
deleteCalls: string[];
|
|
57
56
|
draftAttachments: Map<string, Array<{
|
|
58
57
|
name: string;
|
|
59
58
|
contentType: string;
|
|
60
59
|
size: number;
|
|
61
60
|
}>>;
|
|
62
61
|
addAttachmentError: Error | null;
|
|
63
|
-
deleteError: Error | null;
|
|
64
62
|
outage: Error | null;
|
|
65
63
|
getMessageCalls: number;
|
|
66
64
|
deltaCalls: DeltaOptions[];
|
package/dist/testing/index.js
CHANGED
|
@@ -132,11 +132,6 @@ function createMemoryStore() {
|
|
|
132
132
|
draftRuns.push(clone(run));
|
|
133
133
|
return { created: true, run: clone(run) };
|
|
134
134
|
},
|
|
135
|
-
async getDraftRunGeneration(mailbox, inboundMessageId, generation) {
|
|
136
|
-
await tick();
|
|
137
|
-
const r = draftRuns.find((x) => x.mailbox === mailbox && x.inboundMessageId === inboundMessageId && x.generation === generation);
|
|
138
|
-
return r ? clone(r) : null;
|
|
139
|
-
},
|
|
140
135
|
async getLatestDraftRun(mailbox, inboundMessageId) {
|
|
141
136
|
await tick();
|
|
142
137
|
const runs = draftRuns.filter((r) => r.mailbox === mailbox && r.inboundMessageId === inboundMessageId);
|
|
@@ -186,7 +181,7 @@ function createMemoryStore() {
|
|
|
186
181
|
},
|
|
187
182
|
};
|
|
188
183
|
}
|
|
189
|
-
const WRITE_METHODS = ["createReply", "updateDraftBody", "sendDraft"
|
|
184
|
+
const WRITE_METHODS = ["createReply", "updateDraftBody", "sendDraft"];
|
|
190
185
|
function createFakeMailbox(address) {
|
|
191
186
|
const mails = new Map();
|
|
192
187
|
const log = [];
|
|
@@ -210,10 +205,8 @@ function createFakeMailbox(address) {
|
|
|
210
205
|
sent: [],
|
|
211
206
|
drafts,
|
|
212
207
|
getMessageCalls: 0,
|
|
213
|
-
deleteCalls: [],
|
|
214
208
|
draftAttachments: new Map(),
|
|
215
209
|
addAttachmentError: null,
|
|
216
|
-
deleteError: null,
|
|
217
210
|
outage: null,
|
|
218
211
|
deltaCalls: [],
|
|
219
212
|
calls: [],
|
|
@@ -339,16 +332,6 @@ function createFakeMailbox(address) {
|
|
|
339
332
|
throw new Error(`no draft ${draftId}`);
|
|
340
333
|
d.body = clone(body);
|
|
341
334
|
},
|
|
342
|
-
async deleteDraft(draftId) {
|
|
343
|
-
await tick();
|
|
344
|
-
box.calls.push(`deleteDraft:${draftId}`);
|
|
345
|
-
box.deleteCalls.push(draftId);
|
|
346
|
-
if (box.deleteError)
|
|
347
|
-
throw box.deleteError;
|
|
348
|
-
if (box.sent.includes(draftId))
|
|
349
|
-
return { status: "not_draft" };
|
|
350
|
-
return { status: drafts.delete(draftId) ? "deleted" : "gone" };
|
|
351
|
-
},
|
|
352
335
|
async addAttachment(draftId, attachment) {
|
|
353
336
|
await tick();
|
|
354
337
|
box.calls.push(`addAttachment:${draftId}:${attachment.name}`);
|
package/dist/types.d.ts
CHANGED
|
@@ -78,9 +78,6 @@ export interface MailboxGateway {
|
|
|
78
78
|
contentType: string;
|
|
79
79
|
content: Uint8Array;
|
|
80
80
|
}): Promise<unknown>;
|
|
81
|
-
deleteDraft(draftId: string): Promise<{
|
|
82
|
-
status: "deleted" | "gone" | "not_draft";
|
|
83
|
-
}>;
|
|
84
81
|
}
|
|
85
82
|
export interface Clock {
|
|
86
83
|
now(): Date;
|
|
@@ -140,7 +137,7 @@ export interface ReplyAttachmentsDeps {
|
|
|
140
137
|
files: ReplyFiles;
|
|
141
138
|
}
|
|
142
139
|
export type TicketState = "new" | "ignored" | "drafted" | "review" | "sent" | "waiting" | "closed";
|
|
143
|
-
export type ReviewReason = "low_confidence" | "policy" | "stale_draft" | "drafter_failed" | "
|
|
140
|
+
export type ReviewReason = "low_confidence" | "policy" | "stale_draft" | "drafter_failed" | "staff_edited" | "no_draft" | "delivery_failed";
|
|
144
141
|
export interface Thread {
|
|
145
142
|
id: string;
|
|
146
143
|
mailbox: string;
|
|
@@ -212,8 +209,6 @@ export interface DraftRun {
|
|
|
212
209
|
placedDraftId: string | null;
|
|
213
210
|
placedBodyHtml: string | null;
|
|
214
211
|
commentText: string | null;
|
|
215
|
-
placementError: string | null;
|
|
216
|
-
draftCleanupError: string | null;
|
|
217
212
|
}
|
|
218
213
|
export type SendStatus = "intended" | "sending" | "sent" | "failed" | "unknown";
|
|
219
214
|
export interface SendIntent {
|
|
@@ -249,7 +244,6 @@ export interface HelpdeskStore {
|
|
|
249
244
|
run: DraftRun;
|
|
250
245
|
}>;
|
|
251
246
|
getLatestDraftRun(mailbox: string, inboundMessageId: string): Promise<DraftRun | null>;
|
|
252
|
-
getDraftRunGeneration(mailbox: string, inboundMessageId: string, generation: number): Promise<DraftRun | null>;
|
|
253
247
|
getLatestDraftRunForThread(threadId: string): Promise<DraftRun | null>;
|
|
254
248
|
listInFlightDraftRuns(mailbox: string, olderThan: string): Promise<DraftRun[]>;
|
|
255
249
|
updateDraftRun(key: string, patch: Partial<Omit<DraftRun, "idempotencyKey">>): Promise<DraftRun>;
|
|
@@ -319,7 +313,7 @@ export interface HelpdeskConfig {
|
|
|
319
313
|
minConfidence?: number;
|
|
320
314
|
pageSize?: number;
|
|
321
315
|
}
|
|
322
|
-
export type MailboxWriteMode = "read_only" | "
|
|
316
|
+
export type MailboxWriteMode = "read_only" | "approve_and_send";
|
|
323
317
|
export interface HelpdeskControls {
|
|
324
318
|
paused: boolean;
|
|
325
319
|
writeMode?: MailboxWriteMode;
|
package/package.json
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@dxv-systems/helpdesk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mailbox helpdesk domain logic for DXV apps — idempotent delta ingestion, conservative threading, ticket state machine, noise detection,
|
|
5
|
-
"keywords": [
|
|
6
|
-
"dxv",
|
|
7
|
-
"helpdesk",
|
|
8
|
-
"mail",
|
|
9
|
-
"microsoft-graph",
|
|
10
|
-
"threading",
|
|
11
|
-
"tickets"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20.0.0"
|
|
15
|
-
},
|
|
16
|
-
"exports": {
|
|
17
|
-
".": {
|
|
18
|
-
"types": "./dist/index.d.ts",
|
|
19
|
-
"default": "./dist/index.js"
|
|
20
|
-
},
|
|
21
|
-
"./testing": {
|
|
22
|
-
"types": "./dist/testing/index.d.ts",
|
|
23
|
-
"default": "./dist/testing/index.js"
|
|
24
|
-
},
|
|
25
|
-
"./server": {
|
|
26
|
-
"types": "./dist/server.d.ts",
|
|
27
|
-
"default": "./dist/server.js"
|
|
28
|
-
},
|
|
29
|
-
"./react": {
|
|
30
|
-
"types": "./dist/react.d.ts",
|
|
31
|
-
"default": "./dist/react.js"
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
"main": "./dist/index.js",
|
|
35
|
-
"types": "./dist/index.d.ts",
|
|
36
|
-
"files": [
|
|
37
|
-
"dist"
|
|
38
|
-
],
|
|
39
|
-
"sideEffects": false,
|
|
40
|
-
"scripts": {
|
|
41
|
-
"build": "tsc",
|
|
42
|
-
"test": "vitest run",
|
|
43
|
-
"test:watch": "vitest",
|
|
44
|
-
"prepublishOnly": "npm run build"
|
|
45
|
-
},
|
|
46
|
-
"peerDependencies": {
|
|
47
|
-
"react": ">=18"
|
|
48
|
-
},
|
|
49
|
-
"peerDependenciesMeta": {
|
|
50
|
-
"react": {
|
|
51
|
-
"optional": true
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
"devDependencies": {
|
|
55
|
-
"@testing-library/react": "^16.0.1",
|
|
56
|
-
"@types/node": "^20.19.0",
|
|
57
|
-
"@types/react": "^18.3.12",
|
|
58
|
-
"jsdom": "^25.0.1",
|
|
59
|
-
"react": "^18.3.1",
|
|
60
|
-
"react-dom": "^18.3.1",
|
|
61
|
-
"typescript": "^5.6.0",
|
|
62
|
-
"vitest": "^4.1.11"
|
|
63
|
-
},
|
|
64
|
-
"repository": {
|
|
65
|
-
"type": "git",
|
|
66
|
-
"url": "git+https://github.com/dxv-systems/dxv-packages.git",
|
|
67
|
-
"directory": "packages/helpdesk"
|
|
68
|
-
},
|
|
69
|
-
"publishConfig": {
|
|
70
|
-
"access": "public"
|
|
71
|
-
},
|
|
72
|
-
"license": "MIT"
|
|
73
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxv-systems/helpdesk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Mailbox helpdesk domain logic for DXV apps — idempotent delta ingestion, conservative threading, ticket state machine, noise detection, and replies that a person approves in the helpdesk before they are sent from the mailbox (quoted history kept, retry-safe, never sent twice). Pure logic behind interfaces, plus the staff inbox as a route handler (./server) and a React component (./react). Zero runtime dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dxv",
|
|
7
|
+
"helpdesk",
|
|
8
|
+
"mail",
|
|
9
|
+
"microsoft-graph",
|
|
10
|
+
"threading",
|
|
11
|
+
"tickets"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./testing": {
|
|
22
|
+
"types": "./dist/testing/index.d.ts",
|
|
23
|
+
"default": "./dist/testing/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./server": {
|
|
26
|
+
"types": "./dist/server.d.ts",
|
|
27
|
+
"default": "./dist/server.js"
|
|
28
|
+
},
|
|
29
|
+
"./react": {
|
|
30
|
+
"types": "./dist/react.d.ts",
|
|
31
|
+
"default": "./dist/react.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@testing-library/react": "^16.0.1",
|
|
56
|
+
"@types/node": "^20.19.0",
|
|
57
|
+
"@types/react": "^18.3.12",
|
|
58
|
+
"jsdom": "^25.0.1",
|
|
59
|
+
"react": "^18.3.1",
|
|
60
|
+
"react-dom": "^18.3.1",
|
|
61
|
+
"typescript": "^5.6.0",
|
|
62
|
+
"vitest": "^4.1.11"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/dxv-systems/dxv-packages.git",
|
|
67
|
+
"directory": "packages/helpdesk"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
},
|
|
72
|
+
"license": "MIT"
|
|
73
|
+
}
|
package/dist/autosend.d.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { MailboxWriteMode, TicketState } from "./types";
|
|
2
|
-
export interface OfficeHours {
|
|
3
|
-
timeZone: string;
|
|
4
|
-
windows: Array<{
|
|
5
|
-
days: number[];
|
|
6
|
-
start: string;
|
|
7
|
-
end: string;
|
|
8
|
-
}>;
|
|
9
|
-
}
|
|
10
|
-
export interface AutoSendPolicy {
|
|
11
|
-
enabled: boolean;
|
|
12
|
-
categories?: string[];
|
|
13
|
-
senders?: string[];
|
|
14
|
-
minConfidence: number;
|
|
15
|
-
officeHours?: OfficeHours;
|
|
16
|
-
maxAutoRepliesPerThread: number;
|
|
17
|
-
internalDomains?: string[];
|
|
18
|
-
}
|
|
19
|
-
export declare const DEFAULT_AUTO_SEND_POLICY: AutoSendPolicy;
|
|
20
|
-
export interface AutoSendContext {
|
|
21
|
-
paused: boolean;
|
|
22
|
-
writeMode: MailboxWriteMode;
|
|
23
|
-
threadState: TicketState;
|
|
24
|
-
inboundIgnored: boolean;
|
|
25
|
-
sender: string;
|
|
26
|
-
category?: string | null;
|
|
27
|
-
confidence: number;
|
|
28
|
-
now: Date;
|
|
29
|
-
autoRepliesInThread: number;
|
|
30
|
-
suppressAutoResponse: boolean;
|
|
31
|
-
draftIsStale: boolean;
|
|
32
|
-
ownAddresses: string[];
|
|
33
|
-
connectedMailboxes: string[];
|
|
34
|
-
}
|
|
35
|
-
export type AutoSendDecision = {
|
|
36
|
-
allow: true;
|
|
37
|
-
reason: "all_rules_passed";
|
|
38
|
-
} | {
|
|
39
|
-
allow: false;
|
|
40
|
-
reason: "paused" | "write_mode" | "disabled" | "ignored_mail" | "internal_sender" | "auto_response_suppressed" | "wrong_state" | "stale_draft" | "no_scope_configured" | "sender_not_allowed" | "category_not_allowed" | "low_confidence" | "outside_office_hours" | "max_auto_replies";
|
|
41
|
-
detail?: string;
|
|
42
|
-
};
|
|
43
|
-
export declare function evaluateAutoSend(policy: AutoSendPolicy, ctx: AutoSendContext): AutoSendDecision;
|
package/dist/autosend.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_AUTO_SEND_POLICY = void 0;
|
|
4
|
-
exports.evaluateAutoSend = evaluateAutoSend;
|
|
5
|
-
exports.DEFAULT_AUTO_SEND_POLICY = {
|
|
6
|
-
enabled: false,
|
|
7
|
-
minConfidence: 0.9,
|
|
8
|
-
maxAutoRepliesPerThread: 1,
|
|
9
|
-
};
|
|
10
|
-
const WEEKDAYS = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
|
11
|
-
function minutes(hhmm) {
|
|
12
|
-
const [h, m] = hhmm.split(":");
|
|
13
|
-
return Number(h) * 60 + Number(m ?? 0);
|
|
14
|
-
}
|
|
15
|
-
function withinOfficeHours(hours, now) {
|
|
16
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
17
|
-
timeZone: hours.timeZone,
|
|
18
|
-
weekday: "short",
|
|
19
|
-
hour: "2-digit",
|
|
20
|
-
minute: "2-digit",
|
|
21
|
-
hourCycle: "h23",
|
|
22
|
-
}).formatToParts(now);
|
|
23
|
-
const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
24
|
-
const day = WEEKDAYS[get("weekday")];
|
|
25
|
-
const at = Number(get("hour")) * 60 + Number(get("minute"));
|
|
26
|
-
return hours.windows.some((w) => day !== undefined && w.days.includes(day) && at >= minutes(w.start) && at < minutes(w.end));
|
|
27
|
-
}
|
|
28
|
-
function senderMatches(sender, rules) {
|
|
29
|
-
const domain = sender.split("@")[1] ?? "";
|
|
30
|
-
return rules.some((r) => {
|
|
31
|
-
const rule = r.trim().toLowerCase();
|
|
32
|
-
return rule.startsWith("@") ? domain === rule.slice(1) : sender === rule;
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
function evaluateAutoSend(policy, ctx) {
|
|
36
|
-
const deny = (reason, detail) => ({
|
|
37
|
-
allow: false,
|
|
38
|
-
reason,
|
|
39
|
-
...(detail ? { detail } : {}),
|
|
40
|
-
});
|
|
41
|
-
const sender = ctx.sender.trim().toLowerCase();
|
|
42
|
-
const domain = sender.split("@")[1] ?? "";
|
|
43
|
-
if (ctx.paused)
|
|
44
|
-
return deny("paused");
|
|
45
|
-
if (ctx.writeMode !== "drafts_in_outlook")
|
|
46
|
-
return deny("write_mode", ctx.writeMode);
|
|
47
|
-
if (!policy.enabled)
|
|
48
|
-
return deny("disabled");
|
|
49
|
-
if (ctx.inboundIgnored || ctx.threadState === "ignored")
|
|
50
|
-
return deny("ignored_mail");
|
|
51
|
-
const internalAddresses = [...ctx.ownAddresses, ...ctx.connectedMailboxes].map((a) => a.toLowerCase());
|
|
52
|
-
const internalDomains = (policy.internalDomains ?? []).map((d) => d.toLowerCase());
|
|
53
|
-
if (internalAddresses.includes(sender) || internalDomains.includes(domain)) {
|
|
54
|
-
return deny("internal_sender");
|
|
55
|
-
}
|
|
56
|
-
if (ctx.suppressAutoResponse)
|
|
57
|
-
return deny("auto_response_suppressed");
|
|
58
|
-
if (ctx.threadState !== "drafted")
|
|
59
|
-
return deny("wrong_state", ctx.threadState);
|
|
60
|
-
if (ctx.draftIsStale)
|
|
61
|
-
return deny("stale_draft");
|
|
62
|
-
const senders = policy.senders ?? [];
|
|
63
|
-
const categories = policy.categories ?? [];
|
|
64
|
-
if (senders.length === 0 && categories.length === 0)
|
|
65
|
-
return deny("no_scope_configured");
|
|
66
|
-
const senderOk = senders.length > 0 && senderMatches(sender, senders);
|
|
67
|
-
const category = (ctx.category ?? "").toLowerCase();
|
|
68
|
-
const categoryOk = categories.length > 0 && categories.some((c) => c.toLowerCase() === category);
|
|
69
|
-
if (!senderOk && !categoryOk) {
|
|
70
|
-
return senders.length > 0 && categories.length === 0
|
|
71
|
-
? deny("sender_not_allowed", sender)
|
|
72
|
-
: deny("category_not_allowed", ctx.category ?? "none");
|
|
73
|
-
}
|
|
74
|
-
if (ctx.confidence < policy.minConfidence)
|
|
75
|
-
return deny("low_confidence", String(ctx.confidence));
|
|
76
|
-
if (policy.officeHours && !withinOfficeHours(policy.officeHours, ctx.now)) {
|
|
77
|
-
return deny("outside_office_hours");
|
|
78
|
-
}
|
|
79
|
-
if (ctx.autoRepliesInThread >= policy.maxAutoRepliesPerThread) {
|
|
80
|
-
return deny("max_auto_replies", String(ctx.autoRepliesInThread));
|
|
81
|
-
}
|
|
82
|
-
return { allow: true, reason: "all_rules_passed" };
|
|
83
|
-
}
|
package/dist/cleanup.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { MailboxAccess } from "./controls";
|
|
2
|
-
import type { DraftRun, HelpdeskDeps } from "./types";
|
|
3
|
-
export declare function sendIntentIds(run: DraftRun): {
|
|
4
|
-
perRun: string;
|
|
5
|
-
perDraft: string | null;
|
|
6
|
-
};
|
|
7
|
-
export declare function discardPlacedDraft(deps: HelpdeskDeps, run: DraftRun, access: MailboxAccess, threadId: string): Promise<void>;
|
package/dist/cleanup.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.sendIntentIds = sendIntentIds;
|
|
4
|
-
exports.discardPlacedDraft = discardPlacedDraft;
|
|
5
|
-
const internal_1 = require("./internal");
|
|
6
|
-
const KEEP_FOR_INTENT = ["intended", "sending", "unknown", "sent"];
|
|
7
|
-
function sendIntentIds(run) {
|
|
8
|
-
return {
|
|
9
|
-
perRun: `send:${run.mailbox}:${run.idempotencyKey}`,
|
|
10
|
-
perDraft: run.placedDraftId ? `send:${run.mailbox}:${run.placedDraftId}` : null,
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
async function discardPlacedDraft(deps, run, access, threadId) {
|
|
14
|
-
try {
|
|
15
|
-
const draftId = run.placedDraftId;
|
|
16
|
-
if (access.mode !== "drafts_in_outlook")
|
|
17
|
-
return;
|
|
18
|
-
if (!draftId || run.threadId !== threadId || run.mailbox !== access.mailbox)
|
|
19
|
-
return;
|
|
20
|
-
const { perRun, perDraft } = sendIntentIds(run);
|
|
21
|
-
for (const id of [perRun, perDraft]) {
|
|
22
|
-
const intent = id ? await deps.store.getSendIntent(id) : null;
|
|
23
|
-
if (intent && KEEP_FOR_INTENT.includes(intent.status))
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
try {
|
|
27
|
-
await access.gateway().deleteDraft(draftId);
|
|
28
|
-
await deps.store.updateDraftRun(run.idempotencyKey, {
|
|
29
|
-
placedDraftId: null,
|
|
30
|
-
draftCleanupError: null,
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
await deps.store.updateDraftRun(run.idempotencyKey, { draftCleanupError: (0, internal_1.errorText)(err) });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
}
|
|
39
|
-
}
|