@dxv-systems/helpdesk 0.0.0-stage → 0.1.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.
Files changed (50) hide show
  1. package/README.md +279 -3
  2. package/dist/autosend.d.ts +43 -0
  3. package/dist/autosend.js +83 -0
  4. package/dist/bounce.d.ts +7 -0
  5. package/dist/bounce.js +41 -0
  6. package/dist/cleanup.d.ts +7 -0
  7. package/dist/cleanup.js +39 -0
  8. package/dist/controls.d.ts +13 -0
  9. package/dist/controls.js +87 -0
  10. package/dist/draft-body.d.ts +5 -0
  11. package/dist/draft-body.js +52 -0
  12. package/dist/drafting.d.ts +19 -0
  13. package/dist/drafting.js +311 -0
  14. package/dist/errors.d.ts +63 -0
  15. package/dist/errors.js +139 -0
  16. package/dist/inbox/handlers.d.ts +36 -0
  17. package/dist/inbox/handlers.js +535 -0
  18. package/dist/inbox/messages.d.ts +35 -0
  19. package/dist/inbox/messages.js +57 -0
  20. package/dist/inbox/plain-text.d.ts +1 -0
  21. package/dist/inbox/plain-text.js +58 -0
  22. package/dist/inbox/types.d.ts +108 -0
  23. package/dist/inbox/types.js +2 -0
  24. package/dist/index.d.ts +13 -0
  25. package/dist/index.js +71 -0
  26. package/dist/ingest.d.ts +25 -0
  27. package/dist/ingest.js +335 -0
  28. package/dist/internal.d.ts +8 -0
  29. package/dist/internal.js +44 -0
  30. package/dist/noise.d.ts +17 -0
  31. package/dist/noise.js +77 -0
  32. package/dist/react.d.ts +57 -0
  33. package/dist/react.js +431 -0
  34. package/dist/reply-attachments.d.ts +28 -0
  35. package/dist/reply-attachments.js +119 -0
  36. package/dist/sending.d.ts +33 -0
  37. package/dist/sending.js +291 -0
  38. package/dist/server.d.ts +6 -0
  39. package/dist/server.js +10 -0
  40. package/dist/states.d.ts +13 -0
  41. package/dist/states.js +63 -0
  42. package/dist/subject.d.ts +2 -0
  43. package/dist/subject.js +30 -0
  44. package/dist/testing/index.d.ts +99 -0
  45. package/dist/testing/index.js +489 -0
  46. package/dist/threading.d.ts +18 -0
  47. package/dist/threading.js +66 -0
  48. package/dist/types.d.ts +345 -0
  49. package/dist/types.js +2 -0
  50. package/package.json +73 -6
@@ -0,0 +1,535 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_ATTACHMENT_DOWNLOAD_BYTES = void 0;
4
+ exports.contentDisposition = contentDisposition;
5
+ exports.createHelpdeskHandlers = createHelpdeskHandlers;
6
+ const cleanup_1 = require("../cleanup");
7
+ const controls_1 = require("../controls");
8
+ const drafting_1 = require("../drafting");
9
+ const errors_1 = require("../errors");
10
+ const ingest_1 = require("../ingest");
11
+ const sending_1 = require("../sending");
12
+ const states_1 = require("../states");
13
+ const reply_attachments_1 = require("../reply-attachments");
14
+ const messages_1 = require("./messages");
15
+ const plain_text_1 = require("./plain-text");
16
+ const MAX_BODY_CHARS = 100000;
17
+ const MAX_DRAFT_CHARS = 50000;
18
+ const DEFAULT_PAGE_SIZE = 25;
19
+ const MAX_PAGE_SIZE = 100;
20
+ const MAX_CURSOR_CHARS = 1000;
21
+ exports.MAX_ATTACHMENT_DOWNLOAD_BYTES = 4 * 1024 * 1024;
22
+ function contentDisposition(name) {
23
+ const cleaned = name.replace(/[\u0000-\u001f\u007f"\\/]/g, "_").trim() || "bijlage";
24
+ const ascii = cleaned.replace(/[^\x20-\x7e]/g, "_");
25
+ const encoded = encodeURIComponent(cleaned).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
26
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
27
+ }
28
+ class RouteError extends Error {
29
+ constructor(status, code, extra = {}) {
30
+ super(code);
31
+ this.status = status;
32
+ this.code = code;
33
+ this.extra = extra;
34
+ }
35
+ }
36
+ function json(status, body) {
37
+ return new Response(JSON.stringify(body), {
38
+ status,
39
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
40
+ });
41
+ }
42
+ function fail(status, code, extra = {}) {
43
+ return json(status, { error: code, message: messages_1.INBOX_ERROR_MESSAGES[code], ...extra });
44
+ }
45
+ const DOMAIN_ERRORS = {
46
+ thread_not_found: [404, "thread_not_found"],
47
+ mailbox_not_in_scope: [403, "mailbox_not_in_scope"],
48
+ mailbox_write_blocked: [409, "write_blocked"],
49
+ stale_draft: [409, "stale_draft"],
50
+ draft_not_ready: [409, "draft_not_ready"],
51
+ invalid_transition: [409, "invalid_transition"],
52
+ stale_thread: [409, "stale_thread"],
53
+ quote_not_found: [422, "quote_not_found"],
54
+ send_in_progress: [409, "send_in_progress"],
55
+ send_outcome_unknown: [409, "send_outcome_unknown"],
56
+ empty_reply: [422, "empty_reply"],
57
+ attachments_unavailable: [409, "attachments_unavailable"],
58
+ attachment_limit: [422, "attachment_limit"],
59
+ reply_attachment_not_found: [404, "reply_attachment_not_found"],
60
+ };
61
+ async function readJson(request) {
62
+ const text = await request.text().catch(() => "");
63
+ if (text.length > MAX_BODY_CHARS)
64
+ throw new RouteError(400, "invalid_input");
65
+ if (text.trim() === "")
66
+ return {};
67
+ try {
68
+ const parsed = JSON.parse(text);
69
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
70
+ return parsed;
71
+ }
72
+ catch {
73
+ }
74
+ throw new RouteError(400, "invalid_input");
75
+ }
76
+ const norm = (address) => address.trim().toLowerCase();
77
+ const person = (staff) => ({ id: staff.id, name: staff.name });
78
+ function summary(thread, assignee) {
79
+ return {
80
+ id: thread.id,
81
+ mailbox: thread.mailbox,
82
+ state: thread.state,
83
+ subject: thread.subject,
84
+ customerAddress: thread.customerAddress,
85
+ lastActivityAt: thread.lastActivityAt,
86
+ reviewReason: thread.reviewReason,
87
+ assignee,
88
+ };
89
+ }
90
+ async function latestIntent(deps, run) {
91
+ if (!run)
92
+ return null;
93
+ const { perRun, perDraft } = (0, cleanup_1.sendIntentIds)(run);
94
+ const found = await Promise.all([perRun, perDraft].map((id) => (id ? deps.store.getSendIntent(id) : null)));
95
+ const intents = found.filter((i) => i !== null);
96
+ return intents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null;
97
+ }
98
+ function draftView(thread, run) {
99
+ if (!run)
100
+ return null;
101
+ return {
102
+ generation: run.generation,
103
+ status: run.status,
104
+ text: run.status === "succeeded" ? (run.commentText ?? run.text) : null,
105
+ confidence: run.confidence,
106
+ category: run.category,
107
+ stale: thread.latestInboundId !== run.inboundMessageId,
108
+ inOutlook: run.placedDraftId !== null,
109
+ };
110
+ }
111
+ function allowedActions(thread, run, intent, paused, mode, hasDrafter, canAttach = false) {
112
+ const s = thread.state;
113
+ const actions = [];
114
+ const hasText = run?.status === "succeeded";
115
+ const fresh = run === null && thread.latestInboundId !== null;
116
+ const placed = Boolean(run?.placedDraftId) && run?.placedBodyHtml !== null;
117
+ const pending = intent !== null && ["sending", "unknown", "sent"].includes(intent.status);
118
+ const sendUnderWay = intent !== null && ["sending", "unknown"].includes(intent.status);
119
+ const bounced = s === "review" && thread.reviewReason === "delivery_failed";
120
+ if (!paused) {
121
+ if (!bounced && (0, states_1.canTransition)(s, "redraft") && ((hasText && (mode !== "drafts_in_outlook" || placed)) || fresh)) {
122
+ actions.push("edit");
123
+ }
124
+ if (!bounced && (0, states_1.canTransition)(s, "redraft") && thread.latestInboundId && hasDrafter)
125
+ actions.push("regenerate");
126
+ const sendable = mode === "drafts_in_app" ? hasText : mode === "drafts_in_outlook" && Boolean(run?.placedDraftId);
127
+ if (!bounced && (0, states_1.canTransition)(s, "approve") && (sendable || (fresh && mode !== "read_only")) && !pending)
128
+ actions.push("send");
129
+ if (!bounced && (0, states_1.canTransition)(s, "approve") && mode === "drafts_in_outlook" && hasText && !run?.placedDraftId) {
130
+ actions.push("retry_placement");
131
+ }
132
+ if ((0, states_1.canTransition)(s, "approve"))
133
+ actions.push("replied_externally");
134
+ if ((0, states_1.canTransition)(s, "unignore"))
135
+ actions.push("unignore");
136
+ if ((0, states_1.canTransition)(s, "dismiss") && !sendUnderWay)
137
+ actions.push("dismiss");
138
+ if (canAttach && s === "review" && !bounced && !sendUnderWay && mode !== "read_only")
139
+ actions.push("attach");
140
+ }
141
+ if ((0, states_1.canTransition)(s, "settle"))
142
+ actions.push("mark_waiting");
143
+ if ((0, states_1.canTransition)(s, "close"))
144
+ actions.push("close");
145
+ if (intent?.status === "unknown")
146
+ actions.push("resolve_send");
147
+ return actions;
148
+ }
149
+ function parseStates(value) {
150
+ if (value === null || value.trim() === "")
151
+ return null;
152
+ const states = value.split(",").map((s) => s.trim());
153
+ if (!states.every((s) => states_1.TICKET_STATES.includes(s))) {
154
+ throw new RouteError(400, "invalid_input");
155
+ }
156
+ return states;
157
+ }
158
+ function parseLimit(value) {
159
+ if (value === null)
160
+ return DEFAULT_PAGE_SIZE;
161
+ const n = Number(value);
162
+ if (!Number.isInteger(n) || n < 1)
163
+ throw new RouteError(400, "invalid_input");
164
+ return Math.min(n, MAX_PAGE_SIZE);
165
+ }
166
+ const SEND_MESSAGES = {
167
+ sent: "Verstuurd. Het antwoord staat in Outlook bij Verzonden items.",
168
+ already_sent: "Dit antwoord was al verstuurd.",
169
+ in_progress: "Dit antwoord wordt al verstuurd. Vernieuw zo de pagina.",
170
+ unknown_outcome: "Het is niet zeker of het antwoord is verstuurd. Kijk in Outlook bij Verzonden items en geef hieronder aan wat je ziet.",
171
+ };
172
+ function createHelpdeskHandlers(config) {
173
+ const { deps, inbox } = config;
174
+ const basePath = (config.basePath ?? messages_1.HELPDESK_BASE_PATH).replace(/\/$/, "");
175
+ const onError = config.onError ?? ((error) => console.error("[helpdesk] unexpected error", error));
176
+ async function loadThread(threadId) {
177
+ const thread = await deps.store.getThread(threadId);
178
+ if (!thread)
179
+ throw new errors_1.ThreadNotFoundError(threadId);
180
+ (0, controls_1.assertInScope)(deps.config, thread.mailbox);
181
+ return thread;
182
+ }
183
+ async function detail(threadId, staff) {
184
+ const controls = await (0, controls_1.loadControls)(deps);
185
+ const thread = await loadThread(threadId);
186
+ const mode = (0, controls_1.writeModeFor)(controls, thread.mailbox);
187
+ const [messages, assignee, latestRun] = await Promise.all([
188
+ inbox.listMessages(thread.id),
189
+ inbox.getAssignee(thread.id),
190
+ deps.store.getLatestDraftRunForThread(thread.id),
191
+ ]);
192
+ const run = (0, drafting_1.currentReplyRun)(deps, thread, latestRun);
193
+ const intent = await latestIntent(deps, run);
194
+ return {
195
+ viewer: person(staff),
196
+ thread: {
197
+ ...summary(thread, assignee),
198
+ relatedThreadId: thread.relatedThreadId,
199
+ dismissedBy: thread.dismissedBy,
200
+ participants: thread.participants,
201
+ createdAt: thread.createdAt,
202
+ },
203
+ pilot: { paused: controls.paused, writeMode: mode },
204
+ messages: messages
205
+ .filter((m) => m.threadId === thread.id)
206
+ .map((m) => ({
207
+ id: m.id,
208
+ fromAddress: m.fromAddress,
209
+ subject: m.subject,
210
+ receivedAt: m.receivedAt,
211
+ noise: m.noise.action === "ignore",
212
+ removed: m.removedAt !== null,
213
+ })),
214
+ draft: draftView(thread, run ?? (latestRun?.status === "failed" ? latestRun : null)),
215
+ send: intent ? { status: intent.status, at: intent.updatedAt } : null,
216
+ replyAttachments: await replyAttachmentViews(thread.id, intent),
217
+ actions: allowedActions(thread, run, intent, controls.paused, mode, Boolean(deps.drafter), canAttach(thread.mailbox)),
218
+ };
219
+ }
220
+ async function result(ctx, message, outcome, status = 200) {
221
+ const body = { detail: await detail(ctx.threadId, ctx.staff), message };
222
+ if (outcome)
223
+ body.outcome = outcome;
224
+ return json(status, body);
225
+ }
226
+ function refused(value) {
227
+ const status = value?.status;
228
+ if (status === "paused")
229
+ return fail(423, "paused");
230
+ if (status === "write_blocked")
231
+ return fail(409, "write_blocked", { mode: value.mode });
232
+ return null;
233
+ }
234
+ async function list({ request, staff }) {
235
+ const params = new URL(request.url).searchParams;
236
+ const states = parseStates(params.get("state"));
237
+ const limit = parseLimit(params.get("limit"));
238
+ const cursor = params.get("cursor");
239
+ if (cursor !== null && cursor.length > MAX_CURSOR_CHARS)
240
+ throw new RouteError(400, "invalid_input");
241
+ const mailboxParam = params.get("mailbox");
242
+ let mailbox = null;
243
+ if (mailboxParam) {
244
+ const match = deps.config.connectedMailboxes.find((m) => norm(m) === norm(mailboxParam));
245
+ if (!match)
246
+ throw new RouteError(403, "mailbox_not_in_scope");
247
+ mailbox = match;
248
+ }
249
+ const assigneeParam = params.get("assignee");
250
+ const query = { states, mailbox, cursor: cursor || null, limit };
251
+ if (assigneeParam === "me")
252
+ query.assigneeId = staff.id;
253
+ else if (assigneeParam === "none")
254
+ query.assigneeId = null;
255
+ else if (assigneeParam)
256
+ query.assigneeId = assigneeParam;
257
+ const controls = await (0, controls_1.loadControls)(deps);
258
+ const page = await inbox.listThreads(query);
259
+ const inScope = new Set(deps.config.connectedMailboxes.map(norm));
260
+ const syncIssues = [];
261
+ for (const address of deps.config.connectedMailboxes) {
262
+ const sync = await deps.store.getSyncState(address);
263
+ if (sync?.failingSince && (sync.failures ?? 0) >= ingest_1.SYNC_FAILING_AFTER) {
264
+ syncIssues.push({ mailbox: address, failingSince: sync.failingSince });
265
+ }
266
+ }
267
+ const body = {
268
+ viewer: person(staff),
269
+ syncIssues,
270
+ pilot: {
271
+ paused: controls.paused,
272
+ mailboxes: deps.config.connectedMailboxes.map((address) => ({
273
+ address,
274
+ writeMode: (0, controls_1.writeModeFor)(controls, address),
275
+ })),
276
+ },
277
+ threads: page.rows
278
+ .filter((row) => inScope.has(norm(row.thread.mailbox)))
279
+ .map((row) => summary(row.thread, row.assignee)),
280
+ nextCursor: page.nextCursor,
281
+ };
282
+ return json(200, body);
283
+ }
284
+ async function show(ctx) {
285
+ return json(200, await detail(ctx.threadId, ctx.staff));
286
+ }
287
+ async function ticketMessage(threadId, messageId) {
288
+ const controls = await (0, controls_1.loadControls)(deps);
289
+ if (controls.paused)
290
+ throw new RouteError(423, "paused");
291
+ const thread = await loadThread(threadId);
292
+ const messages = await inbox.listMessages(thread.id);
293
+ if (!messages.some((m) => m.id === messageId && m.threadId === thread.id)) {
294
+ throw new RouteError(404, "message_not_found");
295
+ }
296
+ return (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox).gateway();
297
+ }
298
+ function attachmentView(threadId, messageId, a) {
299
+ const unavailable = a.kind !== "file" ? a.kind : a.size > exports.MAX_ATTACHMENT_DOWNLOAD_BYTES ? "too_large" : null;
300
+ const path = [threadId, "messages", messageId, "attachments", a.id].map((p) => encodeURIComponent(p)).join("/");
301
+ return { id: a.id, name: a.name, size: a.size, kind: a.kind, href: unavailable ? null : `${basePath}/threads/${path}`, unavailable };
302
+ }
303
+ async function messageBody({ threadId, messageId }) {
304
+ const gateway = await ticketMessage(threadId, messageId);
305
+ const message = await gateway.getMessage(messageId, { bodyType: "text" });
306
+ if (!message)
307
+ throw new RouteError(404, "message_unavailable");
308
+ const { contentType, content } = message.body;
309
+ const text = contentType === "html" ? (0, plain_text_1.htmlToPlainText)(content) : content.replace(/\r\n?/g, "\n").trim();
310
+ const listed = gateway.listAttachments && message.hasAttachments !== false ? await gateway.listAttachments(messageId) : [];
311
+ const attachments = listed.filter((a) => !a.isInline).map((a) => attachmentView(threadId, messageId, a));
312
+ const body = { id: messageId, text, attachments };
313
+ return json(200, body);
314
+ }
315
+ async function attachment({ threadId, messageId, attachmentId }) {
316
+ const gateway = await ticketMessage(threadId, messageId);
317
+ if (!gateway.listAttachments || !gateway.downloadAttachment)
318
+ throw new RouteError(404, "attachment_not_found");
319
+ const found = (await gateway.listAttachments(messageId)).find((a) => a.id === attachmentId);
320
+ if (!found)
321
+ throw new RouteError(404, "attachment_not_found");
322
+ if (attachmentView(threadId, messageId, found).href === null)
323
+ throw new RouteError(409, "attachment_unavailable");
324
+ const bytes = await gateway.downloadAttachment(messageId, attachmentId);
325
+ if (bytes.byteLength > exports.MAX_ATTACHMENT_DOWNLOAD_BYTES)
326
+ throw new RouteError(409, "attachment_unavailable");
327
+ return new Response(bytes, {
328
+ status: 200,
329
+ headers: {
330
+ "Content-Type": "application/octet-stream",
331
+ "Content-Disposition": contentDisposition(found.name),
332
+ "Content-Length": String(bytes.byteLength),
333
+ "X-Content-Type-Options": "nosniff",
334
+ "Content-Security-Policy": "sandbox; default-src 'none'",
335
+ "Cache-Control": "private, no-store",
336
+ "Referrer-Policy": "no-referrer",
337
+ },
338
+ });
339
+ }
340
+ async function saveDraft(ctx) {
341
+ const body = await readJson(ctx.request);
342
+ const text = typeof body.text === "string" ? body.text : null;
343
+ if (text === null || text.trim() === "" || text.length > MAX_DRAFT_CHARS)
344
+ throw new RouteError(400, "invalid_input");
345
+ const outcome = await (0, drafting_1.editDraft)(deps, { threadId: ctx.threadId, text });
346
+ return refused(outcome) ?? result(ctx, "Concept opgeslagen.");
347
+ }
348
+ async function regenerate(ctx) {
349
+ const outcome = await (0, drafting_1.regenerateDraft)(deps, ctx.threadId);
350
+ return refused(outcome) ?? result(ctx, "Er wordt een nieuw concept geschreven.");
351
+ }
352
+ async function send(ctx) {
353
+ const body = await readJson(ctx.request);
354
+ const outcome = await (0, sending_1.approveAndSend)(deps, {
355
+ threadId: ctx.threadId,
356
+ approvedBy: ctx.staff.id,
357
+ acceptStale: body.acceptStale === true,
358
+ });
359
+ if (outcome.status === "paused" || outcome.status === "write_blocked")
360
+ return refused(outcome);
361
+ if (outcome.status === "failed_not_sent")
362
+ return fail(502, "send_failed");
363
+ const status = outcome.status;
364
+ return result(ctx, SEND_MESSAGES[status], status, status === "in_progress" ? 202 : 200);
365
+ }
366
+ async function retry(ctx) {
367
+ const outcome = await (0, drafting_1.retryPlacement)(deps, ctx.threadId);
368
+ const blocked = refused(outcome);
369
+ if (blocked)
370
+ return blocked;
371
+ const placed = outcome.placementError === null;
372
+ return result(ctx, placed ? "Het concept staat nu in Outlook." : "Het concept kon nog steeds niet in Outlook worden gezet.");
373
+ }
374
+ async function assign(ctx) {
375
+ await loadThread(ctx.threadId);
376
+ await inbox.setAssignee(ctx.threadId, person(ctx.staff));
377
+ return result(ctx, "Ticket aan jou toegewezen.");
378
+ }
379
+ async function unassign(ctx) {
380
+ await loadThread(ctx.threadId);
381
+ await inbox.setAssignee(ctx.threadId, null);
382
+ return result(ctx, "Toewijzing weggehaald.");
383
+ }
384
+ async function waiting(ctx) {
385
+ await loadThread(ctx.threadId);
386
+ await (0, sending_1.markWaiting)(deps, ctx.threadId);
387
+ return result(ctx, "Ticket wacht nu op de klant.");
388
+ }
389
+ async function close(ctx) {
390
+ await loadThread(ctx.threadId);
391
+ await (0, sending_1.closeThread)(deps, ctx.threadId);
392
+ return result(ctx, "Ticket gesloten.");
393
+ }
394
+ async function unignore(ctx) {
395
+ const outcome = await (0, drafting_1.unignoreThread)(deps, ctx.threadId);
396
+ const next = deps.drafter ? "Er wordt een concept geschreven." : "Schrijf zelf het antwoord.";
397
+ return refused(outcome) ?? result(ctx, `Ticket teruggezet. ${next}`);
398
+ }
399
+ async function repliedExternally(ctx) {
400
+ const outcome = await (0, sending_1.markRepliedExternally)(deps, ctx.threadId);
401
+ return refused(outcome) ?? result(ctx, "Gemarkeerd als beantwoord in Outlook.");
402
+ }
403
+ function canAttach(mailbox) {
404
+ return Boolean(deps.attachments && deps.gatewayFor(mailbox).addAttachment);
405
+ }
406
+ async function replyAttachmentViews(threadId, intent) {
407
+ if (!deps.attachments)
408
+ return [];
409
+ const rows = await deps.attachments.store.listForThread(threadId);
410
+ return rows
411
+ .filter((a) => a.sentIntentId === null || (intent?.status === "sent" && a.sentIntentId === intent.id))
412
+ .map((a) => ({ id: a.id, name: a.name, size: a.size, sent: a.sentIntentId !== null }));
413
+ }
414
+ async function uploadTarget(ctx) {
415
+ const body = await readJson(ctx.request);
416
+ const name = typeof body.name === "string" ? body.name.trim() : "";
417
+ const contentType = typeof body.contentType === "string" && body.contentType.trim() ? body.contentType.trim() : "application/octet-stream";
418
+ const size = body.size;
419
+ if (!name || name.length > 255 || contentType.length > 255 || typeof size !== "number" || !Number.isInteger(size) || size < 1) {
420
+ throw new RouteError(400, "invalid_input");
421
+ }
422
+ await loadThread(ctx.threadId);
423
+ const target = await (0, reply_attachments_1.startReplyUpload)(deps, { threadId: ctx.threadId, staffId: ctx.staff.id, name, contentType, size });
424
+ return json(200, target);
425
+ }
426
+ async function attach(ctx) {
427
+ const body = await readJson(ctx.request);
428
+ if (typeof body.uploadToken !== "string" || body.uploadToken === "" || body.uploadToken.length > 4000) {
429
+ throw new RouteError(400, "invalid_input");
430
+ }
431
+ await loadThread(ctx.threadId);
432
+ const added = await (0, reply_attachments_1.addReplyAttachment)(deps, { threadId: ctx.threadId, staffId: ctx.staff.id, uploadToken: body.uploadToken });
433
+ return result(ctx, `${added.name} is toegevoegd.`);
434
+ }
435
+ async function detach(ctx) {
436
+ await loadThread(ctx.threadId);
437
+ await (0, reply_attachments_1.removeReplyAttachment)(deps, { threadId: ctx.threadId, attachmentId: ctx.attachmentId });
438
+ return result(ctx, "De bijlage is verwijderd.");
439
+ }
440
+ async function dismiss(ctx) {
441
+ const outcome = await (0, sending_1.dismissThread)(deps, { threadId: ctx.threadId, dismissedBy: ctx.staff.id });
442
+ return refused(outcome) ?? result(ctx, "Gemarkeerd als geen antwoord nodig. Het ticket staat nu bij Genegeerd.");
443
+ }
444
+ async function resolveSend(ctx) {
445
+ const body = await readJson(ctx.request);
446
+ if (body.outcome !== "sent" && body.outcome !== "not_sent")
447
+ throw new RouteError(400, "invalid_input");
448
+ await loadThread(ctx.threadId);
449
+ await (0, sending_1.resolveUnknownSend)(deps, { threadId: ctx.threadId, outcome: body.outcome });
450
+ return result(ctx, body.outcome === "sent" ? "Vastgelegd: het antwoord is verstuurd." : "Vastgelegd: niet verstuurd. Je kunt het opnieuw versturen.");
451
+ }
452
+ const threadActions = {
453
+ "": { GET: show },
454
+ draft: { PUT: saveDraft },
455
+ regenerate: { POST: regenerate },
456
+ send: { POST: send },
457
+ "retry-placement": { POST: retry },
458
+ assign: { POST: assign, DELETE: unassign },
459
+ waiting: { POST: waiting },
460
+ close: { POST: close },
461
+ unignore: { POST: unignore },
462
+ "replied-externally": { POST: repliedExternally },
463
+ dismiss: { POST: dismiss },
464
+ "resolve-send": { POST: resolveSend },
465
+ attachments: { POST: attach },
466
+ };
467
+ function route(path) {
468
+ const parts = path.split("/").map((p) => {
469
+ try {
470
+ return decodeURIComponent(p);
471
+ }
472
+ catch {
473
+ return "";
474
+ }
475
+ });
476
+ if (parts.some((p) => p === "") || parts[0] !== "threads")
477
+ return null;
478
+ if (parts.length === 1)
479
+ return { methods: { GET: list }, threadId: "", messageId: "", attachmentId: "" };
480
+ const threadId = parts[1];
481
+ if (parts.length === 4 && parts[2] === "messages") {
482
+ return { methods: { GET: messageBody }, threadId, messageId: parts[3], attachmentId: "" };
483
+ }
484
+ if (parts.length === 6 && parts[2] === "messages" && parts[4] === "attachments") {
485
+ return { methods: { GET: attachment }, threadId, messageId: parts[3], attachmentId: parts[5] };
486
+ }
487
+ if (parts.length === 4 && parts[2] === "attachments") {
488
+ const attachmentId = parts[3];
489
+ const methods = attachmentId === "upload" ? { POST: uploadTarget } : { DELETE: detach };
490
+ return { methods, threadId, messageId: "", attachmentId };
491
+ }
492
+ if (parts.length > 3)
493
+ return null;
494
+ const methods = threadActions[parts[2] ?? ""];
495
+ return methods ? { methods, threadId, messageId: "", attachmentId: "" } : null;
496
+ }
497
+ function toResponse(error) {
498
+ if (error instanceof RouteError)
499
+ return fail(error.status, error.code, error.extra);
500
+ if (error instanceof errors_1.HelpdeskError) {
501
+ const mapped = DOMAIN_ERRORS[error.code];
502
+ if (mapped)
503
+ return fail(mapped[0], mapped[1]);
504
+ }
505
+ onError(error);
506
+ return fail(500, "internal");
507
+ }
508
+ async function handle(request) {
509
+ const url = new URL(request.url);
510
+ const sub = url.pathname.startsWith(`${basePath}/`) ? url.pathname.slice(basePath.length + 1).replace(/\/$/, "") : null;
511
+ const matched = sub === null ? null : route(sub);
512
+ if (!matched)
513
+ return fail(404, "not_found");
514
+ const handler = matched.methods[request.method.toUpperCase()];
515
+ if (!handler)
516
+ return fail(405, "method_not_allowed");
517
+ if (!config.isProduction)
518
+ return fail(403, "not_production");
519
+ const allowedOrigin = config.appOrigin ? new URL(config.appOrigin).origin : url.origin;
520
+ const origin = request.headers.get("origin");
521
+ if (request.method !== "GET" && origin !== null && origin !== allowedOrigin)
522
+ return fail(403, "forbidden_origin");
523
+ try {
524
+ const staff = await config.getStaff(request);
525
+ if (!staff)
526
+ return fail(401, "unauthorized");
527
+ const { threadId, messageId, attachmentId } = matched;
528
+ return await handler({ request, staff, threadId, messageId, attachmentId });
529
+ }
530
+ catch (error) {
531
+ return toResponse(error);
532
+ }
533
+ }
534
+ return { handle };
535
+ }
@@ -0,0 +1,35 @@
1
+ import type { MailboxWriteMode, ReviewReason, TicketState } from "../types";
2
+ export declare const HELPDESK_BASE_PATH = "/api/helpdesk";
3
+ export declare const INBOX_ERROR_MESSAGES: {
4
+ readonly not_production: "De helpdesk werkt alleen in de productieomgeving van de app.";
5
+ readonly unauthorized: "Je hebt geen toegang tot de helpdesk. Log in met een medewerkersaccount.";
6
+ readonly forbidden_origin: "Dit verzoek komt niet van de app zelf en is geweigerd.";
7
+ readonly invalid_input: "Controleer de ingevulde gegevens.";
8
+ readonly not_found: "Onbekende route.";
9
+ readonly method_not_allowed: "Deze methode is hier niet toegestaan.";
10
+ readonly thread_not_found: "Dit ticket bestaat niet (meer).";
11
+ readonly message_not_found: "Dit bericht hoort niet bij dit ticket.";
12
+ readonly message_unavailable: "Dit bericht staat niet meer in de mailbox.";
13
+ readonly attachment_not_found: "Deze bijlage staat niet (meer) bij dit bericht.";
14
+ readonly attachment_unavailable: "Deze bijlage kan alleen in Outlook worden geopend.";
15
+ readonly attachments_unavailable: "Bij dit antwoord kun je nu geen bijlagen toevoegen.";
16
+ readonly attachment_limit: "Een antwoord mag samen hoogstens 20 MB aan bijlagen hebben, en hoogstens 10 bestanden.";
17
+ readonly reply_attachment_not_found: "Deze bijlage staat niet (meer) bij het antwoord.";
18
+ readonly paused: "De helpdesk staat op pauze. Er wordt niets opgehaald of verstuurd tot de pauze voorbij is.";
19
+ readonly write_blocked: "Deze mailbox staat op alleen lezen. Antwoorden kan pas als de mailbox daarvoor is vrijgegeven.";
20
+ readonly mailbox_not_in_scope: "Deze mailbox hoort niet bij de helpdesk.";
21
+ readonly stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit concept. Lees die eerst, of verstuur toch.";
22
+ readonly draft_not_ready: "Er is nog geen concept dat verstuurd of aangepast kan worden.";
23
+ readonly invalid_transition: "Dit kan niet in de huidige status van het ticket. Vernieuw de pagina.";
24
+ readonly stale_thread: "Het ticket is net gewijzigd. Vernieuw de pagina en probeer het opnieuw.";
25
+ readonly quote_not_found: "De eerdere mail in het concept is niet terug te vinden, daarom is de wijziging niet opgeslagen. Pas het concept aan in Outlook.";
26
+ readonly empty_reply: "Het antwoord is leeg. Schrijf eerst een antwoord; alleen de eerdere mail wordt nooit verstuurd.";
27
+ readonly send_in_progress: "Er wordt net een antwoord verstuurd. Vernieuw zo de pagina en kijk of het nog nodig is.";
28
+ readonly send_outcome_unknown: "Het is niet zeker of het vorige antwoord is verstuurd. Geef eerst aan wat je in Outlook bij Verzonden items ziet.";
29
+ readonly send_failed: "Microsoft heeft het antwoord geweigerd. Er is niets verstuurd; probeer het opnieuw.";
30
+ readonly internal: "Er ging iets mis. Probeer het opnieuw.";
31
+ };
32
+ export type InboxErrorCode = keyof typeof INBOX_ERROR_MESSAGES;
33
+ export declare const STATE_LABELS: Record<TicketState, string>;
34
+ export declare const REVIEW_REASON_LABELS: Record<ReviewReason, string>;
35
+ export declare const WRITE_MODE_LABELS: Record<MailboxWriteMode, string>;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WRITE_MODE_LABELS = exports.REVIEW_REASON_LABELS = exports.STATE_LABELS = exports.INBOX_ERROR_MESSAGES = exports.HELPDESK_BASE_PATH = void 0;
4
+ exports.HELPDESK_BASE_PATH = "/api/helpdesk";
5
+ exports.INBOX_ERROR_MESSAGES = {
6
+ not_production: "De helpdesk werkt alleen in de productieomgeving van de app.",
7
+ unauthorized: "Je hebt geen toegang tot de helpdesk. Log in met een medewerkersaccount.",
8
+ forbidden_origin: "Dit verzoek komt niet van de app zelf en is geweigerd.",
9
+ invalid_input: "Controleer de ingevulde gegevens.",
10
+ not_found: "Onbekende route.",
11
+ method_not_allowed: "Deze methode is hier niet toegestaan.",
12
+ thread_not_found: "Dit ticket bestaat niet (meer).",
13
+ message_not_found: "Dit bericht hoort niet bij dit ticket.",
14
+ message_unavailable: "Dit bericht staat niet meer in de mailbox.",
15
+ attachment_not_found: "Deze bijlage staat niet (meer) bij dit bericht.",
16
+ attachment_unavailable: "Deze bijlage kan alleen in Outlook worden geopend.",
17
+ attachments_unavailable: "Bij dit antwoord kun je nu geen bijlagen toevoegen.",
18
+ attachment_limit: "Een antwoord mag samen hoogstens 20 MB aan bijlagen hebben, en hoogstens 10 bestanden.",
19
+ reply_attachment_not_found: "Deze bijlage staat niet (meer) bij het antwoord.",
20
+ paused: "De helpdesk staat op pauze. Er wordt niets opgehaald of verstuurd tot de pauze voorbij is.",
21
+ write_blocked: "Deze mailbox staat op alleen lezen. Antwoorden kan pas als de mailbox daarvoor is vrijgegeven.",
22
+ mailbox_not_in_scope: "Deze mailbox hoort niet bij de helpdesk.",
23
+ stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit concept. Lees die eerst, of verstuur toch.",
24
+ draft_not_ready: "Er is nog geen concept dat verstuurd of aangepast kan worden.",
25
+ invalid_transition: "Dit kan niet in de huidige status van het ticket. Vernieuw de pagina.",
26
+ stale_thread: "Het ticket is net gewijzigd. Vernieuw de pagina en probeer het opnieuw.",
27
+ quote_not_found: "De eerdere mail in het concept is niet terug te vinden, daarom is de wijziging niet opgeslagen. Pas het concept aan in Outlook.",
28
+ empty_reply: "Het antwoord is leeg. Schrijf eerst een antwoord; alleen de eerdere mail wordt nooit verstuurd.",
29
+ send_in_progress: "Er wordt net een antwoord verstuurd. Vernieuw zo de pagina en kijk of het nog nodig is.",
30
+ send_outcome_unknown: "Het is niet zeker of het vorige antwoord is verstuurd. Geef eerst aan wat je in Outlook bij Verzonden items ziet.",
31
+ send_failed: "Microsoft heeft het antwoord geweigerd. Er is niets verstuurd; probeer het opnieuw.",
32
+ internal: "Er ging iets mis. Probeer het opnieuw.",
33
+ };
34
+ exports.STATE_LABELS = {
35
+ new: "Nieuw",
36
+ ignored: "Genegeerd",
37
+ drafted: "Concept wordt gemaakt",
38
+ review: "Te beoordelen",
39
+ sent: "Verstuurd",
40
+ waiting: "Wacht op klant",
41
+ closed: "Gesloten",
42
+ };
43
+ exports.REVIEW_REASON_LABELS = {
44
+ low_confidence: "Concept twijfelachtig",
45
+ policy: "Controle nodig",
46
+ stale_draft: "Nieuwe mail na concept",
47
+ drafter_failed: "Concept mislukt",
48
+ placement_failed: "Niet in Outlook gezet",
49
+ staff_edited: "Aangepast",
50
+ no_draft: "Zelf antwoorden",
51
+ delivery_failed: "Niet afgeleverd",
52
+ };
53
+ exports.WRITE_MODE_LABELS = {
54
+ read_only: "Alleen lezen",
55
+ drafts_in_app: "Concepten in de app",
56
+ drafts_in_outlook: "Concepten in Outlook",
57
+ };
@@ -0,0 +1 @@
1
+ export declare function htmlToPlainText(html: string): string;