@pithy-sh/support 0.5.0 → 0.6.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 +8 -0
- package/dist/audit/actions.d.ts +9 -0
- package/dist/audit/actions.d.ts.map +1 -1
- package/dist/audit/actions.js +9 -0
- package/dist/capability.d.ts +2 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +30 -6
- package/dist/config/config.d.ts +63 -2
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +63 -5
- package/dist/data/assignment.d.ts +43 -0
- package/dist/data/assignment.d.ts.map +1 -0
- package/dist/data/assignment.js +38 -0
- package/dist/data/embedding.d.ts +21 -0
- package/dist/data/embedding.d.ts.map +1 -0
- package/dist/data/embedding.js +23 -0
- package/dist/data/tables.d.ts +28 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/tables.js +10 -2
- package/dist/data/thread.d.ts +9 -4
- package/dist/data/thread.d.ts.map +1 -1
- package/dist/data/thread.js +9 -4
- package/dist/http/guards.d.ts +32 -14
- package/dist/http/guards.d.ts.map +1 -1
- package/dist/http/guards.js +44 -16
- package/dist/http/handlers.d.ts +35 -2
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +81 -8
- package/dist/http/resolve.d.ts +8 -0
- package/dist/http/resolve.d.ts.map +1 -1
- package/dist/http/resolve.js +20 -1
- package/dist/http/responses.d.ts +355 -0
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +64 -2
- package/dist/http/routes.d.ts +25 -11
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -5
- package/dist/http/schemas.d.ts +67 -3
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +44 -5
- package/dist/http/scopes.d.ts +10 -5
- package/dist/http/scopes.d.ts.map +1 -1
- package/dist/http/scopes.js +42 -6
- package/dist/http/views.d.ts +9 -1
- package/dist/http/views.d.ts.map +1 -1
- package/dist/http/views.js +40 -1
- package/dist/inbound/ingest.d.ts +1 -1
- package/dist/inbound/ingest.d.ts.map +1 -1
- package/dist/inbound/ingest.js +3 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/migrations/0002_assignment.d.ts +20 -0
- package/dist/migrations/0002_assignment.d.ts.map +1 -0
- package/dist/migrations/0002_assignment.js +45 -0
- package/dist/migrations/0003_embeddings.d.ts +15 -0
- package/dist/migrations/0003_embeddings.d.ts.map +1 -0
- package/dist/migrations/0003_embeddings.js +23 -0
- package/dist/migrations/set.d.ts +13 -0
- package/dist/migrations/set.d.ts.map +1 -0
- package/dist/migrations/set.js +21 -0
- package/dist/presence/room.d.ts +35 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +66 -0
- package/dist/provision/provisionSupport.d.ts +32 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -1
- package/dist/provision/provisionSupport.js +10 -2
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
- package/dist/provision/resolveSupportConfig.js +13 -2
- package/dist/purge/contribution.d.ts +49 -0
- package/dist/purge/contribution.d.ts.map +1 -0
- package/dist/purge/contribution.js +164 -0
- package/dist/secret/registry.d.ts +26 -2
- package/dist/secret/registry.d.ts.map +1 -1
- package/dist/secret/registry.js +15 -3
- package/dist/semantic/documents.d.ts +21 -0
- package/dist/semantic/documents.d.ts.map +1 -0
- package/dist/semantic/documents.js +128 -0
- package/dist/semantic/embed.d.ts +59 -0
- package/dist/semantic/embed.d.ts.map +1 -0
- package/dist/semantic/embed.js +59 -0
- package/dist/semantic/messageIndex.d.ts +46 -0
- package/dist/semantic/messageIndex.d.ts.map +1 -0
- package/dist/semantic/messageIndex.js +67 -0
- package/dist/semantic/query.d.ts +37 -0
- package/dist/semantic/query.d.ts.map +1 -0
- package/dist/semantic/query.js +46 -0
- package/dist/staff/staff.d.ts +22 -0
- package/dist/staff/staff.d.ts.map +1 -0
- package/dist/staff/staff.js +64 -0
- package/dist/store/assignment.d.ts +63 -0
- package/dist/store/assignment.d.ts.map +1 -0
- package/dist/store/assignment.js +86 -0
- package/dist/store/hybrid.d.ts +134 -0
- package/dist/store/hybrid.d.ts.map +1 -0
- package/dist/store/hybrid.js +185 -0
- package/dist/store/paging.d.ts +10 -0
- package/dist/store/paging.d.ts.map +1 -1
- package/dist/store/paging.js +11 -1
- package/dist/store/provisionIndex.d.ts +21 -0
- package/dist/store/provisionIndex.d.ts.map +1 -0
- package/dist/store/provisionIndex.js +36 -0
- package/dist/store/search.d.ts +18 -9
- package/dist/store/search.d.ts.map +1 -1
- package/dist/store/search.js +36 -34
- package/dist/store/searchIndex.d.ts +4 -4
- package/dist/store/searchIndex.js +4 -4
- package/dist/store/threads.d.ts +174 -8
- package/dist/store/threads.d.ts.map +1 -1
- package/dist/store/threads.js +33 -40
- package/dist/submission/submit.d.ts +1 -1
- package/dist/submission/submit.d.ts.map +1 -1
- package/dist/submission/submit.js +2 -1
- package/dist/test-utils/seed.d.ts +33 -0
- package/dist/test-utils/seed.d.ts.map +1 -0
- package/dist/test-utils/seed.js +102 -0
- package/dist/test-utils/semantic.d.ts +21 -0
- package/dist/test-utils/semantic.d.ts.map +1 -0
- package/dist/test-utils/semantic.js +107 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflows/specs.d.ts +23 -0
- package/dist/workflows/specs.d.ts.map +1 -1
- package/dist/workflows/specs.js +27 -7
- package/dist/workflows/worker.d.ts +15 -2
- package/dist/workflows/worker.d.ts.map +1 -1
- package/dist/workflows/worker.js +48 -4
- package/package.json +9 -7
- package/pithy.manifest.json +21 -3
- package/src/audit/actions.ts +9 -0
- package/src/capability.ts +43 -4
- package/src/config/config.ts +104 -5
- package/src/data/assignment.ts +55 -0
- package/src/data/embedding.ts +27 -0
- package/src/data/tables.ts +8 -0
- package/src/data/thread.ts +26 -4
- package/src/http/guards.ts +52 -15
- package/src/http/handlers.ts +125 -4
- package/src/http/resolve.ts +19 -0
- package/src/http/responses.ts +154 -0
- package/src/http/routes.ts +98 -12
- package/src/http/schemas.ts +117 -28
- package/src/http/scopes.ts +43 -6
- package/src/http/views.ts +44 -0
- package/src/inbound/ingest.ts +5 -2
- package/src/index.ts +5 -0
- package/src/migrations/0002_assignment.ts +65 -0
- package/src/migrations/0003_embeddings.ts +29 -0
- package/src/migrations/set.ts +21 -0
- package/src/presence/room.ts +94 -0
- package/src/provision/provisionSupport.ts +33 -1
- package/src/provision/resolveSupportConfig.ts +24 -13
- package/src/purge/contribution.ts +294 -0
- package/src/secret/registry.ts +17 -2
- package/src/semantic/documents.ts +178 -0
- package/src/semantic/embed.ts +99 -0
- package/src/semantic/messageIndex.ts +74 -0
- package/src/semantic/query.ts +66 -0
- package/src/staff/staff.ts +128 -0
- package/src/store/assignment.ts +179 -0
- package/src/store/hybrid.ts +314 -0
- package/src/store/paging.ts +14 -0
- package/src/store/provisionIndex.ts +46 -0
- package/src/store/search.ts +58 -37
- package/src/store/searchIndex.ts +4 -4
- package/src/store/threads.ts +91 -91
- package/src/submission/submit.ts +3 -2
- package/src/test-utils/seed.ts +141 -0
- package/src/test-utils/semantic.ts +110 -0
- package/src/version.generated.ts +1 -1
- package/src/workflows/specs.ts +34 -0
- package/src/workflows/worker.ts +84 -8
- package/src/workflows/wrangler.jsonc +8 -3
package/src/http/responses.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { asRead } from "@pithy-sh/core/src/projection/asRead";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { SupportAssignmentActorType } from "../data/assignment";
|
|
6
7
|
import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
|
|
7
8
|
import {
|
|
8
9
|
SupportAccountLinkSource,
|
|
@@ -109,6 +110,34 @@ export const SupportThreadView = z
|
|
|
109
110
|
.describe(
|
|
110
111
|
"The control-plane subject that archived it, for convenience only — the answer to 'who marked this done' comes from the audit trail.",
|
|
111
112
|
),
|
|
113
|
+
// Optional as well as nullable (#450, #816): absent says *this Worker predates assignment*, which a client
|
|
114
|
+
// must not read as *nobody has it*.
|
|
115
|
+
assigneeRef: z
|
|
116
|
+
.string()
|
|
117
|
+
.nullable()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(
|
|
120
|
+
"Who has this conversation, as the opaque reference it was assigned to, or null when nobody has it. Absent from a Worker that predates assignment. The listing's `assignee` filter matches it.",
|
|
121
|
+
),
|
|
122
|
+
assigneeName: z
|
|
123
|
+
.string()
|
|
124
|
+
.nullable()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe(
|
|
127
|
+
"How the assignee is shown, as the assigner named them; null when nobody has it. A label, never an identity — `assigneeRef` is that. Untrusted text; render it escaped.",
|
|
128
|
+
),
|
|
129
|
+
assignedAt: z.iso
|
|
130
|
+
.datetime()
|
|
131
|
+
.nullable()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("When the current assignment was made, ISO-8601; null when nobody has it."),
|
|
134
|
+
assignedBy: z
|
|
135
|
+
.string()
|
|
136
|
+
.nullable()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe(
|
|
139
|
+
"Who made the current assignment — a control-plane subject, or a user of the app's id; null when nobody has it. Every earlier change is in the thread's `assignments`.",
|
|
140
|
+
),
|
|
112
141
|
messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
|
|
113
142
|
firstMessageAt: z.iso.datetime().describe("When the thread opened, ISO-8601."),
|
|
114
143
|
lastMessageAt: z.iso.datetime().describe("When the thread last moved, ISO-8601. The inbox sorts on this."),
|
|
@@ -281,6 +310,98 @@ export const SupportThreadsResponse = z
|
|
|
281
310
|
.describe("A page of the inbox, as one viewer sees it.");
|
|
282
311
|
export type SupportThreadsResponse = z.output<typeof SupportThreadsResponse>;
|
|
283
312
|
|
|
313
|
+
/** A search backend, by name. */
|
|
314
|
+
const SupportSearchBackend = z
|
|
315
|
+
.enum(["fts", "like", "semantic"])
|
|
316
|
+
.describe("A search backend: `fts` (the FTS5 index), `like` (the keyword scan), or `semantic` (the message index).");
|
|
317
|
+
|
|
318
|
+
/** One search result. */
|
|
319
|
+
export const SupportSearchResultView = z
|
|
320
|
+
.object({
|
|
321
|
+
thread: SupportListedThreadView.describe("The thread, carrying the calling viewer's own read and snooze state."),
|
|
322
|
+
messageId: z
|
|
323
|
+
.string()
|
|
324
|
+
.describe(
|
|
325
|
+
"The message in the thread that matched best — the one the scores below belong to. Open the thread at it.",
|
|
326
|
+
),
|
|
327
|
+
bm25: z
|
|
328
|
+
.number()
|
|
329
|
+
.optional()
|
|
330
|
+
.describe(
|
|
331
|
+
"That message's FTS5 `bm25`, raw: lower is better, and comparable only within one search. Absent when FTS5 did not run or did not match it — including on the `LIKE` scan, which has no score.",
|
|
332
|
+
),
|
|
333
|
+
similarity: z
|
|
334
|
+
.number()
|
|
335
|
+
.optional()
|
|
336
|
+
.describe(
|
|
337
|
+
"That message's similarity to the query in the semantic index, raw: higher is closer. Absent when semantic search did not run or did not match it. Never normalized against `bm25` — the two are reported as they are.",
|
|
338
|
+
),
|
|
339
|
+
})
|
|
340
|
+
.describe("One thread a search found, named by its best-matching message, with that message's raw scores.");
|
|
341
|
+
export type SupportSearchResultView = z.output<typeof SupportSearchResultView>;
|
|
342
|
+
|
|
343
|
+
/** `GET {base}/threads/search`. */
|
|
344
|
+
export const SupportSearchResponse = z
|
|
345
|
+
.object({
|
|
346
|
+
mode: z
|
|
347
|
+
.enum(["keyword", "semantic", "hybrid"])
|
|
348
|
+
.describe("The mode that ran: the one asked for, or `hybrid` when none was."),
|
|
349
|
+
backends: z
|
|
350
|
+
.object({
|
|
351
|
+
ran: z.array(SupportSearchBackend).describe("The backends that answered."),
|
|
352
|
+
skipped: z
|
|
353
|
+
.array(
|
|
354
|
+
z
|
|
355
|
+
.object({
|
|
356
|
+
backend: SupportSearchBackend,
|
|
357
|
+
reason: z
|
|
358
|
+
.enum(["unprovisioned", "failed"])
|
|
359
|
+
.describe(
|
|
360
|
+
"`unprovisioned` — on in config, and its index or binding is not there until `pithy support provision` runs. `failed` — it was there and did not answer.",
|
|
361
|
+
),
|
|
362
|
+
})
|
|
363
|
+
.describe("A backend that was on and did not answer."),
|
|
364
|
+
)
|
|
365
|
+
.describe(
|
|
366
|
+
"The backends that were on and did not answer. Empty when every one did. A skip degrades the search, never fails it.",
|
|
367
|
+
),
|
|
368
|
+
})
|
|
369
|
+
.describe("Which backends ran, and which were skipped."),
|
|
370
|
+
count: z
|
|
371
|
+
.number()
|
|
372
|
+
.int()
|
|
373
|
+
.min(0)
|
|
374
|
+
.describe(
|
|
375
|
+
"How many results came back. Under the limit when the filters left fewer — that is the true count, not a page.",
|
|
376
|
+
),
|
|
377
|
+
results: z
|
|
378
|
+
.array(SupportSearchResultView)
|
|
379
|
+
.describe("The threads, best first: fused by rank, newest first on a tie."),
|
|
380
|
+
})
|
|
381
|
+
.describe("A ranked search over the inbox. No cursor: a ranked list has a top, not pages.");
|
|
382
|
+
export type SupportSearchResponse = z.output<typeof SupportSearchResponse>;
|
|
383
|
+
|
|
384
|
+
/** One change of a thread's assignee, as its history shows it. */
|
|
385
|
+
export const SupportAssignmentView = z
|
|
386
|
+
.object({
|
|
387
|
+
id: z.string().describe("The history row's id."),
|
|
388
|
+
assigneeRef: z
|
|
389
|
+
.string()
|
|
390
|
+
.nullable()
|
|
391
|
+
.describe("Who had the thread after this change, or null when it was unassigned."),
|
|
392
|
+
assigneeName: z.string().nullable().describe("How that assignee was shown at the time, or null."),
|
|
393
|
+
previousRef: z.string().nullable().describe("Who had it before, or null when nobody did."),
|
|
394
|
+
previousName: z.string().nullable().describe("How the previous assignee was shown, or null."),
|
|
395
|
+
actorType: SupportAssignmentActorType.describe(
|
|
396
|
+
"How whoever made the change was verified: `control-plane` for a management client, `user` for a signed-in user of the app.",
|
|
397
|
+
),
|
|
398
|
+
assignedBy: z.string().describe("Who made the change — a control-plane subject, or a user of the app's id."),
|
|
399
|
+
note: z.string().nullable().describe("Why, in the assigner's words, or null. Untrusted text; render it escaped."),
|
|
400
|
+
createdAt: z.iso.datetime().describe("When the change was made, ISO-8601."),
|
|
401
|
+
})
|
|
402
|
+
.describe("One append-only change of a conversation's assignee.");
|
|
403
|
+
export type SupportAssignmentView = z.output<typeof SupportAssignmentView>;
|
|
404
|
+
|
|
284
405
|
/** `GET {base}/threads/:id`. */
|
|
285
406
|
export const SupportThreadResponse = z
|
|
286
407
|
.object({
|
|
@@ -289,6 +410,13 @@ export const SupportThreadResponse = z
|
|
|
289
410
|
attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
|
|
290
411
|
sender: SenderContextView.describe("What the app already knows about the sender."),
|
|
291
412
|
replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category."),
|
|
413
|
+
// Optional (#450): absent from a Worker that predates assignment.
|
|
414
|
+
assignments: z
|
|
415
|
+
.array(SupportAssignmentView)
|
|
416
|
+
.optional()
|
|
417
|
+
.describe(
|
|
418
|
+
"Every change of the assignee, oldest first. Empty when nobody was ever assigned; absent from a Worker that predates assignment.",
|
|
419
|
+
),
|
|
292
420
|
})
|
|
293
421
|
.describe("A whole conversation, with the customer context and the replies worth offering on it.");
|
|
294
422
|
export type SupportThreadResponse = z.output<typeof SupportThreadResponse>;
|
|
@@ -333,6 +461,24 @@ export const SupportReplySentResponse = z
|
|
|
333
461
|
.describe("The reply that was filed, and which of the two ways it reaches the customer.");
|
|
334
462
|
export type SupportReplySentResponse = z.output<typeof SupportReplySentResponse>;
|
|
335
463
|
|
|
464
|
+
/**
|
|
465
|
+
* `POST {base}/threads/:id/assign` and `POST {base}/staff/threads/:id/assign`.
|
|
466
|
+
*
|
|
467
|
+
* The thread as the assignment left it, and whether anything changed: assigning the current assignee again
|
|
468
|
+
* is a success that wrote nothing, and a pane that re-renders from it should know not to say *reassigned*.
|
|
469
|
+
*/
|
|
470
|
+
export const SupportAssignResponse = z
|
|
471
|
+
.object({
|
|
472
|
+
thread: SupportThreadView.describe("The conversation, as the assignment left it."),
|
|
473
|
+
changed: z
|
|
474
|
+
.boolean()
|
|
475
|
+
.describe(
|
|
476
|
+
"Whether the assignee changed. False when the thread already had that assignee, or had none and was unassigned — nothing was recorded and nothing was audited.",
|
|
477
|
+
),
|
|
478
|
+
})
|
|
479
|
+
.describe("The conversation after an assignment, and whether it changed anything.");
|
|
480
|
+
export type SupportAssignResponse = z.output<typeof SupportAssignResponse>;
|
|
481
|
+
|
|
336
482
|
/** `POST {base}/threads/:id/reclassify`. */
|
|
337
483
|
export const SupportReclassifiedResponse = z
|
|
338
484
|
.object({
|
|
@@ -399,6 +545,10 @@ export type SupportMessageViewAsRead = z.output<typeof SupportMessageViewAsRead>
|
|
|
399
545
|
export const SupportThreadsResponseAsRead = asRead(SupportThreadsResponse);
|
|
400
546
|
export type SupportThreadsResponseAsRead = z.output<typeof SupportThreadsResponseAsRead>;
|
|
401
547
|
|
|
548
|
+
/** `GET {base}/threads/search`, as a client that does not control the Worker must read it. */
|
|
549
|
+
export const SupportSearchResponseAsRead = asRead(SupportSearchResponse);
|
|
550
|
+
export type SupportSearchResponseAsRead = z.output<typeof SupportSearchResponseAsRead>;
|
|
551
|
+
|
|
402
552
|
/** `GET {base}/threads/:id`, as a client that does not control the Worker must read it. */
|
|
403
553
|
export const SupportThreadResponseAsRead = asRead(SupportThreadResponse);
|
|
404
554
|
export type SupportThreadResponseAsRead = z.output<typeof SupportThreadResponseAsRead>;
|
|
@@ -407,6 +557,10 @@ export type SupportThreadResponseAsRead = z.output<typeof SupportThreadResponseA
|
|
|
407
557
|
export const SupportArchiveResponseAsRead = asRead(SupportArchiveResponse);
|
|
408
558
|
export type SupportArchiveResponseAsRead = z.output<typeof SupportArchiveResponseAsRead>;
|
|
409
559
|
|
|
560
|
+
/** `POST {base}/threads/:id/assign`, as a client that does not control the Worker must read it. */
|
|
561
|
+
export const SupportAssignResponseAsRead = asRead(SupportAssignResponse);
|
|
562
|
+
export type SupportAssignResponseAsRead = z.output<typeof SupportAssignResponseAsRead>;
|
|
563
|
+
|
|
410
564
|
/**
|
|
411
565
|
* ## The submitter's own view, and what it deliberately omits
|
|
412
566
|
*
|
package/src/http/routes.ts
CHANGED
|
@@ -8,9 +8,11 @@ import { describeRoute } from "@pithy-sh/core/src/http/routeSpec";
|
|
|
8
8
|
import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
|
|
9
9
|
import { validate } from "@pithy-sh/core/src/http/validation";
|
|
10
10
|
import type { Context, Hono } from "hono";
|
|
11
|
-
import {
|
|
11
|
+
import type { SupportStaffConfig } from "../config/config";
|
|
12
|
+
import { requireAuth, requireStaff } from "./guards";
|
|
12
13
|
import {
|
|
13
14
|
archiveConversation,
|
|
15
|
+
assignConversation,
|
|
14
16
|
type HandlerDeps,
|
|
15
17
|
listInbox,
|
|
16
18
|
listMyThreads,
|
|
@@ -19,33 +21,39 @@ import {
|
|
|
19
21
|
readMyThread,
|
|
20
22
|
reclassifyConversation,
|
|
21
23
|
replyToConversation,
|
|
24
|
+
searchInbox,
|
|
22
25
|
submitFeedbackRequest,
|
|
23
26
|
updateFlags,
|
|
24
27
|
} from "./handlers";
|
|
25
28
|
import {
|
|
26
29
|
SupportArchiveResponse,
|
|
30
|
+
SupportAssignResponse,
|
|
27
31
|
SupportFlagsResponse,
|
|
28
32
|
SupportMyThreadResponse,
|
|
29
33
|
SupportMyThreadsResponse,
|
|
30
34
|
SupportReclassifiedResponse,
|
|
31
35
|
SupportRepliesResponse,
|
|
32
36
|
SupportReplySentResponse,
|
|
37
|
+
SupportSearchResponse,
|
|
33
38
|
SupportSubmissionResponse,
|
|
34
39
|
SupportThreadResponse,
|
|
35
40
|
SupportThreadsResponse,
|
|
36
41
|
} from "./responses";
|
|
37
42
|
import {
|
|
38
43
|
ArchiveThreadInput,
|
|
44
|
+
AssignThreadInput,
|
|
39
45
|
FlagsInput,
|
|
40
46
|
ListThreadsQuery,
|
|
41
47
|
MyThreadsQuery,
|
|
42
48
|
RepliesQuery,
|
|
43
49
|
ReplyInput,
|
|
50
|
+
SearchThreadsQuery,
|
|
44
51
|
SubmitFeedbackInput,
|
|
45
52
|
ThreadIdParam,
|
|
46
53
|
} from "./schemas";
|
|
47
54
|
import {
|
|
48
55
|
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
56
|
+
SUPPORT_THREADS_ASSIGN_SCOPE,
|
|
49
57
|
SUPPORT_THREADS_FLAG_SCOPE,
|
|
50
58
|
SUPPORT_THREADS_READ_SCOPE,
|
|
51
59
|
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
@@ -56,8 +64,10 @@ import {
|
|
|
56
64
|
* The support routes, their verification strategies, and what each accepts:
|
|
57
65
|
*
|
|
58
66
|
* GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
|
|
67
|
+
* GET /support/threads/search → search (control-plane: support:threads:read) query: SearchThreadsQuery
|
|
59
68
|
* GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
|
|
60
69
|
* POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
|
|
70
|
+
* POST /support/threads/:id/assign → who has it (control-plane: support:threads:assign) param + json
|
|
61
71
|
* POST /support/threads/:id/reply → answer (control-plane: support:threads:reply) param + json
|
|
62
72
|
* POST /support/threads/:id/reclassify→ re-run model (control-plane: support:threads:reclassify) param
|
|
63
73
|
* POST /support/threads/:id/flags → read/snooze (control-plane: support:threads:flag) param + json
|
|
@@ -67,20 +77,26 @@ import {
|
|
|
67
77
|
* GET /support/feedback → my requests (bearer/session) query: MyThreadsQuery
|
|
68
78
|
* GET /support/feedback/:id → one of mine (bearer/session) param: ThreadIdParam
|
|
69
79
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
80
|
+
* POST /support/staff/threads/:id/assign → who has it (bearer/session + same-origin + staff assignPower) param + json
|
|
81
|
+
*
|
|
82
|
+
* **Three doors, three gates, and the control plane never stacks with a session.** The management routes
|
|
83
|
+
* answer to a control-plane credential the adopter issued, and with the seam uncomposed every one denies
|
|
84
|
+
* with `controlplane/not_connected` — the correct failure for an inbox holding other people's private
|
|
73
85
|
* correspondence. The `feedback` routes answer to the adopter's own signed-in user, and with no auth
|
|
74
|
-
* capability composed `c.var.auth` is null and every one denies.
|
|
75
|
-
*
|
|
86
|
+
* capability composed `c.var.auth` is null and every one denies. The `staff` route answers to a signed-in
|
|
87
|
+
* user too, and then to `requireStaff()`: a staff power from `support({ staff })`, held in the organization
|
|
88
|
+
* the request acts for, and with no `staff` configured every caller is refused. A control-plane caller
|
|
89
|
+
* holds no session by design and can never satisfy `requireAuth()`; a user's session confers no scope —
|
|
90
|
+
* which is why staff assignment is its own route rather than a second gate on the control-plane one. See
|
|
76
91
|
* `guards.ts`.
|
|
77
92
|
*
|
|
78
|
-
* **`requireSameOrigin()` is on the
|
|
79
|
-
* mutating route CSRF-reachable
|
|
80
|
-
* — a forged submission is somebody else's words attributed to them, in the one place an
|
|
81
|
-
* treats attribution as proven
|
|
82
|
-
*
|
|
83
|
-
*
|
|
93
|
+
* **`requireSameOrigin()` is on the two session routes that write, and on no read.** Cookie-mode sessions
|
|
94
|
+
* make a mutating route CSRF-reachable. The submission route writes into a support inbox under a real
|
|
95
|
+
* customer's name — a forged submission is somebody else's words attributed to them, in the one place an
|
|
96
|
+
* operator treats attribution as proven — and the staff assign route hands a customer's conversation to
|
|
97
|
+
* somebody on a staff member's say-so. The reads are GETs and carry no such risk. A bearer caller is
|
|
98
|
+
* CSRF-exempt, and that exemption belongs to the gate `@pithy-sh/auth` publishes rather than to anything
|
|
99
|
+
* decided here.
|
|
84
100
|
*
|
|
85
101
|
* **Every response has an exported schema**, in `responses.ts`. Each handler's return type is
|
|
86
102
|
* `z.output` of its envelope, so `c.json(await handler(...))` carries the contract without a cast — and
|
|
@@ -158,6 +174,11 @@ export interface SupportRoutesOptions {
|
|
|
158
174
|
submission?: boolean;
|
|
159
175
|
/** Resolve handler deps from the request context. */
|
|
160
176
|
resolveDeps: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
|
|
177
|
+
/**
|
|
178
|
+
* Who of the app's signed-in users works the inbox. Absent, the staff route is still mounted and refuses
|
|
179
|
+
* every caller with 403 — it is not a feature this deployment lacks, it is a door nobody holds a key to.
|
|
180
|
+
*/
|
|
181
|
+
staff?: SupportStaffConfig;
|
|
161
182
|
}
|
|
162
183
|
|
|
163
184
|
/**
|
|
@@ -217,6 +238,20 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
|
|
|
217
238
|
async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
|
|
218
239
|
);
|
|
219
240
|
|
|
241
|
+
// Before `/threads/:id`, so `search` is never read as a thread id.
|
|
242
|
+
app.get(
|
|
243
|
+
`${base}/threads/search`,
|
|
244
|
+
describeRoute({
|
|
245
|
+
summary: "Search the inbox, by words and by meaning, ranked as one.",
|
|
246
|
+
verification: "control-plane",
|
|
247
|
+
scopes: [SUPPORT_THREADS_READ_SCOPE],
|
|
248
|
+
responses: { 200: SupportSearchResponse },
|
|
249
|
+
}),
|
|
250
|
+
requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
|
|
251
|
+
validate("query", SearchThreadsQuery),
|
|
252
|
+
async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("query"), viewer(c))),
|
|
253
|
+
);
|
|
254
|
+
|
|
220
255
|
app.get(
|
|
221
256
|
`${base}/replies`,
|
|
222
257
|
describeRoute({
|
|
@@ -259,6 +294,51 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
|
|
|
259
294
|
c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
|
|
260
295
|
);
|
|
261
296
|
|
|
297
|
+
app.post(
|
|
298
|
+
`${base}/threads/:id/assign`,
|
|
299
|
+
describeRoute({
|
|
300
|
+
summary: "Assign a conversation to somebody, or to nobody.",
|
|
301
|
+
verification: "control-plane",
|
|
302
|
+
scopes: [SUPPORT_THREADS_ASSIGN_SCOPE],
|
|
303
|
+
responses: { 200: SupportAssignResponse },
|
|
304
|
+
}),
|
|
305
|
+
requireControlPlane(SUPPORT_THREADS_ASSIGN_SCOPE),
|
|
306
|
+
validate("param", ThreadIdParam),
|
|
307
|
+
validate("json", AssignThreadInput),
|
|
308
|
+
async (c) =>
|
|
309
|
+
c.json(
|
|
310
|
+
await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
|
|
311
|
+
type: "control-plane",
|
|
312
|
+
id: viewer(c),
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// The same act for a signed-in user of the app who works the inbox: their session, the CSRF gate a
|
|
318
|
+
// cookie-mode session needs on any write, and support's staff power in the organization they act for
|
|
319
|
+
// (`staff.ts`). A control-plane caller holds no session and can never pass `requireAuth()`, so the two
|
|
320
|
+
// doors never stack.
|
|
321
|
+
app.post(
|
|
322
|
+
`${base}/staff/threads/:id/assign`,
|
|
323
|
+
describeRoute({
|
|
324
|
+
summary: "Assign a conversation as a signed-in member of the support staff.",
|
|
325
|
+
verification: ["bearer", "session"],
|
|
326
|
+
responses: { 200: SupportAssignResponse },
|
|
327
|
+
}),
|
|
328
|
+
requireAuth(),
|
|
329
|
+
requireSameOrigin(),
|
|
330
|
+
requireStaff(options.staff, "assignPower"),
|
|
331
|
+
validate("param", ThreadIdParam),
|
|
332
|
+
validate("json", AssignThreadInput),
|
|
333
|
+
async (c) =>
|
|
334
|
+
c.json(
|
|
335
|
+
await assignConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), {
|
|
336
|
+
type: "user",
|
|
337
|
+
id: submitter(c),
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
|
|
262
342
|
app.post(
|
|
263
343
|
`${base}/threads/:id/reply`,
|
|
264
344
|
describeRoute({
|
|
@@ -313,6 +393,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
|
|
|
313
393
|
`${base}/feedback`,
|
|
314
394
|
describeRoute({
|
|
315
395
|
summary: "Write in to support from the app.",
|
|
396
|
+
operation: "feedback.write",
|
|
397
|
+
tool: { effect: "sends" },
|
|
316
398
|
verification: ["bearer", "session"],
|
|
317
399
|
responses: { 201: SupportSubmissionResponse },
|
|
318
400
|
}),
|
|
@@ -326,6 +408,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
|
|
|
326
408
|
`${base}/feedback`,
|
|
327
409
|
describeRoute({
|
|
328
410
|
summary: "List the caller's own support requests.",
|
|
411
|
+
operation: "feedback.list",
|
|
412
|
+
tool: true,
|
|
329
413
|
verification: ["bearer", "session"],
|
|
330
414
|
responses: { 200: SupportMyThreadsResponse },
|
|
331
415
|
}),
|
|
@@ -338,6 +422,8 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
|
|
|
338
422
|
`${base}/feedback/:id`,
|
|
339
423
|
describeRoute({
|
|
340
424
|
summary: "Read one of the caller's own support requests.",
|
|
425
|
+
operation: "feedback.read",
|
|
426
|
+
tool: true,
|
|
341
427
|
verification: ["bearer", "session"],
|
|
342
428
|
responses: { 200: SupportMyThreadResponse },
|
|
343
429
|
}),
|
package/src/http/schemas.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { PresenceParticipantName, PresenceParticipantRef } from "@pithy-sh/core/src/presence/frames";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { MAX_SUBMISSION_ATTACHMENTS, MAX_SUBMISSION_BODY_CHARS, MAX_SUBMISSION_SUBJECT_CHARS } from "../config/config";
|
|
7
|
+
import { UNASSIGNED } from "../data/assignment";
|
|
6
8
|
import { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
|
|
7
9
|
import { SupportSubmissionContext } from "../data/message";
|
|
8
|
-
import { MAX_PAGE_SIZE } from "../store/paging";
|
|
10
|
+
import { DEFAULT_SEARCH_LIMIT, MAX_PAGE_SIZE, MAX_SEARCH_LIMIT, MAX_SEARCH_TERM } from "../store/paging";
|
|
11
|
+
|
|
12
|
+
/** An assignee reference, as the listing filters on it and an assignment names it. */
|
|
13
|
+
const AssigneeRef = PresenceParticipantRef.refine((ref) => ref !== UNASSIGNED, {
|
|
14
|
+
message: `"${UNASSIGNED}" is reserved: it is the filter for threads nobody has`,
|
|
15
|
+
}).describe(
|
|
16
|
+
"Who the thread is assigned to: an opaque reference, the participant shape presence rooms use. A management client's own reference, which it chose; or a user of the app's id. Never `none`, which is the listing's filter for threads nobody has.",
|
|
17
|
+
);
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* The request contracts every support route declares (CLAUDE.md §HTTP).
|
|
@@ -41,34 +50,45 @@ const CategoryFilter = z
|
|
|
41
50
|
"One category key from this project's effective taxonomy. A key an adopter removed simply matches nothing.",
|
|
42
51
|
);
|
|
43
52
|
|
|
44
|
-
/** The inbox listing
|
|
53
|
+
/** The filters the inbox listing and the search route share, applied in D1 either way. */
|
|
54
|
+
const threadFilterFields = {
|
|
55
|
+
archived: z
|
|
56
|
+
.enum(["true", "false"])
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
|
|
59
|
+
category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
|
|
60
|
+
declaredCategory: CategoryFilter.optional().describe(
|
|
61
|
+
"Filter to one category **the submitter chose**, which is a different question from `category` and answers it about different threads. Combining the two is how an operator finds the disagreements; asking for this one alone is how they triage a project with `ai.enabled: false`, where nothing ever writes the other.",
|
|
62
|
+
),
|
|
63
|
+
priority: SupportPriority.optional().describe("Filter to one priority."),
|
|
64
|
+
sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
|
|
65
|
+
channel: SupportChannel.optional().describe(
|
|
66
|
+
"Filter to one channel — `email` for mail, `app` for what signed-in users filed from inside the app. Absent shows both, which is the right default for an inbox that is one queue however things arrived.",
|
|
67
|
+
),
|
|
68
|
+
inbox: z
|
|
69
|
+
.string()
|
|
70
|
+
.min(3)
|
|
71
|
+
.max(256)
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("Filter to one configured inbox address, for a Worker serving several."),
|
|
74
|
+
assignee: z
|
|
75
|
+
.union([z.literal(UNASSIGNED), AssigneeRef])
|
|
76
|
+
.optional()
|
|
77
|
+
.describe(
|
|
78
|
+
"Filter to the threads one assignee has, by their `assigneeRef`, or `none` for the threads nobody has. Absent shows every thread, assigned or not.",
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The inbox listing query.
|
|
84
|
+
*
|
|
85
|
+
* **Strict, and there is no `q`.** Listing lists; `GET {basePath}/threads/search` searches. An unknown
|
|
86
|
+
* key is refused as `validation/invalid_input` rather than ignored, so a client sending `q` is told so
|
|
87
|
+
* instead of getting the unfiltered inbox back as though it were the answer.
|
|
88
|
+
*/
|
|
45
89
|
export const ListThreadsQuery = z
|
|
46
90
|
.object({
|
|
47
|
-
|
|
48
|
-
.enum(["true", "false"])
|
|
49
|
-
.optional()
|
|
50
|
-
.describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
|
|
51
|
-
category: CategoryFilter.optional().describe("Filter to one category — what the classifier made of it."),
|
|
52
|
-
declaredCategory: CategoryFilter.optional().describe(
|
|
53
|
-
"Filter to one category **the submitter chose**, which is a different question from `category` and answers it about different threads. Combining the two is how an operator finds the disagreements; asking for this one alone is how they triage a project with `ai.enabled: false`, where nothing ever writes the other.",
|
|
54
|
-
),
|
|
55
|
-
priority: SupportPriority.optional().describe("Filter to one priority."),
|
|
56
|
-
sentiment: SupportSentiment.optional().describe("Filter to one sentiment."),
|
|
57
|
-
channel: SupportChannel.optional().describe(
|
|
58
|
-
"Filter to one channel — `email` for mail, `app` for what signed-in users filed from inside the app. Absent shows both, which is the right default for an inbox that is one queue however things arrived.",
|
|
59
|
-
),
|
|
60
|
-
inbox: z
|
|
61
|
-
.string()
|
|
62
|
-
.min(3)
|
|
63
|
-
.max(256)
|
|
64
|
-
.optional()
|
|
65
|
-
.describe("Filter to one configured inbox address, for a Worker serving several."),
|
|
66
|
-
q: z
|
|
67
|
-
.string()
|
|
68
|
-
.min(1)
|
|
69
|
-
.max(200)
|
|
70
|
-
.optional()
|
|
71
|
-
.describe("Free text over subjects and bodies. Keyword search — the words people actually remember."),
|
|
91
|
+
...threadFilterFields,
|
|
72
92
|
cursor: z
|
|
73
93
|
.string()
|
|
74
94
|
.max(512)
|
|
@@ -82,9 +102,45 @@ export const ListThreadsQuery = z
|
|
|
82
102
|
.optional()
|
|
83
103
|
.describe("How many threads to return. Bounded, because a verified client can still have a bug."),
|
|
84
104
|
})
|
|
85
|
-
.
|
|
105
|
+
.strict()
|
|
106
|
+
.describe("The inbox query: what to filter by, and where to resume. Search is its own route.");
|
|
86
107
|
export type ListThreadsQuery = z.infer<typeof ListThreadsQuery>;
|
|
87
108
|
|
|
109
|
+
/** How a search runs. */
|
|
110
|
+
export const SupportSearchMode = z
|
|
111
|
+
.enum(["keyword", "semantic", "hybrid"])
|
|
112
|
+
.describe(
|
|
113
|
+
"`keyword` finds the exact words — FTS5 when `search.fts` is on, the `LIKE` scan otherwise. `semantic` finds the meaning, through the message index. `hybrid` runs both and ranks them as one.",
|
|
114
|
+
);
|
|
115
|
+
export type SupportSearchMode = z.infer<typeof SupportSearchMode>;
|
|
116
|
+
|
|
117
|
+
/** The search query. */
|
|
118
|
+
export const SearchThreadsQuery = z
|
|
119
|
+
.object({
|
|
120
|
+
q: z
|
|
121
|
+
.string()
|
|
122
|
+
.trim()
|
|
123
|
+
.min(1)
|
|
124
|
+
.max(MAX_SEARCH_TERM)
|
|
125
|
+
.describe("What to search for, in the customer's words or yours. An order number, an error, or a description."),
|
|
126
|
+
mode: SupportSearchMode.optional().describe(
|
|
127
|
+
"Which backends to run. Absent runs every one that is on, as `hybrid`. `semantic` or `hybrid` while `search.semantic` is off is refused, naming the flag.",
|
|
128
|
+
),
|
|
129
|
+
limit: z.coerce
|
|
130
|
+
.number()
|
|
131
|
+
.int()
|
|
132
|
+
.min(1)
|
|
133
|
+
.max(MAX_SEARCH_LIMIT)
|
|
134
|
+
.optional()
|
|
135
|
+
.describe(
|
|
136
|
+
`How many threads to return, best first. Default ${DEFAULT_SEARCH_LIMIT}, at most ${MAX_SEARCH_LIMIT}. No cursor: a ranked list has a top, not pages.`,
|
|
137
|
+
),
|
|
138
|
+
...threadFilterFields,
|
|
139
|
+
})
|
|
140
|
+
.strict()
|
|
141
|
+
.describe("A search over the inbox: the words, the mode, how many, and the same filters the inbox listing takes.");
|
|
142
|
+
export type SearchThreadsQuery = z.infer<typeof SearchThreadsQuery>;
|
|
143
|
+
|
|
88
144
|
/** The archive/unarchive body. */
|
|
89
145
|
export const ArchiveThreadInput = z
|
|
90
146
|
.object({
|
|
@@ -97,6 +153,39 @@ export const ArchiveThreadInput = z
|
|
|
97
153
|
.describe("Mark a conversation done, or reopen it.");
|
|
98
154
|
export type ArchiveThreadInput = z.infer<typeof ArchiveThreadInput>;
|
|
99
155
|
|
|
156
|
+
/** Who a thread is assigned to. */
|
|
157
|
+
export const AssigneeInput = z
|
|
158
|
+
.object({
|
|
159
|
+
ref: AssigneeRef,
|
|
160
|
+
name: PresenceParticipantName.describe(
|
|
161
|
+
"How the assignee is shown in the inbox and in the history. A label, never an identity — `ref` is that.",
|
|
162
|
+
),
|
|
163
|
+
})
|
|
164
|
+
.describe("Somebody a thread may be assigned to: an opaque reference and the name to show.");
|
|
165
|
+
export type AssigneeInput = z.infer<typeof AssigneeInput>;
|
|
166
|
+
|
|
167
|
+
/** The longest assignment note, in UTF-16 code units. */
|
|
168
|
+
export const MAX_ASSIGNMENT_NOTE_CHARS = 500;
|
|
169
|
+
|
|
170
|
+
/** The assign/unassign body, the same on the control-plane route and the staff session route. */
|
|
171
|
+
export const AssignThreadInput = z
|
|
172
|
+
.object({
|
|
173
|
+
assignee: AssigneeInput.nullable().describe(
|
|
174
|
+
"Who should have this conversation, or `null` to unassign it. Assigning the current assignee again changes nothing and records nothing.",
|
|
175
|
+
),
|
|
176
|
+
note: z
|
|
177
|
+
.string()
|
|
178
|
+
.trim()
|
|
179
|
+
.min(1)
|
|
180
|
+
.max(MAX_ASSIGNMENT_NOTE_CHARS)
|
|
181
|
+
.optional()
|
|
182
|
+
.describe(
|
|
183
|
+
"Why, in your own words. Kept in the thread's assignment history and on the audit event, and shown to anybody who can read the thread — so it is a note to colleagues, never a place for the customer's details.",
|
|
184
|
+
),
|
|
185
|
+
})
|
|
186
|
+
.describe("Assign a conversation to somebody, or to nobody.");
|
|
187
|
+
export type AssignThreadInput = z.infer<typeof AssignThreadInput>;
|
|
188
|
+
|
|
100
189
|
/** The reply body. */
|
|
101
190
|
export const ReplyInput = z
|
|
102
191
|
.object({
|