@pithy-sh/support 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/dist/ai/classify.d.ts +92 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +153 -0
  4. package/dist/attachment/store.d.ts +55 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +69 -0
  7. package/dist/audit/actions.d.ts +70 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +70 -0
  10. package/dist/capability.d.ts +95 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +189 -0
  13. package/dist/client/projection.d.ts +58 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +2 -0
  16. package/dist/config/config.d.ts +217 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +171 -0
  19. package/dist/data/attachment.d.ts +32 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +34 -0
  22. package/dist/data/billingScope.d.ts +32 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +34 -0
  25. package/dist/data/categories.d.ts +56 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +99 -0
  28. package/dist/data/classification.d.ts +36 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +30 -0
  31. package/dist/data/enums.d.ts +84 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +72 -0
  34. package/dist/data/flag.d.ts +27 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +29 -0
  37. package/dist/data/message.d.ts +83 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +86 -0
  40. package/dist/data/tables.d.ts +162 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +46 -0
  43. package/dist/data/thread.d.ts +79 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +67 -0
  46. package/dist/error/errors.d.ts +66 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +84 -0
  49. package/dist/http/guards.d.ts +46 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +55 -0
  52. package/dist/http/handlers.d.ts +91 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +250 -0
  55. package/dist/http/resolve.d.ts +46 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +74 -0
  58. package/dist/http/responses.d.ts +831 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +305 -0
  61. package/dist/http/routes.d.ts +111 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +64 -0
  64. package/dist/http/schemas.d.ts +123 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +91 -0
  67. package/dist/http/scopes.d.ts +58 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +109 -0
  70. package/dist/http/views.d.ts +38 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +146 -0
  73. package/dist/inbound/authenticity.d.ts +83 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +63 -0
  76. package/dist/inbound/guard.d.ts +76 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +80 -0
  79. package/dist/inbound/handler.d.ts +30 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +84 -0
  82. package/dist/inbound/ingest.d.ts +106 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +328 -0
  85. package/dist/inbound/recipient.d.ts +18 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +48 -0
  88. package/dist/index.d.ts +22 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +16 -0
  91. package/dist/link/sender.d.ts +162 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +202 -0
  94. package/dist/migrations/0001_threads.d.ts +28 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +105 -0
  97. package/dist/mime/address.d.ts +24 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +30 -0
  100. package/dist/mime/parse.d.ts +98 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +148 -0
  103. package/dist/mime/sanitize.d.ts +27 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +214 -0
  106. package/dist/mime/threading.d.ts +81 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +113 -0
  109. package/dist/mime/truncate.d.ts +19 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +49 -0
  112. package/dist/provision/provisionSupport.d.ts +160 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +112 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +47 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +24 -0
  118. package/dist/reply/send.d.ts +125 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +155 -0
  121. package/dist/reply/snippets.d.ts +81 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +137 -0
  124. package/dist/secret/registry.d.ts +20 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +23 -0
  127. package/dist/seeds/example.d.ts +5 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +352 -0
  130. package/dist/store/paging.d.ts +20 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +22 -0
  133. package/dist/store/search.d.ts +69 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +162 -0
  136. package/dist/store/searchIndex.d.ts +55 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +68 -0
  139. package/dist/store/threads.d.ts +149 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +199 -0
  142. package/dist/submission/encoding.d.ts +23 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +57 -0
  145. package/dist/submission/guard.d.ts +73 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +48 -0
  148. package/dist/submission/submit.d.ts +154 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +321 -0
  151. package/dist/version.generated.d.ts +7 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +9 -0
  154. package/dist/workflows/classify.d.ts +65 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +70 -0
  157. package/dist/workflows/retryPolicy.d.ts +41 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +45 -0
  160. package/dist/workflows/specs.d.ts +45 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +48 -0
  163. package/dist/workflows/worker.d.ts +50 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +39 -0
  166. package/package.json +24 -15
  167. package/src/http/handlers.ts +1 -1
  168. package/src/submission/encoding.ts +1 -1
  169. package/src/submission/submit.ts +1 -1
  170. package/src/version.generated.ts +1 -1
@@ -0,0 +1,250 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportClassificationError, SupportNotFoundError } from "../error/errors.js";
4
+ import { repliesForCategory } from "../reply/snippets.js";
5
+ import { attachmentUrl } from "../attachment/store.js";
6
+ import { SupportAuditActions } from "../audit/actions.js";
7
+ import { resolveSenderContext, resolveSubmitterAccount, resolveSubmitterContext } from "../link/sender.js";
8
+ import { sendReply } from "../reply/send.js";
9
+ import { listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags } from "../store/threads.js";
10
+ import { decodeBase64 } from "../submission/encoding.js";
11
+ import { submitFeedback } from "../submission/submit.js";
12
+ import { latestInboundMessageId } from "../workflows/classify.js";
13
+ import { listedThreadView, messageView, myMessageView, myThreadView, senderView, threadView } from "./views.js";
14
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
15
+ //#region src/http/handlers.ts
16
+ /** List the inbox. */
17
+ async function listInbox(deps, query, viewer) {
18
+ const page = await listThreads(deps.db, {
19
+ archived: query.archived === "true",
20
+ category: query.category,
21
+ declaredCategory: query.declaredCategory,
22
+ priority: query.priority,
23
+ sentiment: query.sentiment,
24
+ channel: query.channel,
25
+ inbox: query.inbox,
26
+ q: query.q,
27
+ cursor: query.cursor,
28
+ limit: query.limit
29
+ }, {
30
+ fts: deps.fts,
31
+ viewer,
32
+ now: deps.now(),
33
+ log: deps.log
34
+ });
35
+ return {
36
+ threads: page.threads.map(listedThreadView),
37
+ nextCursor: page.nextCursor
38
+ };
39
+ }
40
+ /**
41
+ * Present an attachment.
42
+ *
43
+ * The `storageKey` is **never** in the response. It is server-derived and opaque precisely so a
44
+ * client cannot name an object or guess the one beside it, and echoing it back would hand that
45
+ * capability away for no benefit — a client cannot use a key for anything except guessing.
46
+ */
47
+ async function presentAttachments(deps, attachments) {
48
+ return Promise.all(attachments.map(async (attachment) => {
49
+ let url = null;
50
+ if (deps.store) try {
51
+ url = await attachmentUrl(deps.store, attachment.storageKey);
52
+ } catch (error) {
53
+ deps.log.warn("support attachment URL not signed", {
54
+ attachmentId: attachment.id,
55
+ error
56
+ });
57
+ }
58
+ return {
59
+ id: attachment.id,
60
+ filename: attachment.filename,
61
+ contentType: attachment.contentType,
62
+ size: attachment.size,
63
+ sha256: attachment.sha256,
64
+ inline: attachment.inline,
65
+ url
66
+ };
67
+ }));
68
+ }
69
+ /** Read one conversation in full. */
70
+ async function readConversation(deps, threadId) {
71
+ const detail = await readThread(deps.db, threadId);
72
+ const [attachments, sender] = await Promise.all([presentAttachments(deps, detail.attachments), detail.thread.accountLinkSource === "session" && detail.thread.userId ? resolveSubmitterContext(deps.d1, detail.thread.userId, deps.now()) : resolveSenderContext(deps.d1, detail.thread.fromAddress, deps.now(), { authenticated: detail.thread.senderAuthenticated })]);
73
+ return {
74
+ thread: threadView(detail.thread),
75
+ messages: detail.messages.map(messageView),
76
+ attachments,
77
+ sender: senderView(sender),
78
+ replies: repliesForCategory(deps.snippets, detail.thread.category)
79
+ };
80
+ }
81
+ /** Mark a conversation done, or reopen it. */
82
+ async function archiveConversation(deps, threadId, input, viewer) {
83
+ const thread = await setArchived(deps.db, threadId, input.archived, viewer, deps.now());
84
+ await deps.emit({
85
+ action: input.archived ? SupportAuditActions.threadArchived : SupportAuditActions.threadUnarchived,
86
+ outcome: "success",
87
+ actorType: "control-plane",
88
+ actorId: viewer,
89
+ resourceType: "support_thread",
90
+ resourceId: threadId,
91
+ metadata: { archived: input.archived }
92
+ });
93
+ return threadView(thread);
94
+ }
95
+ /** Answer the customer. */
96
+ async function replyToConversation(deps, threadId, input, viewer) {
97
+ return sendReply({
98
+ db: deps.db,
99
+ config: deps.config,
100
+ enqueue: deps.enqueue,
101
+ fts: deps.fts,
102
+ emit: deps.emit,
103
+ log: deps.log,
104
+ newId: deps.newId,
105
+ now: deps.now
106
+ }, {
107
+ threadId,
108
+ body: input.body,
109
+ agentName: input.agentName,
110
+ viewer
111
+ });
112
+ }
113
+ /** Re-run the classifier over a conversation's latest inbound message. */
114
+ async function reclassifyConversation(deps, threadId, viewer) {
115
+ const messageId = await latestInboundMessageId(deps.db, threadId);
116
+ if (!messageId) throw new SupportNotFoundError({
117
+ message: "That conversation has nothing to classify.",
118
+ detail: `thread ${threadId} has no inbound message`
119
+ });
120
+ if (!await deps.dispatchClassify(messageId)) throw new SupportClassificationError({
121
+ message: "Classification is not available on this deployment.",
122
+ action: "Run `pithy support provision` to deploy the classification worker, then retry.",
123
+ detail: `the SUPPORT_CLASSIFY workflow binding is absent, so no instance was started`
124
+ });
125
+ await deps.emit({
126
+ action: SupportAuditActions.threadReclassified,
127
+ outcome: "success",
128
+ actorType: "control-plane",
129
+ actorId: viewer,
130
+ resourceType: "support_thread",
131
+ resourceId: threadId,
132
+ metadata: { messageId }
133
+ });
134
+ return { messageId };
135
+ }
136
+ /** Set one viewer's private flags. */
137
+ async function updateFlags(deps, threadId, input, viewer) {
138
+ await setFlags(deps.db, {
139
+ threadId,
140
+ viewer,
141
+ read: input.read,
142
+ snoozedUntil: input.snoozedUntil === void 0 ? void 0 : input.snoozedUntil ? new Date(input.snoozedUntil) : null,
143
+ newId: deps.newId,
144
+ now: deps.now()
145
+ });
146
+ return { ok: true };
147
+ }
148
+ /** The canned reply catalog. */
149
+ function listReplies(deps, query) {
150
+ return repliesForCategory(deps.snippets, query.category ?? "");
151
+ }
152
+ /**
153
+ * Take an in-app support request from a signed-in user.
154
+ *
155
+ * `userId` is a parameter rather than something read out of `input`, and that is the contract this
156
+ * whole channel rests on: it comes from `c.var.auth`, which `requireAuth()` filled before the route's
157
+ * validator ran. Nothing in the submitted body names an account, and the schema has no field that
158
+ * could.
159
+ *
160
+ * The configured length bounds are applied here rather than in the schema, for the reason
161
+ * `schemas.ts` states: a request schema is built once at module load and the adopter's numbers are
162
+ * resolved per project. This is config-backed resolution, which belongs in a handler — the same rule
163
+ * that keeps `board()` and `currency()` lookups out of a param schema.
164
+ */
165
+ async function submitFeedbackRequest(deps, input, userId) {
166
+ const bounds = deps.config.submission;
167
+ if (input.subject.length > bounds.maxSubjectChars) throw new ValidationError({
168
+ message: `A subject may be at most ${bounds.maxSubjectChars} characters.`,
169
+ action: "Shorten the subject and try again.",
170
+ detail: `submitted subject is ${input.subject.length} characters, over the ${bounds.maxSubjectChars} bound`
171
+ });
172
+ if (input.body.length > bounds.maxBodyChars) throw new ValidationError({
173
+ message: `A support request may be at most ${bounds.maxBodyChars} characters.`,
174
+ action: "Shorten the message and try again, or attach the detail as a file.",
175
+ detail: `submitted body is ${input.body.length} characters, over the ${bounds.maxBodyChars} bound`
176
+ });
177
+ if (input.attachments.length > bounds.attachments.maxCount) throw new ValidationError({
178
+ message: `A support request may carry at most ${bounds.attachments.maxCount} attachments.`,
179
+ action: `Send at most ${bounds.attachments.maxCount} files.`,
180
+ detail: `submission declared ${input.attachments.length} attachments, over the ${bounds.attachments.maxCount} bound`
181
+ });
182
+ const outcome = await submitFeedback({
183
+ db: deps.db,
184
+ config: deps.config,
185
+ categories: deps.categories,
186
+ bucket: deps.bucket,
187
+ fts: deps.fts,
188
+ resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id),
189
+ dispatchClassify: deps.dispatchClassify,
190
+ emit: deps.emit,
191
+ log: deps.log,
192
+ newId: deps.newId,
193
+ now: deps.now
194
+ }, {
195
+ userId,
196
+ subject: input.subject,
197
+ body: input.body,
198
+ declaredCategory: input.declaredCategory,
199
+ threadId: input.threadId,
200
+ context: input.context,
201
+ attachments: input.attachments.map((attachment) => ({
202
+ filename: attachment.filename,
203
+ contentType: attachment.contentType,
204
+ bytes: decodeBase64(attachment.data, { maxBytes: bounds.attachments.maxBytes })
205
+ }))
206
+ });
207
+ return {
208
+ threadId: outcome.threadId,
209
+ messageId: outcome.messageId,
210
+ opened: outcome.newThread,
211
+ attachments: outcome.attachments
212
+ };
213
+ }
214
+ /** List the caller's own in-app conversations. */
215
+ async function listMyThreads(deps, query, userId) {
216
+ const page = await listOwnThreads(deps.db, userId, {
217
+ cursor: query.cursor,
218
+ limit: query.limit
219
+ });
220
+ return {
221
+ threads: page.threads.map(myThreadView),
222
+ nextCursor: page.nextCursor
223
+ };
224
+ }
225
+ /**
226
+ * Read one of the caller's own conversations.
227
+ *
228
+ * The scoping is `readOwnThread`'s `where`, so a thread belonging to somebody else raises the same
229
+ * `support/not_found` as one that never existed. The projection is `myThreadView`/`myMessageView`,
230
+ * built from scratch rather than by omitting fields from the operator's — see `views.ts` for why that
231
+ * distinction is the security boundary rather than a style preference.
232
+ */
233
+ async function readMyThread(deps, threadId, userId) {
234
+ const detail = await readOwnThread(deps.db, threadId, userId);
235
+ const attachments = await presentAttachments(deps, detail.attachments);
236
+ const byMessage = /* @__PURE__ */ new Map();
237
+ for (const [index, attachment] of detail.attachments.entries()) {
238
+ const view = attachments[index];
239
+ if (!view) continue;
240
+ const bucket = byMessage.get(attachment.messageId);
241
+ if (bucket) bucket.push(view);
242
+ else byMessage.set(attachment.messageId, [view]);
243
+ }
244
+ return {
245
+ thread: myThreadView(detail.thread),
246
+ messages: detail.messages.map((message) => myMessageView(message, byMessage.get(message.id) ?? []))
247
+ };
248
+ }
249
+ //#endregion
250
+ export { archiveConversation, listInbox, listMyThreads, listReplies, readConversation, readMyThread, reclassifyConversation, replyToConversation, submitFeedbackRequest, updateFlags };
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
7
+ import { objectStore } from "@pithy-sh/storage/src/object/store";
8
+ import type { Context } from "hono";
9
+ import type { SupportWiring } from "../capability";
10
+ import type { HandlerDeps } from "./handlers";
11
+ /**
12
+ * Turning a Worker env into the dependencies the handlers take.
13
+ *
14
+ * The pattern every capability uses: nothing below `resolve.ts` ever reads a binding off `env`, so
15
+ * the handlers, the store, the reply path, and the ingest orchestration are all testable by handing
16
+ * them objects. This is the one file that knows what `env.DB` is called.
17
+ */
18
+ /** The bindings support reads. */
19
+ export interface SupportEnv extends SecretsStoreEnv {
20
+ /** The app database every support table lives in. */
21
+ DB: D1Database;
22
+ /** Attachment and raw-message bytes. Optional — an inbox with attachments off never touches it. */
23
+ SUPPORT_BUCKET?: R2Bucket;
24
+ }
25
+ /** Read the `DB` binding, or fail loudly rather than as a silent undefined three layers down. */
26
+ export declare function resolveDb(env: Record<string, unknown>): D1Database;
27
+ /**
28
+ * Start a classification.
29
+ *
30
+ * Dispatched through the registry rather than by reaching for the binding directly, so the params
31
+ * are validated at the call site and a missing binding degrades to a logged skip — which is the
32
+ * whole reason the job is declared `optional`. A project that has not run `pithy support provision`
33
+ * still receives mail; its threads simply stay `uncategorized`.
34
+ */
35
+ export declare function makeClassifyDispatcher(env: Record<string, unknown>, log: Logger): (messageId: string) => Promise<boolean>;
36
+ /**
37
+ * Build the object store attachment URLs are signed through.
38
+ *
39
+ * Returns undefined rather than throwing when the bucket is unbound: a deployment with attachments
40
+ * off has no bucket by design, and a thread view must still render. Credentials resolve lazily
41
+ * inside the seam, so this costs nothing until something is actually signed.
42
+ */
43
+ export declare function resolveStore(env: SupportEnv): ReturnType<typeof objectStore> | undefined;
44
+ /** Build the per-request handler dependencies. */
45
+ export declare function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
46
+ //# sourceMappingURL=resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/http/resolve.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAInD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;GAMG;AAEH,kCAAkC;AAClC,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,qDAAqD;IACrD,EAAE,EAAE,UAAU,CAAC;IACf,mGAAmG;IACnG,cAAc,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAED,iGAAiG;AACjG,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAUlE;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,GAAG,EAAE,MAAM,GACV,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAUzC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,GAAG,SAAS,CAGxF;AAED,kDAAkD;AAClD,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CA2BzG"}
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { supportDatabase } from "../data/tables.js";
4
+ import { SUPPORT_R2_SECRET } from "../secret/registry.js";
5
+ import { supportWorkflowRegistry, supportWorkflows } from "../workflows/specs.js";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { objectStore } from "@pithy-sh/storage/src/object/store";
8
+ //#region src/http/resolve.ts
9
+ /** Read the `DB` binding, or fail loudly rather than as a silent undefined three layers down. */
10
+ function resolveDb(env) {
11
+ const binding = env.DB;
12
+ if (!binding) throw new InternalError({
13
+ message: "The support database binding is missing.",
14
+ action: "Add the `DB` D1 binding to this Worker's wrangler.jsonc, then redeploy.",
15
+ detail: "D1 binding \"DB\" is not present on the worker env"
16
+ });
17
+ return binding;
18
+ }
19
+ /**
20
+ * Start a classification.
21
+ *
22
+ * Dispatched through the registry rather than by reaching for the binding directly, so the params
23
+ * are validated at the call site and a missing binding degrades to a logged skip — which is the
24
+ * whole reason the job is declared `optional`. A project that has not run `pithy support provision`
25
+ * still receives mail; its threads simply stay `uncategorized`.
26
+ */
27
+ function makeClassifyDispatcher(env, log) {
28
+ return async (messageId) => {
29
+ const { triggerWorkflow } = await import("@pithy-sh/core/src/workflow/dispatch");
30
+ await triggerWorkflow(env, supportWorkflowRegistry, "support/classify", { messageId }, log);
31
+ return env[supportWorkflows.classify.binding] != null;
32
+ };
33
+ }
34
+ /**
35
+ * Build the object store attachment URLs are signed through.
36
+ *
37
+ * Returns undefined rather than throwing when the bucket is unbound: a deployment with attachments
38
+ * off has no bucket by design, and a thread view must still render. Credentials resolve lazily
39
+ * inside the seam, so this costs nothing until something is actually signed.
40
+ */
41
+ function resolveStore(env) {
42
+ if (!env.SUPPORT_BUCKET) return void 0;
43
+ return objectStore({
44
+ bucket: env.SUPPORT_BUCKET,
45
+ env,
46
+ secretName: SUPPORT_R2_SECRET
47
+ });
48
+ }
49
+ /** Build the per-request handler dependencies. */
50
+ function makeResolveDeps(wiring) {
51
+ return async (c) => {
52
+ const env = c.env;
53
+ const d1 = resolveDb(env);
54
+ const log = c.var.log.child("support");
55
+ return {
56
+ db: supportDatabase(d1),
57
+ d1,
58
+ config: wiring.config,
59
+ categories: wiring.categories,
60
+ snippets: wiring.snippets,
61
+ fts: wiring.config.search.fts,
62
+ bucket: env.SUPPORT_BUCKET,
63
+ store: resolveStore(env),
64
+ enqueue: wiring.enqueueEmail ? (input) => wiring.enqueueEmail?.(env, input) : void 0,
65
+ dispatchClassify: makeClassifyDispatcher(env, log),
66
+ emit: c.var.emit,
67
+ log,
68
+ newId: () => crypto.randomUUID(),
69
+ now: () => /* @__PURE__ */ new Date()
70
+ };
71
+ };
72
+ }
73
+ //#endregion
74
+ export { makeClassifyDispatcher, makeResolveDeps, resolveDb, resolveStore };