@pithy-sh/support 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +68 -0
- package/pithy.manifest.json +40 -0
- package/src/ai/classify.ts +239 -0
- package/src/attachment/store.ts +78 -0
- package/src/audit/actions.ts +71 -0
- package/src/capability.ts +293 -0
- package/src/client/projection.ts +60 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +400 -0
- package/src/data/attachment.ts +65 -0
- package/src/data/billingScope.ts +32 -0
- package/src/data/categories.ts +117 -0
- package/src/data/classification.ts +50 -0
- package/src/data/enums.ts +90 -0
- package/src/data/flag.ts +37 -0
- package/src/data/message.ts +224 -0
- package/src/data/tables.ts +58 -0
- package/src/data/thread.ts +138 -0
- package/src/error/errors.ts +133 -0
- package/src/http/guards.ts +59 -0
- package/src/http/handlers.ts +418 -0
- package/src/http/resolve.ts +109 -0
- package/src/http/responses.ts +506 -0
- package/src/http/routes.ts +272 -0
- package/src/http/schemas.ts +251 -0
- package/src/http/scopes.ts +117 -0
- package/src/http/views.ts +169 -0
- package/src/inbound/authenticity.ts +114 -0
- package/src/inbound/guard.ts +127 -0
- package/src/inbound/handler.ts +102 -0
- package/src/inbound/ingest.ts +548 -0
- package/src/inbound/recipient.ts +67 -0
- package/src/index.ts +63 -0
- package/src/link/sender.ts +334 -0
- package/src/migrations/0001_threads.ts +296 -0
- package/src/mime/address.ts +37 -0
- package/src/mime/parse.ts +299 -0
- package/src/mime/sanitize.ts +253 -0
- package/src/mime/threading.ts +127 -0
- package/src/mime/truncate.ts +55 -0
- package/src/provision/provisionSupport.ts +179 -0
- package/src/provision/resolveSupportConfig.ts +67 -0
- package/src/reply/send.ts +322 -0
- package/src/reply/snippets.ts +167 -0
- package/src/secret/registry.ts +24 -0
- package/src/seeds/example.ts +385 -0
- package/src/store/paging.ts +22 -0
- package/src/store/search.ts +197 -0
- package/src/store/searchIndex.ts +71 -0
- package/src/store/threads.ts +452 -0
- package/src/submission/encoding.ts +66 -0
- package/src/submission/guard.ts +120 -0
- package/src/submission/submit.ts +539 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/classify.ts +164 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +61 -0
- package/src/workflows/worker.ts +82 -0
- package/src/workflows/wrangler.jsonc +46 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
|
|
5
|
+
import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
import { SupportClassification } from "../data/classification";
|
|
7
|
+
import { SupportMessage } from "../data/message";
|
|
8
|
+
import { SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables";
|
|
9
|
+
import { SupportThread } from "../data/thread";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Four conversations for the canonical example cast, spread across the shapes a dashboard has to
|
|
13
|
+
* render.
|
|
14
|
+
*
|
|
15
|
+
* **Ada** is angry about a double charge — `billing`, `urgent`, and she is the one with a live Apple
|
|
16
|
+
* subscription in the payments seed, so a thread view opened on her shows the linkage working end to
|
|
17
|
+
* end rather than an empty panel. **Grace** cannot sign in — `account_access`, `normal`, two
|
|
18
|
+
* messages, so the thread view has an actual conversation in it and the reply path has something
|
|
19
|
+
* threaded to attach to. **Alan** sent a feature request that has been dealt with — `feature_request`,
|
|
20
|
+
* `low`, `positive`, and **archived**, so the done pile is not empty on a fresh backend and the
|
|
21
|
+
* archived filter is demonstrably doing something.
|
|
22
|
+
*
|
|
23
|
+
* **Grace again, from inside the app** — a `bug_report` filed through `POST /support/feedback`, so the
|
|
24
|
+
* fixture carries both channels and the same person on each. That pairing is the demonstration: her
|
|
25
|
+
* mail thread is linked to her account by an address in a header nobody proved, and her app thread is
|
|
26
|
+
* linked by a session, and a console that renders the two identically has a bug this seed will show
|
|
27
|
+
* you. It is also the only row carrying submission context, which is what a bug report knows and a
|
|
28
|
+
* piece of mail cannot — and the only row carrying a `declaredCategory`, because it is the only sender
|
|
29
|
+
* who was offered a chooser.
|
|
30
|
+
*
|
|
31
|
+
* **And it deliberately disagrees with itself.** Grace filed it under "something is missing" and the
|
|
32
|
+
* model read the text and called it a bug, so `declaredCategory` is `feature_request` while `category`
|
|
33
|
+
* is `bug_report`. That is the ordinary case rather than a contrived one — people file "X does not
|
|
34
|
+
* work for me" under a missing feature constantly — and it is the row that makes the rendering mistake
|
|
35
|
+
* visible before it ships: a console that shows one word, or shows the wrong one, has a bug this seed
|
|
36
|
+
* will hand you. Every mail thread's is null, because nobody asked its sender anything.
|
|
37
|
+
*
|
|
38
|
+
* That is one row per priority, all four sentiments, four categories, both channels, both link
|
|
39
|
+
* provenances, both a stated and an unstated category, and both sides of `archived` — the states
|
|
40
|
+
* anything reading these tables has to handle, without anybody authoring a fixture first.
|
|
41
|
+
*
|
|
42
|
+
* **Everything is fixed — the ids and the clock.** `pithy seed` is `INSERT OR IGNORE`, so a generated
|
|
43
|
+
* UUID would give Ada a second complaint on every run.
|
|
44
|
+
*
|
|
45
|
+
* **No attachments, and no raw messages.** Both would mean seeding R2 objects the D1 rows point at,
|
|
46
|
+
* and a fixture whose rows name bytes that are not there is worse than one that claims less. The
|
|
47
|
+
* classifications are seeded because they are the interesting half: a thread carries a model id, so
|
|
48
|
+
* the seed shows what "which rows came from which model" looks like before anybody has run one.
|
|
49
|
+
*
|
|
50
|
+
* Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and
|
|
51
|
+
* `staging` — an example fixture never targets prod, whatever that setting says.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Where this set sorts. After auth's users (100) and payments' purchases (250), which it links to. */
|
|
55
|
+
const SUPPORT_EXAMPLE_SEED_ORDER = 260;
|
|
56
|
+
|
|
57
|
+
/** Fixed ids, so the fixture is idempotent and a classification cannot drift off its thread. */
|
|
58
|
+
const BILLING_THREAD_ID = "a1b2c3d4-0001-4a11-9c01-0e1f2a3b4c50";
|
|
59
|
+
const ACCESS_THREAD_ID = "a1b2c3d4-0002-4a11-9c01-0e1f2a3b4c51";
|
|
60
|
+
const FEATURE_THREAD_ID = "a1b2c3d4-0003-4a11-9c01-0e1f2a3b4c52";
|
|
61
|
+
const APP_THREAD_ID = "a1b2c3d4-0004-4a11-9c01-0e1f2a3b4c53";
|
|
62
|
+
|
|
63
|
+
const BILLING_MESSAGE_ID = "b1b2c3d4-0001-4a11-9c01-0e1f2a3b4c60";
|
|
64
|
+
const ACCESS_MESSAGE_ID = "b1b2c3d4-0002-4a11-9c01-0e1f2a3b4c61";
|
|
65
|
+
const ACCESS_REPLY_ID = "b1b2c3d4-0003-4a11-9c01-0e1f2a3b4c62";
|
|
66
|
+
const FEATURE_MESSAGE_ID = "b1b2c3d4-0004-4a11-9c01-0e1f2a3b4c63";
|
|
67
|
+
const APP_MESSAGE_ID = "b1b2c3d4-0005-4a11-9c01-0e1f2a3b4c64";
|
|
68
|
+
|
|
69
|
+
const BILLING_CLASSIFICATION_ID = "c1b2c3d4-0001-4a11-9c01-0e1f2a3b4c70";
|
|
70
|
+
const ACCESS_CLASSIFICATION_ID = "c1b2c3d4-0002-4a11-9c01-0e1f2a3b4c71";
|
|
71
|
+
const FEATURE_CLASSIFICATION_ID = "c1b2c3d4-0003-4a11-9c01-0e1f2a3b4c72";
|
|
72
|
+
const APP_CLASSIFICATION_ID = "c1b2c3d4-0004-4a11-9c01-0e1f2a3b4c73";
|
|
73
|
+
|
|
74
|
+
/** The inbox these threads arrived on — a subdomain, which is the only safe shape for a real one. */
|
|
75
|
+
const INBOX = "support@help.example.com";
|
|
76
|
+
|
|
77
|
+
/** The model the seeded classifications claim to have come from. */
|
|
78
|
+
const MODEL = "@cf/meta/llama-3.1-8b-instruct";
|
|
79
|
+
|
|
80
|
+
/** One fixed moment. The demo is about state, not about a calendar. */
|
|
81
|
+
const AT = new Date("2026-01-10T09:00:00.000Z");
|
|
82
|
+
const LATER = new Date("2026-01-10T11:30:00.000Z");
|
|
83
|
+
const ARCHIVED_AT = new Date("2026-01-11T16:00:00.000Z");
|
|
84
|
+
|
|
85
|
+
export const supportExampleSeed: SeedSet = defineSeed({
|
|
86
|
+
name: "example",
|
|
87
|
+
order: SUPPORT_EXAMPLE_SEED_ORDER,
|
|
88
|
+
environments: ["dev", "staging"],
|
|
89
|
+
example: true,
|
|
90
|
+
d1: [
|
|
91
|
+
d1SeedGroup("app", SUPPORT_THREADS_TABLE, SupportThread, [
|
|
92
|
+
{
|
|
93
|
+
id: BILLING_THREAD_ID,
|
|
94
|
+
channel: "email",
|
|
95
|
+
inboxAddress: INBOX,
|
|
96
|
+
subject: "I was charged twice this month",
|
|
97
|
+
fromAddress: EXAMPLE_ADA.email,
|
|
98
|
+
fromName: EXAMPLE_ADA.name,
|
|
99
|
+
// Authenticated, which is what lets the seeded thread show a real customer link.
|
|
100
|
+
senderAuthenticated: true,
|
|
101
|
+
userId: EXAMPLE_ADA.id,
|
|
102
|
+
accountLinkSource: "email_address",
|
|
103
|
+
category: "billing",
|
|
104
|
+
priority: "urgent",
|
|
105
|
+
sentiment: "angry",
|
|
106
|
+
confidence: 0.94,
|
|
107
|
+
model: MODEL,
|
|
108
|
+
classifiedAt: AT,
|
|
109
|
+
archived: false,
|
|
110
|
+
archivedAt: null,
|
|
111
|
+
archivedBy: null,
|
|
112
|
+
messageCount: 1,
|
|
113
|
+
firstMessageAt: AT,
|
|
114
|
+
lastMessageAt: AT,
|
|
115
|
+
createdAt: AT,
|
|
116
|
+
updatedAt: AT,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: ACCESS_THREAD_ID,
|
|
120
|
+
channel: "email",
|
|
121
|
+
inboxAddress: INBOX,
|
|
122
|
+
subject: "Magic link never arrives",
|
|
123
|
+
fromAddress: EXAMPLE_GRACE.email,
|
|
124
|
+
fromName: EXAMPLE_GRACE.name,
|
|
125
|
+
senderAuthenticated: true,
|
|
126
|
+
userId: EXAMPLE_GRACE.id,
|
|
127
|
+
accountLinkSource: "email_address",
|
|
128
|
+
category: "account_access",
|
|
129
|
+
priority: "normal",
|
|
130
|
+
sentiment: "frustrated",
|
|
131
|
+
confidence: 0.81,
|
|
132
|
+
model: MODEL,
|
|
133
|
+
classifiedAt: AT,
|
|
134
|
+
archived: false,
|
|
135
|
+
archivedAt: null,
|
|
136
|
+
archivedBy: null,
|
|
137
|
+
// Two: her message and the answer, so a thread view has a conversation in it.
|
|
138
|
+
messageCount: 2,
|
|
139
|
+
firstMessageAt: AT,
|
|
140
|
+
lastMessageAt: LATER,
|
|
141
|
+
createdAt: AT,
|
|
142
|
+
updatedAt: LATER,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: FEATURE_THREAD_ID,
|
|
146
|
+
channel: "email",
|
|
147
|
+
inboxAddress: INBOX,
|
|
148
|
+
subject: "Could you add CSV export?",
|
|
149
|
+
fromAddress: EXAMPLE_ALAN.email,
|
|
150
|
+
fromName: EXAMPLE_ALAN.name,
|
|
151
|
+
senderAuthenticated: true,
|
|
152
|
+
userId: EXAMPLE_ALAN.id,
|
|
153
|
+
accountLinkSource: "email_address",
|
|
154
|
+
category: "feature_request",
|
|
155
|
+
priority: "low",
|
|
156
|
+
sentiment: "positive",
|
|
157
|
+
confidence: 0.88,
|
|
158
|
+
model: MODEL,
|
|
159
|
+
classifiedAt: AT,
|
|
160
|
+
// Done. The archived filter needs something to find, and the done pile needs to not be empty
|
|
161
|
+
// on a fresh backend.
|
|
162
|
+
archived: true,
|
|
163
|
+
archivedAt: ARCHIVED_AT,
|
|
164
|
+
archivedBy: "example-dashboard",
|
|
165
|
+
messageCount: 1,
|
|
166
|
+
firstMessageAt: AT,
|
|
167
|
+
lastMessageAt: AT,
|
|
168
|
+
createdAt: AT,
|
|
169
|
+
updatedAt: ARCHIVED_AT,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: APP_THREAD_ID,
|
|
173
|
+
// The other channel. Grace already has a mail thread above, and having the same person on both
|
|
174
|
+
// is the point: a console that cannot tell her session-proven link from her header-inferred one
|
|
175
|
+
// is rendering two very different pieces of evidence as one fact.
|
|
176
|
+
channel: "app",
|
|
177
|
+
// Populated even on an app thread: it is the address a reply will come back to, which is the
|
|
178
|
+
// only meaning `inboxAddress` has here.
|
|
179
|
+
inboxAddress: INBOX,
|
|
180
|
+
subject: "Export button does nothing on the reports screen",
|
|
181
|
+
// Her account's own address, read from the account rather than from anything a client sent.
|
|
182
|
+
fromAddress: EXAMPLE_GRACE.email,
|
|
183
|
+
fromName: EXAMPLE_GRACE.name,
|
|
184
|
+
// True for the strongest reason available: there was no header to prove, because the session
|
|
185
|
+
// was proved before the request reached a handler.
|
|
186
|
+
senderAuthenticated: true,
|
|
187
|
+
userId: EXAMPLE_GRACE.id,
|
|
188
|
+
accountLinkSource: "session",
|
|
189
|
+
// What she said, beside what the model made of it. She reached for "something is missing"; the
|
|
190
|
+
// classifier read the text and called it a bug. Both are true, they disagree, and neither may
|
|
191
|
+
// overwrite the other — which is the entire reason these are two columns.
|
|
192
|
+
declaredCategory: "feature_request",
|
|
193
|
+
category: "bug_report",
|
|
194
|
+
priority: "normal",
|
|
195
|
+
sentiment: "neutral",
|
|
196
|
+
confidence: 0.91,
|
|
197
|
+
model: MODEL,
|
|
198
|
+
classifiedAt: AT,
|
|
199
|
+
archived: false,
|
|
200
|
+
archivedAt: null,
|
|
201
|
+
archivedBy: null,
|
|
202
|
+
messageCount: 1,
|
|
203
|
+
firstMessageAt: LATER,
|
|
204
|
+
lastMessageAt: LATER,
|
|
205
|
+
createdAt: LATER,
|
|
206
|
+
updatedAt: LATER,
|
|
207
|
+
},
|
|
208
|
+
]),
|
|
209
|
+
d1SeedGroup("app", SUPPORT_MESSAGES_TABLE, SupportMessage, [
|
|
210
|
+
{
|
|
211
|
+
id: BILLING_MESSAGE_ID,
|
|
212
|
+
threadId: BILLING_THREAD_ID,
|
|
213
|
+
direction: "inbound",
|
|
214
|
+
channel: "email",
|
|
215
|
+
submittedByUserId: null,
|
|
216
|
+
context: null,
|
|
217
|
+
mimeMessageId: "seed-billing-1@example.com",
|
|
218
|
+
mimeInReplyTo: null,
|
|
219
|
+
mimeReferences: null,
|
|
220
|
+
fromAddress: EXAMPLE_ADA.email,
|
|
221
|
+
fromName: EXAMPLE_ADA.name,
|
|
222
|
+
toAddress: INBOX,
|
|
223
|
+
subject: "I was charged twice this month",
|
|
224
|
+
textBody:
|
|
225
|
+
"My card shows two charges for the same subscription on the 3rd. I have only ever had one account. Please refund the duplicate.",
|
|
226
|
+
htmlBody: null,
|
|
227
|
+
emailJobId: null,
|
|
228
|
+
rawKey: null,
|
|
229
|
+
rawBytes: null,
|
|
230
|
+
receivedAt: AT,
|
|
231
|
+
createdAt: AT,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: ACCESS_MESSAGE_ID,
|
|
235
|
+
threadId: ACCESS_THREAD_ID,
|
|
236
|
+
direction: "inbound",
|
|
237
|
+
channel: "email",
|
|
238
|
+
submittedByUserId: null,
|
|
239
|
+
context: null,
|
|
240
|
+
mimeMessageId: "seed-access-1@example.com",
|
|
241
|
+
mimeInReplyTo: null,
|
|
242
|
+
mimeReferences: null,
|
|
243
|
+
fromAddress: EXAMPLE_GRACE.email,
|
|
244
|
+
fromName: EXAMPLE_GRACE.name,
|
|
245
|
+
toAddress: INBOX,
|
|
246
|
+
subject: "Magic link never arrives",
|
|
247
|
+
textBody:
|
|
248
|
+
"I have asked for a sign-in link four times today and none of them have arrived. I checked spam. Is something broken?",
|
|
249
|
+
htmlBody: null,
|
|
250
|
+
emailJobId: null,
|
|
251
|
+
rawKey: null,
|
|
252
|
+
rawBytes: null,
|
|
253
|
+
receivedAt: AT,
|
|
254
|
+
createdAt: AT,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: ACCESS_REPLY_ID,
|
|
258
|
+
threadId: ACCESS_THREAD_ID,
|
|
259
|
+
direction: "outbound",
|
|
260
|
+
// How this answer was delivered: it went out through the durable send path, so it carries the
|
|
261
|
+
// job id below. An answer stored for the app instead would be `app` and carry none.
|
|
262
|
+
channel: "email",
|
|
263
|
+
submittedByUserId: null,
|
|
264
|
+
context: null,
|
|
265
|
+
// Null, exactly as the real reply path leaves it: Cloudflare assigns the sent `Message-ID`
|
|
266
|
+
// and never tells the enqueuer, so a seeded value here would model something that cannot
|
|
267
|
+
// happen.
|
|
268
|
+
mimeMessageId: null,
|
|
269
|
+
mimeInReplyTo: "seed-access-1@example.com",
|
|
270
|
+
mimeReferences: ["seed-access-1@example.com"],
|
|
271
|
+
fromAddress: INBOX,
|
|
272
|
+
fromName: null,
|
|
273
|
+
toAddress: EXAMPLE_GRACE.email,
|
|
274
|
+
subject: "Re: Magic link never arrives",
|
|
275
|
+
textBody:
|
|
276
|
+
"Hi Grace,\n\nI found the problem — your address had bounced earlier in the week and was on our suppression list. I have cleared it. Try again and it should arrive within a minute.\n\nSorry for the trouble.\n",
|
|
277
|
+
htmlBody: null,
|
|
278
|
+
emailJobId: "seed-email-job-access-1",
|
|
279
|
+
rawKey: null,
|
|
280
|
+
rawBytes: null,
|
|
281
|
+
receivedAt: LATER,
|
|
282
|
+
createdAt: LATER,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: FEATURE_MESSAGE_ID,
|
|
286
|
+
threadId: FEATURE_THREAD_ID,
|
|
287
|
+
direction: "inbound",
|
|
288
|
+
channel: "email",
|
|
289
|
+
submittedByUserId: null,
|
|
290
|
+
context: null,
|
|
291
|
+
mimeMessageId: "seed-feature-1@example.com",
|
|
292
|
+
mimeInReplyTo: null,
|
|
293
|
+
mimeReferences: null,
|
|
294
|
+
fromAddress: EXAMPLE_ALAN.email,
|
|
295
|
+
fromName: EXAMPLE_ALAN.name,
|
|
296
|
+
toAddress: INBOX,
|
|
297
|
+
subject: "Could you add CSV export?",
|
|
298
|
+
textBody:
|
|
299
|
+
"Loving the product. The one thing I miss is being able to export my data as CSV for a spreadsheet. No rush.",
|
|
300
|
+
htmlBody: null,
|
|
301
|
+
emailJobId: null,
|
|
302
|
+
rawKey: null,
|
|
303
|
+
rawBytes: null,
|
|
304
|
+
receivedAt: AT,
|
|
305
|
+
createdAt: AT,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: APP_MESSAGE_ID,
|
|
309
|
+
threadId: APP_THREAD_ID,
|
|
310
|
+
direction: "inbound",
|
|
311
|
+
channel: "app",
|
|
312
|
+
// The session's own user id — the identity, not a match on an address.
|
|
313
|
+
submittedByUserId: EXAMPLE_GRACE.id,
|
|
314
|
+
// The only row here with context, because it is the only one whose sender had an app to ask.
|
|
315
|
+
// This is what turns "the button does nothing" into a place to look.
|
|
316
|
+
context: { screen: "reports", appVersion: "2.4.1", platform: "web", environment: "prod", locale: "en-GB" },
|
|
317
|
+
// Server-minted, so a reply carries an `In-Reply-To` her mail client will echo back — without
|
|
318
|
+
// it her answer opens a second thread and the conversation fragments.
|
|
319
|
+
mimeMessageId: `${APP_MESSAGE_ID}@help.example.com`,
|
|
320
|
+
mimeInReplyTo: null,
|
|
321
|
+
mimeReferences: null,
|
|
322
|
+
fromAddress: EXAMPLE_GRACE.email,
|
|
323
|
+
fromName: EXAMPLE_GRACE.name,
|
|
324
|
+
toAddress: INBOX,
|
|
325
|
+
subject: "Export button does nothing on the reports screen",
|
|
326
|
+
textBody:
|
|
327
|
+
"I press Export on the reports screen and nothing happens. No download, no error. It worked last week. Chrome, if that helps.",
|
|
328
|
+
htmlBody: null,
|
|
329
|
+
emailJobId: null,
|
|
330
|
+
rawKey: null,
|
|
331
|
+
rawBytes: null,
|
|
332
|
+
receivedAt: LATER,
|
|
333
|
+
createdAt: LATER,
|
|
334
|
+
},
|
|
335
|
+
]),
|
|
336
|
+
d1SeedGroup("app", SUPPORT_CLASSIFICATIONS_TABLE, SupportClassification, [
|
|
337
|
+
{
|
|
338
|
+
id: BILLING_CLASSIFICATION_ID,
|
|
339
|
+
threadId: BILLING_THREAD_ID,
|
|
340
|
+
messageId: BILLING_MESSAGE_ID,
|
|
341
|
+
category: "billing",
|
|
342
|
+
priority: "urgent",
|
|
343
|
+
sentiment: "angry",
|
|
344
|
+
confidence: 0.94,
|
|
345
|
+
model: MODEL,
|
|
346
|
+
createdAt: AT,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: ACCESS_CLASSIFICATION_ID,
|
|
350
|
+
threadId: ACCESS_THREAD_ID,
|
|
351
|
+
messageId: ACCESS_MESSAGE_ID,
|
|
352
|
+
category: "account_access",
|
|
353
|
+
priority: "normal",
|
|
354
|
+
sentiment: "frustrated",
|
|
355
|
+
confidence: 0.81,
|
|
356
|
+
model: MODEL,
|
|
357
|
+
createdAt: AT,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: APP_CLASSIFICATION_ID,
|
|
361
|
+
threadId: APP_THREAD_ID,
|
|
362
|
+
messageId: APP_MESSAGE_ID,
|
|
363
|
+
// The same classifier, over the same federated taxonomy. An app submission introduces no
|
|
364
|
+
// vocabulary of its own — that is what makes this a channel rather than a second capability.
|
|
365
|
+
category: "bug_report",
|
|
366
|
+
priority: "normal",
|
|
367
|
+
sentiment: "neutral",
|
|
368
|
+
confidence: 0.91,
|
|
369
|
+
model: MODEL,
|
|
370
|
+
createdAt: LATER,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: FEATURE_CLASSIFICATION_ID,
|
|
374
|
+
threadId: FEATURE_THREAD_ID,
|
|
375
|
+
messageId: FEATURE_MESSAGE_ID,
|
|
376
|
+
category: "feature_request",
|
|
377
|
+
priority: "low",
|
|
378
|
+
sentiment: "positive",
|
|
379
|
+
confidence: 0.88,
|
|
380
|
+
model: MODEL,
|
|
381
|
+
createdAt: AT,
|
|
382
|
+
},
|
|
383
|
+
]),
|
|
384
|
+
],
|
|
385
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inbox page bounds, in the one module that has no reason to import anything (#430).
|
|
6
|
+
*
|
|
7
|
+
* `http/schemas.ts` bounds a caller's `limit` with the same number the reader clamps to, and it used to
|
|
8
|
+
* read it out of `store/threads.ts` — the Kysely reader, which brings `kysely`, `kysely-d1` and
|
|
9
|
+
* `@cloudflare/workers-types` with it. A request schema is a client's business: a management client
|
|
10
|
+
* building a call must be able to compile the shape it may send, in a browser, with no Worker types in
|
|
11
|
+
* reach. So the two numbers moved and the reader kept the query.
|
|
12
|
+
*
|
|
13
|
+
* **Both moved, not only the one the schema needs.** They are read together — a default clamped into a
|
|
14
|
+
* maximum, on one line, in two queries — and a pair split across two modules is how the next person
|
|
15
|
+
* picks the wrong one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** How many threads a page holds by default. */
|
|
19
|
+
export const DEFAULT_PAGE_SIZE = 25;
|
|
20
|
+
|
|
21
|
+
/** The most a caller may ask for in one page. */
|
|
22
|
+
export const MAX_PAGE_SIZE = 100;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Expression, type SqlBool, sql } from "kysely";
|
|
5
|
+
import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Text search over the inbox — two backends behind one predicate.
|
|
9
|
+
*
|
|
10
|
+
* **`LIKE` is the default and FTS5 is the opt-in**, which is the opposite of what it looks like it
|
|
11
|
+
* should be. The reason is in `store/searchIndex.ts`: an FTS5 virtual table anywhere in a D1
|
|
12
|
+
* database makes `wrangler d1 export` refuse to dump *the whole database*, so turning it on by
|
|
13
|
+
* default would quietly cost every adopter their backup story for tables that have nothing to do
|
|
14
|
+
* with support. `LIKE` is an unindexed scan and is completely adequate for an inbox measured in
|
|
15
|
+
* thousands of messages, which is what this capability is for.
|
|
16
|
+
*
|
|
17
|
+
* Both paths return a **predicate over `pithy_support_threads`**, not a list of ids, so search
|
|
18
|
+
* composes with the category/archived filters and — crucially — with cursor pagination. Returning
|
|
19
|
+
* ids would mean either capping the match set (silent truncation, which reads as "that is all there
|
|
20
|
+
* is") or paginating twice over.
|
|
21
|
+
*
|
|
22
|
+
* Either way this is keyword search, deliberately. People search a support inbox for a word they
|
|
23
|
+
* remember — an order number, an error string, a surname — not for a concept. `@pithy-sh/vector` is
|
|
24
|
+
* there for anyone who wants meaning-matching, and it is the nicer demo and the wrong default: it
|
|
25
|
+
* cannot find `ORD-40912`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The longest search term accepted. Bounded because it becomes a scan predicate. */
|
|
29
|
+
const MAX_TERM = 200;
|
|
30
|
+
|
|
31
|
+
/** Escape the `LIKE` wildcards so a term containing `%` matches a literal `%`. */
|
|
32
|
+
function escapeLike(term: string): string {
|
|
33
|
+
return term.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Turn a user's words into an FTS5 MATCH expression.
|
|
38
|
+
*
|
|
39
|
+
* Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
|
|
40
|
+
* from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
|
|
41
|
+
* surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
|
|
42
|
+
* query rather than search for a word. This is the same "input is data, never syntax" rule the rest
|
|
43
|
+
* of the package applies to model output and to HTML.
|
|
44
|
+
*/
|
|
45
|
+
export function ftsQuery(term: string): string {
|
|
46
|
+
const tokens = term
|
|
47
|
+
.slice(0, MAX_TERM)
|
|
48
|
+
// FTS5 tokenizes on non-alphanumerics anyway, so anything else is punctuation we would only be
|
|
49
|
+
// handing to its parser.
|
|
50
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
51
|
+
.filter((token) => token.length > 0)
|
|
52
|
+
.slice(0, 16);
|
|
53
|
+
return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a term has anything searchable left after tokenizing. */
|
|
57
|
+
export function isSearchable(term: string, fts: boolean): boolean {
|
|
58
|
+
return fts ? ftsQuery(term).length > 0 : term.trim().length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A predicate matching threads whose messages contain `term`.
|
|
63
|
+
*
|
|
64
|
+
* Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
|
|
65
|
+
* `exists`, so it stops at the first matching message rather than counting them.
|
|
66
|
+
*/
|
|
67
|
+
export function searchPredicate(term: string, options: { fts: boolean }): Expression<SqlBool> {
|
|
68
|
+
if (options.fts) {
|
|
69
|
+
// `<table> MATCH ?` naming the table in full is the documented FTS5 form, and the only one that
|
|
70
|
+
// works: the hidden match column keeps the table's own name, so aliasing the table to `s` and
|
|
71
|
+
// writing `s match ?` is `no such column: s`. `thread_id` is UNINDEXED, so the correlation is a
|
|
72
|
+
// filter over the match set rather than part of the match.
|
|
73
|
+
return sql<SqlBool>`exists (
|
|
74
|
+
select 1 from pithy_support_search
|
|
75
|
+
where pithy_support_search.thread_id = pithy_support_threads.id
|
|
76
|
+
and pithy_support_search match ${ftsQuery(term)}
|
|
77
|
+
)`;
|
|
78
|
+
}
|
|
79
|
+
const pattern = `%${escapeLike(term.slice(0, MAX_TERM))}%`;
|
|
80
|
+
return sql<SqlBool>`exists (
|
|
81
|
+
select 1 from pithy_support_messages m
|
|
82
|
+
where m.thread_id = pithy_support_threads.id
|
|
83
|
+
and (m.subject like ${pattern} escape '\\' or m.text_body like ${pattern} escape '\\')
|
|
84
|
+
)`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Add a message to the full-text index, replacing any row already there for it.
|
|
89
|
+
*
|
|
90
|
+
* **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
|
|
91
|
+
* reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
|
|
92
|
+
* constraint to lean on, so a second call for the same message would silently add a second copy and
|
|
93
|
+
* the thread would start appearing twice in its own search results. Making the write idempotent means
|
|
94
|
+
* a retry, a reindex, and a first index are all the same operation, which is the property every other
|
|
95
|
+
* write path in this repo is built on.
|
|
96
|
+
*
|
|
97
|
+
* Best-effort by contract: a failed index write must never lose a customer's message, so the caller
|
|
98
|
+
* logs and carries on — the row is still there, still readable, still repairable by
|
|
99
|
+
* {@link reindexThread}, and only missing from one search box.
|
|
100
|
+
*/
|
|
101
|
+
export async function indexMessage(
|
|
102
|
+
db: SupportDatabase,
|
|
103
|
+
entry: { threadId: string; messageId: string; subject: string; body: string },
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
await sql`delete from pithy_support_search where message_id = ${entry.messageId}`.execute(db);
|
|
106
|
+
await sql`
|
|
107
|
+
insert into pithy_support_search(thread_id, message_id, subject, body)
|
|
108
|
+
values (${entry.threadId}, ${entry.messageId}, ${entry.subject}, ${entry.body})
|
|
109
|
+
`.execute(db);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rebuild the index for one thread — delete its rows, then re-add every message.
|
|
114
|
+
*
|
|
115
|
+
* The repair path for the failure mode application-side indexing has and triggers would not: a
|
|
116
|
+
* message stored while the index write failed. Also what a reindex after a tokenizer change runs.
|
|
117
|
+
*/
|
|
118
|
+
export async function reindexThread(db: SupportDatabase, threadId: string): Promise<number> {
|
|
119
|
+
// Clears by thread rather than relying on `indexMessage`'s per-message delete, so a row whose
|
|
120
|
+
// message has since been removed goes too — that one is unreachable by any per-message call.
|
|
121
|
+
await sql`delete from pithy_support_search where thread_id = ${threadId}`.execute(db);
|
|
122
|
+
const messages = await db
|
|
123
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
124
|
+
.select(["id", "threadId", "subject", "textBody"])
|
|
125
|
+
.where("threadId", "=", threadId)
|
|
126
|
+
.execute();
|
|
127
|
+
for (const message of messages) {
|
|
128
|
+
await indexMessage(db, {
|
|
129
|
+
threadId: message.threadId,
|
|
130
|
+
messageId: message.id,
|
|
131
|
+
subject: message.subject,
|
|
132
|
+
body: message.textBody,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return messages.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Rebuild the whole index from the messages table, in pages.
|
|
140
|
+
*
|
|
141
|
+
* **Creating the index is not the same as populating it**, and that gap is the one failure a search
|
|
142
|
+
* box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
|
|
143
|
+
* virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
|
|
144
|
+
* answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
|
|
145
|
+
* worse than returning slowly, so provisioning backfills immediately after it creates.
|
|
146
|
+
*
|
|
147
|
+
* Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
|
|
148
|
+
* during a long backfill would shift an offset underneath it and skip messages.
|
|
149
|
+
*/
|
|
150
|
+
export async function reindexAll(
|
|
151
|
+
db: SupportDatabase,
|
|
152
|
+
options: { pageSize?: number; onProgress?: (indexed: number) => void } = {},
|
|
153
|
+
): Promise<number> {
|
|
154
|
+
const pageSize = options.pageSize ?? 200;
|
|
155
|
+
// Cheaper and more obviously correct than deleting per message as we go: the index is derived, so
|
|
156
|
+
// emptying it costs nothing that is not about to be rewritten.
|
|
157
|
+
await sql`delete from pithy_support_search`.execute(db);
|
|
158
|
+
|
|
159
|
+
let cursor: { receivedAt: number; id: string } | undefined;
|
|
160
|
+
let indexed = 0;
|
|
161
|
+
|
|
162
|
+
for (;;) {
|
|
163
|
+
let page = db
|
|
164
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
165
|
+
.select(["id", "threadId", "subject", "textBody", "receivedAt"])
|
|
166
|
+
.orderBy("receivedAt", "asc")
|
|
167
|
+
.orderBy("id", "asc")
|
|
168
|
+
.limit(pageSize);
|
|
169
|
+
if (cursor) {
|
|
170
|
+
const at = cursor.receivedAt;
|
|
171
|
+
const id = cursor.id;
|
|
172
|
+
page = page.where((eb) =>
|
|
173
|
+
eb.or([eb("receivedAt", ">", at), eb.and([eb("receivedAt", "=", at), eb("id", ">", id)])]),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rows = await page.execute();
|
|
178
|
+
if (rows.length === 0) break;
|
|
179
|
+
|
|
180
|
+
for (const row of rows) {
|
|
181
|
+
await indexMessage(db, {
|
|
182
|
+
threadId: row.threadId,
|
|
183
|
+
messageId: row.id,
|
|
184
|
+
subject: row.subject,
|
|
185
|
+
body: row.textBody,
|
|
186
|
+
});
|
|
187
|
+
indexed += 1;
|
|
188
|
+
}
|
|
189
|
+
options.onProgress?.(indexed);
|
|
190
|
+
|
|
191
|
+
const last = rows[rows.length - 1];
|
|
192
|
+
if (!last || rows.length < pageSize) break;
|
|
193
|
+
cursor = { receivedAt: Number(last.receivedAt), id: last.id };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return indexed;
|
|
197
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Kysely, sql } from "kysely";
|
|
5
|
+
import type { SupportDatabase } from "../data/tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
|
|
9
|
+
* migration**.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is not a migration
|
|
12
|
+
*
|
|
13
|
+
* Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
|
|
14
|
+
* `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
|
|
15
|
+
* as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
|
|
16
|
+
* loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
|
|
17
|
+
* support already creates the same way.
|
|
18
|
+
*
|
|
19
|
+
* Making it a migration was the mistake, and the failure was specific rather than theoretical.
|
|
20
|
+
* Composing it conditionally on `search.fts` meant turning the flag **off** removed an
|
|
21
|
+
* already-applied migration from the set, and Kysely reads a previously-executed migration that has
|
|
22
|
+
* vanished as corruption:
|
|
23
|
+
*
|
|
24
|
+
* corrupted migrations: previously executed migration 1200_support_0002_search is missing
|
|
25
|
+
*
|
|
26
|
+
* Every capability's migrations for a database compose into one provider, so that throw blocked
|
|
27
|
+
* `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
|
|
28
|
+
* config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
|
|
29
|
+
* corrupt and toggling either way is just a re-provision.
|
|
30
|
+
*
|
|
31
|
+
* Both statements are idempotent, so provisioning stays safe to re-run — the property every other
|
|
32
|
+
* step of `pithy support provision` already has.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
|
|
36
|
+
export const SEARCH_TABLE = "pithy_support_search";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create the full-text index if it is not already there.
|
|
40
|
+
*
|
|
41
|
+
* A standalone (not external-content) FTS5 table. External content would store the text once rather
|
|
42
|
+
* than twice, but it requires the indexed column names to exist verbatim on the content table, and
|
|
43
|
+
* ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
|
|
44
|
+
* desynchronize on a column rename.
|
|
45
|
+
*
|
|
46
|
+
* `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
|
|
47
|
+
* conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
|
|
48
|
+
* to every query.
|
|
49
|
+
*/
|
|
50
|
+
export async function createSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
|
|
51
|
+
await sql`
|
|
52
|
+
create virtual table if not exists pithy_support_search using fts5(
|
|
53
|
+
thread_id unindexed,
|
|
54
|
+
message_id unindexed,
|
|
55
|
+
subject,
|
|
56
|
+
body,
|
|
57
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
58
|
+
)
|
|
59
|
+
`.execute(db as Kysely<unknown>);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop the full-text index if it is there.
|
|
64
|
+
*
|
|
65
|
+
* Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
|
|
66
|
+
* what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
|
|
67
|
+
* the feature off is now a one-command operation instead of a migration rollback.
|
|
68
|
+
*/
|
|
69
|
+
export async function dropSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
|
|
70
|
+
await sql`drop table if exists pithy_support_search`.execute(db as Kysely<unknown>);
|
|
71
|
+
}
|