@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
package/src/http/scopes.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { AdminRouteDeclaration } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
5
|
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Support's control-plane scopes, and the admin surface a manifest advertises.
|
|
9
9
|
*
|
|
10
|
-
* **Separate from `guards.ts` because a scope name is a client's business
|
|
10
|
+
* **Separate from `guards.ts` because a scope name is a client's business**. A management
|
|
11
11
|
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
12
12
|
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
13
13
|
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
@@ -15,7 +15,7 @@ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/sc
|
|
|
15
15
|
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
16
16
|
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
17
17
|
*
|
|
18
|
-
* ##
|
|
18
|
+
* ## Six scopes, because these are six different blast radii
|
|
19
19
|
*
|
|
20
20
|
* The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
|
|
21
21
|
* single flag was: **reading** an inbox exposes every customer's private correspondence, while
|
|
@@ -35,7 +35,7 @@ export const SUPPORT_THREADS_READ_SCOPE: ControlPlaneScope = "support:threads:re
|
|
|
35
35
|
export const SUPPORT_THREADS_ARCHIVE_SCOPE: ControlPlaneScope = "support:threads:archive";
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Send mail to a customer under the adopter's domain. The most dangerous of the
|
|
38
|
+
* Send mail to a customer under the adopter's domain. The most dangerous of the six and the reason
|
|
39
39
|
* a single admin flag was never an option.
|
|
40
40
|
*/
|
|
41
41
|
export const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope = "support:threads:reply";
|
|
@@ -46,6 +46,12 @@ export const SUPPORT_THREADS_RECLASSIFY_SCOPE: ControlPlaneScope = "support:thre
|
|
|
46
46
|
/** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
|
|
47
47
|
export const SUPPORT_THREADS_FLAG_SCOPE: ControlPlaneScope = "support:threads:flag";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Say who has a thread, or that nobody does. Shared state like archive, and its own scope like
|
|
51
|
+
* archive, because deciding who answers a customer is not the same permission as marking them done.
|
|
52
|
+
*/
|
|
53
|
+
export const SUPPORT_THREADS_ASSIGN_SCOPE: ControlPlaneScope = "support:threads:assign";
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
56
|
* Every control-plane scope support defines — what `pithy dashboard connect` offers for this
|
|
51
57
|
* capability, and the list a manifest or a doc quotes rather than re-typing.
|
|
@@ -56,6 +62,7 @@ export const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
|
56
62
|
SUPPORT_THREADS_REPLY_SCOPE,
|
|
57
63
|
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
58
64
|
SUPPORT_THREADS_FLAG_SCOPE,
|
|
65
|
+
SUPPORT_THREADS_ASSIGN_SCOPE,
|
|
59
66
|
];
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -69,48 +76,78 @@ export const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
|
69
76
|
* The summaries say what the operation is *for*. A client renders these next to a button somebody is
|
|
70
77
|
* about to press on a real customer's conversation.
|
|
71
78
|
*/
|
|
72
|
-
export function supportAdminRoutes(basePath: string):
|
|
79
|
+
export function supportAdminRoutes(basePath: string): AdminRouteDeclaration[] {
|
|
73
80
|
return [
|
|
74
81
|
{
|
|
75
82
|
method: "GET",
|
|
83
|
+
operation: "threads.list",
|
|
76
84
|
path: `${basePath}/threads`,
|
|
77
85
|
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
78
|
-
|
|
86
|
+
kind: "reads",
|
|
87
|
+
summary: "List the inbox, newest first, filtered by category, priority, assignee, and archived.",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
method: "GET",
|
|
91
|
+
operation: "threads.search",
|
|
92
|
+
path: `${basePath}/threads/search`,
|
|
93
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
94
|
+
kind: "reads",
|
|
95
|
+
summary: "Search the inbox by words and by meaning, ranked as one, with the same filters as the list.",
|
|
79
96
|
},
|
|
80
97
|
{
|
|
81
98
|
method: "GET",
|
|
99
|
+
operation: "threads.read",
|
|
82
100
|
path: `${basePath}/threads/:id`,
|
|
83
101
|
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
102
|
+
kind: "reads",
|
|
84
103
|
summary: "Read one conversation, with the sender's linked account and what they bought.",
|
|
85
104
|
},
|
|
86
105
|
{
|
|
87
106
|
method: "POST",
|
|
107
|
+
operation: "threads.archive",
|
|
88
108
|
path: `${basePath}/threads/:id/archive`,
|
|
89
109
|
scope: SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
110
|
+
kind: "writes",
|
|
90
111
|
summary: "Mark a conversation done, or reopen one.",
|
|
91
112
|
},
|
|
92
113
|
{
|
|
93
114
|
method: "POST",
|
|
115
|
+
operation: "threads.assign",
|
|
116
|
+
path: `${basePath}/threads/:id/assign`,
|
|
117
|
+
scope: SUPPORT_THREADS_ASSIGN_SCOPE,
|
|
118
|
+
kind: "writes",
|
|
119
|
+
summary: "Give a conversation to somebody, hand it on, or give it up.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: "POST",
|
|
123
|
+
operation: "threads.reply",
|
|
94
124
|
path: `${basePath}/threads/:id/reply`,
|
|
95
125
|
scope: SUPPORT_THREADS_REPLY_SCOPE,
|
|
126
|
+
kind: "sends",
|
|
96
127
|
summary: "Answer the customer, threaded so their mail client keeps one conversation.",
|
|
97
128
|
},
|
|
98
129
|
{
|
|
99
130
|
method: "POST",
|
|
131
|
+
operation: "threads.reclassify",
|
|
100
132
|
path: `${basePath}/threads/:id/reclassify`,
|
|
101
133
|
scope: SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
134
|
+
kind: "writes",
|
|
102
135
|
summary: "Re-run the classifier over this conversation's latest inbound message.",
|
|
103
136
|
},
|
|
104
137
|
{
|
|
105
138
|
method: "POST",
|
|
139
|
+
operation: "threads.flags",
|
|
106
140
|
path: `${basePath}/threads/:id/flags`,
|
|
107
141
|
scope: SUPPORT_THREADS_FLAG_SCOPE,
|
|
142
|
+
kind: "writes",
|
|
108
143
|
summary: "Set your own read and snooze state for a conversation. Private to you.",
|
|
109
144
|
},
|
|
110
145
|
{
|
|
111
146
|
method: "GET",
|
|
147
|
+
operation: "replies.list",
|
|
112
148
|
path: `${basePath}/replies`,
|
|
113
149
|
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
150
|
+
kind: "reads",
|
|
114
151
|
summary: "The canned reply catalog — starting points to pick from, ordered for a thread's category.",
|
|
115
152
|
},
|
|
116
153
|
];
|
package/src/http/views.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import type { SupportAssignment } from "../data/assignment";
|
|
4
5
|
import type { SupportMessage } from "../data/message";
|
|
5
6
|
import type { SupportThread } from "../data/thread";
|
|
6
7
|
import type { SenderContext } from "../link/sender";
|
|
8
|
+
import type { SearchOutcome, SearchResult } from "../store/hybrid";
|
|
7
9
|
import type { ListedThread } from "../store/threads";
|
|
8
10
|
import type {
|
|
9
11
|
SenderContextView,
|
|
12
|
+
SupportAssignmentView,
|
|
10
13
|
SupportAttachmentView,
|
|
11
14
|
SupportListedThreadView,
|
|
12
15
|
SupportMessageView,
|
|
13
16
|
SupportMyMessageView,
|
|
14
17
|
SupportMyThreadView,
|
|
18
|
+
SupportSearchResponse,
|
|
19
|
+
SupportSearchResultView,
|
|
15
20
|
SupportThreadView,
|
|
16
21
|
} from "./responses";
|
|
17
22
|
|
|
@@ -60,6 +65,10 @@ export function threadView(thread: SupportThread): SupportThreadView {
|
|
|
60
65
|
archived: thread.archived,
|
|
61
66
|
archivedAt: iso(thread.archivedAt),
|
|
62
67
|
archivedBy: thread.archivedBy ?? null,
|
|
68
|
+
assigneeRef: thread.assigneeRef ?? null,
|
|
69
|
+
assigneeName: thread.assigneeName ?? null,
|
|
70
|
+
assignedAt: iso(thread.assignedAt),
|
|
71
|
+
assignedBy: thread.assignedBy ?? null,
|
|
63
72
|
messageCount: thread.messageCount,
|
|
64
73
|
firstMessageAt: thread.firstMessageAt.toISOString(),
|
|
65
74
|
lastMessageAt: thread.lastMessageAt.toISOString(),
|
|
@@ -68,11 +77,46 @@ export function threadView(thread: SupportThread): SupportThreadView {
|
|
|
68
77
|
};
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
/** Project one change of a thread's assignee. The thread id is the route's, so it is not repeated. */
|
|
81
|
+
export function assignmentView(assignment: SupportAssignment): SupportAssignmentView {
|
|
82
|
+
return {
|
|
83
|
+
id: assignment.id,
|
|
84
|
+
assigneeRef: assignment.assigneeRef ?? null,
|
|
85
|
+
assigneeName: assignment.assigneeName ?? null,
|
|
86
|
+
previousRef: assignment.previousRef ?? null,
|
|
87
|
+
previousName: assignment.previousName ?? null,
|
|
88
|
+
actorType: assignment.actorType,
|
|
89
|
+
assignedBy: assignment.assignedBy,
|
|
90
|
+
note: assignment.note ?? null,
|
|
91
|
+
createdAt: assignment.createdAt.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
|
|
72
96
|
export function listedThreadView(thread: ListedThread): SupportListedThreadView {
|
|
73
97
|
return { ...threadView(thread), read: thread.read, snoozedUntil: iso(thread.snoozedUntil) };
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
/** Project one search result: the thread as this viewer sees it, and its best message's raw scores. */
|
|
101
|
+
export function searchResultView(result: SearchResult): SupportSearchResultView {
|
|
102
|
+
return {
|
|
103
|
+
thread: listedThreadView(result.thread),
|
|
104
|
+
messageId: result.messageId,
|
|
105
|
+
...(result.bm25 !== undefined ? { bm25: result.bm25 } : {}),
|
|
106
|
+
...(result.similarity !== undefined ? { similarity: result.similarity } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Project a whole search. */
|
|
111
|
+
export function searchView(outcome: SearchOutcome): SupportSearchResponse {
|
|
112
|
+
return {
|
|
113
|
+
mode: outcome.mode,
|
|
114
|
+
backends: { ran: [...outcome.ran], skipped: outcome.skipped.map((skip) => ({ ...skip })) },
|
|
115
|
+
count: outcome.results.length,
|
|
116
|
+
results: outcome.results.map(searchResultView),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
76
120
|
/**
|
|
77
121
|
* Project one message.
|
|
78
122
|
*
|
package/src/inbound/ingest.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParamet
|
|
|
7
7
|
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
8
8
|
import { attachmentKey, putAttachment, rawMessageKey, sha256Hex } from "../attachment/store";
|
|
9
9
|
import { SupportAuditActions } from "../audit/actions";
|
|
10
|
-
import type
|
|
10
|
+
import { type SupportConfig, supportSemantic } from "../config/config";
|
|
11
11
|
import { SupportAttachment } from "../data/attachment";
|
|
12
12
|
import { SupportMessage } from "../data/message";
|
|
13
13
|
import {
|
|
@@ -520,7 +520,10 @@ export async function ingestInbound(deps: IngestDeps, input: IngestInput): Promi
|
|
|
520
520
|
// 9. Classification, last and allowed to fail. An out-of-office is not worth an inference, and an
|
|
521
521
|
// adopter who turned classification off should pay nothing for it.
|
|
522
522
|
const classifying = deps.config.ai.enabled && !parsed.autoSubmitted;
|
|
523
|
-
|
|
523
|
+
// The same Workflow embeds the message when semantic search is on, so it runs for either reason —
|
|
524
|
+
// an inbox with classification off and semantic search on still wants its mail findable by meaning.
|
|
525
|
+
const embedding = supportSemantic(deps.config.search) !== undefined && !parsed.autoSubmitted;
|
|
526
|
+
if (classifying || embedding) {
|
|
524
527
|
try {
|
|
525
528
|
await deps.dispatchClassify(messageId);
|
|
526
529
|
} catch (error) {
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,9 @@ export {
|
|
|
25
25
|
SupportGuardConfig,
|
|
26
26
|
SupportReplyConfig,
|
|
27
27
|
SupportSearchConfig,
|
|
28
|
+
SupportStaffConfig,
|
|
28
29
|
} from "./config/config";
|
|
30
|
+
export { SupportAssignment, SupportAssignmentActorType, UNASSIGNED } from "./data/assignment";
|
|
29
31
|
export { SupportAttachment } from "./data/attachment";
|
|
30
32
|
export {
|
|
31
33
|
DEFAULT_SUPPORT_CATEGORIES,
|
|
@@ -37,6 +39,7 @@ export { SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZ
|
|
|
37
39
|
export { SupportThreadFlag } from "./data/flag";
|
|
38
40
|
export { SupportMessage } from "./data/message";
|
|
39
41
|
export {
|
|
42
|
+
SUPPORT_ASSIGNMENTS_TABLE,
|
|
40
43
|
SUPPORT_ATTACHMENTS_TABLE,
|
|
41
44
|
SUPPORT_CLASSIFICATIONS_TABLE,
|
|
42
45
|
SUPPORT_FLAGS_TABLE,
|
|
@@ -50,11 +53,13 @@ export { SupportThread } from "./data/thread";
|
|
|
50
53
|
export {
|
|
51
54
|
SUPPORT_CONTROL_PLANE_SCOPES,
|
|
52
55
|
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
56
|
+
SUPPORT_THREADS_ASSIGN_SCOPE,
|
|
53
57
|
SUPPORT_THREADS_FLAG_SCOPE,
|
|
54
58
|
SUPPORT_THREADS_READ_SCOPE,
|
|
55
59
|
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
56
60
|
SUPPORT_THREADS_REPLY_SCOPE,
|
|
57
61
|
} from "./http/scopes";
|
|
62
|
+
export { SUPPORT_THREAD_ACTIVITIES, SUPPORT_THREAD_ROOM } from "./presence/room";
|
|
58
63
|
export {
|
|
59
64
|
DEFAULT_SUPPORT_REPLIES,
|
|
60
65
|
defineSupportReplies,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who has a thread (#816): the current assignee on `pithy_support_threads`, and every change of it in a
|
|
9
|
+
* new append-only `pithy_support_assignments`.
|
|
10
|
+
*
|
|
11
|
+
* **A `0002`, appended, never folded into `0001`.** `@pithy-sh/support` has shipped, so its chain is
|
|
12
|
+
* history.
|
|
13
|
+
*
|
|
14
|
+
* **Four nullable columns and no backfill.** Every thread that exists when this runs has no assignee, and
|
|
15
|
+
* null is exactly that.
|
|
16
|
+
*
|
|
17
|
+
* **One index for the filter.** The inbox lists `archived`, newest first, and `assignee=<ref>` narrows it;
|
|
18
|
+
* `(assigneeRef, archived, lastMessageAt, id)` serves that page and its cursor the way
|
|
19
|
+
* `pithySupportThreadsArchivedIdx` serves the unfiltered one. `assignee=none` reads the unfiltered index
|
|
20
|
+
* and is a residual predicate on it.
|
|
21
|
+
*/
|
|
22
|
+
export const support_0002_assignment: Migration = {
|
|
23
|
+
async up(db: Kysely<unknown>): Promise<void> {
|
|
24
|
+
await db.schema.alterTable("pithySupportThreads").addColumn("assigneeRef", "text").execute();
|
|
25
|
+
await db.schema.alterTable("pithySupportThreads").addColumn("assigneeName", "text").execute();
|
|
26
|
+
await db.schema.alterTable("pithySupportThreads").addColumn("assignedAt", "integer").execute();
|
|
27
|
+
await db.schema.alterTable("pithySupportThreads").addColumn("assignedBy", "text").execute();
|
|
28
|
+
await db.schema
|
|
29
|
+
.createIndex("pithySupportThreadsAssigneeIdx")
|
|
30
|
+
.on("pithySupportThreads")
|
|
31
|
+
.columns(["assigneeRef", "archived", "lastMessageAt", "id"])
|
|
32
|
+
.execute();
|
|
33
|
+
|
|
34
|
+
await db.schema
|
|
35
|
+
.createTable("pithySupportAssignments")
|
|
36
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
37
|
+
.addColumn("threadId", "text", (c) => c.notNull())
|
|
38
|
+
.addColumn("assigneeRef", "text")
|
|
39
|
+
.addColumn("assigneeName", "text")
|
|
40
|
+
.addColumn("previousRef", "text")
|
|
41
|
+
.addColumn("previousName", "text")
|
|
42
|
+
.addColumn("actorType", "text", (c) => c.notNull())
|
|
43
|
+
.addColumn("assignedBy", "text", (c) => c.notNull())
|
|
44
|
+
.addColumn("note", "text")
|
|
45
|
+
.addColumn("createdAt", "integer", (c) => c.notNull())
|
|
46
|
+
.execute();
|
|
47
|
+
// A thread's history, oldest first — the only way this table is read.
|
|
48
|
+
await db.schema
|
|
49
|
+
.createIndex("pithySupportAssignmentsThreadIdx")
|
|
50
|
+
.on("pithySupportAssignments")
|
|
51
|
+
.columns(["threadId", "createdAt"])
|
|
52
|
+
.execute();
|
|
53
|
+
},
|
|
54
|
+
async down(db: Kysely<unknown>): Promise<void> {
|
|
55
|
+
// The exact inverse, in reverse. The index goes before its columns, because SQLite refuses to drop a
|
|
56
|
+
// column an index still names.
|
|
57
|
+
await db.schema.dropIndex("pithySupportAssignmentsThreadIdx").execute();
|
|
58
|
+
await db.schema.dropTable("pithySupportAssignments").execute();
|
|
59
|
+
await db.schema.dropIndex("pithySupportThreadsAssigneeIdx").execute();
|
|
60
|
+
await db.schema.alterTable("pithySupportThreads").dropColumn("assignedBy").execute();
|
|
61
|
+
await db.schema.alterTable("pithySupportThreads").dropColumn("assignedAt").execute();
|
|
62
|
+
await db.schema.alterTable("pithySupportThreads").dropColumn("assigneeName").execute();
|
|
63
|
+
await db.schema.alterTable("pithySupportThreads").dropColumn("assigneeRef").execute();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The semantic index's model stamps: one row per embedded message in `pithy_support_embeddings`.
|
|
9
|
+
*
|
|
10
|
+
* **Appended, and the messages table is not touched**: a released migration is never edited, and the stamp
|
|
11
|
+
* describes a derived index, not the mail. The table exists whether or not `search.semantic` is on, because the
|
|
12
|
+
* migration set must not vary with a config flag (`store/searchIndex.ts` says why); an empty table costs nothing.
|
|
13
|
+
*
|
|
14
|
+
* No index beyond the primary key. A reprocess page reads messages in id order and joins this on `messageId`,
|
|
15
|
+
* which the key serves.
|
|
16
|
+
*/
|
|
17
|
+
export const support_0003_embeddings: Migration = {
|
|
18
|
+
async up(db: Kysely<unknown>): Promise<void> {
|
|
19
|
+
await db.schema
|
|
20
|
+
.createTable("pithySupportEmbeddings")
|
|
21
|
+
.addColumn("messageId", "text", (c) => c.primaryKey())
|
|
22
|
+
.addColumn("model", "text", (c) => c.notNull())
|
|
23
|
+
.addColumn("embeddedAt", "integer", (c) => c.notNull())
|
|
24
|
+
.execute();
|
|
25
|
+
},
|
|
26
|
+
async down(db: Kysely<unknown>): Promise<void> {
|
|
27
|
+
await db.schema.dropTable("pithySupportEmbeddings").execute();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Migration } from "kysely/migration";
|
|
5
|
+
import { support_0001_threads } from "./0001_threads";
|
|
6
|
+
import { support_0002_assignment } from "./0002_assignment";
|
|
7
|
+
import { support_0003_embeddings } from "./0003_embeddings";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every support migration, in order — the set the capability contributes and the one every test that
|
|
11
|
+
* needs today's schema runs. Append-only: a released chain is history.
|
|
12
|
+
*
|
|
13
|
+
* The full-text index is **not** here: it is a provisioned resource `pithy support provision` creates and
|
|
14
|
+
* drops (`store/searchIndex.ts`), so a config flag can never corrupt a migration set shared with every
|
|
15
|
+
* other capability in the database.
|
|
16
|
+
*/
|
|
17
|
+
export const SUPPORT_MIGRATIONS: Record<string, Migration> = {
|
|
18
|
+
"0001_threads": support_0001_threads,
|
|
19
|
+
"0002_assignment": support_0002_assignment,
|
|
20
|
+
"0003_embeddings": support_0003_embeddings,
|
|
21
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import {
|
|
6
|
+
PRESENCE_TICKET_KEY_SECRET,
|
|
7
|
+
type PresenceDeclaration,
|
|
8
|
+
type PresenceSessionGate,
|
|
9
|
+
} from "@pithy-sh/core/src/presence/declaration";
|
|
10
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
11
|
+
import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
12
|
+
import type { Context } from "hono";
|
|
13
|
+
import type { SupportWiring } from "../capability";
|
|
14
|
+
import type { SupportStaffConfig } from "../config/config";
|
|
15
|
+
import { SUPPORT_THREADS_READ_SCOPE } from "../http/scopes";
|
|
16
|
+
import { resolveSubmitterAccount } from "../link/sender";
|
|
17
|
+
import { supportPresenceRegistry } from "../secret/registry";
|
|
18
|
+
import { staffMay } from "../staff/staff";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Who else has a support thread open (#816) — support's one room kind on core's presence seam (#815).
|
|
22
|
+
*
|
|
23
|
+
* Anyone who may read a thread may see who else has it open, so the room joins with the scope that already
|
|
24
|
+
* reads it, `support:threads:read`, and adds none of its own. A management client mints its ticket at
|
|
25
|
+
* `POST /control-plane/presence/tickets`; a signed-in user of the app mints one at
|
|
26
|
+
* `POST {basePath}/presence/tickets` when `support({ staff })` names a read power their role holds. The room
|
|
27
|
+
* holds nothing but who is here and writes nothing anywhere.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The room kind: one room per thread, keyed by the thread's id. */
|
|
31
|
+
export const SUPPORT_THREAD_ROOM = "support.thread";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a participant may announce. `viewing` first, because the first is what a participant joins as;
|
|
35
|
+
* `replying` is announced by the client when its reply form opens, and cleared when it closes.
|
|
36
|
+
*/
|
|
37
|
+
export const SUPPORT_THREAD_ACTIVITIES = ["viewing", "replying"] as const;
|
|
38
|
+
|
|
39
|
+
/** The account name a staff participant joins under, or null when it cannot be read. */
|
|
40
|
+
export type StaffName = (c: Context<PithyHonoEnv>, userId: string) => Promise<{ name?: string } | null>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The session gate: the read power in the organization this request acts for, the same question the staff
|
|
44
|
+
* routes ask. Admits under the account's own name; with none to read, core falls back to the user id.
|
|
45
|
+
*/
|
|
46
|
+
export function staffPresenceGate(staff: SupportStaffConfig, nameOf: StaffName): PresenceSessionGate {
|
|
47
|
+
return async (c) => {
|
|
48
|
+
const userId = c.var.auth?.userId;
|
|
49
|
+
if (!userId) return null;
|
|
50
|
+
if (!staffMay(await c.var.actingOrganization(), staff, "readPower")) return null;
|
|
51
|
+
const account = await nameOf(c, userId);
|
|
52
|
+
return account?.name ? { name: account.name } : {};
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The ticket signing keys, read through `@pithy-sh/secrets` per request — the current version signs and
|
|
58
|
+
* every version still honored verifies, so a rotation never strands a socket mid-handshake.
|
|
59
|
+
*/
|
|
60
|
+
async function ticketKeys(env: Record<string, unknown>): Promise<string[]> {
|
|
61
|
+
const secrets = await sharedSecretsStore(env as unknown as SecretsStoreEnv, supportPresenceRegistry);
|
|
62
|
+
const { currentVersion, versions } = secrets.getVersions(PRESENCE_TICKET_KEY_SECRET);
|
|
63
|
+
const current = versions[currentVersion];
|
|
64
|
+
return [
|
|
65
|
+
...(current === undefined ? [] : [current]),
|
|
66
|
+
...Object.entries(versions)
|
|
67
|
+
.filter(([version]) => version !== currentVersion)
|
|
68
|
+
.map(([, value]) => value),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Support's presence declaration, built from its resolved mount and staff config. */
|
|
73
|
+
export function supportPresence(basePath: string, wiring: SupportWiring): PresenceDeclaration {
|
|
74
|
+
const staff = wiring.config.staff;
|
|
75
|
+
return {
|
|
76
|
+
basePath,
|
|
77
|
+
ticketKey: ticketKeys,
|
|
78
|
+
rooms: [
|
|
79
|
+
{
|
|
80
|
+
kind: SUPPORT_THREAD_ROOM,
|
|
81
|
+
scope: SUPPORT_THREADS_READ_SCOPE,
|
|
82
|
+
activities: SUPPORT_THREAD_ACTIVITIES,
|
|
83
|
+
...(staff
|
|
84
|
+
? {
|
|
85
|
+
session: staffPresenceGate(staff, async (c, userId) => {
|
|
86
|
+
const d1 = (c.env as unknown as { DB?: Parameters<typeof resolveSubmitterAccount>[0] }).DB;
|
|
87
|
+
return d1 ? resolveSubmitterAccount(d1, userId, wiring.peers) : null;
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -113,6 +113,24 @@ export interface SupportProvisioner {
|
|
|
113
113
|
* and backfills the gap, and says which of the three it did.
|
|
114
114
|
*/
|
|
115
115
|
ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean; repaired: boolean }>;
|
|
116
|
+
/**
|
|
117
|
+
* The semantic message index in this environment, **before** the worker that binds it is deployed:
|
|
118
|
+
* created when `search.semantic` is on and it does not exist yet, untouched otherwise. Before, because a
|
|
119
|
+
* deploy that binds an index nobody created fails. Idempotent — an index that exists is reused as-is, and
|
|
120
|
+
* one with a different shape is refused rather than silently queried in the wrong space.
|
|
121
|
+
*/
|
|
122
|
+
ensureSemanticIndex(env: ManagedEnvironment): Promise<{ indexName: string; created: boolean }>;
|
|
123
|
+
/**
|
|
124
|
+
* The semantic message index in this environment, **after** the worker is deployed:
|
|
125
|
+
*
|
|
126
|
+
* - On, and holding nothing yet — `created` by this run, or holding no stamp while inbound mail exists, which
|
|
127
|
+
* is what a run that failed after creating it leaves: backfill it once, by starting the reprocess Workflow
|
|
128
|
+
* over every inbound message. An index holding any stamp is never re-embedded here — that is
|
|
129
|
+
* `pithy vector reprocess`.
|
|
130
|
+
* - Off: drop the index if it exists, and the model stamps with it, since every stamp now names a vector
|
|
131
|
+
* that is gone. After the deploy, because the worker that bound it no longer does.
|
|
132
|
+
*/
|
|
133
|
+
settleSemanticIndex(env: ManagedEnvironment, created: boolean): Promise<{ dropped: boolean; backfilled: boolean }>;
|
|
116
134
|
/**
|
|
117
135
|
* Ensure the inbound Email Routing rule that delivers the support address to the app worker.
|
|
118
136
|
* Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
|
|
@@ -128,6 +146,14 @@ export interface SupportProvisionResult {
|
|
|
128
146
|
environments: ManagedEnvironment[];
|
|
129
147
|
/** What the full-text index did, per environment — created, dropped, repaired, or already correct. */
|
|
130
148
|
search: Array<{ env: ManagedEnvironment; created: boolean; dropped: boolean; repaired: boolean }>;
|
|
149
|
+
/** What the semantic message index did, per environment — created and backfilled, dropped, or unchanged. */
|
|
150
|
+
semantic: Array<{
|
|
151
|
+
env: ManagedEnvironment;
|
|
152
|
+
indexName: string;
|
|
153
|
+
created: boolean;
|
|
154
|
+
dropped: boolean;
|
|
155
|
+
backfilled: boolean;
|
|
156
|
+
}>;
|
|
131
157
|
/** Whether the inbound routing rule was created, already present, or skipped. */
|
|
132
158
|
routing: { created: boolean; skipped: boolean };
|
|
133
159
|
}
|
|
@@ -155,19 +181,25 @@ export async function provisionSupport(
|
|
|
155
181
|
await provisioner.preflight();
|
|
156
182
|
const bucket = await provisioner.ensureBucket();
|
|
157
183
|
const search: SupportProvisionResult["search"] = [];
|
|
184
|
+
const semantic: SupportProvisionResult["semantic"] = [];
|
|
158
185
|
const deployed = managedEnvironments(environments);
|
|
159
186
|
for (const env of deployed) {
|
|
187
|
+
// The message index first: the worker about to be deployed binds it, and a binding to an index nobody
|
|
188
|
+
// created fails the deploy.
|
|
189
|
+
const index = await provisioner.ensureSemanticIndex(env);
|
|
160
190
|
await provisioner.deployWorker(env);
|
|
161
191
|
// Per environment, because each has its own app database — and after the worker, so a database
|
|
162
192
|
// that gains the index always has something able to write to it.
|
|
163
193
|
search.push({ env, ...(await provisioner.ensureSearchIndex(env)) });
|
|
194
|
+
// The backfill runs on the worker just deployed, and a drop waits until no worker binds the index.
|
|
195
|
+
semantic.push({ env, ...index, ...(await provisioner.settleSemanticIndex(env, index.created)) });
|
|
164
196
|
}
|
|
165
197
|
// Only when at least one classification host is up. The rule is what starts delivering real customer
|
|
166
198
|
// mail, so making it over a run that deployed nothing is the same window the ordering above closes,
|
|
167
199
|
// widened to the whole project.
|
|
168
200
|
const routing =
|
|
169
201
|
deployed.length > 0 ? await provisioner.ensureRoutingRule() : { created: false, skipped: true as const };
|
|
170
|
-
return { bucket, environments: deployed, routing, search };
|
|
202
|
+
return { bucket, environments: deployed, routing, search, semantic };
|
|
171
203
|
}
|
|
172
204
|
|
|
173
205
|
/** The teardown seam — the inverse of {@link SupportProvisioner}. Every step idempotent. */
|
|
@@ -5,6 +5,7 @@ import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
|
|
|
5
5
|
import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
6
6
|
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
7
7
|
import type { SupportConfig } from "../config/config";
|
|
8
|
+
import { supportMessageIndex, supportMessageIndexName } from "../semantic/messageIndex";
|
|
8
9
|
import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -60,21 +61,31 @@ export function resolveSupportConfig(
|
|
|
60
61
|
): WorkflowHostTemplate {
|
|
61
62
|
const feature = params.feature;
|
|
62
63
|
const { project, env } = params;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// account-scoped, so it has to carry the project, and only the registry knows the job.
|
|
73
|
-
workflows: hostWorkflowsFor(supportWorkflowRegistry, {
|
|
64
|
+
// The semantic message index, only when `search.semantic` is on. The template carries no `vectorize`
|
|
65
|
+
// entry because most inboxes have none; one is added here, named the way provisioning created it.
|
|
66
|
+
const index = supportMessageIndex(params.supportConfig.search);
|
|
67
|
+
const indexName = index ? supportMessageIndexName(project, env, feature) : undefined;
|
|
68
|
+
return resolveWorkflowHost(
|
|
69
|
+
index && indexName
|
|
70
|
+
? { ...template, vectorize: [{ binding: index.binding, index_name: indexName, remote: true }] }
|
|
71
|
+
: template,
|
|
72
|
+
{
|
|
74
73
|
project,
|
|
75
74
|
capability: SUPPORT_CAPABILITY,
|
|
76
75
|
env,
|
|
77
76
|
...(feature ? { feature } : {}),
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
databaseIds: { DB: params.appDatabaseId },
|
|
78
|
+
...(index && indexName ? { vectorizeIndexNames: { [index.binding]: indexName } } : {}),
|
|
79
|
+
remoteBindings: index ? ["AI", index.binding] : ["AI"],
|
|
80
|
+
vars: { SUPPORT_CONFIG: JSON.stringify(params.supportConfig) },
|
|
81
|
+
// The classification Workflow, derived from support's own specs. A Workflow name is
|
|
82
|
+
// account-scoped, so it has to carry the project, and only the registry knows the job.
|
|
83
|
+
workflows: hostWorkflowsFor(supportWorkflowRegistry, {
|
|
84
|
+
project,
|
|
85
|
+
capability: SUPPORT_CAPABILITY,
|
|
86
|
+
env,
|
|
87
|
+
...(feature ? { feature } : {}),
|
|
88
|
+
}).workflows,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
80
91
|
}
|