@crvouga/mockingbird-service-vpi 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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog — @crvouga/mockingbird-service-vpi
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ Initial release.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # @crvouga/mockingbird-service-vpi
2
+
3
+ > [!WARNING]
4
+ > **Our app's `VPI_API_URL` defaults to PRODUCTION** (`https://api.vpicompounding.net`, see
5
+ > `apps/backend/src/modules/erx/clients/vpi-api.client.ts`). An unset variable sends real
6
+ > prescriptions to the real pharmacy. The stack **must** set `VPI_API_URL` to this mock (e.g.
7
+ > `http://127.0.0.1:8802`) whenever the VPI rail is reachable.
8
+
9
+ Stateful mock of the **VPI** compounding-pharmacy clinic API that our backend drives as a
10
+ draft-only eRx rail: JWT authentication, the product taxonomy/details/discounts, day supply,
11
+ shipping states and rates, the clinic location, providers, the patient roster and details,
12
+ the provider-signature duplicate check, `saveNewPrescription`, and the three paged prescription
13
+ status lists our poller reads. Prescriptions move only when a test says so (an admin
14
+ transition). VPI sends no webhooks: our backend polls page 1 (limit 5) of each list.
15
+
16
+ - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/vpi/SUPPORT.md)
17
+ - The vendor publishes no spec: the contract (`openapi.yaml`) is hand-derived from our client's
18
+ zod schemas (`vpi-api.contracts.ts` plus the client-local schemas in `vpi-api.client.ts`).
19
+ The acceptance tests parse every mock response with a verbatim port of those schemas.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -D @crvouga/mockingbird-service-vpi
25
+ ```
26
+
27
+ ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
28
+ `npx mockingbird-vpi serve`, `createServer` from `./server` (Node), or `createRuntime` with any
29
+ Fetch server.
30
+
31
+ ## Usage
32
+
33
+ Point the app at the mock and give it any credentials (any pair logs in unless `accounts` is set):
34
+
35
+ ```bash
36
+ npx mockingbird-vpi serve --port 8802
37
+ # app env:
38
+ # VPI_API_URL=http://127.0.0.1:8802 (REQUIRED: the default is production)
39
+ # VPI_API_EMAIL=clinic@example.com VPI_API_PASSWORD=anything
40
+ # VPI_CLINIC_LOCATION_ID=65a1c0de00000000000000d1
41
+ ```
42
+
43
+ ```ts
44
+ import { createRuntime } from "@crvouga/mockingbird-service-vpi"
45
+
46
+ const vpi = createRuntime()
47
+ const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
48
+ vpi.fetch(
49
+ new Request(`http://vpi.test${path}`, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json", ...headers },
52
+ body: JSON.stringify(body),
53
+ }),
54
+ )
55
+
56
+ const { jwtToken } = (await (
57
+ await post("/accounts/authenticate", { email: "clinic@example.com", password: "x" })
58
+ ).json()) as { jwtToken: string }
59
+ // …the app saves a draft with POST /clinic/rxOrdering/saveNewPrescription (Bearer jwtToken)…
60
+
61
+ // Move it the way the pharmacy would; our poller maps each status.
62
+ await post("/__admin/prescriptions/66b200000000000000000001/transition", {
63
+ to: "Order Completed",
64
+ trackingNumber: "1Z999",
65
+ })
66
+ ```
67
+
68
+ ### Seed data
69
+
70
+ Every namespace starts with: user `65a1c0de00000000000000a1` (every login resolves to it unless
71
+ `accounts` is set), clinic `65a1c0de00000000000000c1`, clinic location
72
+ `65a1c0de00000000000000d1` ("Geviti Main"), providers Grace Hopper (NPI `1234567893`, id
73
+ `65a1c0de00000000000000e1`) and Alan Turing (NPI `1987654320`), patient Ada Lovelace
74
+ (`65a1c0de00000000000000f1`, DOB 1985-02-14, 1 Main St, Phoenix AZ 85004), and four products:
75
+ Testosterone Cypionate (`64f1c2a9e4b0a1b2c3d4e5f6` / `2185_INJ`, sterile, 10% clinic discount),
76
+ Semaglutide / B6 Troche (`3097_POW`, cold-shipped), Enclomiphene (`4410_CAP`, no compounding
77
+ reason needed) and Nandrolone (`5120_INJ`, **controlled**: refused by `saveNewPrescription`).
78
+ Replace any of it with `createRuntime({ data: { products, providers, clinicLocations, patients } })`.
79
+
80
+ ### Routes
81
+
82
+ | Route | Behaviour |
83
+ | --- | --- |
84
+ | `POST /accounts/authenticate` | `{email, password, isPatientLogin: false}` → `{id, jwtToken, refreshToken}`. The JWT payload carries `sub` (user id), `email`, `iat` and `exp` = mock-clock now + `tokenTtlSeconds` (default 3600). Our client caches it until `exp` − 30 s and re-authenticates once on a 401. `isPatientLogin: true` is refused. |
85
+ | every other route | Requires `Authorization: Bearer <jwtToken>`: missing or tampered → 401 `Unauthorized`; expired on the mock clock → 401 `jwt expired`. |
86
+ | `GET /products/getAllFamiliesAndCategories` | `[{family, categories: [subCategory1…]}]`. |
87
+ | `POST /products/getProductsByCategory` | `{category, subCategory1}` → `[{subCategory2_item, commonNames: [{commonName, products: [...]}]}]`. |
88
+ | `GET /products/getProductDetailsByProductId/{id}` | By Mongo id: full details incl. `sigOptions`, `reasonForCompoundedMedication`, `patientPayAmount`, `ndc` (a number). 404 if unknown. |
89
+ | `POST /products/getProductDiscountByProductIds` | `{clinicId, productIds}` → `[{id, productId, discountedPrice, unitPrice, discountedPercentage, controlledSubstance}]` for known products. |
90
+ | `POST /products/calculateDaySupply` | `{productId, quantity, sig}` → `{daySupply, daySupplyReason}` (per-each products: 1 per day; otherwise 30). |
91
+ | `GET /admin/rxOrdering/getShippingStates` | `{data: [{states: [{name: "Arizona", booleanCheck, nonSterile, sterile}]}]}` (full names; no sterile shipping to Alabama or DC). |
92
+ | `POST /portal/getShippingRate` | `{clinicId, clinicLocationId, patientId, productIds, shippingState: "AZ", isRushOrder}` → `{shippingMethod, rushOrderCost, rushOrderMethod, isSignatureRequired}`; 400 for a state VPI does not ship (sterile) to. |
93
+ | `POST /clinic/rxOrdering/checkProviderSignatureNeededDuplicate` | `isDuplicate` is true when an active (not archived) prescription exists for the same patient and product; `isProviderSignatureNeeded` from settings (default true). |
94
+ | `POST /clinic/rxOrdering/saveNewPrescription` | Validates the whole payload against the contract (the shipping state must be the **canonical full name**, `controlledSubstance` must be `"0"`, …) → 400 `{message, errors: [{path, message}]}`; unknown clinic/location/provider/patient/product → 404; a controlled or code-mismatched product → 400. Success: a draft `Provider Signature Needed` in the incomplete list → `{message, prescriptionId, isRefillRequest: false, refillFromPrescriptionId: null}`. |
95
+ | `POST /patients/getPatientByPatientId`, `…/getPatientAddressesByPatientId` | `{patientId, userId}` → the patient (`dateOfBirth`, `phoneNumber`, `cellPhone`), or `{addresses: [...]}`. |
96
+ | `POST /patients/getPatientsInClinic` | `{clinicId, userId, limit, currentPage}` → `{pagination: {hasNextPage, currentPage, limit, totalCount}, patients}`. |
97
+ | `POST /staffs/getAllProvidersByClinicLocationId` | `{clinicLocationId, clinicId}` → providers with `npi`. |
98
+ | `POST /clinicLocations/getClinicLocationByClinicLocationId` | `{clinicLocationId}` → `{id, clinicId, locationName, …}`. |
99
+ | `POST /clinic/rxOrdering/getIncompleteSavedPrescriptionsInClinicLocation`, `…/getSubmittedPrescriptionsInClinicLocation`, `…/getArchivedPrescriptionsInClinic` | `{clinicLocationId, userId, limit, currentPage}` → one page, newest first, of `{prescriptionId, prescriptionStatus, trackingNumber, patientId, createdAt}`. Envelope per `statusEnvelope` (default `vendor`: submitted `{message: {prescriptions}}`, archived `{message: [...]}` with rows keyed `id`, incomplete a bare array). |
100
+
101
+ ### Lifecycle and status lists
102
+
103
+ | Transition `to` | List | Our consumer's mapping |
104
+ | --- | --- | --- |
105
+ | `Provider Signature Needed` (draft), `Signature Needed`, `New Formula Pending` | incomplete | processing |
106
+ | `Received`, `Order Received` | submitted | submitted |
107
+ | `In Process`, `Order In Process`, `Prescriptions In Process`, `On Hold`, `Order On Hold` | submitted | processing |
108
+ | `Completed`, `Order Complete`, `Order Completed` (adds a `1Z…` tracking number when none is given) | submitted | shipped |
109
+ | `Cancelled`, `Order Cancelled` | archived | cancelled |
110
+ | `Archived`, or any other string (used verbatim) | archived / submitted | unmapped (no refresh) |
111
+
112
+ Our consumer has **no delivered status** for VPI and the draft rail starts at `processing`, so a
113
+ payment on this rail moves processing → shipped (or cancelled).
114
+
115
+ ### Admin (beyond the standard contract)
116
+
117
+ | Route | Effect |
118
+ | --- | --- |
119
+ | `POST /__admin/prescriptions/:id/transition` | `{to, trackingNumber?, list?: "incomplete" \| "submitted" \| "archived"}`. |
120
+ | `GET /__admin/prescriptions` | The namespace's prescriptions (ids and status only). |
121
+ | `POST /__admin/patients` | Seed a clinic patient `{firstName, lastName, dateOfBirth, email?, phoneNumber?, cellPhone?, id?, clinicId?, addresses?: [{addressLine1, addressLine2?, city, state, zipcode}]}` (VPI patient creation is not part of our client). `GET` lists them. |
122
+ | `GET /__admin/catalog` | Products, providers and clinic locations. |
123
+ | `PUT /__admin/settings` | `{tokenTtlSeconds?, accounts?: [{email, password, id}], statusEnvelope?, isProviderSignatureNeeded?}`. |
124
+
125
+ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /__admin/faults/presets`):
126
+ `token_expired` (401 on authorized calls; with `count: 1` our client re-authenticates exactly
127
+ once and the retry succeeds), `unauthorized_twice` (the retry fails too), `auth_rejected`,
128
+ `server_error`, `duplicate_prescription`, `save_ambiguous_409` (saves the draft, then 409: our
129
+ classifier says needs_review), `save_rate_limited` (429, not saved: needs_review), `save_400`
130
+ (retry via the browser agent), `response_drift` (taxonomy and product-details fields change type:
131
+ our zod parse fails closed).
132
+
133
+ ### Namespaces
134
+
135
+ `x-mockingbird-namespace`, a `/ns/<name>` prefix on `VPI_API_URL`, or by login email:
136
+ `PUT /__admin/credentials {"credentials": {"<VPI_API_EMAIL>": "<namespace>"}}` (the JWT carries
137
+ the email). Authentication itself lands in the default namespace (or the `/ns/` one); tokens
138
+ verify in every namespace.
139
+
140
+ ### Deliberately not modelled
141
+
142
+ - Webhooks: VPI has none; our backend polls.
143
+ - Patient creation, clinic default-card billing (`encryptedBillingInfo`) and provider signing:
144
+ our client has not captured those contracts; seed patients through the admin API.
145
+ - The refresh-token exchange (our client re-authenticates with email/password instead).
146
+ - Real pricing, tax and shipping-rate tables: rates are fixed per cold/sterile/rush.
147
+ - The live catalog: the seed is synthesised in our client's field names (no sandbox recording).
148
+
149
+ ## API
150
+
151
+ | Export | Kind | Description |
152
+ | --- | --- | --- |
153
+ | `VpiAPI` | class | The in-process mock: `fetch(request)`, `reset()`, `transition(id, {to, trackingNumber?, list?})`, `addPatient(input)`, `prescriptions()`. Options: `sqlite`, `now`, `namespace`, `seed`, `settings`. |
154
+ | `createRuntime` | function | The mock with the full service contract (health, admin, namespaces, credentials, presets, journal). Options: `data`, `settings`, `clock`, `seed`, `adminKey`, `onLog`, `sqlite`. |
155
+ | `VPI_PRESETS` | object | Every named fault preset. |
156
+ | `VPI_NAMESPACE` | string | The service name, `"vpi"`. |
157
+ | `tokenCredential` | function | The login email a bearer JWT carries (how credentials map to namespaces). |
158
+ | `issueJwt` | function | Mint a JWT the mock accepts, from `{sub, email, iat, exp}`. |
159
+ | `DEFAULT_USER_ID`, `DEFAULT_CLINIC_ID`, `DEFAULT_CLINIC_LOCATION_ID`, `DEFAULT_PROVIDER_ID`, `DEFAULT_PATIENT_ID` | strings | The seeded ids. |
160
+ | `DEFAULT_PRODUCTS`, `DEFAULT_PROVIDERS`, `DEFAULT_PATIENTS`, `DEFAULT_CLINIC_LOCATION`, `SHIPPING_STATES` | values | The seed data. |
161
+ | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
162
+ | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http`; the `serve` CLI target (`--token-ttl`); port 8802. |
163
+
164
+ Part of [mockingbird](https://github.com/crvouga/mockingbird).