@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,109 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
8
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
9
|
+
import { objectStore } from "@pithy-sh/storage/src/object/store";
|
|
10
|
+
import type { Context } from "hono";
|
|
11
|
+
import type { SupportWiring } from "../capability";
|
|
12
|
+
import { supportDatabase } from "../data/tables";
|
|
13
|
+
import { SUPPORT_R2_SECRET } from "../secret/registry";
|
|
14
|
+
import { supportWorkflowRegistry, supportWorkflows } from "../workflows/specs";
|
|
15
|
+
import type { HandlerDeps } from "./handlers";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Turning a Worker env into the dependencies the handlers take.
|
|
19
|
+
*
|
|
20
|
+
* The pattern every capability uses: nothing below `resolve.ts` ever reads a binding off `env`, so
|
|
21
|
+
* the handlers, the store, the reply path, and the ingest orchestration are all testable by handing
|
|
22
|
+
* them objects. This is the one file that knows what `env.DB` is called.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The bindings support reads. */
|
|
26
|
+
export interface SupportEnv extends SecretsStoreEnv {
|
|
27
|
+
/** The app database every support table lives in. */
|
|
28
|
+
DB: D1Database;
|
|
29
|
+
/** Attachment and raw-message bytes. Optional — an inbox with attachments off never touches it. */
|
|
30
|
+
SUPPORT_BUCKET?: R2Bucket;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read the `DB` binding, or fail loudly rather than as a silent undefined three layers down. */
|
|
34
|
+
export function resolveDb(env: Record<string, unknown>): D1Database {
|
|
35
|
+
const binding = env.DB;
|
|
36
|
+
if (!binding) {
|
|
37
|
+
throw new InternalError({
|
|
38
|
+
message: "The support database binding is missing.",
|
|
39
|
+
action: "Add the `DB` D1 binding to this Worker's wrangler.jsonc, then redeploy.",
|
|
40
|
+
detail: 'D1 binding "DB" is not present on the worker env',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return binding as D1Database;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a classification.
|
|
48
|
+
*
|
|
49
|
+
* Dispatched through the registry rather than by reaching for the binding directly, so the params
|
|
50
|
+
* are validated at the call site and a missing binding degrades to a logged skip — which is the
|
|
51
|
+
* whole reason the job is declared `optional`. A project that has not run `pithy support provision`
|
|
52
|
+
* still receives mail; its threads simply stay `uncategorized`.
|
|
53
|
+
*/
|
|
54
|
+
export function makeClassifyDispatcher(
|
|
55
|
+
env: Record<string, unknown>,
|
|
56
|
+
log: Logger,
|
|
57
|
+
): (messageId: string) => Promise<boolean> {
|
|
58
|
+
return async (messageId) => {
|
|
59
|
+
const { triggerWorkflow } = await import("@pithy-sh/core/src/workflow/dispatch");
|
|
60
|
+
await triggerWorkflow(env, supportWorkflowRegistry, "support/classify", { messageId }, log);
|
|
61
|
+
// Whether an instance actually started. `triggerWorkflow` degrades to a logged skip when the
|
|
62
|
+
// binding is absent — correct for the inbound path, where a missing classifier must never stop
|
|
63
|
+
// mail being stored — but a caller that reports success to a human needs to know the difference.
|
|
64
|
+
// A dashboard told "reclassified" while nothing ran would keep saying so forever.
|
|
65
|
+
return env[supportWorkflows.classify.binding] != null;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the object store attachment URLs are signed through.
|
|
71
|
+
*
|
|
72
|
+
* Returns undefined rather than throwing when the bucket is unbound: a deployment with attachments
|
|
73
|
+
* off has no bucket by design, and a thread view must still render. Credentials resolve lazily
|
|
74
|
+
* inside the seam, so this costs nothing until something is actually signed.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveStore(env: SupportEnv): ReturnType<typeof objectStore> | undefined {
|
|
77
|
+
if (!env.SUPPORT_BUCKET) return undefined;
|
|
78
|
+
return objectStore({ bucket: env.SUPPORT_BUCKET, env, secretName: SUPPORT_R2_SECRET });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build the per-request handler dependencies. */
|
|
82
|
+
export function makeResolveDeps(wiring: SupportWiring): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps> {
|
|
83
|
+
return async (c) => {
|
|
84
|
+
const env = c.env as unknown as SupportEnv;
|
|
85
|
+
const d1 = resolveDb(env as unknown as Record<string, unknown>);
|
|
86
|
+
const log = c.var.log.child("support");
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
db: supportDatabase(d1),
|
|
90
|
+
d1,
|
|
91
|
+
config: wiring.config,
|
|
92
|
+
categories: wiring.categories,
|
|
93
|
+
snippets: wiring.snippets,
|
|
94
|
+
fts: wiring.config.search.fts,
|
|
95
|
+
bucket: env.SUPPORT_BUCKET,
|
|
96
|
+
store: resolveStore(env),
|
|
97
|
+
// Bound at compose time when `email()` is composed; undefined otherwise, which the reply
|
|
98
|
+
// handler turns into a `support/reply_failed` naming the missing capability.
|
|
99
|
+
enqueue: wiring.enqueueEmail
|
|
100
|
+
? (input) => wiring.enqueueEmail?.(env as never, input) as Promise<{ jobId: string }>
|
|
101
|
+
: undefined,
|
|
102
|
+
dispatchClassify: makeClassifyDispatcher(env as unknown as Record<string, unknown>, log),
|
|
103
|
+
emit: c.var.emit,
|
|
104
|
+
log,
|
|
105
|
+
newId: () => crypto.randomUUID(),
|
|
106
|
+
now: () => new Date(),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { asRead } from "@pithy-sh/core/src/projection/asRead";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
|
|
7
|
+
import {
|
|
8
|
+
SupportAccountLinkSource,
|
|
9
|
+
SupportChannel,
|
|
10
|
+
SupportMessageDirection,
|
|
11
|
+
SupportPriority,
|
|
12
|
+
SupportSentiment,
|
|
13
|
+
} from "../data/enums";
|
|
14
|
+
import { SupportSubmissionContext } from "../data/message";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What the support routes return, as Zod objects a management client can validate against.
|
|
18
|
+
*
|
|
19
|
+
* `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
|
|
20
|
+
* runtime values for the same reason: a management client reading a customer's Worker is crossing a
|
|
21
|
+
* trust boundary and must validate what comes back, and a TypeScript interface is erased before it
|
|
22
|
+
* can help — so every client that had only an interface hand-wrote a mirror, and the mirror drifted
|
|
23
|
+
* the first time a field landed here.
|
|
24
|
+
*
|
|
25
|
+
* **Dates are ISO-8601 strings, and that is not a change.** These handlers used to hand `Date` objects
|
|
26
|
+
* to `c.json`, which serializes them as ISO strings anyway — so the bytes on the wire were always
|
|
27
|
+
* this, while the type said otherwise and no schema could describe it. `views.ts` now converts
|
|
28
|
+
* explicitly, which is what makes the shape statable at all.
|
|
29
|
+
*
|
|
30
|
+
* **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
|
|
31
|
+
* one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
|
|
32
|
+
* with the projection's output and fail on a field either side forgot.
|
|
33
|
+
*
|
|
34
|
+
* **A browser imports this file, so it reaches no module that needs the Workers runtime.** That is not
|
|
35
|
+
* a preference about bundle size; it is what "a client validates what comes back" means once the client
|
|
36
|
+
* is a Vite program with the DOM lib and no Workers types. This file imported `SUPPORT_BILLING_SCOPE`
|
|
37
|
+
* from `link/sender.ts` for one release and brought D1, Kysely and two capabilities' table maps with it,
|
|
38
|
+
* which took `pithy-sh/dashboard`'s client build down on an error in `@pithy-sh/auth` (#419). The rule is
|
|
39
|
+
* held by `tooling/browser-scopes`, which compiles this module with `types: []` and asks the compiler
|
|
40
|
+
* which files that program included.
|
|
41
|
+
*
|
|
42
|
+
* **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
|
|
43
|
+
* version boundary — a management client validates a response with this schema against a customer's
|
|
44
|
+
* Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
|
|
45
|
+
* below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
|
|
46
|
+
* say*, which is a different fact from `null`.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Where a page resumes, or the end of the list. */
|
|
50
|
+
const NextCursor = z
|
|
51
|
+
.string()
|
|
52
|
+
.nullable()
|
|
53
|
+
.describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
|
|
54
|
+
|
|
55
|
+
/** One conversation, as the inbox and the reading pane show it. */
|
|
56
|
+
export const SupportThreadView = z
|
|
57
|
+
.object({
|
|
58
|
+
id: z.string().describe("The thread's UUID — what every other route on this surface takes."),
|
|
59
|
+
channel: SupportChannel.describe(
|
|
60
|
+
"How this conversation started — `email` at an inbound address, or `app` from a signed-in user of the adopter's own app.",
|
|
61
|
+
),
|
|
62
|
+
inboxAddress: z
|
|
63
|
+
.string()
|
|
64
|
+
.nullable()
|
|
65
|
+
.describe(
|
|
66
|
+
"The support address this thread arrived on, lowercased, and the address a reply comes back to. Null only on an `app` thread in a project with no inbound address configured.",
|
|
67
|
+
),
|
|
68
|
+
subject: z.string().describe("The subject of the message that opened the thread. Later replies never rewrite it."),
|
|
69
|
+
fromAddress: z.string().describe("The sender's address, lowercased."),
|
|
70
|
+
fromName: z.string().nullable().describe("The sender's display name, or null. Untrusted text; render it escaped."),
|
|
71
|
+
senderAuthenticated: z
|
|
72
|
+
.boolean()
|
|
73
|
+
.describe(
|
|
74
|
+
"Whether the sender was proved to be who they claim. On `email` that means the `From:` header was proved, and false means unproven rather than forged — it is what withholds the customer link. Always true on `app`, where a session proved it before the request arrived.",
|
|
75
|
+
),
|
|
76
|
+
userId: z.string().nullable().describe("The user this sender resolves to, or null when nobody or unproven."),
|
|
77
|
+
accountLinkSource: SupportAccountLinkSource.nullable().describe(
|
|
78
|
+
"How `userId` was established, or null when there is no link. **Render these differently.** `session` means an authenticated request proved it and the account *is* the caller; `email_address` means it was matched against an address in a header nobody proved. The same operator action — a refund, a reset — follows from very different evidence.",
|
|
79
|
+
),
|
|
80
|
+
declaredCategory: z
|
|
81
|
+
.string()
|
|
82
|
+
.nullable()
|
|
83
|
+
.describe(
|
|
84
|
+
"**What the submitter said this is about**, or null when nobody said — every mail thread, and every app thread filed without a chooser. **Render this and `category` differently, and never fold one into the other.** They are two facts with two authors: this is a claim by the person writing, `category` is a machine's judgment about them, and an operator deciding what to do needs to know which is on screen. Where they disagree, that disagreement is the most useful thing on the row. Where `classifiedAt` is null this is the only category anybody stated.",
|
|
85
|
+
),
|
|
86
|
+
category: z
|
|
87
|
+
.string()
|
|
88
|
+
.describe(
|
|
89
|
+
"**What the classifier made of it** — the current category key, from this project's own federated taxonomy. `uncategorized` until a classification lands, and forever on a project with `ai.enabled: false`.",
|
|
90
|
+
),
|
|
91
|
+
priority: SupportPriority.describe("How fast this thread needs a human."),
|
|
92
|
+
sentiment: SupportSentiment.describe("How the sender sounds — the churn signal."),
|
|
93
|
+
confidence: z
|
|
94
|
+
.number()
|
|
95
|
+
.min(0)
|
|
96
|
+
.max(1)
|
|
97
|
+
.nullable()
|
|
98
|
+
.describe("The model's confidence in the current classification, or null until one lands."),
|
|
99
|
+
model: z.string().nullable().describe("The Workers AI model that produced the classification, or null."),
|
|
100
|
+
classifiedAt: z.iso
|
|
101
|
+
.datetime()
|
|
102
|
+
.nullable()
|
|
103
|
+
.describe("When the classification was written, ISO-8601; null until it is."),
|
|
104
|
+
archived: z.boolean().describe("Done. One shared boolean, not per viewer."),
|
|
105
|
+
archivedAt: z.iso.datetime().nullable().describe("When it was last archived, ISO-8601; null while open."),
|
|
106
|
+
archivedBy: z
|
|
107
|
+
.string()
|
|
108
|
+
.nullable()
|
|
109
|
+
.describe(
|
|
110
|
+
"The control-plane subject that archived it, for convenience only — the answer to 'who marked this done' comes from the audit trail.",
|
|
111
|
+
),
|
|
112
|
+
messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
|
|
113
|
+
firstMessageAt: z.iso.datetime().describe("When the thread opened, ISO-8601."),
|
|
114
|
+
lastMessageAt: z.iso.datetime().describe("When the thread last moved, ISO-8601. The inbox sorts on this."),
|
|
115
|
+
createdAt: z.iso.datetime().describe("When the thread row was created, ISO-8601."),
|
|
116
|
+
updatedAt: z.iso.datetime().describe("When the thread row was last written, ISO-8601."),
|
|
117
|
+
})
|
|
118
|
+
.describe("One support conversation, as a management client sees it.");
|
|
119
|
+
export type SupportThreadView = z.output<typeof SupportThreadView>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A thread in the inbox listing — the thread plus this viewer's own private state.
|
|
123
|
+
*
|
|
124
|
+
* Re-described after `.extend()`: extending builds a new schema, and a description does not follow it.
|
|
125
|
+
*/
|
|
126
|
+
export const SupportListedThreadView = SupportThreadView.extend({
|
|
127
|
+
read: z.boolean().describe("Whether this viewer has read it. False when they have no flag row, the common case."),
|
|
128
|
+
snoozedUntil: z.iso.datetime().nullable().describe("When this viewer's snooze expires, ISO-8601, or null."),
|
|
129
|
+
}).describe("One conversation in the inbox, carrying the calling viewer's own read and snooze state.");
|
|
130
|
+
export type SupportListedThreadView = z.output<typeof SupportListedThreadView>;
|
|
131
|
+
|
|
132
|
+
/** One message in a conversation. */
|
|
133
|
+
export const SupportMessageView = z
|
|
134
|
+
.object({
|
|
135
|
+
id: z.string().describe("The message's UUID — Pithy's id, not the sender's."),
|
|
136
|
+
direction: SupportMessageDirection.describe("`inbound` from the customer, `outbound` from this Worker."),
|
|
137
|
+
channel: SupportChannel.describe(
|
|
138
|
+
"How this message traveled, which on an outbound row says how the answer was delivered: `email` means it went out, `app` means it is waiting for the submitter to read it and no mail was sent.",
|
|
139
|
+
),
|
|
140
|
+
context: SupportSubmissionContext.nullable().describe(
|
|
141
|
+
"The bounded context an app submission carried — screen, build, platform, environment, locale. Null on every mail-path message.",
|
|
142
|
+
),
|
|
143
|
+
fromAddress: z
|
|
144
|
+
.string()
|
|
145
|
+
.nullable()
|
|
146
|
+
.describe(
|
|
147
|
+
"The address this message was sent from, lowercased. Null on an answer delivered in the app, which left no envelope.",
|
|
148
|
+
),
|
|
149
|
+
fromName: z.string().nullable().describe("The sender's display name, or null. Untrusted text."),
|
|
150
|
+
toAddress: z
|
|
151
|
+
.string()
|
|
152
|
+
.nullable()
|
|
153
|
+
.describe("The address this message was addressed to, lowercased. Null on an app submission with no envelope."),
|
|
154
|
+
subject: z.string().describe("This message's own subject, which may differ from the thread's."),
|
|
155
|
+
textBody: z.string().describe("The plain-text body."),
|
|
156
|
+
htmlBody: z
|
|
157
|
+
.string()
|
|
158
|
+
.nullable()
|
|
159
|
+
.describe(
|
|
160
|
+
"The sanitized HTML body, or null. Sanitized at ingest; the raw original stays in R2 and is never served.",
|
|
161
|
+
),
|
|
162
|
+
emailJobId: z
|
|
163
|
+
.string()
|
|
164
|
+
.nullable()
|
|
165
|
+
.describe(
|
|
166
|
+
"The email job this message was enqueued as. Present exactly when `direction` is `outbound` and `channel` is `email`; read `channel` to ask whether an answer went out, never the absence of this.",
|
|
167
|
+
),
|
|
168
|
+
receivedAt: z.iso.datetime().describe("When the message arrived or was sent, ISO-8601."),
|
|
169
|
+
})
|
|
170
|
+
.describe("One message in a conversation, with the threading internals dropped.");
|
|
171
|
+
export type SupportMessageView = z.output<typeof SupportMessageView>;
|
|
172
|
+
|
|
173
|
+
/** One attachment — metadata plus a short-lived URL, never the storage key. */
|
|
174
|
+
export const SupportAttachmentView = z
|
|
175
|
+
.object({
|
|
176
|
+
id: z.string().describe("The attachment's UUID."),
|
|
177
|
+
filename: z.string().describe("The filename as it arrived, sanitized."),
|
|
178
|
+
contentType: z.string().describe("The declared content type."),
|
|
179
|
+
size: z.number().int().describe("The size in bytes."),
|
|
180
|
+
sha256: z.string().describe("The content digest, so a client can tell two identical attachments apart from one."),
|
|
181
|
+
inline: z.boolean().describe("Whether it was referenced inline in the HTML body rather than attached."),
|
|
182
|
+
url: z
|
|
183
|
+
.string()
|
|
184
|
+
.nullable()
|
|
185
|
+
.describe(
|
|
186
|
+
"A signed URL valid for a few minutes, or null when no credentials were available to sign one. The `storageKey` is never here: it is server-derived precisely so a client cannot name an object or guess the one beside it.",
|
|
187
|
+
),
|
|
188
|
+
})
|
|
189
|
+
.describe("One attachment as a client receives it — metadata and a signed URL, never the storage key.");
|
|
190
|
+
export type SupportAttachmentView = z.output<typeof SupportAttachmentView>;
|
|
191
|
+
|
|
192
|
+
/** One of the sender's purchases, flattened to what a support console renders. */
|
|
193
|
+
export const SenderPurchaseView = z
|
|
194
|
+
.object({
|
|
195
|
+
id: z.string().describe("The purchase row id."),
|
|
196
|
+
rail: z.string().describe("Which rail it went through — `apple`, `google`, or `stripe`."),
|
|
197
|
+
productId: z.string().describe("The Pithy product key."),
|
|
198
|
+
status: z.string().describe("Its lifecycle state — `active`, `refunded`, `expired`, and so on."),
|
|
199
|
+
environment: z.string().describe("Whether it happened in the store's sandbox rather than production."),
|
|
200
|
+
purchasedAt: z.iso.datetime().describe("When it was bought, ISO-8601."),
|
|
201
|
+
expiresAt: z.iso.datetime().nullable().describe("When it runs out, ISO-8601; null for something owned forever."),
|
|
202
|
+
revokedAt: z.iso.datetime().nullable().describe("When it was refunded or revoked, ISO-8601; null while it stands."),
|
|
203
|
+
})
|
|
204
|
+
.describe("One purchase beside the sender, so an operator answering a billing question need not go and look.");
|
|
205
|
+
export type SenderPurchaseView = z.output<typeof SenderPurchaseView>;
|
|
206
|
+
|
|
207
|
+
/** One of the sender's entitlements, lapsed ones included and marked inactive. */
|
|
208
|
+
export const SenderEntitlementView = z
|
|
209
|
+
.object({
|
|
210
|
+
key: z.string().describe("The entitlement key."),
|
|
211
|
+
active: z.boolean().describe("Whether it granted when the projection last wrote it."),
|
|
212
|
+
expiresAt: z.iso.datetime().nullable().describe("When the grant lapses, ISO-8601; null when it never does."),
|
|
213
|
+
source: z.string().nullable().describe("Opaque provenance — a purchase id, or a support grant."),
|
|
214
|
+
})
|
|
215
|
+
.describe("One entitlement beside the sender. Lapsed ones are here too — a paywall wants to say when Pro ended.");
|
|
216
|
+
export type SenderEntitlementView = z.output<typeof SenderEntitlementView>;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Which billing subject the purchase and entitlement lists cover — always `user`, and said out loud.
|
|
220
|
+
*
|
|
221
|
+
* `@pithy-sh/payments` keys a purchase on a **subject pair**, a user or an organization. Support starts
|
|
222
|
+
* from a `From:` header and resolves a *person*; at thread-read time it holds no Hono `Context`, so it
|
|
223
|
+
* cannot ask the adopter's subject seam which organization this caller is acting for. It therefore reads
|
|
224
|
+
* `user`-subject rows and nothing else.
|
|
225
|
+
*
|
|
226
|
+
* **A field rather than a footnote, because empty already means too many things.** The purchase and
|
|
227
|
+
* entitlement lookups are guarded dynamic imports whose `catch` returns `[]`, so an empty panel is
|
|
228
|
+
* indistinguishable between "bought nothing", "`@pithy-sh/payments` is not installed" and "billed to an
|
|
229
|
+
* organization this seam cannot see" — and an operator looking at a customer with a live subscription
|
|
230
|
+
* reads it as the first. That is the actual hazard, and it is a wrong refund decision away from costing
|
|
231
|
+
* something. Declaring the scope on the wire lets a console say *why* the panel is thin.
|
|
232
|
+
*
|
|
233
|
+
* **A closed literal, not a string.** Widening it is then a compile error at every consumer, which is
|
|
234
|
+
* correct: the day support can resolve an organization, every panel has to decide what to render.
|
|
235
|
+
*/
|
|
236
|
+
export const SenderBillingScope = z
|
|
237
|
+
.literal(SUPPORT_BILLING_SCOPE)
|
|
238
|
+
.describe(
|
|
239
|
+
"Which billing subject the purchase and entitlement lists cover. Always `user`: support resolves a person from an address and cannot reach the adopter's subject seam, so an organization-billed customer's purchases are not shown here.",
|
|
240
|
+
);
|
|
241
|
+
export type SenderBillingScope = z.output<typeof SenderBillingScope>;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What the app already knows about the sender. Derived at read time, so it is always current.
|
|
245
|
+
*
|
|
246
|
+
* **Everything but `authenticated` and `billingScope` is empty when the sender is unproven.** The whole
|
|
247
|
+
* value of the panel is that an operator trusts it, so it must not be populated from an address anybody
|
|
248
|
+
* could have written — that is what turns a spoofed message into support-driven account takeover. The
|
|
249
|
+
* scope is declared either way: it describes what this seam *can* see, not what it found.
|
|
250
|
+
*/
|
|
251
|
+
export const SenderContextView = z
|
|
252
|
+
.object({
|
|
253
|
+
authenticated: z.boolean().describe("Whether the `From:` header was proved to belong to the sender."),
|
|
254
|
+
userId: z.string().nullable().describe("The linked user id, or null when this address belongs to nobody."),
|
|
255
|
+
name: z.string().optional().describe("The account's display name, when auth is composed and the sender is known."),
|
|
256
|
+
emailVerified: z.boolean().optional().describe("Whether the account has verified this address."),
|
|
257
|
+
billingScope: SenderBillingScope,
|
|
258
|
+
purchases: z.array(SenderPurchaseView).describe("Their purchase history, newest first, bounded. Empty when none."),
|
|
259
|
+
entitlements: z.array(SenderEntitlementView).describe("Their entitlements, lapsed ones marked inactive."),
|
|
260
|
+
})
|
|
261
|
+
.describe("The customer context beside a conversation, withheld entirely when the sender is unproven.");
|
|
262
|
+
export type SenderContextView = z.output<typeof SenderContextView>;
|
|
263
|
+
|
|
264
|
+
/** One canned reply, ordered for this thread's category. */
|
|
265
|
+
export const SupportReplyView = z
|
|
266
|
+
.object({
|
|
267
|
+
key: z.string().describe("The snippet's key in the catalog."),
|
|
268
|
+
label: z.string().describe("What the picker shows, e.g. `Refund issued`."),
|
|
269
|
+
category: z.string().optional().describe("The category it is offered first for. Absent means always offered."),
|
|
270
|
+
body: z.string().describe("The reply text, ready to edit."),
|
|
271
|
+
})
|
|
272
|
+
.describe("One canned reply from the effective catalog.");
|
|
273
|
+
export type SupportReplyView = z.output<typeof SupportReplyView>;
|
|
274
|
+
|
|
275
|
+
/** `GET {base}/threads`. */
|
|
276
|
+
export const SupportThreadsResponse = z
|
|
277
|
+
.object({
|
|
278
|
+
threads: z.array(SupportListedThreadView).describe("The page, newest first."),
|
|
279
|
+
nextCursor: NextCursor,
|
|
280
|
+
})
|
|
281
|
+
.describe("A page of the inbox, as one viewer sees it.");
|
|
282
|
+
export type SupportThreadsResponse = z.output<typeof SupportThreadsResponse>;
|
|
283
|
+
|
|
284
|
+
/** `GET {base}/threads/:id`. */
|
|
285
|
+
export const SupportThreadResponse = z
|
|
286
|
+
.object({
|
|
287
|
+
thread: SupportThreadView.describe("The conversation."),
|
|
288
|
+
messages: z.array(SupportMessageView).describe("Its messages, oldest first — a conversation reads downward."),
|
|
289
|
+
attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
|
|
290
|
+
sender: SenderContextView.describe("What the app already knows about the sender."),
|
|
291
|
+
replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category."),
|
|
292
|
+
})
|
|
293
|
+
.describe("A whole conversation, with the customer context and the replies worth offering on it.");
|
|
294
|
+
export type SupportThreadResponse = z.output<typeof SupportThreadResponse>;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `POST {base}/threads/:id/archive`.
|
|
298
|
+
*
|
|
299
|
+
* The thread itself rather than an envelope around it: archiving is a write whose entire result is the
|
|
300
|
+
* new state of the row, and a pane re-renders from it.
|
|
301
|
+
*/
|
|
302
|
+
export const SupportArchiveResponse = SupportThreadView.describe("The conversation, as the archive left it.");
|
|
303
|
+
export type SupportArchiveResponse = z.output<typeof SupportArchiveResponse>;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `POST {base}/threads/:id/reply`.
|
|
307
|
+
*
|
|
308
|
+
* **A union on `channel`, not an object with an optional `jobId`.** A reply either left by mail or was
|
|
309
|
+
* stored for the submitter to read in the app, and those are different promises about when somebody
|
|
310
|
+
* sees the answer. An optional field is exactly what lets a console render "sent" over both of them,
|
|
311
|
+
* so the two arms are structurally different and a client has to branch to read either one.
|
|
312
|
+
*/
|
|
313
|
+
export const SupportReplySentResponse = z
|
|
314
|
+
.discriminatedUnion("channel", [
|
|
315
|
+
z
|
|
316
|
+
.object({
|
|
317
|
+
channel: z.literal("email").describe("The reply was handed to the durable send path. It is on its way."),
|
|
318
|
+
messageId: z.string().describe("The outbound message row this reply created."),
|
|
319
|
+
jobId: z.string().describe("The email job it was enqueued as. The send itself is a Workflow's job."),
|
|
320
|
+
})
|
|
321
|
+
.describe("A reply that was queued for sending, by message and by mail job."),
|
|
322
|
+
z
|
|
323
|
+
.object({
|
|
324
|
+
channel: z
|
|
325
|
+
.literal("app")
|
|
326
|
+
.describe(
|
|
327
|
+
"The reply was stored, and no mail was sent. The submitter reads it next time they open the conversation — which means nobody has told them yet, and telling them is the application's call.",
|
|
328
|
+
),
|
|
329
|
+
messageId: z.string().describe("The outbound message row this reply created."),
|
|
330
|
+
})
|
|
331
|
+
.describe("A reply that is waiting in the app. There is no mail job, because there was no send."),
|
|
332
|
+
])
|
|
333
|
+
.describe("The reply that was filed, and which of the two ways it reaches the customer.");
|
|
334
|
+
export type SupportReplySentResponse = z.output<typeof SupportReplySentResponse>;
|
|
335
|
+
|
|
336
|
+
/** `POST {base}/threads/:id/reclassify`. */
|
|
337
|
+
export const SupportReclassifiedResponse = z
|
|
338
|
+
.object({
|
|
339
|
+
messageId: z.string().describe("The latest inbound message the classifier was started against."),
|
|
340
|
+
})
|
|
341
|
+
.describe("Which message a reclassification was started for.");
|
|
342
|
+
export type SupportReclassifiedResponse = z.output<typeof SupportReclassifiedResponse>;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `POST {base}/threads/:id/flags`.
|
|
346
|
+
*
|
|
347
|
+
* `ok` and nothing else. A private read flag is not audited and carries no state a client did not
|
|
348
|
+
* just send, so echoing the thread back would only invite somebody to render it.
|
|
349
|
+
*/
|
|
350
|
+
export const SupportFlagsResponse = z
|
|
351
|
+
.object({ ok: z.literal(true).describe("Always true. The write either happened or the request failed.") })
|
|
352
|
+
.describe("The acknowledgement of one viewer's private flags.");
|
|
353
|
+
export type SupportFlagsResponse = z.output<typeof SupportFlagsResponse>;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* ## The reader's contracts, beside the producers above
|
|
357
|
+
*
|
|
358
|
+
* Everything above this line is stated for a Worker checking its own projection, and it is strict on
|
|
359
|
+
* purpose: `SupportChannel` and `SupportAccountLinkSource` are what `inbound/` branches on and what D1
|
|
360
|
+
* holds, and a tolerated-unknown member there would license this capability to *store* a channel it
|
|
361
|
+
* cannot handle. Nothing below changes any of that. The enums are untouched, and so is every schema
|
|
362
|
+
* above.
|
|
363
|
+
*
|
|
364
|
+
* Below is the same projection for the other consumer: a management client reading a Worker **it does
|
|
365
|
+
* not control**. That client is crossing a trust boundary — a fork, a bug, a half-finished deploy, or a
|
|
366
|
+
* hostile Worker each put a member here that no enum of ours declares — and under the strict schema one
|
|
367
|
+
* such token costs it the entire response. It cost `pithy-sh/dashboard#15` exactly that: one
|
|
368
|
+
* unrecognized `channel` and the support pane rendered zero of twenty-five conversations. The client
|
|
369
|
+
* then had to widen our shape locally, which is the hand-held mirror `#113` exists to forbid.
|
|
370
|
+
*
|
|
371
|
+
* So the tolerance is published rather than reimplemented, by the one pattern
|
|
372
|
+
* `@pithy-sh/core/src/projection/asRead` states for the whole kit: same objects, every enum read as a
|
|
373
|
+
* string, every other field the identical schema instance.
|
|
374
|
+
*
|
|
375
|
+
* **What a client does with a widened value is ask the enum.** `SupportChannel.safeParse(value).success`
|
|
376
|
+
* answers *does this capability declare it*, and a value it does not declare is **marked, never mapped**
|
|
377
|
+
* — rendering an unknown provenance as the nearest one you know is a lie about evidence on the screen
|
|
378
|
+
* where somebody decides whether to act on a stranger's request.
|
|
379
|
+
*
|
|
380
|
+
* **Only the management surface.** The submitter's own views stay strict: an app reading its own
|
|
381
|
+
* Worker's `/feedback` is not reading a stranger, and `SupportReplySentResponse` is a union discriminated
|
|
382
|
+
* on a literal the client itself sent, where tolerating an unknown arm would mean tolerating a response
|
|
383
|
+
* with no shape at all.
|
|
384
|
+
*/
|
|
385
|
+
|
|
386
|
+
/** {@link SupportThreadView}, as a client that does not control the Worker must read it. */
|
|
387
|
+
export const SupportThreadViewAsRead = asRead(SupportThreadView);
|
|
388
|
+
export type SupportThreadViewAsRead = z.output<typeof SupportThreadViewAsRead>;
|
|
389
|
+
|
|
390
|
+
/** {@link SupportListedThreadView}, as a client that does not control the Worker must read it. */
|
|
391
|
+
export const SupportListedThreadViewAsRead = asRead(SupportListedThreadView);
|
|
392
|
+
export type SupportListedThreadViewAsRead = z.output<typeof SupportListedThreadViewAsRead>;
|
|
393
|
+
|
|
394
|
+
/** {@link SupportMessageView}, as a client that does not control the Worker must read it. */
|
|
395
|
+
export const SupportMessageViewAsRead = asRead(SupportMessageView);
|
|
396
|
+
export type SupportMessageViewAsRead = z.output<typeof SupportMessageViewAsRead>;
|
|
397
|
+
|
|
398
|
+
/** `GET {base}/threads`, as a client that does not control the Worker must read it. */
|
|
399
|
+
export const SupportThreadsResponseAsRead = asRead(SupportThreadsResponse);
|
|
400
|
+
export type SupportThreadsResponseAsRead = z.output<typeof SupportThreadsResponseAsRead>;
|
|
401
|
+
|
|
402
|
+
/** `GET {base}/threads/:id`, as a client that does not control the Worker must read it. */
|
|
403
|
+
export const SupportThreadResponseAsRead = asRead(SupportThreadResponse);
|
|
404
|
+
export type SupportThreadResponseAsRead = z.output<typeof SupportThreadResponseAsRead>;
|
|
405
|
+
|
|
406
|
+
/** `POST {base}/threads/:id/archive`, as a client that does not control the Worker must read it. */
|
|
407
|
+
export const SupportArchiveResponseAsRead = asRead(SupportArchiveResponse);
|
|
408
|
+
export type SupportArchiveResponseAsRead = z.output<typeof SupportArchiveResponseAsRead>;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* ## The submitter's own view, and what it deliberately omits
|
|
412
|
+
*
|
|
413
|
+
* Everything above this line is written for a **management client**: an operator triaging an inbox,
|
|
414
|
+
* who needs the classification to sort by and the sender's purchase history to act on. Everything
|
|
415
|
+
* below is written for the **person who wrote the message**, and it is a different object rather than
|
|
416
|
+
* the same one with fields nulled — because a projection that starts from the operator's shape leaks
|
|
417
|
+
* the first time somebody adds a column and forgets which of the two callers is reading.
|
|
418
|
+
*
|
|
419
|
+
* A submitter is shown their own words, the answers to them, and whether the thread is done. They are
|
|
420
|
+
* never shown the classification (a machine's judgment about them, and a filter on it is the inbox's
|
|
421
|
+
* business), the priority or sentiment (the same, and `angry` rendered back to the person it describes
|
|
422
|
+
* is its own kind of disaster), the per-viewer flags (private to an operator), the account link or its
|
|
423
|
+
* provenance (they know who they are), or anything at all about another account.
|
|
424
|
+
*/
|
|
425
|
+
|
|
426
|
+
/** One of the submitter's own conversations. */
|
|
427
|
+
export const SupportMyThreadView = z
|
|
428
|
+
.object({
|
|
429
|
+
id: z.string().describe("The conversation's id — what the read-back and a follow-up submission take."),
|
|
430
|
+
subject: z.string().describe("What they called it when they opened it. Later messages never rewrite it."),
|
|
431
|
+
resolved: z
|
|
432
|
+
.boolean()
|
|
433
|
+
.describe(
|
|
434
|
+
"Whether support has marked this done. Named for the reader rather than mirroring the column: `archived` is an inbox's word for a thread it has finished with, and to the person waiting on an answer the fact is that it was resolved. Writing again reopens it.",
|
|
435
|
+
),
|
|
436
|
+
messageCount: z.number().int().describe("How many messages the conversation holds, theirs and the answers."),
|
|
437
|
+
lastMessageAt: z.iso.datetime().describe("When the conversation last moved, ISO-8601. The list sorts on this."),
|
|
438
|
+
createdAt: z.iso.datetime().describe("When they opened it, ISO-8601."),
|
|
439
|
+
})
|
|
440
|
+
.describe("One of the caller's own in-app support conversations, as the person who opened it sees it.");
|
|
441
|
+
export type SupportMyThreadView = z.output<typeof SupportMyThreadView>;
|
|
442
|
+
|
|
443
|
+
/** One message in the submitter's own conversation. */
|
|
444
|
+
export const SupportMyMessageView = z
|
|
445
|
+
.object({
|
|
446
|
+
id: z.string().describe("The message's id."),
|
|
447
|
+
direction: SupportMessageDirection.describe(
|
|
448
|
+
"`inbound` is theirs, `outbound` is the answer. The only identity on this view: who answered is a person's name in the body if they signed it, and never an operator's account.",
|
|
449
|
+
),
|
|
450
|
+
body: z
|
|
451
|
+
.string()
|
|
452
|
+
.describe(
|
|
453
|
+
"The message text. Plain text on both sides — what they sent, and what was sent back. Never HTML, so a client renders it escaped and there is nothing here to sanitize.",
|
|
454
|
+
),
|
|
455
|
+
context: SupportSubmissionContext.nullable().describe(
|
|
456
|
+
"The context their app attached to this message, so a client can show what was sent on their behalf. Null on an answer.",
|
|
457
|
+
),
|
|
458
|
+
attachments: z.array(SupportAttachmentView).describe("What they attached to this message. Empty on an answer."),
|
|
459
|
+
sentAt: z.iso.datetime().describe("When it was sent, ISO-8601."),
|
|
460
|
+
})
|
|
461
|
+
.describe("One message in the caller's own conversation — their words, or the answer to them.");
|
|
462
|
+
export type SupportMyMessageView = z.output<typeof SupportMyMessageView>;
|
|
463
|
+
|
|
464
|
+
/** `POST {base}/feedback`. */
|
|
465
|
+
export const SupportSubmissionResponse = z
|
|
466
|
+
.object({
|
|
467
|
+
threadId: z.string().describe("The conversation it landed in — a new one, or the one it continued."),
|
|
468
|
+
messageId: z.string().describe("The message that was stored."),
|
|
469
|
+
opened: z
|
|
470
|
+
.boolean()
|
|
471
|
+
.describe(
|
|
472
|
+
"True when this opened a conversation, false when it continued one. A client that sent no `threadId` and gets false has hit nothing surprising — it cannot happen — so this is here for the client that sent one and wants to confirm which.",
|
|
473
|
+
),
|
|
474
|
+
attachments: z
|
|
475
|
+
.number()
|
|
476
|
+
.int()
|
|
477
|
+
.describe(
|
|
478
|
+
"How many attachments were stored. Reported rather than assumed: a deployment with no bucket bound stores the report and none of its files, and a client that showed a paperclip should be able to say so.",
|
|
479
|
+
),
|
|
480
|
+
})
|
|
481
|
+
.describe("What an in-app support request produced.");
|
|
482
|
+
export type SupportSubmissionResponse = z.output<typeof SupportSubmissionResponse>;
|
|
483
|
+
|
|
484
|
+
/** `GET {base}/feedback`. */
|
|
485
|
+
export const SupportMyThreadsResponse = z
|
|
486
|
+
.object({
|
|
487
|
+
threads: z.array(SupportMyThreadView).describe("The caller's own conversations, newest first."),
|
|
488
|
+
nextCursor: NextCursor,
|
|
489
|
+
})
|
|
490
|
+
.describe("A page of the caller's own support conversations.");
|
|
491
|
+
export type SupportMyThreadsResponse = z.output<typeof SupportMyThreadsResponse>;
|
|
492
|
+
|
|
493
|
+
/** `GET {base}/feedback/:id`. */
|
|
494
|
+
export const SupportMyThreadResponse = z
|
|
495
|
+
.object({
|
|
496
|
+
thread: SupportMyThreadView.describe("The conversation."),
|
|
497
|
+
messages: z.array(SupportMyMessageView).describe("Its messages, oldest first — a conversation reads downward."),
|
|
498
|
+
})
|
|
499
|
+
.describe("One of the caller's own conversations, in full.");
|
|
500
|
+
export type SupportMyThreadResponse = z.output<typeof SupportMyThreadResponse>;
|
|
501
|
+
|
|
502
|
+
/** `GET {base}/replies`. */
|
|
503
|
+
export const SupportRepliesResponse = z
|
|
504
|
+
.object({ replies: z.array(SupportReplyView).describe("The catalog, ordered for the requested category.") })
|
|
505
|
+
.describe("The canned reply catalog.");
|
|
506
|
+
export type SupportRepliesResponse = z.output<typeof SupportRepliesResponse>;
|