@cuvo-health-us/api 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # `@cuvo-health-us/api`
2
+
3
+ The TypeScript client for the [Cuvo Integrations API](https://developers.cuvo.co): create
4
+ patients, record consents, file cases for a licensed clinician to decide, and follow
5
+ prescriptions and orders through events and webhooks.
6
+
7
+ Every type in this package is generated from `api/openapi/v1.yaml`, which is itself generated
8
+ from the schemas the server validates with. If it compiles, it speaks the deployed contract.
9
+
10
+ > Prerelease. Published under the `next` tag while the developer product settles; `0.1.x` may
11
+ > still move.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @cuvo-health-us/api@next
17
+ ```
18
+
19
+ Requires Node 20 or newer. `verifySignature` uses `node:crypto`; the client itself runs anywhere
20
+ there is a `fetch`.
21
+
22
+ ## Authenticate
23
+
24
+ Two credentials reach the same API, and both arrive as `Authorization: Bearer …`.
25
+
26
+ **An API key** is what one integration uses for its own traffic. Mint it in the developer portal;
27
+ `cuvo_sk_test_…` reaches the sandbox and `cuvo_sk_live_…` reaches production. Never ship a live key
28
+ to a browser.
29
+
30
+ ```ts
31
+ import { createCuvoClient } from "@cuvo-health-us/api";
32
+
33
+ const cuvo = createCuvoClient({
34
+ apiKey: process.env.CUVO_API_KEY!,
35
+ // organization: "org_…", // only when the credential is granted to more than one
36
+ });
37
+ ```
38
+
39
+ **An OAuth client** is what a platform uses to act for the organizations that granted it access.
40
+ Exchange the client id and secret for a token at
41
+ `https://developers.cuvo.co/api/auth/oauth2/token` with `grant_type=client_credentials`, ask for
42
+ the scopes you need, and hand the access token to the same option. The client id and secret go in
43
+ the `Authorization` header as HTTP Basic credentials, which is the one method a client is
44
+ registered for:
45
+
46
+ ```ts
47
+ const basic = Buffer.from(
48
+ `${process.env.CUVO_CLIENT_ID}:${process.env.CUVO_CLIENT_SECRET}`,
49
+ ).toString("base64");
50
+
51
+ const response = await fetch("https://developers.cuvo.co/api/auth/oauth2/token", {
52
+ method: "POST",
53
+ headers: {
54
+ "content-type": "application/x-www-form-urlencoded",
55
+ authorization: `Basic ${basic}`,
56
+ },
57
+ body: new URLSearchParams({
58
+ grant_type: "client_credentials",
59
+ scope: "patients:write cases:write events:read",
60
+ resource: "https://api.cuvo.co",
61
+ }),
62
+ });
63
+ const { access_token } = (await response.json()) as { access_token: string };
64
+
65
+ const cuvo = createCuvoClient({ apiKey: access_token, organization: "org_…" });
66
+ ```
67
+
68
+ `resource` is not optional. It is what the provider reads to set the token's audience, and the API
69
+ refuses a token whose audience does not name it, so an exchange without it mints a token that fails
70
+ on the first call.
71
+
72
+ Scopes are per credential and are intersected with what the organization granted: a key with
73
+ `cases:write` acting for an organization that granted only `cases:read` may read and nothing more.
74
+ Tokens are short lived, so mint one per run rather than caching it past its expiry.
75
+
76
+ `Cuvo-Organization` names the organization you are acting for. It is required whenever the
77
+ credential holds more than one grant, and the client sends it on every call once you name it.
78
+
79
+ The client adds three things to every call, which is most of why it exists:
80
+
81
+ - `Authorization: Bearer <key>`, and `Cuvo-Organization` when you named one.
82
+ - An `Idempotency-Key` on every POST, PATCH and DELETE. Pass your own to make a retry from your
83
+ own job queue replay instead of creating a second row.
84
+ - Retries on 429 and on the server faults a second attempt can clear, with exponential backoff,
85
+ jitter, and `Retry-After` when the server names a delay. A write with no idempotency key is
86
+ never retried.
87
+
88
+ ## File a case
89
+
90
+ ```ts
91
+ import { createCuvoClient, unwrap } from "@cuvo-health-us/api";
92
+
93
+ const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
94
+
95
+ const patient = unwrap(
96
+ await cuvo.POST("/v1/patients", {
97
+ body: {
98
+ first_name: "Ada",
99
+ last_name: "Lovelace",
100
+ date_of_birth: "1990-04-14",
101
+ sex_at_birth: "female",
102
+ email: "ada@example.com",
103
+ phone: "+14155550123",
104
+ address: {
105
+ line1: "1 Market St",
106
+ city: "San Francisco",
107
+ state: "CA",
108
+ postal_code: "94105",
109
+ },
110
+ },
111
+ }),
112
+ );
113
+
114
+ // A case needs the consents the patient gave before it can be filed.
115
+ const consents = await Promise.all(
116
+ (["telehealth", "privacy"] as const).map(async (kind) =>
117
+ unwrap(
118
+ await cuvo.POST("/v1/patients/{id}/consents", {
119
+ params: { path: { id: patient.id } },
120
+ body: { kind, version: "2026-01" },
121
+ }),
122
+ ),
123
+ ),
124
+ );
125
+
126
+ const filed = unwrap(
127
+ await cuvo.POST("/v1/cases", {
128
+ body: {
129
+ patient: patient.id,
130
+ requested_medications: [
131
+ {
132
+ medication_id: "med_sema_0_25",
133
+ quantity: 4,
134
+ refills: 0,
135
+ days_supply: 28,
136
+ directions: "Inject 0.25 mg subcutaneously once weekly.",
137
+ },
138
+ ],
139
+ answers: [
140
+ { id: "q_pregnant", question: "Are you pregnant?", answer: false, type: "boolean" },
141
+ ],
142
+ consent_ids: consents.map((consent) => consent.id),
143
+ hold: false,
144
+ },
145
+ }),
146
+ );
147
+
148
+ console.log(filed.id, filed.status); // case_… queued
149
+ ```
150
+
151
+ `unwrap` turns a failure into a thrown `CuvoApiError` carrying `status`, `code`, `issues` and
152
+ `requestId`. Drop it and use the generated `{ data, error }` pair instead when you would rather
153
+ branch than catch.
154
+
155
+ ## Page through a list
156
+
157
+ ```ts
158
+ import { paginate, unwrap } from "@cuvo-health-us/api";
159
+
160
+ const inReview = paginate(async (starting_after) =>
161
+ unwrap(
162
+ await cuvo.GET("/v1/cases", {
163
+ params: { query: { limit: 100, status: "in_review", starting_after } },
164
+ }),
165
+ ),
166
+ );
167
+
168
+ for await (const item of inReview) {
169
+ console.log(item.id, item.status);
170
+ }
171
+ ```
172
+
173
+ The iterator is lazy and follows `starting_after` until the API says there is no more. Pass the
174
+ cursor into your own query rather than handing the filters over, so `status` stays checked
175
+ against that endpoint's enum instead of widening to `string`.
176
+
177
+ ## Receive a webhook
178
+
179
+ Verify before you parse, and verify the raw bytes. Re-serializing the body changes key order and
180
+ every signature fails.
181
+
182
+ ```ts
183
+ import { expandEvent, parseEvent, verifySignature } from "@cuvo-health-us/api";
184
+
185
+ export async function POST(request: Request) {
186
+ const rawBody = await request.text();
187
+
188
+ const verified = verifySignature({
189
+ rawBody,
190
+ signatureHeader: request.headers.get("Cuvo-Signature"),
191
+ secret: process.env.CUVO_WEBHOOK_SECRET!,
192
+ });
193
+ if (!verified) return new Response("bad signature", { status: 400 });
194
+
195
+ const event = parseEvent(rawBody);
196
+
197
+ // Delivery is at-least-once: dedupe on event.id before you act.
198
+ if (await alreadyHandled(event.id)) return new Response(null, { status: 200 });
199
+
200
+ if (event.type === "case.approved.v1") {
201
+ // Events are thin. This returns the embedded resource when the endpoint opted into
202
+ // include_resource, and reads it back otherwise.
203
+ const approved = await expandEvent(cuvo, event);
204
+ console.log(approved);
205
+ }
206
+
207
+ return new Response(null, { status: 200 });
208
+ }
209
+ ```
210
+
211
+ During the 24 hour grace after a secret rotation a delivery carries two signatures.
212
+ `verifySignature` accepts a match against either, so you can move to the new secret whenever you
213
+ like inside the window.
214
+
215
+ `parseEvent` returns the event keyed by its `type`, so the branch above knows `event.data` is a
216
+ `Case` and `expandEvent` answers with one. An event read from `GET /v1/events` arrives as the
217
+ contract's single `Event` shape instead, because that is what one schema for the whole catalog
218
+ can say; pass it through `typedEvent` for the same narrowing:
219
+
220
+ ```ts
221
+ import { paginate, typedEvent, unwrap } from "@cuvo-health-us/api";
222
+
223
+ for await (const row of paginate(async (starting_after) =>
224
+ unwrap(await cuvo.GET("/v1/events", { params: { query: { since, starting_after } } })),
225
+ )) {
226
+ const event = typedEvent(row);
227
+ if (event.type === "order.shipped.v1") console.log(event.resource.id);
228
+ }
229
+ ```
230
+
231
+ `typedEvent` also refuses a body that pairs an event type with the wrong resource, which no
232
+ delivery from Cuvo does.
233
+
234
+ `expandEvent` returns `undefined` for messages, prescriptions, consents and charges: v1 reads
235
+ those through their case or patient rather than on their own id. Turn on `include_resource` on the
236
+ endpoint, or pass `include_resource: true` to `GET /v1/events`, and the resource arrives embedded
237
+ as `data` with no second call.
238
+
239
+ ## Development
240
+
241
+ ```sh
242
+ pnpm install
243
+ pnpm generate # rewrite src/generated/v1.d.ts from api/openapi/v1.yaml
244
+ pnpm check:generated # fail if the committed types no longer match the contract
245
+ pnpm gates # typecheck, lint, dead code, tests, drift
246
+ pnpm build # compile src to dist, the published shape
247
+ ```
248
+
249
+ `src/generated/v1.d.ts` is committed and never edited by hand. `pnpm build` compiles everything
250
+ but the tests into `dist` and copies that declaration file beside the output, because a
251
+ declaration-only input produces no emit of its own.
252
+
253
+ ## Publishing
254
+
255
+ The package publishes from `dist`, under the `next` tag, at the prerelease version in
256
+ `package.json`. `pnpm pack` builds the tarball without sending anything.
257
+
258
+ ```sh
259
+ pnpm pack # inspect the tarball first
260
+ npm publish --tag next # requires an npm login with access to the @cuvo scope
261
+ ```
262
+
263
+ Nothing goes out under `latest` until the contract is stable and the founder says so: a stable tag
264
+ is what a partner's `pnpm add @cuvo-health-us/api` resolves to by default.
@@ -0,0 +1,31 @@
1
+ import { type Client } from "openapi-fetch";
2
+ import type { paths } from "./generated/v1.js";
3
+ /** The public API. Test and live mode are selected by the key, not by the host. */
4
+ export declare const DEFAULT_BASE_URL = "https://api.cuvo.co";
5
+ export declare const DEFAULT_MAX_RETRIES = 3;
6
+ export interface CuvoClientOptions {
7
+ /**
8
+ * `cuvo_sk_test_…`, `cuvo_sk_live_…`, or an OAuth access token: the API takes all three as the
9
+ * same bearer, so `@cuvo-health-us/cli` passes the token it was issued straight into this option. Never
10
+ * ship a live key to a browser.
11
+ */
12
+ apiKey: string;
13
+ baseUrl?: string;
14
+ /** Required when the credential holds a grant to more than one organization. */
15
+ organization?: string;
16
+ /** Swap the transport, for a proxy, a test double, or a runtime without a global `fetch`. */
17
+ fetch?: typeof globalThis.fetch;
18
+ /** Attempts after the first, on 429 and retryable 5xx. `0` disables retrying. */
19
+ maxRetries?: number;
20
+ }
21
+ /** The generated client: one method per HTTP verb, typed by path from the v1 contract. */
22
+ export type CuvoClient = Client<paths>;
23
+ /**
24
+ * Build a client for one credential.
25
+ *
26
+ * ```ts
27
+ * const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
28
+ * const { data, error } = await cuvo.POST("/v1/patients", { body: patient });
29
+ * ```
30
+ */
31
+ export declare function createCuvoClient(options: CuvoClientOptions): CuvoClient;
package/dist/client.js ADDED
@@ -0,0 +1,50 @@
1
+ import createClient, {} from "openapi-fetch";
2
+ import { createRetryingFetch, IDEMPOTENCY_KEY_HEADER } from "./retry.js";
3
+ /** The public API. Test and live mode are selected by the key, not by the host. */
4
+ export const DEFAULT_BASE_URL = "https://api.cuvo.co";
5
+ export const DEFAULT_MAX_RETRIES = 3;
6
+ /** The methods the contract requires an `Idempotency-Key` on. */
7
+ const WRITE_METHODS = new Set(["POST", "PATCH", "DELETE"]);
8
+ /**
9
+ * Authorization, the acting organization, and an idempotency key for every write.
10
+ *
11
+ * The key matters most. The contract requires one on every POST, PATCH and DELETE, and a caller
12
+ * who has to invent one per call will eventually reuse a constant and turn every create after
13
+ * the first into a replay. A caller who wants their own key still wins: this only fills a gap.
14
+ */
15
+ function cuvoHeaders(apiKey, organization) {
16
+ return {
17
+ onRequest({ request }) {
18
+ request.headers.set("Authorization", `Bearer ${apiKey}`);
19
+ if (organization !== undefined)
20
+ request.headers.set("Cuvo-Organization", organization);
21
+ if (WRITE_METHODS.has(request.method.toUpperCase()) &&
22
+ !request.headers.has(IDEMPOTENCY_KEY_HEADER)) {
23
+ request.headers.set(IDEMPOTENCY_KEY_HEADER, globalThis.crypto.randomUUID());
24
+ }
25
+ return request;
26
+ },
27
+ };
28
+ }
29
+ /**
30
+ * Build a client for one credential.
31
+ *
32
+ * ```ts
33
+ * const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
34
+ * const { data, error } = await cuvo.POST("/v1/patients", { body: patient });
35
+ * ```
36
+ */
37
+ export function createCuvoClient(options) {
38
+ const { apiKey, baseUrl = DEFAULT_BASE_URL, organization, maxRetries = DEFAULT_MAX_RETRIES, } = options;
39
+ if (apiKey.length === 0)
40
+ throw new Error("createCuvoClient needs an API key.");
41
+ const client = createClient({
42
+ baseUrl,
43
+ fetch: createRetryingFetch({
44
+ fetch: options.fetch ?? globalThis.fetch,
45
+ maxRetries,
46
+ }),
47
+ });
48
+ client.use(cuvoHeaders(apiKey, organization));
49
+ return client;
50
+ }
@@ -0,0 +1,34 @@
1
+ import type { components } from "./generated/v1.js";
2
+ /** RFC 9457 problem details, the one error body every endpoint answers with. */
3
+ export type Problem = components["schemas"]["Problem"];
4
+ /** One validation failure, addressed by the dotted path of the field that produced it. */
5
+ export type ProblemIssue = NonNullable<Problem["issues"]>[number];
6
+ /**
7
+ * A request the API refused, or an answer it could not have sent.
8
+ *
9
+ * `code` is the stable machine string to branch on; `message` is the problem's `detail`, which
10
+ * the contract guarantees is safe to show an end user. `problem` carries the body verbatim for
11
+ * the RFC's `type` and `title`, which nothing branches on but a bug report wants.
12
+ */
13
+ export declare class CuvoApiError extends Error {
14
+ /** The stable machine string, for example `case_not_found` or `idempotency_key_reused`. */
15
+ readonly code: string;
16
+ readonly status: number;
17
+ /** Present on a 400: the fields that failed validation. Empty on every other status. */
18
+ readonly issues: readonly ProblemIssue[];
19
+ /** The server's id for this request. Quote it in a support ticket. */
20
+ readonly requestId: string | undefined;
21
+ readonly problem: Problem;
22
+ constructor(problem: Problem);
23
+ }
24
+ /**
25
+ * Turn openapi-fetch's `{ data, error }` into a value or a throw.
26
+ *
27
+ * The generated client returns errors rather than throwing, which is right for a caller that
28
+ * wants to branch on both. Everything inside this SDK, and most callers, want the throwing form.
29
+ */
30
+ export declare function unwrap<Data>(result: {
31
+ data?: Data;
32
+ error?: unknown;
33
+ response: Response;
34
+ }): Data;
package/dist/errors.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A request the API refused, or an answer it could not have sent.
3
+ *
4
+ * `code` is the stable machine string to branch on; `message` is the problem's `detail`, which
5
+ * the contract guarantees is safe to show an end user. `problem` carries the body verbatim for
6
+ * the RFC's `type` and `title`, which nothing branches on but a bug report wants.
7
+ */
8
+ export class CuvoApiError extends Error {
9
+ /** The stable machine string, for example `case_not_found` or `idempotency_key_reused`. */
10
+ code;
11
+ status;
12
+ /** Present on a 400: the fields that failed validation. Empty on every other status. */
13
+ issues;
14
+ /** The server's id for this request. Quote it in a support ticket. */
15
+ requestId;
16
+ problem;
17
+ constructor(problem) {
18
+ super(problem.detail);
19
+ this.name = "CuvoApiError";
20
+ this.code = problem.code;
21
+ this.status = problem.status;
22
+ this.issues = problem.issues ?? [];
23
+ this.requestId = problem.request_id;
24
+ this.problem = problem;
25
+ }
26
+ }
27
+ /**
28
+ * Coerce whatever came back into problem details. A gateway between the caller and the API can
29
+ * answer with HTML or nothing at all, and an SDK that throws `undefined.code` on a 502 is worse
30
+ * than useless during exactly the incident it should be explaining.
31
+ */
32
+ function toProblem(status, body) {
33
+ const candidate = body;
34
+ if (candidate && typeof candidate.code === "string" && typeof candidate.detail === "string") {
35
+ return { ...candidate, status: candidate.status ?? status };
36
+ }
37
+ return {
38
+ type: "about:blank",
39
+ title: "Unexpected response",
40
+ status,
41
+ code: "unexpected_response",
42
+ detail: `The API answered ${status} with a body that is not problem details.`,
43
+ };
44
+ }
45
+ /**
46
+ * Turn openapi-fetch's `{ data, error }` into a value or a throw.
47
+ *
48
+ * The generated client returns errors rather than throwing, which is right for a caller that
49
+ * wants to branch on both. Everything inside this SDK, and most callers, want the throwing form.
50
+ */
51
+ export function unwrap(result) {
52
+ if (result.error !== undefined) {
53
+ throw new CuvoApiError(toProblem(result.response.status, result.error));
54
+ }
55
+ if (result.data === undefined) {
56
+ throw new CuvoApiError(toProblem(result.response.status, undefined));
57
+ }
58
+ return result.data;
59
+ }
@@ -0,0 +1,126 @@
1
+ import type { CuvoClient } from "./client.js";
2
+ import type { components } from "./generated/v1.js";
3
+ /**
4
+ * An event: what happened, to which resource, and the status that resource is in now. This is
5
+ * the body of every webhook delivery and every row of `GET /v1/events`.
6
+ *
7
+ * This is the contract's own shape, where `data` is `unknown` because one schema describes every
8
+ * event in the catalog. `CuvoTypedEvent` below is the same thing keyed by `type`, which is what a
9
+ * handler wants.
10
+ */
11
+ export type CuvoEvent = components["schemas"]["Event"];
12
+ /** The catalog, for example `case.approved.v1`. Versioned, so a payload change is a new type. */
13
+ export type CuvoEventType = CuvoEvent["type"];
14
+ export type CuvoEventResourceType = CuvoEvent["resource"]["type"];
15
+ /**
16
+ * THE TABLE: which resource each event in the catalog is about.
17
+ *
18
+ * It mirrors `RESOURCE_OF` in api/lib/events/clinical-refs.ts, and for the same reason: the
19
+ * prefix of an event name is USUALLY its resource, and `case.message_created.v1` is the one
20
+ * where it is not. The fact is about a message; the case it belongs to rides in the payload.
21
+ *
22
+ * `satisfies Record<CuvoEventType, …>` is the gate. An event type added to the contract, or one
23
+ * renamed, fails to compile here until this table names its resource, which is what keeps
24
+ * `CuvoTypedEvent` honest without a second source of truth.
25
+ */
26
+ declare const RESOURCE_OF: {
27
+ readonly "patient.created.v1": "patient";
28
+ readonly "patient.updated.v1": "patient";
29
+ readonly "patient.deleted.v1": "patient";
30
+ readonly "case.received.v1": "case";
31
+ readonly "case.queued.v1": "case";
32
+ readonly "case.in_review.v1": "case";
33
+ readonly "case.waiting_on_patient.v1": "case";
34
+ readonly "case.waiting_on_integration.v1": "case";
35
+ readonly "case.approved.v1": "case";
36
+ readonly "case.declined.v1": "case";
37
+ readonly "case.cancelled.v1": "case";
38
+ readonly "case.withdrawn.v1": "case";
39
+ readonly "case.question_asked.v1": "case";
40
+ readonly "case.message_created.v1": "message";
41
+ readonly "prescription.created.v1": "prescription";
42
+ readonly "prescription.updated.v1": "prescription";
43
+ readonly "order.created.v1": "order";
44
+ readonly "order.at_pharmacy.v1": "order";
45
+ readonly "order.shipped.v1": "order";
46
+ readonly "order.delivered.v1": "order";
47
+ readonly "order.blocked.v1": "order";
48
+ readonly "order.failed.v1": "order";
49
+ readonly "consent.recorded.v1": "consent";
50
+ readonly "file.attached.v1": "file";
51
+ readonly "charge.created.v1": "charge";
52
+ readonly "webhook_endpoint.disabled.v1": "webhook_endpoint";
53
+ };
54
+ /** The resource type `T` is about, at the type level: `"case.approved.v1"` is a `"case"`. */
55
+ export type CuvoEventResourceTypeOf<T extends CuvoEventType> = (typeof RESOURCE_OF)[T];
56
+ /**
57
+ * Each resource type as the API returns it.
58
+ *
59
+ * `charge` is `never`: v1 publishes no charge DTO, so nothing the contract describes can be
60
+ * embedded in a charge event or read back for one. Saying `unknown` there would swallow every
61
+ * other member of the union at the first `|`.
62
+ */
63
+ interface ResourceDtos {
64
+ patient: components["schemas"]["Patient"];
65
+ case: components["schemas"]["Case"];
66
+ message: components["schemas"]["Message"];
67
+ prescription: components["schemas"]["Prescription"];
68
+ order: components["schemas"]["Order"];
69
+ consent: components["schemas"]["Consent"];
70
+ file: components["schemas"]["File"];
71
+ charge: never;
72
+ webhook_endpoint: components["schemas"]["WebhookEndpoint"];
73
+ }
74
+ /** The resource DTO an event of type `T` carries and points at. */
75
+ export type CuvoEventResourceOf<T extends CuvoEventType> = ResourceDtos[CuvoEventResourceTypeOf<T>];
76
+ /** Every resource an event can point at, as the API returns it. */
77
+ export type ExpandedResource = ResourceDtos[CuvoEventResourceType];
78
+ /** One event of the catalog, with its resource type and its embedded resource both pinned. */
79
+ export type CuvoEventOf<T extends CuvoEventType> = Omit<CuvoEvent, "type" | "resource" | "data"> & {
80
+ type: T;
81
+ resource: {
82
+ type: CuvoEventResourceTypeOf<T>;
83
+ id: string;
84
+ };
85
+ /** Present when the endpoint opted into `include_resource`, absent otherwise. */
86
+ data?: CuvoEventResourceOf<T>;
87
+ };
88
+ /**
89
+ * The catalog as a union discriminated by `type`.
90
+ *
91
+ * ```ts
92
+ * if (event.type === "case.approved.v1") {
93
+ * event.data?.decided_at; // a Case, checked. No cast, no second lookup table.
94
+ * }
95
+ * ```
96
+ */
97
+ export type CuvoTypedEvent = {
98
+ [T in CuvoEventType]: CuvoEventOf<T>;
99
+ }[CuvoEventType];
100
+ /**
101
+ * Key an event by its `type`, checking the one thing the type system cannot see: that the
102
+ * resource the body names is the resource that event type is about.
103
+ *
104
+ * `parseEvent` runs this over every webhook body. Call it on an event read from
105
+ * `GET /v1/events`, whose generated type is the contract's single `Event` shape, to get the same
106
+ * narrowing there. A body that pairs a type with the wrong resource did not come from Cuvo, and
107
+ * a cast that assumed otherwise would hand a handler a `Case` that is really a `Message`.
108
+ */
109
+ export declare function typedEvent(event: CuvoEvent): CuvoTypedEvent;
110
+ /**
111
+ * Fetch the resource an event is about.
112
+ *
113
+ * Events are thin on purpose: ids, type, status, timestamps. An endpoint that opted into
114
+ * `include_resource` already carries the resource as `data`, and this returns that without a
115
+ * round trip. Otherwise it reads the resource back.
116
+ *
117
+ * The return type follows the event: hand it a `case.approved.v1` and you get a `Case`, with no
118
+ * second narrowing at the call site.
119
+ *
120
+ * `undefined` means the v1 contract has no read addressed by that id alone: messages,
121
+ * prescriptions and consents are listed through their case or patient, and charges have no
122
+ * public read until billing ships. Turn on `include_resource` for endpoints that need those, or
123
+ * read the parent case. The return type says `| undefined` so this cannot be forgotten.
124
+ */
125
+ export declare function expandEvent<T extends CuvoEventType>(client: CuvoClient, event: CuvoEventOf<T>): Promise<CuvoEventResourceOf<T> | undefined>;
126
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1,100 @@
1
+ import { unwrap } from "./errors.js";
2
+ /**
3
+ * THE TABLE: which resource each event in the catalog is about.
4
+ *
5
+ * It mirrors `RESOURCE_OF` in api/lib/events/clinical-refs.ts, and for the same reason: the
6
+ * prefix of an event name is USUALLY its resource, and `case.message_created.v1` is the one
7
+ * where it is not. The fact is about a message; the case it belongs to rides in the payload.
8
+ *
9
+ * `satisfies Record<CuvoEventType, …>` is the gate. An event type added to the contract, or one
10
+ * renamed, fails to compile here until this table names its resource, which is what keeps
11
+ * `CuvoTypedEvent` honest without a second source of truth.
12
+ */
13
+ const RESOURCE_OF = {
14
+ "patient.created.v1": "patient",
15
+ "patient.updated.v1": "patient",
16
+ "patient.deleted.v1": "patient",
17
+ "case.received.v1": "case",
18
+ "case.queued.v1": "case",
19
+ "case.in_review.v1": "case",
20
+ "case.waiting_on_patient.v1": "case",
21
+ "case.waiting_on_integration.v1": "case",
22
+ "case.approved.v1": "case",
23
+ "case.declined.v1": "case",
24
+ "case.cancelled.v1": "case",
25
+ "case.withdrawn.v1": "case",
26
+ "case.question_asked.v1": "case",
27
+ "case.message_created.v1": "message",
28
+ "prescription.created.v1": "prescription",
29
+ "prescription.updated.v1": "prescription",
30
+ "order.created.v1": "order",
31
+ "order.at_pharmacy.v1": "order",
32
+ "order.shipped.v1": "order",
33
+ "order.delivered.v1": "order",
34
+ "order.blocked.v1": "order",
35
+ "order.failed.v1": "order",
36
+ "consent.recorded.v1": "consent",
37
+ "file.attached.v1": "file",
38
+ "charge.created.v1": "charge",
39
+ "webhook_endpoint.disabled.v1": "webhook_endpoint",
40
+ };
41
+ /**
42
+ * Key an event by its `type`, checking the one thing the type system cannot see: that the
43
+ * resource the body names is the resource that event type is about.
44
+ *
45
+ * `parseEvent` runs this over every webhook body. Call it on an event read from
46
+ * `GET /v1/events`, whose generated type is the contract's single `Event` shape, to get the same
47
+ * narrowing there. A body that pairs a type with the wrong resource did not come from Cuvo, and
48
+ * a cast that assumed otherwise would hand a handler a `Case` that is really a `Message`.
49
+ */
50
+ export function typedEvent(event) {
51
+ const expected = RESOURCE_OF[event.type];
52
+ if (expected === undefined) {
53
+ throw new Error(`${event.type} is not an event type this version of the SDK knows.`);
54
+ }
55
+ if (event.resource.type !== expected) {
56
+ throw new Error(`${event.type} is an event about a ${expected}, but this one names a ${event.resource.type}.`);
57
+ }
58
+ return event;
59
+ }
60
+ /**
61
+ * Fetch the resource an event is about.
62
+ *
63
+ * Events are thin on purpose: ids, type, status, timestamps. An endpoint that opted into
64
+ * `include_resource` already carries the resource as `data`, and this returns that without a
65
+ * round trip. Otherwise it reads the resource back.
66
+ *
67
+ * The return type follows the event: hand it a `case.approved.v1` and you get a `Case`, with no
68
+ * second narrowing at the call site.
69
+ *
70
+ * `undefined` means the v1 contract has no read addressed by that id alone: messages,
71
+ * prescriptions and consents are listed through their case or patient, and charges have no
72
+ * public read until billing ships. Turn on `include_resource` for endpoints that need those, or
73
+ * read the parent case. The return type says `| undefined` so this cannot be forgotten.
74
+ */
75
+ export async function expandEvent(client, event) {
76
+ if (event.data !== undefined)
77
+ return event.data;
78
+ const params = { path: { id: event.resource.id } };
79
+ // The read is chosen by the resource the event names, and `RESOURCE_OF` has already tied that
80
+ // to `T`. The generated client types each path's answer independently, so the assignment back
81
+ // to `CuvoEventResourceOf<T>` is the one place this file asserts what the table proved.
82
+ const resourceType = event.resource.type;
83
+ const resource = await (async () => {
84
+ switch (resourceType) {
85
+ case "patient":
86
+ return unwrap(await client.GET("/v1/patients/{id}", { params }));
87
+ case "case":
88
+ return unwrap(await client.GET("/v1/cases/{id}", { params }));
89
+ case "order":
90
+ return unwrap(await client.GET("/v1/orders/{id}", { params }));
91
+ case "file":
92
+ return unwrap(await client.GET("/v1/files/{id}", { params }));
93
+ case "webhook_endpoint":
94
+ return unwrap(await client.GET("/v1/webhook_endpoints/{id}", { params }));
95
+ default:
96
+ return undefined;
97
+ }
98
+ })();
99
+ return resource;
100
+ }