@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 +5 -0
- package/README.md +164 -0
- package/dist/chunk-32KSYN2C.js +3210 -0
- package/dist/chunk-32KSYN2C.js.map +7 -0
- package/dist/chunk-JYMS3YHP.js +348 -0
- package/dist/chunk-JYMS3YHP.js.map +7 -0
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +1038 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +1344 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +7 -0
- package/package.json +89 -0
package/CHANGELOG.md
ADDED
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).
|