@alvera-ai/platform-sdk 0.17.0 → 0.18.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/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,801 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Send a patient review-request SMS after a fulfilled appointment
|
|
3
|
-
summary: End-to-end standard workflow — provision a Review SMS workflow over the FHIR appointment dataset, ingest two CAHPS appointment rows (one fulfilled, one cancelled) through patient + appointment interop contracts, run the workflow so the status filter routes each row, and close the connected-app reply loop. No agent, no LLM.
|
|
4
|
-
industry: healthcare
|
|
5
|
-
slug: appointment-review-sms-workflow
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/healthcare/standard-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/healthcare/interoperability-contracts.test.ts
|
|
9
|
-
- integration-tests/tests/healthcare/run-dac-single.test.ts
|
|
10
|
-
- integration-tests/tests/healthcare/create-dac.test.ts
|
|
11
|
-
- integration-tests/tests/healthcare/tools.test.ts
|
|
12
|
-
- integration-tests/tests/healthcare/data-sources.test.ts
|
|
13
|
-
- integration-tests/tests/healthcare/bootstrap.test.ts
|
|
14
|
-
status: green
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
# Problem
|
|
18
|
-
|
|
19
|
-
A clinic wants to ask every patient for feedback after a visit —
|
|
20
|
-
but only after a visit that actually happened. A cancelled
|
|
21
|
-
appointment should never trigger a "how was your visit?" message.
|
|
22
|
-
The classic implementation queries the appointments table on a
|
|
23
|
-
nightly cron, filters on status, and hands each match to an SMS
|
|
24
|
-
sender; the status logic, the scheduling, and the EMR field
|
|
25
|
-
mapping all leak into application code.
|
|
26
|
-
|
|
27
|
-
The Alvera platform's standard workflow primitive keeps all three
|
|
28
|
-
inside the platform. A FHIR R4 `appointment` dataset is the
|
|
29
|
-
workflow's event source; a pure-Liquid filter gates on
|
|
30
|
-
`appointment.status == "fulfilled"`; a single SMS action carries
|
|
31
|
-
a deep-link to a connected-app feedback form. The EMR-to-FHIR
|
|
32
|
-
mapping is a separate concern owned by the interoperability
|
|
33
|
-
contracts that ingest the rows — the workflow sees only the
|
|
34
|
-
canonical `appointment.status`, never the vendor's raw
|
|
35
|
-
`appt_slot_status` string.
|
|
36
|
-
|
|
37
|
-
This cookbook walks the **whole** scenario, not just the
|
|
38
|
-
provisioning: it creates the workflow, ingests two CAHPS-shaped
|
|
39
|
-
appointment rows through the production data-activation chain
|
|
40
|
-
(one checked-out, one cancelled), runs the workflow so the filter
|
|
41
|
-
routes each row, and finally resolves the connected-app deep-link
|
|
42
|
-
the SMS carries — closing the SMS → reply loop.
|
|
43
|
-
|
|
44
|
-
The scenario is anchored to
|
|
45
|
-
`platform/integration-tests/tests/healthcare/standard-workflow.test.ts`
|
|
46
|
-
— a green end-to-end test (§1–§6) that exercises this exact shape.
|
|
47
|
-
This cookbook is a prose re-presentation of what that test walks.
|
|
48
|
-
The setup file `_setup/healthcare.md` already provisioned the
|
|
49
|
-
tenant, datalake, and industry-admin client; this cookbook starts
|
|
50
|
-
from there.
|
|
51
|
-
|
|
52
|
-
# Composition
|
|
53
|
-
|
|
54
|
-
| Resource provisioned | Owner |
|
|
55
|
-
|-------------------------------------|-------------|
|
|
56
|
-
| SMS tool (SNS-backed) | build |
|
|
57
|
-
| Review SMS connected app | build |
|
|
58
|
-
| Review SMS workflow | build |
|
|
59
|
-
| Athenahealth data source | build |
|
|
60
|
-
| Manual Upload tool | build |
|
|
61
|
-
| CAHPS Patient interop contract | build |
|
|
62
|
-
| CAHPS Appointment interop contract | build |
|
|
63
|
-
| Manual-upload DAC | build |
|
|
64
|
-
|
|
65
|
-
The setup file `_setup/healthcare.md` already provisioned the
|
|
66
|
-
tenant + datalake + tenant-scoped client; this cookbook starts
|
|
67
|
-
from there.
|
|
68
|
-
|
|
69
|
-
# Walkthrough
|
|
70
|
-
|
|
71
|
-
## 001 — create the SMS tool
|
|
72
|
-
|
|
73
|
-
The Review SMS workflow's action invokes an SMS tool. The tool's
|
|
74
|
-
`body.tool_body_type: 'sns'` means it routes via AWS SNS; local
|
|
75
|
-
dev points it at LocalStack on `http://localhost:4566` via
|
|
76
|
-
`endpoint_url` so no real AWS credentials are needed. The
|
|
77
|
-
`intent: 'sms'` tags this tool for workflow actions that send SMS
|
|
78
|
-
(versus `data_exchange` for ingestion tools).
|
|
79
|
-
|
|
80
|
-
```typescript
|
|
81
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
82
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
83
|
-
description: 'SNS-backed SMS dispatcher for the Review SMS workflow, wired to LocalStack.',
|
|
84
|
-
intent: 'sms',
|
|
85
|
-
status: 'active',
|
|
86
|
-
datalake_id: ctx.datalakeId,
|
|
87
|
-
body: {
|
|
88
|
-
tool_body_type: 'sns',
|
|
89
|
-
auth_method: 'access_key',
|
|
90
|
-
region: 'us-east-1',
|
|
91
|
-
phone_number: '+15551234567',
|
|
92
|
-
endpoint_url: 'http://localhost:4566',
|
|
93
|
-
access_key_id: 'test',
|
|
94
|
-
secret_access_key: 'test',
|
|
95
|
-
},
|
|
96
|
-
})
|
|
97
|
-
toolId = smsToolResp.data.id!
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
## 002 — create the Review SMS connected app
|
|
101
|
-
|
|
102
|
-
The workflow's SMS action carries a deep-link to a connected-app
|
|
103
|
-
page so the patient can complete the feedback form. The connected
|
|
104
|
-
app is a thin registration of the form's URL and mode; the actual
|
|
105
|
-
page is hosted outside the platform (`mode: 'self_hosted'`). The
|
|
106
|
-
server-derived `slug` is captured for the resolve-page call in
|
|
107
|
-
§014.
|
|
108
|
-
|
|
109
|
-
```typescript
|
|
110
|
-
const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
111
|
-
name: `Cookbook Review SMS Form ${runSuffix}`,
|
|
112
|
-
description: 'Patient-review form linked from outbound SMS — used by the Review SMS workflow.',
|
|
113
|
-
mode: 'self_hosted',
|
|
114
|
-
urls: [
|
|
115
|
-
{
|
|
116
|
-
url: 'https://review.example.local',
|
|
117
|
-
is_primary: true,
|
|
118
|
-
label: 'production',
|
|
119
|
-
},
|
|
120
|
-
],
|
|
121
|
-
})
|
|
122
|
-
connectedAppId = connectedAppResp.data.id!
|
|
123
|
-
ctx.connectedAppSlug = connectedAppResp.data.slug!
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
## 003 — create the Review SMS workflow
|
|
127
|
-
|
|
128
|
-
The workflow has the standard shape: a filter (Liquid; passes
|
|
129
|
-
appointments whose `status` is `fulfilled`), a decision (a literal
|
|
130
|
-
Liquid array naming one decision key), and one SMS action. The SMS
|
|
131
|
-
body references `{{ connected_app_form_url }}` — the platform
|
|
132
|
-
injects that variable at action-execution time after minting the
|
|
133
|
-
connected-app page token, so §014 can resolve the deep-link.
|
|
134
|
-
|
|
135
|
-
Two details distinguish this workflow from a generic-table one.
|
|
136
|
-
First, `dataset_type: 'appointment'` with `skip_mdm_resolution:
|
|
137
|
-
false` means the workflow resolves each appointment's patient via
|
|
138
|
-
MDM, so the SMS `to` and body templates can read
|
|
139
|
-
`mdm_output.regulated_patient`. Second, a `context_datasets` entry
|
|
140
|
-
queries the `message` dataset for any review SMS already sent to
|
|
141
|
-
this patient in the last six months — the platform's built-in
|
|
142
|
-
guard against re-pestering a patient. On a fresh tenant that
|
|
143
|
-
lookup returns empty, which is harmless.
|
|
144
|
-
|
|
145
|
-
```typescript
|
|
146
|
-
const FILTER_BODY = '{% if appointment.status == "fulfilled" %}true{% endif %}'
|
|
147
|
-
const DECISION_KEY = 'send_appointment_review_sms'
|
|
148
|
-
const DECISION_BODY = `["${DECISION_KEY}"]`
|
|
149
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
150
|
-
const SMS_TO_TEMPLATE =
|
|
151
|
-
'{{ mdm_output.regulated_patient.telecom | where: "system", "phone" | first | map: "value" | e164 }}'
|
|
152
|
-
const SMS_BODY_TEMPLATE =
|
|
153
|
-
'Hi {{ mdm_output.regulated_patient.name | first | map: "given" | first }}, ' +
|
|
154
|
-
'thank you for your recent visit. ' +
|
|
155
|
-
'Please share your feedback at {{ connected_app_form_url }}'
|
|
156
|
-
|
|
157
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
158
|
-
name: `Cookbook Review SMS Workflow ${runSuffix}`,
|
|
159
|
-
description: 'Sends a review-request SMS with a connected-app form link after a fulfilled appointment.',
|
|
160
|
-
dataset_type: 'appointment',
|
|
161
|
-
status: 'live',
|
|
162
|
-
tags: ['appointments', 'review'],
|
|
163
|
-
skip_mdm_resolution: false,
|
|
164
|
-
filter_config: {
|
|
165
|
-
type: 'custom',
|
|
166
|
-
body: FILTER_BODY,
|
|
167
|
-
output_schema: { type: 'boolean' },
|
|
168
|
-
},
|
|
169
|
-
decision_config: {
|
|
170
|
-
type: 'custom',
|
|
171
|
-
body: DECISION_BODY,
|
|
172
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
173
|
-
},
|
|
174
|
-
context_datasets: [
|
|
175
|
-
{
|
|
176
|
-
dataset_type: 'message',
|
|
177
|
-
where_clause:
|
|
178
|
-
`rm.patient_id = '{{ patient_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
|
|
179
|
-
"AND rm.sent_at > NOW() - INTERVAL '6 months'",
|
|
180
|
-
limit: 1,
|
|
181
|
-
position: 0,
|
|
182
|
-
},
|
|
183
|
-
],
|
|
184
|
-
actions: [
|
|
185
|
-
{
|
|
186
|
-
action_type: 'sms',
|
|
187
|
-
tool_id: toolId,
|
|
188
|
-
decision_key: DECISION_KEY,
|
|
189
|
-
position: 0,
|
|
190
|
-
trigger_template: 'now',
|
|
191
|
-
idempotency_template:
|
|
192
|
-
'{{ patient_id }}-{{ appointment.unregulated_appointment_id }}-{{ decision_key }}',
|
|
193
|
-
connected_app_id: connectedAppId,
|
|
194
|
-
connected_app_route: '/forms/review',
|
|
195
|
-
connected_app_metadata_template:
|
|
196
|
-
'{"appointment_id":"{{ appointment.unregulated_appointment_id }}","patient_id":"{{ mdm_output.patient.id }}"}',
|
|
197
|
-
tool_call: {
|
|
198
|
-
tool_call_type: 'sms_request',
|
|
199
|
-
to: { type: 'custom', body: SMS_TO_TEMPLATE },
|
|
200
|
-
body: { type: 'custom', body: SMS_BODY_TEMPLATE },
|
|
201
|
-
sms_type: 'transactional',
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
],
|
|
205
|
-
})
|
|
206
|
-
workflowId = workflowResp.data.id!
|
|
207
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
## 004 — create the athenahealth data source
|
|
211
|
-
|
|
212
|
-
A workflow runs on rows; rows arrive through the data-activation
|
|
213
|
-
chain. The chain's first link is a `DataSource` — a registration
|
|
214
|
-
of where the rows originate. The `uri` is the system-of-record
|
|
215
|
-
address; it flows into each ingested row's `source_uri`, which the
|
|
216
|
-
CAHPS templates render onto the FHIR identifier `system` URN.
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
220
|
-
name: `Cookbook Athenahealth Source ${runSuffix}`,
|
|
221
|
-
uri: '12345.athenahealth.com',
|
|
222
|
-
description: 'Athenahealth EMR — origin of the CAHPS appointment rows the Review SMS workflow runs on.',
|
|
223
|
-
status: 'active',
|
|
224
|
-
is_default: false,
|
|
225
|
-
})
|
|
226
|
-
dataSourceId = dataSourceResp.data.id!
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
## 005 — create the Manual Upload tool
|
|
230
|
-
|
|
231
|
-
The Data Activation Client needs a tool. For inline-JSON ingest a
|
|
232
|
-
`manual_upload` tool is the minimal choice — `intent:
|
|
233
|
-
'data_exchange'` distinguishes it from the SMS tool, and
|
|
234
|
-
`tool_body_type: 'manual_upload'` needs no endpoint or credential
|
|
235
|
-
wiring (the rows arrive in the ingest call body, not by the tool
|
|
236
|
-
fetching them).
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
240
|
-
name: `Cookbook Manual Upload Tool ${runSuffix}`,
|
|
241
|
-
description: 'Manual-upload data-exchange tool — backs the DAC that ingests CAHPS appointment rows.',
|
|
242
|
-
intent: 'data_exchange',
|
|
243
|
-
status: 'active',
|
|
244
|
-
datalake_id: ctx.datalakeId,
|
|
245
|
-
data_source_id: dataSourceId,
|
|
246
|
-
body: { tool_body_type: 'manual_upload' },
|
|
247
|
-
})
|
|
248
|
-
ctx.manualUploadToolId = manualUploadToolResp.data.id!
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
## 006 — create the CAHPS Patient interoperability contract
|
|
252
|
-
|
|
253
|
-
A CAHPS appointment row carries both patient and appointment
|
|
254
|
-
facts. The patient interoperability contract maps the row into a
|
|
255
|
-
FHIR R4 `Patient` upsert; this cookbook loads the production CAHPS
|
|
256
|
-
patient + MDM templates from the vendored fixtures directory
|
|
257
|
-
rather than inlining several hundred lines of Liquid.
|
|
258
|
-
|
|
259
|
-
Two template slots matter. `template_config` shapes the FHIR
|
|
260
|
-
Patient resource. `mdm_input_config` shapes the MDM input — the
|
|
261
|
-
identifiers, name, and demographics the platform's master-data
|
|
262
|
-
resolution keys on to decide whether this is a new patient or an
|
|
263
|
-
existing one.
|
|
264
|
-
|
|
265
|
-
The `filter_template` uses the CAHPS pipeline's parent-row guard.
|
|
266
|
-
Its semantics are inverted from a naive reading: a template that
|
|
267
|
-
renders **empty** means the row **passes**. `{% unless prnt_apptyn
|
|
268
|
-
== 'Y' %}true{% endunless %}` renders empty exactly when
|
|
269
|
-
`prnt_apptyn == 'Y'`, so only parent (canonical) appointment rows
|
|
270
|
-
flow through — the child rows the CAHPS export duplicates are
|
|
271
|
-
skipped.
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
const { readFileSync } = await import('node:fs')
|
|
275
|
-
const { join } = await import('node:path')
|
|
276
|
-
const patientTemplate = readFileSync(
|
|
277
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_patient.liquid'),
|
|
278
|
-
'utf8',
|
|
279
|
-
)
|
|
280
|
-
const mdmTemplate = readFileSync(
|
|
281
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_mdm.liquid'),
|
|
282
|
-
'utf8',
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
const PARENT_APPOINTMENT_FILTER =
|
|
286
|
-
"{% unless msg.row.prnt_apptyn == 'Y' or msg.row.prnt_apptyn == 'y' %}true{% endunless %}"
|
|
287
|
-
|
|
288
|
-
const patientContractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
289
|
-
name: `Cookbook CAHPS Patient Contract ${runSuffix}`,
|
|
290
|
-
description: 'CAHPS appointments → FHIR R4 Patient (custom Liquid + MDM input).',
|
|
291
|
-
resource_type: 'patient',
|
|
292
|
-
filter_template: PARENT_APPOINTMENT_FILTER,
|
|
293
|
-
template_config: { type: 'custom', body: patientTemplate },
|
|
294
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
295
|
-
generic_table_id: null,
|
|
296
|
-
})
|
|
297
|
-
interopContractId = patientContractResp.data.id!
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
## 007 — create the CAHPS Appointment interoperability contract
|
|
301
|
-
|
|
302
|
-
The appointment contract maps the same CAHPS row into a FHIR R4
|
|
303
|
-
`Appointment` upsert. Its `template_config` is the appointment
|
|
304
|
-
template — the one that maps the vendor's `appt_slot_status` string
|
|
305
|
-
(`3 - checked out`, `x - cancelled`, …) onto the canonical FHIR
|
|
306
|
-
`status` enum (`fulfilled`, `cancelled`, …) the workflow filter
|
|
307
|
-
reads. It carries the same `mdm_input_config` as the patient
|
|
308
|
-
contract so the appointment upsert can populate its `patient_id`
|
|
309
|
-
foreign key from the resolved patient.
|
|
310
|
-
|
|
311
|
-
The generator runs each numbered step as an isolated function, so
|
|
312
|
-
this step re-reads the vendored templates rather than relying on
|
|
313
|
-
§006's local variables.
|
|
314
|
-
|
|
315
|
-
```typescript
|
|
316
|
-
const { readFileSync } = await import('node:fs')
|
|
317
|
-
const { join } = await import('node:path')
|
|
318
|
-
const appointmentTemplate = readFileSync(
|
|
319
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_appointment.liquid'),
|
|
320
|
-
'utf8',
|
|
321
|
-
)
|
|
322
|
-
const mdmTemplate = readFileSync(
|
|
323
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_mdm.liquid'),
|
|
324
|
-
'utf8',
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
const PARENT_APPOINTMENT_FILTER =
|
|
328
|
-
"{% unless msg.row.prnt_apptyn == 'Y' or msg.row.prnt_apptyn == 'y' %}true{% endunless %}"
|
|
329
|
-
|
|
330
|
-
const appointmentContractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
331
|
-
name: `Cookbook CAHPS Appointment Contract ${runSuffix}`,
|
|
332
|
-
description: 'CAHPS appointments → FHIR R4 Appointment (custom Liquid + MDM input).',
|
|
333
|
-
resource_type: 'appointment',
|
|
334
|
-
filter_template: PARENT_APPOINTMENT_FILTER,
|
|
335
|
-
template_config: { type: 'custom', body: appointmentTemplate },
|
|
336
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
337
|
-
generic_table_id: null,
|
|
338
|
-
})
|
|
339
|
-
ctx.appointmentContractId = appointmentContractResp.data.id!
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
## 008 — create the manual-upload DAC
|
|
343
|
-
|
|
344
|
-
The Data Activation Client binds the ingestion pieces — the
|
|
345
|
-
manual-upload tool, the data source, and **both** interop
|
|
346
|
-
contracts — into one ingestion endpoint. A single ingested CAHPS
|
|
347
|
-
row fans out through both contracts: the patient contract upserts
|
|
348
|
-
a `Patient`, the appointment contract upserts an `Appointment`
|
|
349
|
-
linked to it. `tool_call.tool_call_type: 'manual_upload'` selects
|
|
350
|
-
the inline-JSON ingest path. The server-derived `slug` is the
|
|
351
|
-
handle §009 ingests rows against.
|
|
352
|
-
|
|
353
|
-
```typescript
|
|
354
|
-
const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
355
|
-
name: `Cookbook CAHPS DAC ${runSuffix}`,
|
|
356
|
-
description: 'Manual-upload DAC — fans each CAHPS row through the patient + appointment contracts.',
|
|
357
|
-
tool_id: ctx.manualUploadToolId,
|
|
358
|
-
data_source_id: dataSourceId,
|
|
359
|
-
tool_call: { tool_call_type: 'manual_upload' },
|
|
360
|
-
interoperability_contract_ids: [interopContractId, ctx.appointmentContractId],
|
|
361
|
-
})
|
|
362
|
-
dacId = dacResp.data.id!
|
|
363
|
-
ctx.dacSlug = dacResp.data.slug!
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
## 009 — ingest two CAHPS appointment rows
|
|
367
|
-
|
|
368
|
-
Two rows, ingested as inline JSON through the manual-upload DAC.
|
|
369
|
-
Both are parent rows (`prnt_apptyn: 'Y'`) so the contract filter
|
|
370
|
-
passes them. They differ in one field that the workflow cares
|
|
371
|
-
about: `appt_slot_status`. The first row is `3 - checked out`,
|
|
372
|
-
which the appointment template maps to FHIR `status: fulfilled` —
|
|
373
|
-
the workflow filter will pass it. The second is `x - cancelled`,
|
|
374
|
-
mapped to `status: cancelled` — the filter must reject it.
|
|
375
|
-
|
|
376
|
-
The fulfilled patient's mobile number is per-run unique so §013
|
|
377
|
-
can find the exact SMS in LocalStack SNS. Each ingest call gets
|
|
378
|
-
its own batch id; both are pinned for the run scope in §011.
|
|
379
|
-
|
|
380
|
-
```typescript
|
|
381
|
-
const phone7 = String(Date.now()).slice(-7).padStart(7, '0')
|
|
382
|
-
ctx.fulfilledPhone = `+1555${phone7}`
|
|
383
|
-
const fulfilledMobile = `555-${phone7.slice(0, 3)}-${phone7.slice(3)}`
|
|
384
|
-
|
|
385
|
-
const fulfilledRow = {
|
|
386
|
-
appointment_id: `APPT-FUL-${runSuffix}`,
|
|
387
|
-
parent_appointment_id: `APPT-FUL-${runSuffix}`,
|
|
388
|
-
appt_date: '3/12/2026',
|
|
389
|
-
prnt_apptyn: 'Y',
|
|
390
|
-
appt_start_time: '2:30 PM',
|
|
391
|
-
appt_slot_duration: '45',
|
|
392
|
-
appt_slot_status: '3 - checked out',
|
|
393
|
-
appt_type: 'Annual wellness visit',
|
|
394
|
-
patient_id: `PT-FUL-${runSuffix}`,
|
|
395
|
-
enterprise_id: `PT-FUL-${runSuffix}`,
|
|
396
|
-
patient_name: 'Elena Rivera',
|
|
397
|
-
patientdob: '7/22/1950',
|
|
398
|
-
patient_mobile_no: fulfilledMobile,
|
|
399
|
-
patient_risk_level: 'medium',
|
|
400
|
-
svc_department: 'EASTSIDE',
|
|
401
|
-
rndrng_provider_id: '602',
|
|
402
|
-
rndrng_provider: 'Dr. Thomas Nguyen',
|
|
403
|
-
}
|
|
404
|
-
const cancelledRow = {
|
|
405
|
-
appointment_id: `APPT-CAN-${runSuffix}`,
|
|
406
|
-
parent_appointment_id: `APPT-CAN-${runSuffix}`,
|
|
407
|
-
appt_date: '3/14/2026',
|
|
408
|
-
prnt_apptyn: 'Y',
|
|
409
|
-
appt_start_time: '9:00 AM',
|
|
410
|
-
appt_slot_duration: '30',
|
|
411
|
-
appt_slot_status: 'x - cancelled',
|
|
412
|
-
appt_type: 'Follow-up consultation',
|
|
413
|
-
patient_id: `PT-CAN-${runSuffix}`,
|
|
414
|
-
enterprise_id: `PT-CAN-${runSuffix}`,
|
|
415
|
-
patient_name: 'Marcus Doyle',
|
|
416
|
-
patientdob: '1/15/1972',
|
|
417
|
-
patient_mobile_no: '555-100-2000',
|
|
418
|
-
patient_risk_level: 'low',
|
|
419
|
-
svc_department: 'WESTSIDE',
|
|
420
|
-
rndrng_provider_id: '714',
|
|
421
|
-
rndrng_provider: 'Dr. Aisha Bello',
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const [fulfilledIngest, cancelledIngest] = await Promise.all([
|
|
425
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: fulfilledRow }),
|
|
426
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: cancelledRow }),
|
|
427
|
-
])
|
|
428
|
-
ctx.batchFulfilled = fulfilledIngest.data.batch_id!
|
|
429
|
-
ctx.batchCancelled = cancelledIngest.data.batch_id!
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
## 010 — wait for both ingest batches to reach steady-state
|
|
433
|
-
|
|
434
|
-
Ingestion is async — the DAC enqueues per-row jobs that drain
|
|
435
|
-
through both contracts and merge into the regulated patient +
|
|
436
|
-
appointment tables. Each CAHPS row produces two top-level DAC log
|
|
437
|
-
rows (one per contract) with `rows_ingested >= 1`; cascade rows
|
|
438
|
-
the appointment template emits for nested resources carry
|
|
439
|
-
`rows_ingested = 0` and are filtered out. Poll the DAC's
|
|
440
|
-
activation logs until both batches show their two top-level rows
|
|
441
|
-
with a non-empty `output_files` array (the merged Parquet landed
|
|
442
|
-
in object storage). Only then is it safe to run the workflow.
|
|
443
|
-
|
|
444
|
-
```typescript
|
|
445
|
-
const targetBatches = new Set([ctx.batchFulfilled, ctx.batchCancelled])
|
|
446
|
-
const deadline = Date.now() + 120_000
|
|
447
|
-
let greenCount = 0
|
|
448
|
-
while (Date.now() < deadline) {
|
|
449
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
450
|
-
const ingestedByBatch: Record<string, number> = {}
|
|
451
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
452
|
-
const b = row.batch_id
|
|
453
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
454
|
-
if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
|
|
455
|
-
const files = row.output_files
|
|
456
|
-
if (!Array.isArray(files) || files.length === 0) continue
|
|
457
|
-
ingestedByBatch[b] = (ingestedByBatch[b] ?? 0) + 1
|
|
458
|
-
}
|
|
459
|
-
greenCount = Object.values(ingestedByBatch).filter((n) => n >= 2).length
|
|
460
|
-
if (greenCount === targetBatches.size) break
|
|
461
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
462
|
-
}
|
|
463
|
-
if (greenCount !== targetBatches.size) {
|
|
464
|
-
throw new Error(
|
|
465
|
-
`only ${greenCount}/2 appointment batches reached steady-state ` +
|
|
466
|
-
'(patient + appointment contracts both merged) within 120s',
|
|
467
|
-
)
|
|
468
|
-
}
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
## 011 — run the workflow against the two batches
|
|
472
|
-
|
|
473
|
-
`workflows.run` with `manual_override: false` evaluates the
|
|
474
|
-
filter, so the `status == "fulfilled"` filter genuinely routes
|
|
475
|
-
each row. The SQL where-clause scopes the run to exactly the two
|
|
476
|
-
batches §009 ingested (`ra` is the regulated-appointments alias
|
|
477
|
-
the run-query exposes).
|
|
478
|
-
|
|
479
|
-
The SMS action's `trigger_template: 'now'` dispatches the action
|
|
480
|
-
immediately rather than deferring it, so once the run fires it
|
|
481
|
-
reaches a terminal status on its own — poll `batchLogs.refresh`
|
|
482
|
-
until it leaves `:pending`. The run itself is still scheduled:
|
|
483
|
-
`workflows.run` records it and returns, which is why the setup
|
|
484
|
-
file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
|
|
485
|
-
one row passed and one was filtered.
|
|
486
|
-
|
|
487
|
-
```typescript
|
|
488
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
489
|
-
sql_where_clause: `ra.batch_id IN ('${ctx.batchFulfilled}', '${ctx.batchCancelled}')`,
|
|
490
|
-
mode: 'live',
|
|
491
|
-
manual_override: false,
|
|
492
|
-
})
|
|
493
|
-
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
494
|
-
// written when it fires, so read them back via workflowRuns.get.
|
|
495
|
-
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
496
|
-
ctx.runLogId = fired.workflowRunLogId
|
|
497
|
-
ctx.runBatchId = fired.batchId!
|
|
498
|
-
|
|
499
|
-
const deadline = Date.now() + 120_000
|
|
500
|
-
let status: string | null = null
|
|
501
|
-
while (Date.now() < deadline) {
|
|
502
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
503
|
-
status = log.status ?? null
|
|
504
|
-
if (status && status !== 'pending') break
|
|
505
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
506
|
-
}
|
|
507
|
-
if (status === 'failed') throw new Error('workflow run reached :failed')
|
|
508
|
-
if (!status || status === 'pending') {
|
|
509
|
-
throw new Error('workflow run did not leave :pending within 120s')
|
|
510
|
-
}
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
## 012 — verify the filter routed: fulfilled passes, cancelled is filtered
|
|
514
|
-
|
|
515
|
-
Each row produced a Workflow Execution Log. The fulfilled
|
|
516
|
-
appointment passes the filter, so its WEL is `:executing` or
|
|
517
|
-
`:completed`. The cancelled appointment fails the filter, so its
|
|
518
|
-
WEL is `:filtered`. A run where both passed — or both were
|
|
519
|
-
filtered — would mean the filter is not actually evaluating the
|
|
520
|
-
appointment status.
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
524
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
525
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
526
|
-
)
|
|
527
|
-
if (ourWels.length !== 2) {
|
|
528
|
-
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const byStatus: Record<string, number> = {}
|
|
532
|
-
for (const w of ourWels) {
|
|
533
|
-
const st = (w as { status?: string }).status ?? 'unknown'
|
|
534
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
535
|
-
}
|
|
536
|
-
const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
|
|
537
|
-
if (passCount !== 1) {
|
|
538
|
-
throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
|
|
539
|
-
}
|
|
540
|
-
if ((byStatus.filtered ?? 0) !== 1) {
|
|
541
|
-
throw new Error(
|
|
542
|
-
`expected 1 :filtered WEL (the cancelled appointment) — distribution ${JSON.stringify(byStatus)}`,
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
## 013 — confirm the review SMS landed in LocalStack SNS
|
|
548
|
-
|
|
549
|
-
The SMS tool publishes direct-to-phone-number via AWS SNS;
|
|
550
|
-
LocalStack records every direct publish under
|
|
551
|
-
`/_aws/sns/sms-messages` keyed by recipient phone. The SMS `to`
|
|
552
|
-
template resolved the fulfilled patient's `patient_mobile_no`
|
|
553
|
-
through the `e164` Liquid filter, so the recipient is the
|
|
554
|
-
`+1555…` number §009 stamped onto that patient. Poll for a record
|
|
555
|
-
whose body carries the rendered greeting — proof the workflow's
|
|
556
|
-
stated business outcome (a review-request SMS) actually happened.
|
|
557
|
-
|
|
558
|
-
```typescript
|
|
559
|
-
const LOCALSTACK = 'http://localhost:4566'
|
|
560
|
-
const deadline = Date.now() + 30_000
|
|
561
|
-
let matched: { Message: string; PhoneNumber: string } | undefined
|
|
562
|
-
while (Date.now() < deadline && !matched) {
|
|
563
|
-
const resp = await fetch(`${LOCALSTACK}/_aws/sns/sms-messages`)
|
|
564
|
-
if (resp.ok) {
|
|
565
|
-
const body = (await resp.json()) as {
|
|
566
|
-
sms_messages?: Record<string, Array<{ Message: string; PhoneNumber: string }>>
|
|
567
|
-
}
|
|
568
|
-
const records = body.sms_messages?.[ctx.fulfilledPhone] ?? []
|
|
569
|
-
matched = records.find(
|
|
570
|
-
(m) => m.Message.includes('thank you for your recent visit') && m.Message.includes('Elena'),
|
|
571
|
-
)
|
|
572
|
-
}
|
|
573
|
-
if (!matched) await new Promise((r) => setTimeout(r, 500))
|
|
574
|
-
}
|
|
575
|
-
if (!matched) {
|
|
576
|
-
throw new Error(`no review-request SMS landed in LocalStack SNS for ${ctx.fulfilledPhone} within 30s`)
|
|
577
|
-
}
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
## 014 — fire the action via workflows.execute for an introspectable WEL
|
|
581
|
-
|
|
582
|
-
The §011 run dispatched the SMS (§013 proved it landed in SNS),
|
|
583
|
-
but a run-created WEL schedules each action through a datetime
|
|
584
|
-
slot — the rendered `message_body` is not surfaced on its
|
|
585
|
-
action-execution-log. `workflows.execute` is the direct path: it
|
|
586
|
-
fires one named action on one dataset row and the resulting WEL
|
|
587
|
-
carries the fully-rendered message. `manual_override: true` is
|
|
588
|
-
load-bearing — §011 already processed this appointment, so without
|
|
589
|
-
the override the action's idempotency tuple would dedupe the
|
|
590
|
-
re-fire.
|
|
591
|
-
|
|
592
|
-
`workflows.execute` is addressed by the *unregulated* dataset id,
|
|
593
|
-
so first resolve the fulfilled appointment's id via a dataset
|
|
594
|
-
search scoped to its batch, then poll the execute WEL until it
|
|
595
|
-
reaches `:completed`.
|
|
596
|
-
|
|
597
|
-
```typescript
|
|
598
|
-
const { data: us } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'appointment', {
|
|
599
|
-
search_query: `ra.batch_id = '${ctx.batchFulfilled}'`,
|
|
600
|
-
})
|
|
601
|
-
if (us.status !== 'completed') {
|
|
602
|
-
throw new Error(`appointment user-search status=${us.status} error=${us.error_message ?? '(none)'}`)
|
|
603
|
-
}
|
|
604
|
-
const { data: search } = await api.datasets.search(tenantSlug, datalakeSlug, 'appointment', {
|
|
605
|
-
userSearchId: us.id!,
|
|
606
|
-
dataAccessMode: 'unregulated',
|
|
607
|
-
})
|
|
608
|
-
const fulfilledAppointmentId = (search.data?.[0] as { id?: string } | undefined)?.id
|
|
609
|
-
if (!fulfilledAppointmentId) {
|
|
610
|
-
throw new Error('fulfilled appointment not found via unregulated search')
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const { data: execResp } = await api.workflows.execute(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
614
|
-
dataset_id: fulfilledAppointmentId,
|
|
615
|
-
decision_key: 'send_appointment_review_sms',
|
|
616
|
-
manual_override: true,
|
|
617
|
-
})
|
|
618
|
-
ctx.execWelId = execResp.workflow_execution_log_id!
|
|
619
|
-
|
|
620
|
-
const deadline = Date.now() + 60_000
|
|
621
|
-
let welStatus: string | null = null
|
|
622
|
-
while (Date.now() < deadline) {
|
|
623
|
-
const { data: wel } = await api.workflows.workflowLogs.get(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.execWelId)
|
|
624
|
-
welStatus = (wel as { status?: string }).status ?? null
|
|
625
|
-
if (welStatus && welStatus !== 'pending' && welStatus !== 'executing') break
|
|
626
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
627
|
-
}
|
|
628
|
-
if (welStatus !== 'completed') {
|
|
629
|
-
throw new Error(`execute WEL did not reach :completed (last status: ${welStatus})`)
|
|
630
|
-
}
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
## 015 — resolve the connected-app deep-link and post tracking
|
|
634
|
-
|
|
635
|
-
The action's SMS carries a `/t/<token>` shortlink minted from the
|
|
636
|
-
action's `connected_app_id` + `connected_app_route`. Read the
|
|
637
|
-
execute WEL in regulated mode to get the raw rendered
|
|
638
|
-
`message_body`, extract the token, and resolve it via
|
|
639
|
-
`connectedApps.resolvePage` — the `route_path` must match the
|
|
640
|
-
action's `connected_app_route`. Posting `opened_at` +
|
|
641
|
-
`form_submitted_at` via `updateMessageTracking` then mirrors what
|
|
642
|
-
the connected-app frontend does when the patient opens the page,
|
|
643
|
-
closing the SMS → reply loop end-to-end.
|
|
644
|
-
|
|
645
|
-
```typescript
|
|
646
|
-
const { data: regulatedWel } = await api.workflows.workflowLogs.get(
|
|
647
|
-
tenantSlug,
|
|
648
|
-
datalakeSlug,
|
|
649
|
-
ctx.workflowSlug,
|
|
650
|
-
ctx.execWelId,
|
|
651
|
-
{ dataAccessMode: 'regulated' },
|
|
652
|
-
)
|
|
653
|
-
const aelWithBody = (regulatedWel.action_execution_logs ?? []).find(
|
|
654
|
-
(ael) => typeof ael.message_body === 'string' && ael.message_body.length > 0,
|
|
655
|
-
)
|
|
656
|
-
if (!aelWithBody?.message_body) {
|
|
657
|
-
throw new Error('no AEL message_body on the executed WEL')
|
|
658
|
-
}
|
|
659
|
-
const tokenMatch = aelWithBody.message_body.match(/\/t\/([A-Za-z0-9_-]+)/)
|
|
660
|
-
if (!tokenMatch) {
|
|
661
|
-
throw new Error(`no /t/<token> in rendered SMS body: ${aelWithBody.message_body}`)
|
|
662
|
-
}
|
|
663
|
-
const shortPath = tokenMatch[1]!
|
|
664
|
-
|
|
665
|
-
const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
666
|
-
short_path: shortPath,
|
|
667
|
-
user_agent: 'cookbook-doctest/appointment-review',
|
|
668
|
-
})
|
|
669
|
-
if (resolved.route_path !== '/forms/review') {
|
|
670
|
-
throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
const now = new Date().toISOString()
|
|
674
|
-
const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
675
|
-
short_path: shortPath,
|
|
676
|
-
opened_at: now,
|
|
677
|
-
form_submitted_at: now,
|
|
678
|
-
})
|
|
679
|
-
if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
680
|
-
throw new Error('message tracking did not persist opened_at + form_submitted_at')
|
|
681
|
-
}
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
## 016 — write the integration test
|
|
685
|
-
|
|
686
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
687
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
688
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
689
|
-
decision) and intercepts only the final action call, so no message
|
|
690
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
691
|
-
block runs live under `make validate-cookbook`.
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
695
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
696
|
-
if (wfRow.status !== 'live') {
|
|
697
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
698
|
-
}
|
|
699
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
700
|
-
// the pipeline executes end-to-end, the final action call is
|
|
701
|
-
// intercepted, and the acknowledgement carries the scheduled run id.
|
|
702
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
703
|
-
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
704
|
-
mode: 'dry_run',
|
|
705
|
-
manual_override: false,
|
|
706
|
-
})
|
|
707
|
-
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
708
|
-
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
709
|
-
if (probeFired.workflowRunLogId.length === 0) {
|
|
710
|
-
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
711
|
-
}
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
If the probe fails in production, escalate with the run response as
|
|
715
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
716
|
-
chase the error.
|
|
717
|
-
|
|
718
|
-
# Branches
|
|
719
|
-
|
|
720
|
-
- **The cancelled appointment is filtered, not failed** — §012
|
|
721
|
-
asserts the cancelled row's WEL is `:filtered`, a distinct
|
|
722
|
-
terminal status from `:failed`. A filtered row is a *correct*
|
|
723
|
-
outcome: the workflow looked at it, the `status == "fulfilled"`
|
|
724
|
-
filter rendered empty, and the platform recorded that the row
|
|
725
|
-
was intentionally skipped. No SMS action runs for a filtered row
|
|
726
|
-
— which is exactly the business rule (never ask for feedback on
|
|
727
|
-
a visit that did not happen).
|
|
728
|
-
- **The contract filter's inverted semantics** — §006/§007 wire a
|
|
729
|
-
`filter_template` whose convention is "renders empty ⇒ row
|
|
730
|
-
passes". The CAHPS export emits both parent and child rows for
|
|
731
|
-
one logical appointment; `{% unless prnt_apptyn == 'Y' %}…`
|
|
732
|
-
keeps only the parent. A row with `prnt_apptyn` other than `Y`
|
|
733
|
-
is dropped at the contract layer and never reaches the
|
|
734
|
-
appointment table — a different rejection point from the
|
|
735
|
-
workflow filter in §012.
|
|
736
|
-
- **The six-month recency guard** — §003's `context_datasets`
|
|
737
|
-
entry queries the `message` dataset for a prior review SMS to
|
|
738
|
-
the same patient. On this fresh tenant it returns empty, so the
|
|
739
|
-
action runs. On a tenant with history, a recent prior message
|
|
740
|
-
would make the action skip — the platform's built-in
|
|
741
|
-
anti-pestering guard, expressed as a context query rather than
|
|
742
|
-
application code.
|
|
743
|
-
|
|
744
|
-
# Rollback
|
|
745
|
-
|
|
746
|
-
The cookbook doctest harness does not currently tear down created
|
|
747
|
-
resources. The `_setup/healthcare.md` setup file's runSuffix-scoped
|
|
748
|
-
tenant / datalake / user names mean each run is naturally isolated;
|
|
749
|
-
the seeded local DB is cheap to reset (`mix ecto.reset` on the
|
|
750
|
-
platform repo).
|
|
751
|
-
|
|
752
|
-
# Outcome
|
|
753
|
-
|
|
754
|
-
After this cookbook's fifteen steps run green:
|
|
755
|
-
|
|
756
|
-
- A healthcare tenant exists with a healthcare-domain datalake
|
|
757
|
-
- An SMS tool, a Review SMS connected app, and a Review SMS
|
|
758
|
-
standard workflow (status `live`, `appointment` dataset) are
|
|
759
|
-
registered
|
|
760
|
-
- An athenahealth data source, a Manual Upload tool, a CAHPS
|
|
761
|
-
Patient interop contract, a CAHPS Appointment interop contract,
|
|
762
|
-
and a manual-upload DAC form a working ingestion chain
|
|
763
|
-
- Two appointments have been ingested through that chain — one
|
|
764
|
-
checked-out, one cancelled — each with its own resolved patient
|
|
765
|
-
- Running the workflow routed them correctly: the fulfilled
|
|
766
|
-
appointment passed the filter and dispatched an SMS; the
|
|
767
|
-
cancelled appointment was `:filtered`
|
|
768
|
-
- The dispatched SMS is observable in LocalStack SNS with the
|
|
769
|
-
rendered "thank you for your recent visit" greeting
|
|
770
|
-
- The connected-app deep-link the SMS carried resolves, and
|
|
771
|
-
message tracking records the open + form-submit timestamps
|
|
772
|
-
|
|
773
|
-
The business outcome — a review-request SMS sent to a patient
|
|
774
|
-
after a fulfilled visit, with a working feedback link — is
|
|
775
|
-
demonstrated end-to-end, not merely provisioned.
|
|
776
|
-
|
|
777
|
-
# See also
|
|
778
|
-
|
|
779
|
-
- `_setup/healthcare.md` — the inlined bootstrap that provisions
|
|
780
|
-
the tenant + datalake this cookbook starts from
|
|
781
|
-
- `contact-us-triage-with-llm.md` — the agent-driven healthcare
|
|
782
|
-
cookbook; same SMS shape, an LLM agent in the decision
|
|
783
|
-
- `.agent/tools.md` — SMS (`tool_body_type: sns`) and
|
|
784
|
-
manual-upload tool body shapes; intent classification
|
|
785
|
-
- `.agent/connected_apps.md` — connected-app registration + the
|
|
786
|
-
resolve-page / message-tracking reply loop
|
|
787
|
-
- `.agent/interoperability_contracts.md` — custom contract shape,
|
|
788
|
-
`template_config` vs `mdm_input_config`, the inverted
|
|
789
|
-
`filter_template` convention
|
|
790
|
-
- `.agent/data_activation_clients.md` — data source → tool →
|
|
791
|
-
interop contract → DAC ingestion chain
|
|
792
|
-
- `.agent/workflows.md` — standard workflow primitive (filter +
|
|
793
|
-
decision + actions), `context_datasets`, `workflows.run`
|
|
794
|
-
- `.agent/cookbook/_fixtures/healthcare/` — the vendored CAHPS
|
|
795
|
-
patient / appointment / MDM Liquid templates §006–§007 load
|
|
796
|
-
- `integration-tests/tests/healthcare/standard-workflow.test.ts` —
|
|
797
|
-
the anchor green test (§1–§6) these snippets are lifted from
|
|
798
|
-
- `integration-tests/tests/healthcare/interoperability-contracts.test.ts`,
|
|
799
|
-
`run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
|
|
800
|
-
`data-sources.test.ts` — the per-resource create + ingest
|
|
801
|
-
snippets §004–§010 are lifted from
|