@pithy-sh/support 0.5.1 → 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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `assignee` filter value meaning *nobody has it*. Reserved: an assignment to a ref spelled this way is
|
|
9
|
+
* refused at the boundary (`http/schemas.ts`), so the filter can never mean two things.
|
|
10
|
+
*/
|
|
11
|
+
export const UNASSIGNED = "none";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Who assigned a thread: a management client through the control plane, or a signed-in user of the app
|
|
15
|
+
* through support's staff power. Recorded per change, because the two are different evidence about who
|
|
16
|
+
* acted, exactly as the audit trail's `actorType` is.
|
|
17
|
+
*/
|
|
18
|
+
export const SupportAssignmentActorType = z
|
|
19
|
+
.enum(["control-plane", "user"])
|
|
20
|
+
.describe(
|
|
21
|
+
"How the assigner was verified: `control-plane` for a management client's scoped credential, `user` for a signed-in user of the app holding support's staff power.",
|
|
22
|
+
);
|
|
23
|
+
export type SupportAssignmentActorType = z.output<typeof SupportAssignmentActorType>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One change of a thread's assignee in `pithy_support_assignments` — append-only, one row per change.
|
|
27
|
+
*
|
|
28
|
+
* The thread carries the **current** assignee because that is what the inbox filters on; this carries
|
|
29
|
+
* **every** change, the same split the classification table has with the thread. Nothing here is updated
|
|
30
|
+
* and nothing is deleted while the thread stands. A change that changed nothing — the current assignee
|
|
31
|
+
* assigned again — writes no row, so every row here is a real handover.
|
|
32
|
+
*/
|
|
33
|
+
export const SupportAssignment = z
|
|
34
|
+
.object({
|
|
35
|
+
id: z.string().describe("UUID primary key."),
|
|
36
|
+
threadId: z.string().describe("The `pithy_support_threads.id` whose assignee changed."),
|
|
37
|
+
assigneeRef: z
|
|
38
|
+
.string()
|
|
39
|
+
.nullish()
|
|
40
|
+
.describe("Who has the thread after this change, as an opaque reference, or null when it was unassigned."),
|
|
41
|
+
assigneeName: z.string().nullish().describe("How that assignee was shown at the time, or null when unassigned."),
|
|
42
|
+
previousRef: z.string().nullish().describe("Who had the thread before this change, or null when nobody did."),
|
|
43
|
+
previousName: z.string().nullish().describe("How the previous assignee was shown, or null when nobody had it."),
|
|
44
|
+
actorType: SupportAssignmentActorType.describe("How the person or client making the change was verified."),
|
|
45
|
+
assignedBy: z.string().describe("Who made the change — the control-plane subject, or the signed-in user's id."),
|
|
46
|
+
note: z
|
|
47
|
+
.string()
|
|
48
|
+
.nullish()
|
|
49
|
+
.describe(
|
|
50
|
+
"Why, in the assigner's own words, when they gave a reason. Staff-written, never customer text, and shown to anybody who can read the thread.",
|
|
51
|
+
),
|
|
52
|
+
createdAt: SQLiteDate.describe("When the change was made."),
|
|
53
|
+
})
|
|
54
|
+
.describe("One append-only change of a support thread's assignee in `pithy_support_assignments`.");
|
|
55
|
+
export type SupportAssignment = z.output<typeof SupportAssignment>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which model embedded a message into the semantic index, in `pithy_support_embeddings`.
|
|
9
|
+
*
|
|
10
|
+
* **Its own table, not a column on the message**, for two reasons. A released migration is never edited, and
|
|
11
|
+
* a stamp belongs to a derived index rather than to the mail: drop the index and every stamp is a lie, so they
|
|
12
|
+
* go together, and nothing about a message changes. It is what `pithy vector reprocess --index
|
|
13
|
+
* support-messages` reads to re-embed exactly the rows whose model differs from the configured one — the same
|
|
14
|
+
* job `pithy_vector_documents.model` does for an index `vector()` owns.
|
|
15
|
+
*
|
|
16
|
+
* A message with no row was never embedded, which is the set a backfill and a repair both pick up.
|
|
17
|
+
*/
|
|
18
|
+
export const SupportEmbedding = z
|
|
19
|
+
.object({
|
|
20
|
+
messageId: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe("The `pithy_support_messages.id` this stamp is for — also the vector's id in the index."),
|
|
23
|
+
model: z.string().describe("The Workers AI embedding model that produced the vector now in the index."),
|
|
24
|
+
embeddedAt: SQLiteDate.describe("When the vector was written."),
|
|
25
|
+
})
|
|
26
|
+
.describe("The model stamp for one message's vector in the semantic index.");
|
|
27
|
+
export type SupportEmbedding = z.output<typeof SupportEmbedding>;
|
package/src/data/tables.ts
CHANGED
|
@@ -5,8 +5,10 @@ import type { D1Database } from "@cloudflare/workers-types";
|
|
|
5
5
|
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
6
|
import type { Kysely } from "kysely";
|
|
7
7
|
import type { z } from "zod";
|
|
8
|
+
import { SupportAssignment } from "./assignment";
|
|
8
9
|
import { SupportAttachment } from "./attachment";
|
|
9
10
|
import { SupportClassification } from "./classification";
|
|
11
|
+
import { SupportEmbedding } from "./embedding";
|
|
10
12
|
import { SupportThreadFlag } from "./flag";
|
|
11
13
|
import { SupportMessage } from "./message";
|
|
12
14
|
import { SupportThread } from "./thread";
|
|
@@ -26,6 +28,10 @@ export const SUPPORT_ATTACHMENTS_TABLE = "pithySupportAttachments";
|
|
|
26
28
|
export const SUPPORT_CLASSIFICATIONS_TABLE = "pithySupportClassifications";
|
|
27
29
|
/** Per-viewer read/snooze state. `pithy_support_thread_flags`. */
|
|
28
30
|
export const SUPPORT_FLAGS_TABLE = "pithySupportThreadFlags";
|
|
31
|
+
/** Append-only assignee history. `pithy_support_assignments`. */
|
|
32
|
+
export const SUPPORT_ASSIGNMENTS_TABLE = "pithySupportAssignments";
|
|
33
|
+
/** The semantic index's per-message model stamps. `pithy_support_embeddings`. */
|
|
34
|
+
export const SUPPORT_EMBEDDINGS_TABLE = "pithySupportEmbeddings";
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* The FTS5 full-text index. Not a Kysely table and deliberately not in {@link supportTables}: it is a
|
|
@@ -44,6 +50,8 @@ export const supportTables = {
|
|
|
44
50
|
[SUPPORT_ATTACHMENTS_TABLE]: SupportAttachment,
|
|
45
51
|
[SUPPORT_CLASSIFICATIONS_TABLE]: SupportClassification,
|
|
46
52
|
[SUPPORT_FLAGS_TABLE]: SupportThreadFlag,
|
|
53
|
+
[SUPPORT_ASSIGNMENTS_TABLE]: SupportAssignment,
|
|
54
|
+
[SUPPORT_EMBEDDINGS_TABLE]: SupportEmbedding,
|
|
47
55
|
} satisfies Record<string, z.ZodObject>;
|
|
48
56
|
|
|
49
57
|
/** The support tables, as a type. */
|
package/src/data/thread.ts
CHANGED
|
@@ -10,10 +10,11 @@ import { SupportAccountLinkSource, SupportChannel, SupportPriority, SupportSenti
|
|
|
10
10
|
*
|
|
11
11
|
* **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
|
|
12
12
|
* repaired; the user link is a lookup, not a decision; the counters are facts about the messages
|
|
13
|
-
* below. There is no
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* below. There is no status, because a status field is a state machine and a state machine attracts
|
|
14
|
+
* SLAs, escalation, and reporting — which is a ticketing product and explicitly not this. The
|
|
15
|
+
* exceptions are deliberate and named: `archived` (one shared boolean meaning done), the assignee (one
|
|
16
|
+
* fact, who has this, with its history in `pithy_support_assignments` — #816), and the per-viewer
|
|
17
|
+
* flags, which live in their own table because nobody coordinates around them.
|
|
17
18
|
*
|
|
18
19
|
* The classification axes are **denormalized onto the thread** rather than joined from
|
|
19
20
|
* `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
|
|
@@ -105,6 +106,27 @@ export const SupportThread = z
|
|
|
105
106
|
.describe(
|
|
106
107
|
"The control-plane subject that archived it. Recorded for the dashboard's convenience only — the answer to 'who marked this done' comes from the audit trail, which is why this is not an ownership column.",
|
|
107
108
|
),
|
|
109
|
+
assigneeRef: z
|
|
110
|
+
.string()
|
|
111
|
+
.nullish()
|
|
112
|
+
.describe(
|
|
113
|
+
"**Who has this thread** — an opaque reference to the person or agent it is assigned to, or null when nobody has it. Opaque on purpose: a dashboard member, a user of the app and an agent are all assignable, and support never needs to know which. Not a status: there are no transitions to get wrong, and unassigning is one write. Every change is kept in `pithy_support_assignments`. Added by `0002_assignment`, so a row written before it reads null.",
|
|
114
|
+
),
|
|
115
|
+
assigneeName: z
|
|
116
|
+
.string()
|
|
117
|
+
.nullish()
|
|
118
|
+
.describe(
|
|
119
|
+
"How the assignee is shown, as the assigner named them. Null exactly when `assigneeRef` is. Display text, never an identity: two refs may share a name, and the ref is what a filter matches.",
|
|
120
|
+
),
|
|
121
|
+
assignedAt: SQLiteDate.nullish().describe(
|
|
122
|
+
"When the current assignment was made; null while the thread has no assignee.",
|
|
123
|
+
),
|
|
124
|
+
assignedBy: z
|
|
125
|
+
.string()
|
|
126
|
+
.nullish()
|
|
127
|
+
.describe(
|
|
128
|
+
"Who made the current assignment — the control-plane subject, or the signed-in user's id. Null while the thread has no assignee. The full answer, with every earlier change, is the history table and the audit trail.",
|
|
129
|
+
),
|
|
108
130
|
messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
|
|
109
131
|
firstMessageAt: SQLiteDate.describe("When the thread opened — the receive time of its first message."),
|
|
110
132
|
lastMessageAt: SQLiteDate.describe(
|
package/src/http/guards.ts
CHANGED
|
@@ -2,38 +2,47 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
-
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { ForbiddenError, UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
6
|
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
import type { SupportStaffConfig } from "../config/config";
|
|
8
|
+
import { staffMay } from "../staff/staff";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
* Support's identity gates: the copied `requireAuth()` its in-app
|
|
10
|
-
*
|
|
11
|
+
* Support's identity gates: the copied `requireAuth()` its in-app routes wear, the control-plane gate its
|
|
12
|
+
* management routes take from core's seam, and `requireStaff()`, which lets the adopter's own staff onto a
|
|
13
|
+
* slice of the management side through their session (#816).
|
|
11
14
|
*
|
|
12
15
|
* **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
|
|
13
16
|
* connection may do, and it reads them in a browser — so they cannot live in a module that imports
|
|
14
17
|
* Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
|
|
15
18
|
* the routes import both.
|
|
16
19
|
*
|
|
17
|
-
* ##
|
|
20
|
+
* ## Three doors, and the split is the whole shape of this file
|
|
18
21
|
*
|
|
19
22
|
* **The management surface is `control-plane` and default-denied.** It reads and acts on every
|
|
20
|
-
* customer's private correspondence, so it answers to a credential the adopter issued
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* customer's private correspondence, so it answers to a credential the adopter issued. The seam's gate is
|
|
24
|
+
* imported from core rather than copied — core is a hard dependency of every capability, so importing its
|
|
25
|
+
* gate cannot leave a deployment without one, and with the seam uncomposed `requireControlPlane` raises
|
|
26
|
+
* `controlplane/not_connected` rather than passing.
|
|
24
27
|
*
|
|
25
28
|
* **The submission surface is `bearer`/`session` and is the adopter's own signed-in user.** It exists
|
|
26
29
|
* because a product with a logged-in user, a session, and a support console should not have to ask
|
|
27
30
|
* that user to open their mail client — and because the hardest problem on the mail path, proving a
|
|
28
31
|
* `From:` header, does not exist on a request whose session was already proved.
|
|
29
32
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* **The staff door is a session, and it reaches management work.** `requireAuth()` then `requireStaff()`
|
|
34
|
+
* admit a signed-in user whose role, in the organization the request acts for, holds one of the powers
|
|
35
|
+
* `support({ staff })` names — and nobody at all when `staff` is absent. It opens two things and no more:
|
|
36
|
+
* assigning a thread, at its own `/staff/` path, and joining a thread's presence room. Everything else on
|
|
37
|
+
* the management side still answers to the control plane alone.
|
|
38
|
+
*
|
|
39
|
+
* **`requireAuth()` never appears on a control-plane route, and `requireControlPlane` never appears on a
|
|
40
|
+
* session route.** They are not two strengths of the same gate. A management client holds no session and
|
|
41
|
+
* owns no account row — core leaves `c.var.auth` null for one deliberately — so stacking them would deny
|
|
42
|
+
* every legitimate call on both, permanently, with no credential able to fix it. That is why staff
|
|
43
|
+
* assignment is a second route beside the control-plane one rather than a second gate on it. What each
|
|
44
|
+
* route may *see* follows from which gate it wears: an operator reads a thread with its classification and
|
|
45
|
+
* its sender's purchases, and a submitter reads their own words back and nothing else.
|
|
37
46
|
*/
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -57,3 +66,31 @@ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
|
57
66
|
await next();
|
|
58
67
|
};
|
|
59
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Require one of support's staff powers in the organization this request acts for (#816) — the gate on
|
|
72
|
+
* the staff session routes, after `requireAuth()`.
|
|
73
|
+
*
|
|
74
|
+
* With no `staff` configured, or no organization composed, or no organization in force, every caller is
|
|
75
|
+
* refused: the inbox is every customer's correspondence, and the absence of a policy is never an open one.
|
|
76
|
+
* The refusal names no power and no organization, because which power reaches the inbox is the adopter's
|
|
77
|
+
* internal arrangement and a caller who does not hold it has no use for the name.
|
|
78
|
+
*/
|
|
79
|
+
export function requireStaff(
|
|
80
|
+
staff: SupportStaffConfig | undefined,
|
|
81
|
+
power: keyof SupportStaffConfig,
|
|
82
|
+
): MiddlewareHandler<PithyHonoEnv> {
|
|
83
|
+
return async (c, next) => {
|
|
84
|
+
const acting = await c.var.actingOrganization();
|
|
85
|
+
if (!staffMay(acting, staff, power)) {
|
|
86
|
+
throw new ForbiddenError({
|
|
87
|
+
message: "You do not work this support inbox.",
|
|
88
|
+
action: staff
|
|
89
|
+
? `Give the user an unassignable role holding \`${staff[power]}\`, through the control plane.`
|
|
90
|
+
: "Name staff powers in support({ staff }) to let signed-in users work the inbox.",
|
|
91
|
+
detail: `user ${c.var.auth?.userId ?? "unknown"} holds no ${power} for support in ${acting?.organizationId ?? "no organization"}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
await next();
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/http/handlers.ts
CHANGED
|
@@ -7,9 +7,13 @@ import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
|
|
|
7
7
|
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
8
|
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
9
9
|
import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
|
|
10
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
11
|
+
import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
12
|
+
import type { VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
10
13
|
import { attachmentUrl } from "../attachment/store";
|
|
11
14
|
import { SupportAuditActions } from "../audit/actions";
|
|
12
15
|
import type { SupportConfig } from "../config/config";
|
|
16
|
+
import type { SupportAssignmentActorType } from "../data/assignment";
|
|
13
17
|
import type { SupportAttachment } from "../data/attachment";
|
|
14
18
|
import type { SupportCategories } from "../data/categories";
|
|
15
19
|
import type { SupportDatabase } from "../data/tables";
|
|
@@ -23,11 +27,14 @@ import {
|
|
|
23
27
|
import { sendReply } from "../reply/send";
|
|
24
28
|
import type { SupportReplySnippets } from "../reply/snippets";
|
|
25
29
|
import { repliesForCategory } from "../reply/snippets";
|
|
30
|
+
import { assignThread, listAssignments } from "../store/assignment";
|
|
31
|
+
import { searchThreads } from "../store/hybrid";
|
|
26
32
|
import { listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags } from "../store/threads";
|
|
27
33
|
import { decodeBase64 } from "../submission/encoding";
|
|
28
34
|
import { submitFeedback } from "../submission/submit";
|
|
29
35
|
import { latestInboundMessageId } from "../workflows/classify";
|
|
30
36
|
import type {
|
|
37
|
+
SupportAssignResponse,
|
|
31
38
|
SupportAttachmentView,
|
|
32
39
|
SupportFlagsResponse,
|
|
33
40
|
SupportMyThreadResponse,
|
|
@@ -35,6 +42,7 @@ import type {
|
|
|
35
42
|
SupportReclassifiedResponse,
|
|
36
43
|
SupportReplySentResponse,
|
|
37
44
|
SupportReplyView,
|
|
45
|
+
SupportSearchResponse,
|
|
38
46
|
SupportSubmissionResponse,
|
|
39
47
|
SupportThreadResponse,
|
|
40
48
|
SupportThreadsResponse,
|
|
@@ -42,14 +50,25 @@ import type {
|
|
|
42
50
|
} from "./responses";
|
|
43
51
|
import type {
|
|
44
52
|
ArchiveThreadInput,
|
|
53
|
+
AssignThreadInput,
|
|
45
54
|
FlagsInput,
|
|
46
55
|
ListThreadsQuery,
|
|
47
56
|
MyThreadsQuery,
|
|
48
57
|
RepliesQuery,
|
|
49
58
|
ReplyInput,
|
|
59
|
+
SearchThreadsQuery,
|
|
50
60
|
SubmitFeedbackInput,
|
|
51
61
|
} from "./schemas";
|
|
52
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
assignmentView,
|
|
64
|
+
listedThreadView,
|
|
65
|
+
messageView,
|
|
66
|
+
myMessageView,
|
|
67
|
+
myThreadView,
|
|
68
|
+
searchView,
|
|
69
|
+
senderView,
|
|
70
|
+
threadView,
|
|
71
|
+
} from "./views";
|
|
53
72
|
|
|
54
73
|
/**
|
|
55
74
|
* The support handlers — every one of them behind the `control-plane` gate, and every one of them
|
|
@@ -73,6 +92,11 @@ export interface HandlerDeps {
|
|
|
73
92
|
snippets: SupportReplySnippets;
|
|
74
93
|
/** Whether the FTS5 index is composed. */
|
|
75
94
|
fts: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The semantic message index and the bindings it runs on. `index` is absent when `search.semantic` is
|
|
97
|
+
* off; `ai` and `store` are absent when this Worker was not given them — until `pithy support provision`.
|
|
98
|
+
*/
|
|
99
|
+
semantic: { index?: VectorIndexConfig; ai?: VectorAi; store?: VectorStore };
|
|
76
100
|
/** The attachment bucket, when one is bound. */
|
|
77
101
|
bucket?: R2Bucket;
|
|
78
102
|
/** The object store attachment URLs are signed through, when credentials are available. */
|
|
@@ -109,15 +133,51 @@ export async function listInbox(
|
|
|
109
133
|
sentiment: query.sentiment,
|
|
110
134
|
channel: query.channel,
|
|
111
135
|
inbox: query.inbox,
|
|
112
|
-
|
|
136
|
+
assignee: query.assignee,
|
|
113
137
|
cursor: query.cursor,
|
|
114
138
|
limit: query.limit,
|
|
115
139
|
},
|
|
116
|
-
{
|
|
140
|
+
{ viewer, now: deps.now() },
|
|
117
141
|
);
|
|
118
142
|
return { threads: page.threads.map(listedThreadView), nextCursor: page.nextCursor };
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Search the inbox: keyword and semantic, ranked as one. The semantic seams are whatever this Worker
|
|
147
|
+
* was given — absent bindings skip that backend and say so, rather than failing the search.
|
|
148
|
+
*/
|
|
149
|
+
export async function searchInbox(
|
|
150
|
+
deps: HandlerDeps,
|
|
151
|
+
query: SearchThreadsQuery,
|
|
152
|
+
viewer: string,
|
|
153
|
+
): Promise<SupportSearchResponse> {
|
|
154
|
+
const outcome = await searchThreads(
|
|
155
|
+
{
|
|
156
|
+
db: deps.db,
|
|
157
|
+
fts: deps.fts,
|
|
158
|
+
...(deps.semantic.index ? { index: deps.semantic.index } : {}),
|
|
159
|
+
...(deps.semantic.ai ? { ai: deps.semantic.ai } : {}),
|
|
160
|
+
...(deps.semantic.store ? { store: deps.semantic.store } : {}),
|
|
161
|
+
viewer,
|
|
162
|
+
log: deps.log,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
q: query.q,
|
|
166
|
+
...(query.mode !== undefined ? { mode: query.mode } : {}),
|
|
167
|
+
...(query.limit !== undefined ? { limit: query.limit } : {}),
|
|
168
|
+
archived: query.archived === "true",
|
|
169
|
+
category: query.category,
|
|
170
|
+
declaredCategory: query.declaredCategory,
|
|
171
|
+
priority: query.priority,
|
|
172
|
+
sentiment: query.sentiment,
|
|
173
|
+
channel: query.channel,
|
|
174
|
+
inbox: query.inbox,
|
|
175
|
+
assignee: query.assignee,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return searchView(outcome);
|
|
179
|
+
}
|
|
180
|
+
|
|
121
181
|
/**
|
|
122
182
|
* Present an attachment.
|
|
123
183
|
*
|
|
@@ -156,7 +216,7 @@ async function presentAttachments(
|
|
|
156
216
|
/** Read one conversation in full. */
|
|
157
217
|
export async function readConversation(deps: HandlerDeps, threadId: string): Promise<SupportThreadResponse> {
|
|
158
218
|
const detail = await readThread(deps.db, threadId);
|
|
159
|
-
const [attachments, sender] = await Promise.all([
|
|
219
|
+
const [attachments, sender, assignments] = await Promise.all([
|
|
160
220
|
presentAttachments(deps, detail.attachments),
|
|
161
221
|
// A session-proven thread already holds the id its session established, so the context is resolved
|
|
162
222
|
// from that rather than re-derived from an address. Not a shortcut: `resolveSenderUserId` matches
|
|
@@ -171,6 +231,7 @@ export async function readConversation(deps: HandlerDeps, threadId: string): Pro
|
|
|
171
231
|
{ authenticated: detail.thread.senderAuthenticated },
|
|
172
232
|
deps.peers,
|
|
173
233
|
),
|
|
234
|
+
listAssignments(deps.db, threadId),
|
|
174
235
|
]);
|
|
175
236
|
|
|
176
237
|
return {
|
|
@@ -179,6 +240,7 @@ export async function readConversation(deps: HandlerDeps, threadId: string): Pro
|
|
|
179
240
|
attachments,
|
|
180
241
|
sender: senderView(sender),
|
|
181
242
|
replies: repliesForCategory(deps.snippets, detail.thread.category),
|
|
243
|
+
assignments: assignments.map(assignmentView),
|
|
182
244
|
};
|
|
183
245
|
}
|
|
184
246
|
|
|
@@ -206,6 +268,56 @@ export async function archiveConversation(
|
|
|
206
268
|
return threadView(thread);
|
|
207
269
|
}
|
|
208
270
|
|
|
271
|
+
/** Who is assigning: how they were verified, and their id under it. */
|
|
272
|
+
export interface Assigner {
|
|
273
|
+
/** `control-plane` for a management client, `user` for a signed-in user holding support's staff power. */
|
|
274
|
+
type: SupportAssignmentActorType;
|
|
275
|
+
/** The control-plane subject, or the user's id. */
|
|
276
|
+
id: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Assign a conversation to somebody, or to nobody — the one handler behind both doors.
|
|
281
|
+
*
|
|
282
|
+
* The audit event is emitted **only when something changed**. The current assignee again, or unassigning
|
|
283
|
+
* a thread nobody has, wrote nothing to the thread or its history, so an event would record a handover
|
|
284
|
+
* that did not happen.
|
|
285
|
+
*/
|
|
286
|
+
export async function assignConversation(
|
|
287
|
+
deps: HandlerDeps,
|
|
288
|
+
threadId: string,
|
|
289
|
+
input: AssignThreadInput,
|
|
290
|
+
assigner: Assigner,
|
|
291
|
+
): Promise<SupportAssignResponse> {
|
|
292
|
+
const result = await assignThread(deps.d1, {
|
|
293
|
+
threadId,
|
|
294
|
+
assignee: input.assignee,
|
|
295
|
+
actorType: assigner.type,
|
|
296
|
+
assignedBy: assigner.id,
|
|
297
|
+
note: input.note,
|
|
298
|
+
newId: deps.newId,
|
|
299
|
+
now: deps.now(),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (result.changed) {
|
|
303
|
+
await deps.emit({
|
|
304
|
+
action: input.assignee ? SupportAuditActions.threadAssigned : SupportAuditActions.threadUnassigned,
|
|
305
|
+
outcome: "success",
|
|
306
|
+
actorType: assigner.type,
|
|
307
|
+
actorId: assigner.id,
|
|
308
|
+
resourceType: "support_thread",
|
|
309
|
+
resourceId: threadId,
|
|
310
|
+
metadata: {
|
|
311
|
+
previousAssignee: result.previous,
|
|
312
|
+
assignee: input.assignee,
|
|
313
|
+
note: input.note ?? null,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { thread: threadView(result.thread), changed: result.changed };
|
|
319
|
+
}
|
|
320
|
+
|
|
209
321
|
/** Answer the customer. */
|
|
210
322
|
export async function replyToConversation(
|
|
211
323
|
deps: HandlerDeps,
|
|
@@ -237,6 +349,15 @@ export async function reclassifyConversation(
|
|
|
237
349
|
threadId: string,
|
|
238
350
|
viewer: string,
|
|
239
351
|
): Promise<SupportReclassifiedResponse> {
|
|
352
|
+
// The worker classifies only with `ai.enabled` on. Dispatching anyway would start a run that classifies
|
|
353
|
+
// nothing, and the 200 and the audit event below would both say it had.
|
|
354
|
+
if (!deps.config.ai.enabled) {
|
|
355
|
+
throw new SupportClassificationError({
|
|
356
|
+
message: "Classification is off for this project.",
|
|
357
|
+
action: `Turn on \`ai.enabled\` in the support config, run \`${pithyCommandOf("support provision", {}, { runtime: true })}\`, then retry.`,
|
|
358
|
+
detail: `ai.enabled is false, so no instance was started for thread ${threadId}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
240
361
|
const messageId = await latestInboundMessageId(deps.db, threadId);
|
|
241
362
|
if (!messageId) {
|
|
242
363
|
throw new SupportNotFoundError({
|
package/src/http/resolve.ts
CHANGED
|
@@ -7,10 +7,14 @@ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
|
7
7
|
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
8
8
|
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
9
9
|
import { objectStore } from "@pithy-sh/storage/src/object/store";
|
|
10
|
+
import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
11
|
+
import type { VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
10
12
|
import type { Context } from "hono";
|
|
11
13
|
import type { SupportWiring } from "../capability";
|
|
14
|
+
import type { SupportConfig } from "../config/config";
|
|
12
15
|
import { supportDatabase } from "../data/tables";
|
|
13
16
|
import { SUPPORT_R2_SECRET } from "../secret/registry";
|
|
17
|
+
import { SUPPORT_AI_BINDING, SUPPORT_VECTORIZE_BINDING, supportMessageIndex } from "../semantic/messageIndex";
|
|
14
18
|
import { supportWorkflowRegistry, supportWorkflows } from "../workflows/specs";
|
|
15
19
|
import type { HandlerDeps } from "./handlers";
|
|
16
20
|
|
|
@@ -78,6 +82,20 @@ export function resolveStore(env: SupportEnv): ReturnType<typeof objectStore> |
|
|
|
78
82
|
return objectStore({ bucket: env.SUPPORT_BUCKET, env, secretName: SUPPORT_R2_SECRET });
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The semantic index and the two bindings it runs on, as this Worker has them.
|
|
87
|
+
*
|
|
88
|
+
* Absent rather than refused: a project that turned `search.semantic` on and has not provisioned yet still
|
|
89
|
+
* serves every route, and the search route names the backend it skipped.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveSemantic(env: Record<string, unknown>, config: SupportConfig): HandlerDeps["semantic"] {
|
|
92
|
+
const index = supportMessageIndex(config.search);
|
|
93
|
+
if (!index) return {};
|
|
94
|
+
const ai = env[SUPPORT_AI_BINDING] as VectorAi | undefined;
|
|
95
|
+
const store = env[SUPPORT_VECTORIZE_BINDING] as VectorStore | undefined;
|
|
96
|
+
return { index, ...(ai ? { ai } : {}), ...(store ? { store } : {}) };
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
/** Build the per-request handler dependencies. */
|
|
82
100
|
export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps> {
|
|
83
101
|
return async (c) => {
|
|
@@ -92,6 +110,7 @@ export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv
|
|
|
92
110
|
categories: wiring.categories,
|
|
93
111
|
snippets: wiring.snippets,
|
|
94
112
|
fts: wiring.config.search.fts,
|
|
113
|
+
semantic: resolveSemantic(env as unknown as Record<string, unknown>, wiring.config),
|
|
95
114
|
bucket: env.SUPPORT_BUCKET,
|
|
96
115
|
store: resolveStore(env),
|
|
97
116
|
// Bound at compose time when `email()` is composed; undefined otherwise, which the reply
|