@pithy-sh/support 0.1.1 → 0.1.3

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 (167) hide show
  1. package/dist/ai/classify.d.ts +90 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +151 -0
  4. package/dist/attachment/store.d.ts +53 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +67 -0
  7. package/dist/audit/actions.d.ts +68 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +68 -0
  10. package/dist/capability.d.ts +93 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +187 -0
  13. package/dist/client/projection.d.ts +56 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +0 -0
  16. package/dist/config/config.d.ts +215 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +169 -0
  19. package/dist/data/attachment.d.ts +30 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +32 -0
  22. package/dist/data/billingScope.d.ts +30 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +32 -0
  25. package/dist/data/categories.d.ts +54 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +97 -0
  28. package/dist/data/classification.d.ts +34 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +28 -0
  31. package/dist/data/enums.d.ts +82 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +70 -0
  34. package/dist/data/flag.d.ts +25 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +27 -0
  37. package/dist/data/message.d.ts +81 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +84 -0
  40. package/dist/data/tables.d.ts +160 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +44 -0
  43. package/dist/data/thread.d.ts +77 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +65 -0
  46. package/dist/error/errors.d.ts +64 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +82 -0
  49. package/dist/http/guards.d.ts +44 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +53 -0
  52. package/dist/http/handlers.d.ts +89 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +248 -0
  55. package/dist/http/resolve.d.ts +44 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +72 -0
  58. package/dist/http/responses.d.ts +829 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +303 -0
  61. package/dist/http/routes.d.ts +109 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +62 -0
  64. package/dist/http/schemas.d.ts +121 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +89 -0
  67. package/dist/http/scopes.d.ts +56 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +107 -0
  70. package/dist/http/views.d.ts +36 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +144 -0
  73. package/dist/inbound/authenticity.d.ts +81 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +61 -0
  76. package/dist/inbound/guard.d.ts +74 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +78 -0
  79. package/dist/inbound/handler.d.ts +28 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +82 -0
  82. package/dist/inbound/ingest.d.ts +104 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +326 -0
  85. package/dist/inbound/recipient.d.ts +16 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +46 -0
  88. package/dist/index.d.ts +20 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +14 -0
  91. package/dist/link/sender.d.ts +160 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +200 -0
  94. package/dist/migrations/0001_threads.d.ts +26 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +103 -0
  97. package/dist/mime/address.d.ts +22 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +28 -0
  100. package/dist/mime/parse.d.ts +96 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +146 -0
  103. package/dist/mime/sanitize.d.ts +25 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +212 -0
  106. package/dist/mime/threading.d.ts +79 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +111 -0
  109. package/dist/mime/truncate.d.ts +17 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +47 -0
  112. package/dist/provision/provisionSupport.d.ts +158 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +110 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +45 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +22 -0
  118. package/dist/reply/send.d.ts +123 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +153 -0
  121. package/dist/reply/snippets.d.ts +79 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +135 -0
  124. package/dist/secret/registry.d.ts +18 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +21 -0
  127. package/dist/seeds/example.d.ts +3 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +350 -0
  130. package/dist/store/paging.d.ts +18 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +20 -0
  133. package/dist/store/search.d.ts +67 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +160 -0
  136. package/dist/store/searchIndex.d.ts +53 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +66 -0
  139. package/dist/store/threads.d.ts +147 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +197 -0
  142. package/dist/submission/encoding.d.ts +21 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +55 -0
  145. package/dist/submission/guard.d.ts +71 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +46 -0
  148. package/dist/submission/submit.d.ts +152 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +319 -0
  151. package/dist/version.generated.d.ts +5 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +7 -0
  154. package/dist/workflows/classify.d.ts +63 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +68 -0
  157. package/dist/workflows/retryPolicy.d.ts +39 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +43 -0
  160. package/dist/workflows/specs.d.ts +43 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +46 -0
  163. package/dist/workflows/worker.d.ts +48 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +37 -0
  166. package/package.json +24 -15
  167. package/src/version.generated.ts +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enums.d.ts","sourceRoot":"","sources":["../../src/data/enums.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;GAQG;AAEH,uCAAuC;AACvC,eAAO,MAAM,eAAe;;;;EAEoF,CAAC;AACjH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,uEAAuE;AACvE,eAAO,MAAM,gBAAgB;;;;;EAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,oCAAoC;AACpC,eAAO,MAAM,uBAAuB;;;EAEwE,CAAC;AAC7G,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc;;;EAIxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB;;;EAIlC,CAAC;AACJ,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEhF;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAC;AAE7C;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,SAAS,CAAC"}
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ //#region src/data/enums.ts
3
+ /**
4
+ * The closed axes of the support model — the ones that are genuinely fixed, as opposed to the
5
+ * taxonomy in `categories.ts`, which is federated because every app has its own vocabulary.
6
+ *
7
+ * Priority and sentiment are not federated on purpose. A category answers *what is this*, and that
8
+ * question has as many answers as there are products. Priority answers *how fast*, and sentiment
9
+ * answers *who is about to churn* — both are questions about the sender, not about the product, so a
10
+ * fourth priority level or a fifth sentiment would describe the same reality with more words.
11
+ */
12
+ /** How fast a thread needs a human. */
13
+ const SupportPriority = z.enum([
14
+ "urgent",
15
+ "normal",
16
+ "low"
17
+ ]).describe("How fast this thread needs a human: `urgent` today, `normal` this week, `low` when there is time.");
18
+ /** How the sender sounds. The churn signal, not a politeness score. */
19
+ const SupportSentiment = z.enum([
20
+ "angry",
21
+ "frustrated",
22
+ "neutral",
23
+ "positive"
24
+ ]).describe("How the sender sounds — the churn signal. `angry` and `frustrated` are the ones worth sorting an inbox by.");
25
+ /** Which way a message traveled. */
26
+ const SupportMessageDirection = z.enum(["inbound", "outbound"]).describe("`inbound` arrived from the customer; `outbound` is a reply this Worker sent on the send path.");
27
+ /**
28
+ * How a message reached this inbox.
29
+ *
30
+ * Closed for the same reason priority is: a channel is a *transport this capability implements*, not a
31
+ * vocabulary an adopter brings — there is no third answer a project could need that the code would not
32
+ * also have to be taught to speak. It is the axis the two halves of the capability differ on, and the
33
+ * differences are real rather than cosmetic: `email` arrives at a public address from an unauthenticated
34
+ * `From:` header, and `app` arrives on a request whose session was already proved.
35
+ */
36
+ const SupportChannel = z.enum(["email", "app"]).describe("How this arrived: `email` at a configured inbound address, or `app` from a signed-in user of the adopter's own app. The axis the console filters on, and the one the account link's provenance follows from.");
37
+ /**
38
+ * How a thread came to name an account — the provenance of `userId`, and the distinction
39
+ * `inbound/authenticity.ts` spends two hundred lines earning.
40
+ *
41
+ * A `From:` header is an unauthenticated claim, so an email thread's link is a *match on an address
42
+ * anybody could have written*. An in-app submission has no `From:` to spoof: `requireAuth()` proved
43
+ * the session before the handler ran, so the link is the identity rather than a guess about it.
44
+ *
45
+ * **A console must not render the two the same way.** They differ in exactly the situation that
46
+ * matters — deciding whether to act on somebody's billing history — and a single boolean cannot say
47
+ * which one it is looking at. `senderAuthenticated` answers *may we believe this*, and this answers
48
+ * *how did we come to believe it*; the second is what an operator needs when the first is false and
49
+ * there is still a name on the thread.
50
+ */
51
+ const SupportAccountLinkSource = z.enum(["session", "email_address"]).describe("How this thread's `userId` was established: `session` means an authenticated request proved it, `email_address` means it was matched from the address in a `From:` header. Never equivalent — one is the identity, the other is a lookup on a claim.");
52
+ /**
53
+ * The category every taxonomy carries, whatever else it declares, and the value a classification
54
+ * falls back to.
55
+ *
56
+ * It exists so "the model was unsure" is a first-class answer rather than a guess. A text model will
57
+ * always produce *a* label, and a plausible-sounding invented one silently poisons every filter
58
+ * downstream — so an out-of-taxonomy answer lands here instead.
59
+ */
60
+ const UNCATEGORIZED = "uncategorized";
61
+ /**
62
+ * The one other category key the code names directly, because a behavior hangs off it: a thread the
63
+ * classifier calls spam is archived on sight when `guard.archiveSpam` is on.
64
+ *
65
+ * Naming it here rather than inline keeps the string in one place — an adopter may reword the
66
+ * *description* the model reads, but the key is the join between the taxonomy and that behavior.
67
+ */
68
+ const SPAM = "spam";
69
+ //#endregion
70
+ export { SPAM, SupportAccountLinkSource, SupportChannel, SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZED };
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * One viewer's private view of one thread, in `pithy_support_thread_flags`.
4
+ *
5
+ * The other deliberate exception to "no coordination state", and the milder of the two. `archived` is
6
+ * shared because done means done; these are **per viewer** because nobody coordinates around them —
7
+ * whether Ada has read a thread says nothing about whether Grace has, and two people marking the same
8
+ * thread read is not a conflict. Keeping them in their own table rather than as columns on the thread
9
+ * is what makes that structural instead of a convention.
10
+ *
11
+ * `viewer` is the control-plane subject — the management client's identity, not a Pithy user. There
12
+ * is no user table behind it and there does not need to be: the value is opaque, and a connection
13
+ * that goes away simply leaves rows nothing reads.
14
+ */
15
+ export declare const SupportThreadFlag: z.ZodObject<{
16
+ id: z.ZodString;
17
+ threadId: z.ZodString;
18
+ viewer: z.ZodString;
19
+ read: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
20
+ snoozedUntil: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
21
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
22
+ updatedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
23
+ }, z.core.$strip>;
24
+ export type SupportThreadFlag = z.output<typeof SupportThreadFlag>;
25
+ //# sourceMappingURL=flag.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flag.d.ts","sourceRoot":"","sources":["../../src/data/flag.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;iBAgB0E,CAAC;AACzG,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
3
+ //#region src/data/flag.ts
4
+ /**
5
+ * One viewer's private view of one thread, in `pithy_support_thread_flags`.
6
+ *
7
+ * The other deliberate exception to "no coordination state", and the milder of the two. `archived` is
8
+ * shared because done means done; these are **per viewer** because nobody coordinates around them —
9
+ * whether Ada has read a thread says nothing about whether Grace has, and two people marking the same
10
+ * thread read is not a conflict. Keeping them in their own table rather than as columns on the thread
11
+ * is what makes that structural instead of a convention.
12
+ *
13
+ * `viewer` is the control-plane subject — the management client's identity, not a Pithy user. There
14
+ * is no user table behind it and there does not need to be: the value is opaque, and a connection
15
+ * that goes away simply leaves rows nothing reads.
16
+ */
17
+ const SupportThreadFlag = z.object({
18
+ id: z.string().describe("UUID primary key."),
19
+ threadId: z.string().describe("The `pithy_support_threads.id` these flags apply to."),
20
+ viewer: z.string().describe("The control-plane subject whose view this is. Unique together with `threadId`, so a viewer has exactly one row per thread and marking read twice is an upsert rather than a second row."),
21
+ read: SQLiteBoolean.describe("Whether this viewer has read the thread."),
22
+ snoozedUntil: SQLiteDate.nullish().describe("Hide the thread from this viewer's inbox until this moment. Null means not snoozed. A time rather than a boolean, so a snooze expires on its own and nothing has to sweep."),
23
+ createdAt: SQLiteDate.describe("When the flag row was created."),
24
+ updatedAt: SQLiteDate.describe("When the flag row was last written.")
25
+ }).describe("One viewer's read/snooze state for one thread — private, uncoordinated, and safe to lose.");
26
+ //#endregion
27
+ export { SupportThreadFlag };
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * What the app knows about the moment a report was written, and the user did not have to type.
4
+ *
5
+ * A bug report without the screen, the build, and the environment is a round trip — the first reply is
6
+ * always "which version, and where were you?". The app already holds all three, so asking a human to
7
+ * retype them is the kind of friction this channel exists to remove.
8
+ *
9
+ * **It is a closed object, and unknown keys are refused rather than stripped.** This is a capability,
10
+ * not a telemetry pipe: the risk of an open bag is that an adopter passes their whole client state
11
+ * through it and quietly lands a customer's data in a support inbox that a console renders and an
12
+ * operator reads. A closed set means a field that is not here cannot be posted by accident, and a
13
+ * refusal says so at the boundary rather than silently dropping what somebody believed they sent.
14
+ * Every field is bounded, because every one of them is a string a client chose.
15
+ */
16
+ export declare const SupportSubmissionContext: z.ZodObject<{
17
+ screen: z.ZodOptional<z.ZodString>;
18
+ appVersion: z.ZodOptional<z.ZodString>;
19
+ platform: z.ZodOptional<z.ZodString>;
20
+ environment: z.ZodOptional<z.ZodString>;
21
+ locale: z.ZodOptional<z.ZodString>;
22
+ }, z.core.$strict>;
23
+ export type SupportSubmissionContext = z.output<typeof SupportSubmissionContext>;
24
+ /** The `References` header, split into ids. A JSON column so the chain stays a list rather than a string to re-split. */
25
+ export declare const SupportReferenceIds: z.ZodArray<z.ZodString>;
26
+ export type SupportReferenceIds = z.output<typeof SupportReferenceIds>;
27
+ /**
28
+ * One message in `pithy_support_messages` — inbound mail as it arrived, or a reply as it went out.
29
+ *
30
+ * **The raw MIME is kept immutable in R2 and never rewritten.** `textBody`/`htmlBody` are a derived,
31
+ * sanitized rendering of it, so a parser bug, a sanitizer change, or a reclassification pass can all
32
+ * be re-run from the bytes that actually arrived. Storing only the parsed form would make the parse a
33
+ * one-way decision taken at the worst possible moment — inside an `email()` handler with a CPU budget.
34
+ *
35
+ * `mimeMessageId` carries a **unique index**, and that is the idempotency anchor: Email Routing can
36
+ * deliver the same message twice, and the second delivery must be a no-op rather than a duplicate.
37
+ * SQLite permits repeated `NULL`s in a unique index, so a message that arrived without a `Message-ID`
38
+ * (rare, and always a sign of something hand-rolled) still stores instead of colliding.
39
+ */
40
+ export declare const SupportMessage: z.ZodObject<{
41
+ id: z.ZodString;
42
+ threadId: z.ZodString;
43
+ direction: z.ZodEnum<{
44
+ inbound: "inbound";
45
+ outbound: "outbound";
46
+ }>;
47
+ channel: z.ZodEnum<{
48
+ app: "app";
49
+ email: "email";
50
+ }>;
51
+ submittedByUserId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
52
+ context: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
53
+ screen: z.ZodOptional<z.ZodString>;
54
+ appVersion: z.ZodOptional<z.ZodString>;
55
+ platform: z.ZodOptional<z.ZodString>;
56
+ environment: z.ZodOptional<z.ZodString>;
57
+ locale: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strict>]>, z.ZodObject<{
59
+ screen: z.ZodOptional<z.ZodString>;
60
+ appVersion: z.ZodOptional<z.ZodString>;
61
+ platform: z.ZodOptional<z.ZodString>;
62
+ environment: z.ZodOptional<z.ZodString>;
63
+ locale: z.ZodOptional<z.ZodString>;
64
+ }, z.core.$strict>>>>;
65
+ mimeMessageId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
66
+ mimeInReplyTo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
67
+ mimeReferences: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodArray<z.ZodString>>>>;
68
+ fromAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
+ fromName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
70
+ toAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
71
+ subject: z.ZodString;
72
+ textBody: z.ZodString;
73
+ htmlBody: z.ZodOptional<z.ZodNullable<z.ZodString>>;
74
+ emailJobId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
75
+ rawKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
76
+ rawBytes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
77
+ receivedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
78
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
79
+ }, z.core.$strip>;
80
+ export type SupportMessage = z.output<typeof SupportMessage>;
81
+ //# sourceMappingURL=message.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/data/message.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB;;;;;;kBA2DlC,CAAC;AACJ,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEjF,yHAAyH;AACzH,eAAO,MAAM,mBAAmB,yBAEoF,CAAC;AACrH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEvE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwHvB,CAAC;AACL,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC"}
@@ -0,0 +1,84 @@
1
+ import { SupportChannel, SupportMessageDirection } from "./enums.js";
2
+ import { z } from "zod";
3
+ import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
4
+ //#region src/data/message.ts
5
+ /**
6
+ * What the app knows about the moment a report was written, and the user did not have to type.
7
+ *
8
+ * A bug report without the screen, the build, and the environment is a round trip — the first reply is
9
+ * always "which version, and where were you?". The app already holds all three, so asking a human to
10
+ * retype them is the kind of friction this channel exists to remove.
11
+ *
12
+ * **It is a closed object, and unknown keys are refused rather than stripped.** This is a capability,
13
+ * not a telemetry pipe: the risk of an open bag is that an adopter passes their whole client state
14
+ * through it and quietly lands a customer's data in a support inbox that a console renders and an
15
+ * operator reads. A closed set means a field that is not here cannot be posted by accident, and a
16
+ * refusal says so at the boundary rather than silently dropping what somebody believed they sent.
17
+ * Every field is bounded, because every one of them is a string a client chose.
18
+ */
19
+ const SupportSubmissionContext = z.object({
20
+ screen: z.string().min(1).max(120).optional().describe("Where in the app they were — a route, a screen name, whatever the app calls it. The single most useful field, because it turns 'the button does nothing' into a place to look."),
21
+ appVersion: z.string().min(1).max(64).optional().describe("The build the report came from. Answers 'is this already fixed' without a round trip, which is the whole reason a bug report asks for it."),
22
+ platform: z.string().min(1).max(64).optional().describe("The client the app is running as — `ios`, `android`, `web`, or whatever the adopter's own build calls itself. Free text rather than an enum: the set of clients is the adopter's, not this capability's."),
23
+ environment: z.string().min(1).max(32).optional().describe("Which environment the client was pointed at. A report from staging read as production is a bug hunted in the wrong database, and the client is the only thing that knows."),
24
+ locale: z.string().min(1).max(32).optional().describe("The locale the app was rendering in. Carried because a whole class of reports — a date a day out, a currency in the wrong place, text that overflows — is only reproducible in the reporter's locale. Not validated as a tag here: see `enqueueEmail`, which normalizes it before it reaches a column that is.")
25
+ }).strict().describe("The bounded context an app supplies with a submission, so a bug report arrives with the facts its first reply would otherwise have to ask for.");
26
+ /** The `References` header, split into ids. A JSON column so the chain stays a list rather than a string to re-split. */
27
+ const SupportReferenceIds = z.array(z.string().describe("One RFC 5322 message id from the `References` chain, without its angle brackets.")).describe("The `References` chain, oldest first — the thread's ancestry as the sender's mail client recorded it.");
28
+ /**
29
+ * One message in `pithy_support_messages` — inbound mail as it arrived, or a reply as it went out.
30
+ *
31
+ * **The raw MIME is kept immutable in R2 and never rewritten.** `textBody`/`htmlBody` are a derived,
32
+ * sanitized rendering of it, so a parser bug, a sanitizer change, or a reclassification pass can all
33
+ * be re-run from the bytes that actually arrived. Storing only the parsed form would make the parse a
34
+ * one-way decision taken at the worst possible moment — inside an `email()` handler with a CPU budget.
35
+ *
36
+ * `mimeMessageId` carries a **unique index**, and that is the idempotency anchor: Email Routing can
37
+ * deliver the same message twice, and the second delivery must be a no-op rather than a duplicate.
38
+ * SQLite permits repeated `NULL`s in a unique index, so a message that arrived without a `Message-ID`
39
+ * (rare, and always a sign of something hand-rolled) still stores instead of colliding.
40
+ */
41
+ const SupportMessage = z.object({
42
+ id: z.string().describe("UUID primary key for this message row — Pithy's id, not the sender's."),
43
+ threadId: z.string().describe("The `pithy_support_threads.id` this message belongs to."),
44
+ direction: SupportMessageDirection.describe("Which way this message traveled."),
45
+ channel: SupportChannel.describe("How this message traveled, which on an outbound row is **how the answer was delivered**: `email` means it was handed to `@pithy-sh/email`'s durable send path, `app` means it was stored for the submitter to read in the app and no mail was sent. Per message rather than only per thread, because the two genuinely differ — one `app` thread can hold an answer that was mailed and an answer that was not, and those are different promises about when somebody will read them."),
46
+ submittedByUserId: z.string().nullish().describe("The authenticated `pithy_auth_users.id` that posted this message from inside the app. Null on every mail-path message and on every outbound reply. Taken from the request's session and **never** from anything the client sent — it is the identity, so it is also what the per-account submission bound counts."),
47
+ context: sqliteJson(SupportSubmissionContext).nullish().describe("The bounded context the app supplied — screen, build, platform, environment, locale. Null on every mail-path message, and on an app submission that declared none."),
48
+ mimeMessageId: z.string().nullish().describe("The RFC 5322 `Message-ID`, without angle brackets. Uniquely indexed, so a redelivered message is dropped rather than duplicated. Null when the sender omitted one."),
49
+ mimeInReplyTo: z.string().nullish().describe("The `In-Reply-To` id, without angle brackets — the direct parent. The first key threading tries, because it names exactly one message."),
50
+ mimeReferences: sqliteJson(SupportReferenceIds).nullish().describe("The `References` chain as a JSON column. Threading falls back to it when `In-Reply-To` names nothing we hold, which is what keeps a conversation together across a client that rewrites the subject."),
51
+ fromAddress: z.string().nullish().describe("The address this message was sent from, lowercased. **Null exactly on an answer delivered in the app**, which left no envelope — there is no address a reply that was never mailed came from, and putting the deployment's own address there would claim a send that did not happen. Never null on anything that traveled by mail, and never present on anything that did not. Checked on the object, both ways, so it is a rule rather than a note."),
52
+ fromName: z.string().nullish().describe("The sender's display name. Untrusted text; render it escaped."),
53
+ toAddress: z.string().nullish().describe("The address this message was addressed to, lowercased. **Null on an app submission to a project with no inbound address configured** — there was no envelope, and inventing one would put a string nothing can deliver to into the column the idempotency index keys on. Never null on a mail-path message."),
54
+ subject: z.string().describe("This message's own subject line, which may differ from the thread's."),
55
+ textBody: z.string().describe("The plain-text body, or a text rendering of an HTML-only message. Always present, because the classifier reads this and a model should never be handed markup."),
56
+ htmlBody: z.string().nullish().describe("The HTML body **after sanitization** — scripts, event handlers, styles, frames, and remote-loading attributes removed. Null when the message carried no HTML. The raw original is still in R2 if the sanitizer ever needs re-running."),
57
+ emailJobId: z.string().nullish().describe("The `pithy_email_jobs.id` this message was enqueued as. **Present exactly when `direction` is `outbound` and `channel` is `email`**, and null everywhere else — it is the join to the job, never the way to ask whether an answer went out. That question is `channel`'s, because a null here would otherwise mean both 'this arrived' and 'this was delivered in the app', and a client would have to consult `direction` to tell them apart. It is the *job* id rather than the sent `Message-ID` deliberately: Cloudflare assigns the real id at send time and never tells the enqueuer, so a column claiming to hold it would be null exactly when somebody needed it."),
58
+ rawKey: z.string().nullish().describe("The R2 object key holding the immutable raw MIME. Null only when the bucket binding was absent at ingest, which is a degraded mode rather than a normal one."),
59
+ rawBytes: z.number().int().nullish().describe("The raw message's size in bytes, as received."),
60
+ receivedAt: SQLiteDate.describe("When this Worker received the message — our clock, never the sender's `Date` header, which is attacker-controlled and would let anyone place themselves at the top of an inbox."),
61
+ createdAt: SQLiteDate.describe("When the message row was written.")
62
+ }).describe("One message in `pithy_support_messages` — the derived rendering of mail whose raw form lives in R2.").check((ctx) => {
63
+ const row = ctx.value;
64
+ const mailed = row.direction === "outbound" && row.channel === "email";
65
+ if ((row.emailJobId !== null && row.emailJobId !== void 0) === mailed) return;
66
+ ctx.issues.push({
67
+ code: "custom",
68
+ input: ctx.value,
69
+ path: ["emailJobId"],
70
+ message: mailed ? "An outbound `email` message must carry the `emailJobId` it was enqueued as. The row is written after the enqueue is accepted, so one without a job id is a reply nothing was ever asked to send." : "Only an outbound `email` message carries an `emailJobId`. An answer delivered in the app was never enqueued, and an inbound message was never sent — a job id on either makes the column mean something other than the job it names."
71
+ });
72
+ }).check((ctx) => {
73
+ const row = ctx.value;
74
+ const inApp = row.direction === "outbound" && row.channel === "app";
75
+ if ((row.fromAddress !== null && row.fromAddress !== void 0) === !inApp) return;
76
+ ctx.issues.push({
77
+ code: "custom",
78
+ input: ctx.value,
79
+ path: ["fromAddress"],
80
+ message: inApp ? "An answer delivered in the app has no `fromAddress`. It left no envelope, so an address there names a send that did not happen." : "Everything but an answer delivered in the app carries the `fromAddress` it came from or went out as. Without one there is no address to reply to."
81
+ });
82
+ });
83
+ //#endregion
84
+ export { SupportMessage, SupportReferenceIds, SupportSubmissionContext };
@@ -0,0 +1,160 @@
1
+ import type { D1Database } from "@cloudflare/workers-types";
2
+ import { type DatabaseSchema } from "@pithy-sh/core/src/data/db";
3
+ import type { Kysely } from "kysely";
4
+ import type { z } from "zod";
5
+ /**
6
+ * The support tables. Keys are camelCase; `CamelCasePlugin` snake-cases them to the `pithy_support_*`
7
+ * SQL, so query code in this package never types the prefix and never types an underscore.
8
+ */
9
+ /** Conversations. `pithy_support_threads`. */
10
+ export declare const SUPPORT_THREADS_TABLE = "pithySupportThreads";
11
+ /** Messages, inbound and outbound. `pithy_support_messages`. */
12
+ export declare const SUPPORT_MESSAGES_TABLE = "pithySupportMessages";
13
+ /** Attachment metadata. `pithy_support_attachments`. */
14
+ export declare const SUPPORT_ATTACHMENTS_TABLE = "pithySupportAttachments";
15
+ /** Append-only classification history. `pithy_support_classifications`. */
16
+ export declare const SUPPORT_CLASSIFICATIONS_TABLE = "pithySupportClassifications";
17
+ /** Per-viewer read/snooze state. `pithy_support_thread_flags`. */
18
+ export declare const SUPPORT_FLAGS_TABLE = "pithySupportThreadFlags";
19
+ /**
20
+ * The FTS5 full-text index. Not a Kysely table and deliberately not in {@link supportTables}: it is a
21
+ * virtual table with no Zod schema and no rows of its own worth typing, reached through raw `sql`
22
+ * from `store/search.ts` alone.
23
+ *
24
+ * Named in snake_case because nothing translates it — `CamelCasePlugin` rewrites identifiers Kysely
25
+ * builds, and every statement touching this one is written by hand.
26
+ */
27
+ export declare const SUPPORT_SEARCH_TABLE = "pithy_support_search";
28
+ /** The table map the capability contributes to the app database. */
29
+ export declare const supportTables: {
30
+ pithySupportThreads: z.ZodObject<{
31
+ id: z.ZodString;
32
+ channel: z.ZodEnum<{
33
+ app: "app";
34
+ email: "email";
35
+ }>;
36
+ inboxAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
37
+ subject: z.ZodString;
38
+ fromAddress: z.ZodString;
39
+ fromName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
40
+ senderAuthenticated: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
41
+ userId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ accountLinkSource: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
43
+ email_address: "email_address";
44
+ session: "session";
45
+ }>>>;
46
+ declaredCategory: z.ZodOptional<z.ZodNullable<z.ZodString>>;
47
+ category: z.ZodString;
48
+ priority: z.ZodEnum<{
49
+ low: "low";
50
+ normal: "normal";
51
+ urgent: "urgent";
52
+ }>;
53
+ sentiment: z.ZodEnum<{
54
+ angry: "angry";
55
+ frustrated: "frustrated";
56
+ neutral: "neutral";
57
+ positive: "positive";
58
+ }>;
59
+ confidence: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
60
+ model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
61
+ classifiedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
62
+ archived: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
63
+ archivedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
64
+ archivedBy: z.ZodOptional<z.ZodNullable<z.ZodString>>;
65
+ messageCount: z.ZodNumber;
66
+ firstMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
67
+ lastMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
68
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
69
+ updatedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
70
+ }, z.core.$strip>;
71
+ pithySupportMessages: z.ZodObject<{
72
+ id: z.ZodString;
73
+ threadId: z.ZodString;
74
+ direction: z.ZodEnum<{
75
+ inbound: "inbound";
76
+ outbound: "outbound";
77
+ }>;
78
+ channel: z.ZodEnum<{
79
+ app: "app";
80
+ email: "email";
81
+ }>;
82
+ submittedByUserId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
83
+ context: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
84
+ screen: z.ZodOptional<z.ZodString>;
85
+ appVersion: z.ZodOptional<z.ZodString>;
86
+ platform: z.ZodOptional<z.ZodString>;
87
+ environment: z.ZodOptional<z.ZodString>;
88
+ locale: z.ZodOptional<z.ZodString>;
89
+ }, z.core.$strict>]>, z.ZodObject<{
90
+ screen: z.ZodOptional<z.ZodString>;
91
+ appVersion: z.ZodOptional<z.ZodString>;
92
+ platform: z.ZodOptional<z.ZodString>;
93
+ environment: z.ZodOptional<z.ZodString>;
94
+ locale: z.ZodOptional<z.ZodString>;
95
+ }, z.core.$strict>>>>;
96
+ mimeMessageId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
97
+ mimeInReplyTo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
98
+ mimeReferences: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodArray<z.ZodString>>>>;
99
+ fromAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
100
+ fromName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
101
+ toAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
102
+ subject: z.ZodString;
103
+ textBody: z.ZodString;
104
+ htmlBody: z.ZodOptional<z.ZodNullable<z.ZodString>>;
105
+ emailJobId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
106
+ rawKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
107
+ rawBytes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
108
+ receivedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
109
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
110
+ }, z.core.$strip>;
111
+ pithySupportAttachments: z.ZodObject<{
112
+ id: z.ZodString;
113
+ messageId: z.ZodString;
114
+ threadId: z.ZodString;
115
+ filename: z.ZodString;
116
+ contentType: z.ZodString;
117
+ size: z.ZodNumber;
118
+ sha256: z.ZodString;
119
+ storageKey: z.ZodString;
120
+ contentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
121
+ inline: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
122
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
123
+ }, z.core.$strip>;
124
+ pithySupportClassifications: z.ZodObject<{
125
+ id: z.ZodString;
126
+ threadId: z.ZodString;
127
+ messageId: z.ZodString;
128
+ category: z.ZodString;
129
+ priority: z.ZodEnum<{
130
+ low: "low";
131
+ normal: "normal";
132
+ urgent: "urgent";
133
+ }>;
134
+ sentiment: z.ZodEnum<{
135
+ angry: "angry";
136
+ frustrated: "frustrated";
137
+ neutral: "neutral";
138
+ positive: "positive";
139
+ }>;
140
+ confidence: z.ZodNumber;
141
+ model: z.ZodString;
142
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
143
+ }, z.core.$strip>;
144
+ pithySupportThreadFlags: z.ZodObject<{
145
+ id: z.ZodString;
146
+ threadId: z.ZodString;
147
+ viewer: z.ZodString;
148
+ read: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
149
+ snoozedUntil: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
150
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
151
+ updatedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
152
+ }, z.core.$strip>;
153
+ };
154
+ /** The support tables, as a type. */
155
+ export type SupportTables = typeof supportTables;
156
+ /** The typed Kysely view over the support tables. Carries `CamelCasePlugin`. */
157
+ export type SupportDatabase = Kysely<DatabaseSchema<SupportTables>>;
158
+ /** Build the support database from a `DB` binding. */
159
+ export declare function supportDatabase(d1: D1Database): SupportDatabase;
160
+ //# sourceMappingURL=tables.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../../src/data/tables.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAO7B;;;GAGG;AAEH,8CAA8C;AAC9C,eAAO,MAAM,qBAAqB,wBAAwB,CAAC;AAC3D,gEAAgE;AAChE,eAAO,MAAM,sBAAsB,yBAAyB,CAAC;AAC7D,wDAAwD;AACxD,eAAO,MAAM,yBAAyB,4BAA4B,CAAC;AACnE,2EAA2E;AAC3E,eAAO,MAAM,6BAA6B,gCAAgC,CAAC;AAC3E,kEAAkE;AAClE,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAE7D;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAE3D,oEAAoE;AACpE,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAMa,CAAC;AAExC,qCAAqC;AACrC,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC;AAEjD,gFAAgF;AAChF,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,cAAc,CAAC,aAAa,CAAC,CAAC,CAAC;AAEpE,sDAAsD;AACtD,wBAAgB,eAAe,CAAC,EAAE,EAAE,UAAU,GAAG,eAAe,CAE/D"}
@@ -0,0 +1,44 @@
1
+ import { SupportAttachment } from "./attachment.js";
2
+ import { SupportClassification } from "./classification.js";
3
+ import { SupportThreadFlag } from "./flag.js";
4
+ import { SupportMessage } from "./message.js";
5
+ import { SupportThread } from "./thread.js";
6
+ import { createDatabase } from "@pithy-sh/core/src/data/db";
7
+ //#region src/data/tables.ts
8
+ /**
9
+ * The support tables. Keys are camelCase; `CamelCasePlugin` snake-cases them to the `pithy_support_*`
10
+ * SQL, so query code in this package never types the prefix and never types an underscore.
11
+ */
12
+ /** Conversations. `pithy_support_threads`. */
13
+ const SUPPORT_THREADS_TABLE = "pithySupportThreads";
14
+ /** Messages, inbound and outbound. `pithy_support_messages`. */
15
+ const SUPPORT_MESSAGES_TABLE = "pithySupportMessages";
16
+ /** Attachment metadata. `pithy_support_attachments`. */
17
+ const SUPPORT_ATTACHMENTS_TABLE = "pithySupportAttachments";
18
+ /** Append-only classification history. `pithy_support_classifications`. */
19
+ const SUPPORT_CLASSIFICATIONS_TABLE = "pithySupportClassifications";
20
+ /** Per-viewer read/snooze state. `pithy_support_thread_flags`. */
21
+ const SUPPORT_FLAGS_TABLE = "pithySupportThreadFlags";
22
+ /**
23
+ * The FTS5 full-text index. Not a Kysely table and deliberately not in {@link supportTables}: it is a
24
+ * virtual table with no Zod schema and no rows of its own worth typing, reached through raw `sql`
25
+ * from `store/search.ts` alone.
26
+ *
27
+ * Named in snake_case because nothing translates it — `CamelCasePlugin` rewrites identifiers Kysely
28
+ * builds, and every statement touching this one is written by hand.
29
+ */
30
+ const SUPPORT_SEARCH_TABLE = "pithy_support_search";
31
+ /** The table map the capability contributes to the app database. */
32
+ const supportTables = {
33
+ [SUPPORT_THREADS_TABLE]: SupportThread,
34
+ [SUPPORT_MESSAGES_TABLE]: SupportMessage,
35
+ [SUPPORT_ATTACHMENTS_TABLE]: SupportAttachment,
36
+ [SUPPORT_CLASSIFICATIONS_TABLE]: SupportClassification,
37
+ [SUPPORT_FLAGS_TABLE]: SupportThreadFlag
38
+ };
39
+ /** Build the support database from a `DB` binding. */
40
+ function supportDatabase(d1) {
41
+ return createDatabase(d1, supportTables);
42
+ }
43
+ //#endregion
44
+ export { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_SEARCH_TABLE, SUPPORT_THREADS_TABLE, supportDatabase, supportTables };
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * One conversation in `pithy_support_threads` — the row the inbox is a list of.
4
+ *
5
+ * **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
6
+ * repaired; the user link is a lookup, not a decision; the counters are facts about the messages
7
+ * below. There is no assignee and no status, because a status field is a state machine and a state
8
+ * machine attracts SLAs, escalation, and reporting — which is a ticketing product and explicitly not
9
+ * this. The two exceptions are deliberate and named: `archived` (one shared boolean meaning done) and
10
+ * the per-viewer flags, which live in their own table because nobody coordinates around them.
11
+ *
12
+ * The classification axes are **denormalized onto the thread** rather than joined from
13
+ * `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
14
+ * category and priority and archived", and that is a composite-index workload — a join to find the
15
+ * latest classification per thread would make the one query this table exists to serve the expensive
16
+ * one. The classification table keeps the history; this keeps the answer.
17
+ */
18
+ export declare const SupportThread: z.ZodObject<{
19
+ id: z.ZodString;
20
+ channel: z.ZodEnum<{
21
+ app: "app";
22
+ email: "email";
23
+ }>;
24
+ inboxAddress: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ subject: z.ZodString;
26
+ fromAddress: z.ZodString;
27
+ fromName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ senderAuthenticated: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
29
+ userId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
30
+ accountLinkSource: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
31
+ email_address: "email_address";
32
+ session: "session";
33
+ }>>>;
34
+ declaredCategory: z.ZodOptional<z.ZodNullable<z.ZodString>>;
35
+ category: z.ZodString;
36
+ priority: z.ZodEnum<{
37
+ low: "low";
38
+ normal: "normal";
39
+ urgent: "urgent";
40
+ }>;
41
+ sentiment: z.ZodEnum<{
42
+ angry: "angry";
43
+ frustrated: "frustrated";
44
+ neutral: "neutral";
45
+ positive: "positive";
46
+ }>;
47
+ confidence: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
48
+ model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
49
+ classifiedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
50
+ archived: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
51
+ archivedAt: z.ZodOptional<z.ZodNullable<z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>>>;
52
+ archivedBy: z.ZodOptional<z.ZodNullable<z.ZodString>>;
53
+ messageCount: z.ZodNumber;
54
+ firstMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
55
+ lastMessageAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
56
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
57
+ updatedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
58
+ }, z.core.$strip>;
59
+ export type SupportThread = z.output<typeof SupportThread>;
60
+ /**
61
+ * What a brand-new thread's classification columns hold before the Workflow has run.
62
+ *
63
+ * **`declaredCategory` is deliberately not in here**, and the omission is load-bearing rather than an
64
+ * oversight. This object is the set of columns the classifier owns — `runClassification` overwrites
65
+ * every one of them on each run — so a submitter's claim listed among them would be erased by the
66
+ * first classification and by every retry after it. It is set beside this spread, from the submission,
67
+ * and nothing on the classification path names it.
68
+ */
69
+ export declare const UNCLASSIFIED: {
70
+ readonly category: "uncategorized";
71
+ readonly priority: "normal";
72
+ readonly sentiment: "neutral";
73
+ readonly confidence: null;
74
+ readonly model: null;
75
+ readonly classifiedAt: null;
76
+ };
77
+ //# sourceMappingURL=thread.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread.d.ts","sourceRoot":"","sources":["../../src/data/thread.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA4FmF,CAAC;AAC9G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY;;uBAEb,QAAQ;wBACP,SAAS;;;;CAOrB,CAAC"}