Patient cross-tenant appointments — QA cases
Test cases for the three workstreams shipped May 2026. Companion to the developer guide and the Patient::Domain QA doc.
Scope: covers
GET /patient/appointments(Workstream A),GET /clinical/patients/federated_search+ auto-provision at booking (Workstream B), andPOST /patient/appointment_orders(Workstream C).
1. Test environment setup
1.1 Required fixtures
-- Three tenants (representing three independent clinics)
SELECT id, name FROM identity.tenants WHERE slug IN ('clinic-a', 'clinic-b', 'clinic-c');
-- A patient user with Clinical::Patient rows in clinics A and B (NOT C)
SELECT u.id AS user_id, u.email, u.phone_country_code, u.phone_number,
cp.tenant_id, cp.id AS clinical_patient_id
FROM identity.users u
LEFT JOIN clinical.patients cp ON cp.user_id = u.id
WHERE u.email = 'patient-cross-tenant@example.com';
-- One vendor with an active appointment for this user (no tenant)
SELECT a.id, a.start_time, a.appointable_type, a.attendee_user_id, a.tenant_id
FROM clinical.appointments a
WHERE a.attendee_user_id = '<user_id>' AND a.appointable_type = 'Identity::Vendor';
-- A PHONELESS dependent managed by the guardian (#1864). phone_number must be
-- NULL, not an 'm:<hex>' placeholder — the placeholder is what #1864 removed.
SELECT ip.id AS dependent_identity_patient_id, ip.user_id AS dependent_user_id,
ip.phone_number, ip.managed_by_user_id
FROM identity.patients ip
WHERE ip.managed_by_user_id = '<guardian_user_id>' AND ip.phone_number IS NULL;
-- Nothing anywhere should still carry a placeholder:
SELECT count(*) FROM identity.patients WHERE phone_number LIKE 'm:%'; -- expect 01.2 Auth tokens
| Token | Use for |
|---|---|
| Patient JWT (Identity::User session) | /patient/appointments, /patient/appointment_orders |
Patient JWT + X-On-Behalf-Of: <dependent_id> header | Guardian-on-behalf-of flow |
| Clinic-A staff JWT (via tenant + facility) | /clinical/patients/federated_search from clinic A |
2. Workstream A — GET /patient/appointments
2.1 Cross-tenant aggregation
Setup: patient has appointments at clinics A and B (one each) plus one vendor appointment.
Steps:
GET /patient/appointmentswith patient JWT.
Expected:
- 200 OK
data.length == 3meta.fan_out == 2(two tenants iterated)- One row per appointment, sorted by
appointment_time DESC - Practitioner appointments have
clinic_details.location.address_line_1populated - Vendor appointment has
doctor_details.kind == 'vendor'andclinic_details == nullandtenant_id == null
2.2 N+1 absence
Steps:
- Tail Rails log while running test 2.1.
Expected:
- Number of SQL queries scales with
meta.fan_out, not withdata.length. Per-tenant: one query for appointments, one for practitioner includes, one for facility+locations. Vendor: one global query.
2.3 Vendor appointment without practitioner
Setup: patient has only a vendor appointment, no practitioner appointments.
Expected:
- 200 OK with one row
doctor_details.kind == 'vendor'chief_complaintis null (vendor appointments have no reason_codes typically)
2.4 Filtering
Steps:
GET /patient/appointments?status=cancelledGET /patient/appointments?from=2026-01-01&to=2026-12-31
Expected:
- Status filter narrows correctly
- Range filter works on
start_time - Combined with vendor union — vendor query also respects filters
2.5 LHR endpoint removal
Steps:
GET /patient/clinical/appointmentswith patient JWT.
Expected:
- 404 Not Found (
:appointmentsremoved fromClinical::Patients::LongitudinalRecordService::CLINICAL_RESOURCE_TYPES)
2.6 Subject vs current user (guardian on-behalf-of)
Setup: Guardian user has dependent. Dependent has appointments at clinic B.
Steps:
GET /patient/appointmentswith guardian JWT +X-On-Behalf-Of: <dependent_id>header.
Expected:
- Returns dependent's appointments, NOT guardian's.
- Pre-fix bug: this would return guardian's appointments because
current_userwas used instead ofsubject_user.
3. Workstream B — GET /clinical/patients/federated_search
3.1 Three-bucket return
Setup: Patient X has Clinical::Patient at clinic A. Patient Y is registered via patient app (Identity::User exists, no Clinical::Patient anywhere). Patient Z phone has no match.
Steps:
- From clinic A:
GET /clinical/patients/federated_search?phone=<X.phone>&country_code=91 - From clinic A:
GET /clinical/patients/federated_search?phone=<Y.phone>&country_code=91 - From clinic A:
GET /clinical/patients/federated_search?phone=<Z.phone>&country_code=91
Expected:
| Step | in_clinic | in_kulcare | no_match |
|---|---|---|---|
| 1 | 1 (X's clinic A row) | 0 | [] |
| 2 | 0 | 1 (Y's identity, registered_via: 'patient_app') | [] |
| 3 | 0 | 0 | [] |
3.2 Privacy guard — no other-clinic leakage
Setup: Patient X has Clinical::Patient at clinics B and C. Phone 9876543210.
Steps:
- From clinic A:
GET /clinical/patients/federated_search?phone=9876543210&country_code=91
Expected:
- Response includes
in_kulcarerow for X - Response JSON does NOT contain clinic B's id, clinic B's name, clinic C's id, or clinic C's name
registered_viais'another_clinic'(no Identity::User) or'patient_app'(if X has one)
Verification:
# Run the search and grep the response for clinic-B/C identifiers
curl -H "Authorization: Bearer <clinic_a_jwt>" \
"<api>/clinical/patients/federated_search?phone=9876543210&country_code=91" \
| jq . | grep -E '<clinic_b_id>|<clinic_b_name>|<clinic_c_id>|<clinic_c_name>'
# Expect: 0 matches3.3 Phone country_code required
Steps:
GET /clinical/patients/federated_search?phone=9876543210(no country_code)
Expected:
- 422 with body containing
phone search requires country_code - Response time fast — early validation, no DB hit.
3.4 LIKE wildcard sanitization
Steps:
GET /clinical/patients/federated_search?name=%GET /clinical/patients/federated_search?name=ab(2 chars, below minimum)GET /clinical/patients/federated_search?name=%25%5F%25(literal%_%)
Expected:
- Either matches a literal
%in name (escaped) or returns no rows. Does NOT enumerate the entire identity table. - 422 with
name must be at least 3 characters. - Treats
%_%as literal characters, not wildcards.
Verification: the underlying query uses ActiveRecord::Base.sanitize_sql_like(@name.downcase) per Clinical::Patients::FederatedSearchService#safe_like_term.
3.5 Auto-provision via identity_patient_id
Setup: Identity::Patient X exists. No Clinical::Patient for X in clinic A.
Steps:
POST /clinical/appointment_orderswithidentity_patient_id: <X.id>,practitioner_id,facility_id, time slot.
Expected:
- 201 Created
- Exactly one new row in
clinical.patientsfor tenant A linked toidentity_patient: <X.id> Audit::PhiAccessLogrow withreason_detail.via == 'federated_search_auto_provision'andpre_existing_in_other_tenants > 0(if X had Clinical::Patient elsewhere)- The
pre_existing_in_other_tenantsvalue never appears in the booking response JSON
3.6 Auto-provision race-safety
Setup: Identity::Patient X exists. No Clinical::Patient for X in clinic A.
Steps:
- Submit two
POST /clinical/appointment_ordersfor X concurrently from clinic A (usexargs -P 2or similar).
Expected:
- Exactly one new
Clinical::Patientrow created in clinic A - The unique partial index
idx_cl_patients_tenant_identity_uniqueenforces this - One booking succeeds, the other either succeeds (re-read found existing) or fails cleanly with a known error
3.7 Audit B1 — in_kulcare exposure
Steps:
- Search returning 2
in_kulcarerows - Search returning 0
in_kulcarerows
Expected:
- Step 1 creates 2
Audit::PhiAccessLogrows withreason_detail.via == 'federated_search',resource_type == 'Identity::Patient', one per result. - Step 2 creates 0 audit rows (nothing exposed = nothing audited).
4. Workstream C — POST /patient/appointment_orders
4.1 First-time booking — consent required
Setup: Patient has no Clinical::Patient at clinic Q.
Steps:
POST /patient/appointment_orderswithtenant_id: <Q>and withoutconsent_acknowledged: true.
Expected:
- 422 with
error.code == 'consent_required' - No new
clinical.patientsrow created (transaction rolled back) - No
Audit::ConsentAuditLogrow created
4.2 First-time booking — consent given
Setup: Same as 4.1.
Steps:
POST /patient/appointment_orderswithtenant_id: <Q>, all required fields,consent_acknowledged: true.
Expected:
- 201 Created
- One new
clinical.patientsrow in tenant Q linked to current user - One new
clinical.appointmentsrow - One
Audit::ConsentAuditLogrow with:consent_type: 'data_sharing'action: 'granted'collection_method: 'electronic'ip_addressmatches request remote_ipgranted_to.facility_idmatches the bookingscope.via == 'patient_app_first_time_booking'scope.user_id == subject_user.id
- One
Audit::PhiAccessLogrow withreason_detail.via == 'patient_app_authenticated_booking' - Response includes
meta.first_time_at_facility: true - New appointment shows up in
GET /patient/appointments
4.3 Returning patient — no consent step
Setup: Patient already has Clinical::Patient at clinic R from a previous booking.
Steps:
POST /patient/appointment_orderswithtenant_id: <R>, all required fields.consent_acknowledgedomitted.
Expected:
- 201 Created
- No new
clinical.patientsrow - No new
Audit::ConsentAuditLogorAudit::PhiAccessLogrows - Response:
meta.first_time_at_facility: false
4.4 Status passthrough rejection
Setup: First-time booking at clinic Q.
Steps:
POST /patient/appointment_orderswithconsent_acknowledged: true, status: 'fulfilled'.
Expected:
- 201 Created (request succeeds)
- The created AppointmentOrder has
status: 'pending'— caller-suppliedstatus: 'fulfilled'is silently ignored because the controller's permit list doesn't include:status. Only the gateway callback advances an order pastpending. - No
clinical.appointmentsrow yet. Since #1841 this endpoint creates an order and a checkout session;OrderFulfillmentJobmaterialises the appointment after payment succeeds.
4.5 Acting vs subject user (guardian-on-behalf-of)
Setup: Guardian user has dependent. Dependent has no Clinical::Patient at clinic Q. The dependent is phoneless — identity.patients.phone_number IS NULL (see 1.1 fixtures).
Steps:
POST /patient/appointment_orderswith guardian JWT +X-On-Behalf-Of: <dependent_id>header +consent_acknowledged: true.
Expected:
- 201 Created
- New Clinical::Patient created in clinic Q linked to the dependent's user_id
- The new row has
phone_number IS NULLandidentity_patient_id== the dependent'sidentity.patients.id Audit::ConsentAuditLog.scope.user_id== dependent's idAudit::ConsentAuditLog.scope.acting_user_id== guardian's idAudit::PhiAccessLog.reason_detail.acting_user_id== guardian's id
Regression watch (#1864): before the fix this returned 422
Could not provision patient recordfor every dependent, because the flow wrote a syntheticm:<hex>placeholder phone thatClinical::Patient's digits-only validation rejected. A 422 here means the placeholder has come back.
4.6 Tenant validation
Steps:
- POST with non-existent
tenant_id. - POST with suspended
tenant_id. - POST with
facility_idbelonging to a different tenant. - POST with
facility_idof a closed facility (closed_at < now).
Expected:
- 1: 404,
error.code == 'tenant_not_found' - 2: 404,
error.code == 'tenant_not_found' - 3: 404,
error.code == 'facility_not_found' - 4: 404,
error.code == 'facility_not_found'(viafacility.operational?check)
4.7 RLS context cleanup
Steps:
- Force a failure inside the booking transaction (e.g., supply an invalid
practitioner_id). - Immediately follow with a query that depends on
app.current_tenant_idbeing unset.
Expected:
- The booking returns the appropriate failure
- Subsequent queries see
app.current_tenant_idempty (theensureblock inwith_rls_contextcleared it even on raise) - No leak of tenant context to subsequent requests on the same connection
4.8 Concurrent first-time bookings
Setup: Patient has no Clinical::Patient at clinic Q.
Steps:
- Submit two
POST /patient/appointment_ordersfor clinic Q concurrently (same user, both withconsent_acknowledged: true).
Expected:
- One booking succeeds with
first_time_at_facility: true - The other either succeeds with
first_time_at_facility: false(re-read found the row provisioned by the winner) or fails cleanly with a knowable error - Exactly one Clinical::Patient row in tenant Q for this user
- No
PG::InFailedSqlTransactionerrors in logs (savepoint absorbs theRecordNotUnique)
4.9 Pre-existing appointment race
Setup: Patient has Clinical::Patient at clinic Q. Practitioner has a slot booked.
Steps:
POST /patient/appointment_orderswith the same slot start_time as an existing appointment.
Expected:
- 422 with
error.code == 'appointment_create_failed'and the actual conflict message preserved (e.g., "Practitioner is unavailable" or "Slot is already booked") - No new Clinical::Patient row (this is a returning patient anyway)
- No Audit rows
5. Cross-workstream integration
5.1 End-to-end: search → book → list
Setup: Patient registered via patient app. Identity::User exists with phone.
Steps:
- From clinic A:
GET /clinical/patients/federated_search?phone=<phone>&country_code=91. Verifyin_kulcarerow. - From clinic A:
POST /clinical/appointment_orderswithidentity_patient_idfrom step 1. - From patient app (logged in as the patient):
GET /patient/appointments.
Expected:
- Step 2 creates the Clinical::Patient + appointment in clinic A
- Step 3 returns the appointment from step 2 in the patient's list, with
clinic_detailspopulated for clinic A
5.2 Audit trail across workstreams
After step 5.1, query audit.phi_access_logs and audit.consent_audit_logs:
SELECT created_at, resource_type, access_type, reason_detail
FROM audit.phi_access_logs
WHERE tenant_id = '<clinic_a_id>'
ORDER BY created_at;Expected timeline:
B1log when federated search returned the in_kulcare row (resource_type: 'Identity::Patient', access_type: 'view')B2log when auto-provision created the Clinical::Patient (resource_type: 'Clinical::Patient', access_type: 'amend')- Existing patient-history log when the appointment was created
5A. Post-review hardening (May 2026)
Follow-ups from review of the original three workstreams (#1344). Shipped in PRs #1438, #1436, #1439. Verify each scenario alongside the workstream it tightens.
5A.1 Patient app — clinic-internal note no longer surfaces cross-tenant (#1438)
The patient-facing appointment payload used to ship Clinical::Appointment#note — a free-text field clinic staff historically treated as clinic-internal ("non-compliant," "drug-seeking," suspected-diagnosis shorthand). Cross-tenant fan-out meant another clinic's internal commentary landed on the patient's screen.
Setup: Patient has appointments at clinics A and B. A clinic-A staff member writes the following on one of those appointments:
note: "Internal flag — patient missed last two appointments"patient_instruction: "Fast 12 hours before lab"
Steps:
GET /patient/appointmentswith patient JWT.
Expected:
- 200 OK.
- The clinic-A appointment row contains:
patient_instruction: "Fast 12 hours before lab"✅ still surfaced (explicitly patient-facing).cancellation_reasonkey present (null in this case).- No
notekey on the row.
- Patient app
ClinicAppointmentCarddisplays the appointment usingchief_complaint(derived fromreason_codes[0]); the historical fallback tonoteshows nothing extra for this row, which is the intended behaviour.
Negative check (regression guard):
curl -s -H "Authorization: Bearer <patient_jwt>" \
https://api-stg.kulcare.com/patient/appointments | \
jq '.data[] | select(has("note"))'→ Output must be empty. If any row still ships note, the fix has regressed.
Spec: backend/domains/patient/spec/blueprints/appointment_blueprint_spec.rb — has a positive assertion on not_to have_key(:note).
5A.2 Federated search — identity_user_id no longer leaked (#1436)
The in_kulcare bucket used to carry identity_user_id — a stable global handle that downstream endpoints (POST /clinical/appointment_orders) accept as an actor-of-record. Shipping it from a per-clinic search let a clinic-side caller replay it cross-flow.
Setup: Identity::Patient P exists in clinic B with a linked Identity::User. Clinic A has no record of P.
Steps:
- From clinic A:
GET /clinical/patients/federated_search?phone=<P.phone>&country_code=91.
Expected:
- 200 OK.
data.in_kulcare[0]contains exactly these keys:identity_patient_id,name,phone,phone_country_code,age,gender,registered_via.- No
identity_user_idkey. registered_via: "patient_app"(because the patient has a linked Identity::User — this is now the only signal of patient-app provenance).
Negative check:
curl -s -H "Authorization: Bearer <clinic_a_staff_jwt>" \
"https://api-stg.kulcare.com/clinical/patients/federated_search?phone=<phone>&country_code=91" | \
jq '.data.in_kulcare[] | keys'→ The resulting key list must not include identity_user_id.
Spec: backend/domains/clinical/spec/services/patients/federated_search_service_spec.rb — positive assertion not_to have_key(:identity_user_id) on both the linked-user and unlinked-user cases.
5A.3 CMS FederatedPatientPicker building blocks (#1439)
Reusable hook + component shipped to @kulcare/shared-services and @kulcare/cms respectively. Not yet wired into any flow — these are building blocks future intake-flow work will consume. QA scope is limited to verifying the contracts hold.
Component contracts
| Contract | Verify |
|---|---|
| Hook short-circuits below input floors | Type 1, 12, 123 in phone mode → no request fires (check Network tab). Type 1234 → request fires. |
| Name mode threshold | Type a, ab in name mode → no request. Type abc → request fires. |
| Email floor | Type a, a@, a@b → no request. Type a@b.c → request fires. |
Phone mode without countryCode prop | Picker renders inline text-warning hint: "Pass a countryCode prop to enable phone-mode search." User does not see indefinite empty state. |
+91 country code is normalized | Pass countryCode="+91" to picker → search succeeds (not ++91 URL-encoded). |
| Phone rendering | phone_country_code stored as "+91" and as "91" both display as +91 ... (never ++91 ...). |
| Privacy contract | in_kulcare rows show name, phone, age, gender, and one of two provenance strings — never a tenant name, facility name, or other-clinic identifier. |
| Error states | Returning a 403 from /clinical/patients/federated_search (e.g. revoke federated_search permission) shows "Your clinic doesn't have federated search access — contact your admin." (not the generic retry copy). A 422 shows "Check your search input". Network error shows the retry copy. |
| "Add new patient" CTA | When onCreateNew is wired, the CTA renders both when buckets are empty AND when they have matches — supports the "patient with similar name to someone in results" intake case. |
Backend audit double-write check
The picker deliberately does not call hipaaAuditService.logPatientEvent for in_kulcare rendering — the backend already emits one Audit::PhiAccessLog row per row returned (the B1 log from §3.7 / #1344 item 9).
Verify: open the picker and trigger a search that returns 3 in_kulcare rows. Query:
SELECT count(*) FROM audit.phi_access_logs
WHERE resource_type = 'Identity::Patient'
AND created_at > NOW() - INTERVAL '1 minute';→ Expect 3 rows, not 6. If you see 6, a frontend audit handler has been added in error and is double-writing.
5A.4 Audit::Dispatch envelope (#1441 / #1338 item 1)
All 7 audit-emitting call sites in domains/ now write through Audit::Dispatch. The migration shouldn't change observable row content — same metadata, scope, granted_to, data_categories. Verify by re-running the §3.7 (B1) and §4.x (consent) scenarios and inspecting rows:
SELECT id, resource_type, access_type, user_role, metadata
FROM audit.phi_access_logs
WHERE created_at > NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 10;Expected (regression checks):
| Scenario | Row should carry |
|---|---|
Federated search returns N in_kulcare rows | N PhiAccessLog rows, metadata->>'via' = 'federated_search', metadata->>'matched_via' non-empty, metadata->>'request_id' populated from the calling request's X-Request-ID |
Clinic-side POST /clinical/appointment_orders with identity_patient_id for a brand-new in-tenant patient | One PhiAccessLog row, metadata->>'via' = 'federated_search_auto_provision', access_type = 'amend' |
Patient-app POST /patient/appointment_orders for first-time facility (consent path) | One ConsentAuditLog (data_sharing/granted) + one PhiAccessLog (via = 'patient_app_authenticated_booking') |
| Public booking phone-match that links to an existing Identity::Patient at another tenant | One ConsentAuditLog with scope->>'via' = 'public_booking_phone_match', scope->>'request_id' populated |
| Discharge create | One PhiAccessLog row, user_role = 'practitioner' (not 'unknown' — explicit role required after silent-failure review) |
Negative regression check:
SELECT count(*) FROM audit.phi_access_logs
WHERE created_at > NOW() - INTERVAL '1 hour'
AND user_role = 'unknown';→ Expect 0 rows for the production-shape flows above. 'unknown' is the Dispatch fallback when only actor_id is known AND no actor_role is passed; the current call sites all pass enough info to avoid it. A non-zero count means a call site regressed to id-only without explicit role.
Sidekiq context check (compliance-critical):
If you run any Sidekiq job that touches PHI on staging, confirm the job sets Shared::Current.tenant = job.tenant (or passes tenant: explicitly to Dispatch). Run a job, inspect the PhiAccessLog row's tenant_id, confirm it matches the job's intended tenant. The Dispatch fallback to Shared::Current is safe only when the worker hygiene holds.
Audit::Dispatch.log_consent deliberately doesn't fall back — it requires tenant: explicitly, so this risk is contained to log_phi_access callers.
5A.5 New-tenant wizard RLS context (#1443 / #1387)
PR #1443 fixes a PostgreSQL RLS blocker that prevented existing-tenant users from creating new tenants via the Connect dashboard's "New practice" flow. The dependent INSERTs (identity.organizations / facilities / user_memberships) have WITH CHECK clauses that reject inserts whose tenant_id doesn't match app.current_tenant_id. The wizard now switches the GUC to the new tenant before those INSERTs run, then restores the caller's context after.
Test scenarios:
| Step | Expected |
|---|---|
| User logs into existing Tenant A as owner/admin | JWT carries tenant_id=A. Active GUC = A. |
| User clicks "New practice" on Connect Dashboard | Wizard opens at /connect/facility/new?force_new_tenant=true (PR #1386). |
| User completes wizard (About → Address → Team → Ops → Review → Plan) | All fields collected as wizard payload. |
Wizard submits to POST /identity/facilities/setup with force_new_tenant: true | Returns 201 with { data: { tenant: <new B>, organization, facility } }. |
Frontend switchFacility(B) | Subsequent requests run under Tenant B context. |
| User navigates to CMS dashboard | Empty CMS state (Tenant B has no patients/appointments/etc. yet). |
Negative regression checks (must NOT happen):
-- After a successful "New practice" wizard run, the new tenant and
-- its dependents must all carry the SAME tenant_id.
WITH last_tenant AS (
SELECT id FROM identity.tenants
WHERE slug = '<the slug from the wizard payload>'
)
SELECT
(SELECT count(*) FROM identity.organizations WHERE tenant_id = (SELECT id FROM last_tenant)) AS org_count,
(SELECT count(*) FROM identity.facilities WHERE tenant_id = (SELECT id FROM last_tenant)) AS facility_count,
(SELECT count(*) FROM identity.user_memberships WHERE tenant_id = (SELECT id FROM last_tenant)) AS membership_count;→ Expect org_count = 1, facility_count = 1, membership_count = 1. If any are 0, the wizard transaction silently rolled back partway (the RLS fix is broken).
Negative on the caller's context:
After the wizard succeeds and the frontend issues switchFacility(B), navigate back to Tenant A in another tab (with the original session). Confirm Tenant A's data is still visible — i.e. creating Tenant B didn't leak the GUC and break Tenant A's session.
Failure-path observability check:
On staging, force a wizard failure (e.g. by passing a duplicate slug, or a malformed practitioner payload that triggers Identity::PractitionerRole validation). Confirm:
- The response is
4xxwith a structured error. SELECT count(*) FROM identity.tenants WHERE name = '<wizard name>'returns0— the failed tenant did NOT persist.- The caller's session can still hit
/identity/profilesand see their original Tenant A.
5A.6 Patient app — hospital search wiring (#1444 / #1226)
PR #1444 replaces the mock-only searchHospitals stub with a real call to GET /identity/facilities/search. Before this fix, toggling the Hospital tab on the patient app's search page either rendered a hardcoded fixture (regardless of filter input) or rendered nothing at all — the user-visible behavior in #1226 ("Book Appointment & Filters Issues") was: hospital toggle shows no UI; filter selections never change the doctor list either.
Setup:
- Staging patient app at
/patientroute. - Backend
GET /identity/facilities/searchreturning at least one facility forcity=<city>and one forq=<facility name>— verify via curl first if unsure. - Browser DevTools Network panel open.
Test scenarios:
| Step | Expected |
|---|---|
| Open patient app search page (default state — Doctors tab) | Doctors render via /identity/practitioners/search. |
| Click the Hospital toggle | Network panel shows a GET /identity/facilities/search?page=1&per_page=10 (no mock data; URL hits the real backend). |
| Hospital cards render | Each card shows name, city, beds_count (if present), service_level badge (if present), and any specialities. |
| Type a city in the location filter | New GET /identity/facilities/search?city=<city>&page=1&per_page=10 fires. Results scope to that city. |
| Select a speciality filter | New request carries specialty=<speciality name> (note: backend uses American spelling). Results filter accordingly. |
Toggle appointmentType = virtual | New request carries telemedicine=true (facilities endpoint; note: the practitioner endpoint uses teleconsultation=true — different param names by design). |
| Search box: type a hospital name | New request carries q=<term>. Results filter by name. |
Negative regression checks (must NOT happen):
- The Hospital tab must NEVER return more than
per_pageresults in one page. (Earlier mock returned the entire fixture in one shot — that's the regression to watch for.) - The DevTools Network panel must NEVER show
mockHospitals.tsbeing hot-reloaded — that file is deleted. If you see references togetMockHospitalsin the source map, the build is stale. - A hospital with
specialties: ["Cardiology", "Neurology"]in the backend response must render those badges in the UI. (Backend emits Americanspecialties; frontend reads Britishspecialities. The fix mapsresponse.specialties→card.specialitiesviamapHospitalSpecialties. If the badges don't render even though the backend payload has them, the mapper has regressed.) - Facilities with
latitude: null, longitude: null(PG-fallback rows) must still render — the card must not crash. Verify by switching backend to PG fallback (Typesense disabled) in a dev env.
Sanity check:
# Confirm searchHospitals is no longer a mock
grep -n "simulateDelay\|getMockHospitals\|mockHospitals" patient/src/features/search/services/search.service.ts
# Expected: no matches.
# Confirm specialities mapping is in place
grep -n "mapHospitalSpecialties\|response.specialties" patient/src/features/search/services/search.service.ts
# Expected: at least one match showing American → British mapping.5A.7 CMS patient creation — identity-link backfill (#1445 / #1226)
PR #1445 closes "Upcoming appointment not showing in patient app" from #1226. CMS staff creates a patient whose phone matches an existing Identity::User (the patient already has a patient-app account); the CMS books an appointment; the patient opens the app — and the appointment is invisible.
Root cause: Clinical::PatientsController#create was doing a bare Clinical::Patient.new(...).save, so the new row landed with identity_patient_id: nil AND user_id: nil. Patient::Appointments::ListService queries Clinical::Patient.where(user_id: <identity_user_id>) — an orphan row is invisible to the cross-tenant aggregator.
The fix keeps the bare-create path (preserving the full CMS form surface — mrn, middle_name, display_name, address, emergency_contact, communication_preferences, etc.) and adds a post-save link_to_global_identity step that, when phone is present, attaches the new clinical row to the global Identity::Patient and (when one exists) the linked Identity::User.
Setup:
- Two tenants: Tenant A (where CMS staff is logged in) and Tenant B (irrelevant for this test, but useful for the negative check).
- An existing patient-app user:
Identity::Userwith phone+919999900001, registered through patient-app OTP (soIdentity::Patientalready exists too). - CMS staff at Tenant A logged in with JWT.
Test scenarios:
| Step | Expected |
|---|---|
| CMS staff opens "Add patient" modal at Tenant A | Form renders. |
Fill out: first_name, last_name, phone=9999900001, phone_country_code=+91, gender, dob, mrn, address fields | All fields collected. |
Submit POST /clinical/patients | Returns 201 with { data: { id, user_id: <non-null>, identity_patient_id: <non-null>, ... } }. |
| CMS books appointment for that patient | Standard CMS booking flow. |
| Patient opens their app, navigates to "Upcoming" | The newly booked appointment is visible — same Identity::User perspective. |
SQL verification:
-- After CMS create, the new clinical.patients row should be fully linked
-- (NOT orphaned with user_id=nil).
SELECT id, tenant_id, user_id, identity_patient_id, phone_number, first_name
FROM clinical.patients
WHERE phone_number = '9999900001'
ORDER BY created_at DESC
LIMIT 1;
-- Expected: user_id IS NOT NULL, identity_patient_id IS NOT NULL.
-- Verify the link points to the right Identity::User.
SELECT cp.user_id, iu.phone_country_code || iu.phone_number AS user_phone,
cp.identity_patient_id, ip.phone_country_code || ip.phone_number AS identity_patient_phone
FROM clinical.patients cp
JOIN identity.users iu ON iu.id = cp.user_id
JOIN identity.patients ip ON ip.id = cp.identity_patient_id
WHERE cp.phone_number = '9999900001'
ORDER BY cp.created_at DESC
LIMIT 1;
-- Expected: user_phone = identity_patient_phone = '+919999900001'.Negative regression checks (must NOT happen):
- Phoneless POST
/clinical/patients(rare, but: dependent infant, John Doe ER intake) must still succeed and return 201. Verify: omitphone_numberfrom the request body. The link step is a no-op for phoneless rows. The resulting row will haveuser_id: nil, identity_patient_id: nil— that is the correct state for an anonymous / phoneless entry (it simply won't surface in any patient app, since there's no app account to surface it under). - The CMS form surface must NOT regress. After this PR, submitting
mrn,middle_name,display_name,marital_status,address_line_1,emergency_contact_*,communication_preferences_*must all persist exactly as before. (The earlier abandoned approach of routing throughFindOrCreateServicesilently dropped these fields; the post-save-link approach keeps them intact.) - A duplicate-phone race (CMS staff creates John Doe at +919999900001 simultaneously in two browser tabs) must not raise a 500. The
link_to_global_identityrescuesRecordInvalid/RecordNotUniqueand logs a warn; the clinical row still persists.
Audit-trail check:
-- If the phone-matched Identity::Patient was first created at Tenant B,
-- a cross-tenant consent audit row should be emitted on this Tenant A create.
SELECT *
FROM clinical.consent_audit_logs
WHERE patient_user_id = (
SELECT user_id FROM clinical.patients WHERE phone_number = '9999900001' ORDER BY created_at DESC LIMIT 1
)
ORDER BY created_at DESC
LIMIT 3;
-- Expected: at least one row with action='cross_tenant_link', tenant_id=<Tenant A>.Note: the cross-tenant consent audit only fires when
link_to_global_identityfinds anIdentity::Patientthat was previously created in a different tenant. If the patient was provisioned via patient-app OTP (tenant-agnostic), there's no prior tenant to record consent against and no audit row is emitted — that's correct.
Negative cross-leak check:
- After CMS at Tenant A creates a patient with phone
9999900001, log in at Tenant B as a different clinic. Navigate to Tenant B's patient list. The Tenant A patient must NOT appear. RLS continues to scopeclinical.patientsbytenant_id— the identity link is global but the clinical row is per-tenant.
5A.8 Patient app — document View opens with JWT (#1446 / #1226)
PR #1446 fixes the documents-tab "View" button showing a blank tab. The previous implementation rendered <a href="<rails_blob_url>" target="_blank">; the new tab strips the Authorization: Bearer <JWT> header (browsers never propagate auth headers across target="_blank" navigations), so the ActiveStorage redirect endpoint returned 401 and the user saw a blank page.
The fix opens a synchronous window.open('about:blank') inside the click handler (preserving user-gesture context for pop-up blockers), then fetches the document with apiClient (which attaches the JWT), constructs a Blob with the explicit Content-Type from the response, builds an URL.createObjectURL(...) URL, and redirects the already-open popup to it. The blob URL is revoked after 5 minutes to free memory.
Setup:
- Patient app user logged in (has JWT in localStorage).
- At least one document attached to the user's records — either patient-uploaded (
Patient::Document) or clinical-attached (Clinical::Documentsurfaced via the medical-records aggregator). Mix of file types: PDF, JPEG, PNG. - Browser DevTools Network panel open. Pop-up blocker enabled (Chrome default).
Test scenarios:
| Step | Expected |
|---|---|
| Patient app → Documents tab | Document list renders with title, type badge, date, source badge. |
| Click "View" on a PDF document | A new browser tab opens (pop-up blocker does NOT block — synchronous window.open inside a click handler is whitelisted). Tab title initially shows about:blank, then within a beat redirects to a blob:https://... URL. PDF renders inline. |
| Click "View" on an image (JPEG/PNG) | Same flow. Image renders in the new tab. |
| Network panel | The rails_blob_url fetch carries Authorization: Bearer <JWT> (visible in request headers). Response carries Content-Type: application/pdf (or image/jpeg etc.). |
| Click "View" on a patient-uploaded document the user owns | Opens successfully. |
| Click "View" on a clinical-attached document the user has consented to | Opens successfully (same code path; ActiveStorage authz is enforced by the backend Patient::Documents endpoints already). |
Negative regression checks (must NOT happen):
- The fetch must NEVER fall back to a raw
<a href="...">link. Confirm: theDocumentCard.tsxsource must not contain<a target="_blank">for the View button anymore. Verify withgrep -n 'target=\"_blank\"' patient/src/features/documents/components/DocumentCard.tsx→ expect zero matches. - Pop-up blocker must NOT block the new tab. If it does, the
window.opencall has been moved out of the click-handler's synchronous call frame (e.g. moved after anawait). Watch for this regression specifically — the order of operations is load-bearing. - The Content-Type must NEVER be
text/htmlfor a document fetch. If a 401/302 is returned and the browser treats the HTML body as the content, the user sees a blank page or a Rails error page. Verify: responseContent-Typein Network panel matches the original file type. - The blob URL must NEVER leak beyond 5 minutes. Hold the tab open for 6+ minutes, then reload — expect the URL to 404 (revoked). The 5-minute revoke timer protects against unbounded memory growth from many opens.
- Logging out (clearing JWT) and then clicking "View" must NOT silently fail. The
apiClientinterceptor should redirect to login; the popup should close or show an explicit error toast. Verify: in DevTools, after JWT clear, click View → expect a 401 from the fetch, then the popup is closed and a toast surfaces. (The popup is opened sync, so if the fetch fails after-the-fact, the code closes the popup explicitly — verify no orphanabout:blanktab is left behind.)
Cross-browser check:
- Chrome: pop-up blocker default. Verify View works without granting per-site exception.
- Firefox: default pop-up settings. Same.
- Safari: synchronous
window.openfrom a user gesture should work; verify on macOS Safari. - Mobile Safari: tap View on a real iPhone (PWA install or web). Verify the new tab opens (iOS Safari can be finicky about
window.open— the synchronous open is essential here).
MIME-type preservation check:
ActiveStorage occasionally omits Content-Type on the redirect target. The fix explicitly reads response.headers['content-type'] and reconstructs the Blob with that type. If it's missing, the code falls back to application/octet-stream, which forces a download instead of inline rendering. Verify by uploading a PDF with no explicit content-type set in S3 metadata — it should still render inline, not trigger a "save as" prompt.
5B. Phoneless dependents (August 2026, #1864)
Guardian bookings for a dependent 422'd from 2026-06-26 to 2026-08-17. The
dependent flow wrote a synthetic m:<hex> placeholder into
identity.patients.phone_number (which was NOT NULL + globally unique), and
PR #1735 added a digits-only validation to Clinical::Patient that the
placeholder could not satisfy. The column is now genuinely nullable and the
placeholder is gone.
5B.1 Dependent creation is phoneless
Steps:
POST /patient/dependentswith guardian JWT and nophone_number.
Expected:
- 201 Created
- Response
data.phone_numberisnull— not a string, and neverm:... identity.patients.phone_number IS NULLfor the new row
The patient mobile app is not in this repo. This field previously always held a truthy string for dependents, so confirm the client tolerates
nullhere and onGET /patient/profile,GET /identity/patients/me, andGET/POST /identity/patients/family.
5B.2 Booking twice reuses one Clinical::Patient
Steps:
- Book for the same dependent at the same facility twice.
Expected:
- Exactly one
clinical.patientsrow for that(tenant_id, identity_patient_id) - Dedup is by
identity_patient_id, not by phone — a phoneless row has no phone to dedup on
5B.3 Shared family phone must not merge charts (PHI)
The important one. A guardian may supply a phone when adding a dependent, and families share numbers.
Setup: Clinic Q has a clinic-created Clinical::Patient for a relative with
phone P and user_id IS NULL. Add a dependent whose identity.patients row
also carries phone P.
Steps:
- Book for the dependent at clinic Q.
Expected:
- A new
clinical.patientsrow is created for the dependent - The relative's row is untouched: its
user_idis stillNULL - The dependent must not be resolved onto the relative's chart
If the dependent's
user_idlands on the relative's row, theclinical.patientsRLS self-access branch (user_id = app.current_patient_user_id) turns that into a cross-patient PHI read. Treat any occurrence as a P0.
5B.4 Walk-in booking still resolves by phone
Steps:
- From CMS, check in / book a walk-in with phone
Pand no identity supplied.
Expected:
- Resolves to the existing
clinical.patientsrow with phoneP(unchanged behaviour) - Only an explicitly supplied identity skips the phone lookup
5B.5 Payment link reaches the guardian
Setup: AFTER-payment teleconsult for a phoneless dependent whose guardian has a phone.
Expected:
- The post-consultation payment SMS goes to the guardian's number
- If neither has a phone, the service fails permanently and the job logs and drops it — it must not retry forever
5B.6 Legacy rows stay editable
Setup: A clinical.patients row whose phone predates #1735 and is not digits-only (e.g. +91 98765-43210).
Steps:
- Edit an unrelated field (name, DOB) from CMS.
Expected:
- Saves successfully. The digits-only format is enforced only when the phone itself changes.
- Changing that phone to another non-digit value is still rejected.
6. Performance expectations
| Operation | Expected latency p95 |
|---|---|
GET /patient/appointments for a 5-tenant patient | < 300ms |
GET /clinical/patients/federated_search (single phone) | < 100ms |
POST /patient/appointment_orders (returning patient) | < 200ms |
POST /patient/appointment_orders (first-time, auto-provision) | < 400ms |
fan_out is capped at 10 (MAX_TENANTS_PER_REQUEST). Patients with more than 10 affiliations get the most recent 10.
7. Regression checklist
Run before each release that touches patient/clinical domains. Commands are
Minitest — domains/ moved off RSpec in #1739, so the old bundle exec rspec backend/domains/**/spec/... invocations no longer resolve. Run them from
backend/.
-
bundle exec rails test domains/patient/test/services/appointments/list_service_test.rb— green -
bundle exec rails test domains/patient/test/services/appointment_orders/create_service_test.rb— green -
bundle exec rails test domains/clinical/test/services/patients/federated_search_service_test.rb— green -
bundle exec rails test domains/clinical/test/services/orders/create_appointment_order_service_provisioning_test.rb— green -
bundle exec rails test domains/patient/test/blueprints/appointment_blueprint_test.rb— green (#1438 regression guard onnote) -
bundle exec rails test domains/audit/test/services/dispatch_test.rb— green (#1441 / #1338 item 1) -
bundle exec rails test domains/identity/test/services/facilities/wizard_setup_service_new_tenant_test.rb— green (#1443 / #1387) -
bundle exec rails test domains/shared/test/services/tenancy/rls_context_test.rb— green (#1443 — RlsContext activate/clear/with) - Manual: cross-clinic search → 0 leakage of other-clinic identifiers in JSON
- Manual:
GET /patient/appointmentsresponse has nonotekey on any row (#1438) - Manual:
GET /clinical/patients/federated_searchin_kulcarerows have noidentity_user_idkey (#1436) - Manual:
FederatedPatientPickerrenders thetext-warninghint when used in phone mode withoutcountryCode(#1439) - Manual: guardian-on-behalf-of booking — audit logs record both user_id (dependent) and acting_user_id (guardian)
- Manual: book at a closed facility — 404 with
facility_not_found - Manual: patient-app Hospital toggle fires
GET /identity/facilities/searchand renders real backend results (#1444) - Manual: CMS-created patient with matching phone surfaces in patient-app Upcoming after booking (#1445)
- SQL:
clinical.patientsrow from CMS create hasuser_id IS NOT NULL AND identity_patient_id IS NOT NULLwhen phone is present (#1445) - Manual: phoneless CMS patient create still succeeds with
user_id IS NULL(#1445 negative) - Manual: patient-app Documents "View" opens in new tab with content rendered, no blank page (#1446)
- Manual: patient-app Documents "View" — pop-up blocker does not block, Network panel shows JWT-authenticated fetch with correct Content-Type (#1446)
-
bundle exec rails test domains/clinical/test/services/patients/find_or_create_service_test.rb— green (#1864 identity-vs-phone resolution) -
bundle exec rails test domains/clinical/test/migrations/allow_null_phone_on_identity_patients_test.rb— green (#1864 backfill under enforced RLS) - Manual: guardian books for a phoneless dependent — 201, not 422
Could not provision patient record(#1864) - Manual: dependent sharing a family phone with an existing clinic patient gets its OWN
clinical.patientsrow (#1864 — PHI, see 5B.3) - SQL:
SELECT count(*) FROM identity.patients WHERE phone_number LIKE 'm:%'returns 0 (#1864 placeholder fully retired) - SQL: the partial unique index is valid —
SELECT indisvalid FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid WHERE c.relname='idx_patients_phone_unique_not_null'returnst(#1864)
8. Known limitations
| Limitation | Issue |
|---|---|
| Fixed in PR #1352 (#1344 item 7) | |
Clinical::Practitioners::AvailabilityService references nonexistent column | Fixed #1347 |
identity_user_id | Fixed in PR #1436 (#1344 item 11) |
note cross-tenant | Fixed in PR #1438 (#1344 item 4) |
Building blocks shipped in PR #1439 (#1344 item 13). Full intake-flow integration (the in_kulcare → provision-from-identity UX) is deferred to a follow-up. | |
Fixed in PR #1441 (#1338 item 1). All emitters now route through Audit::Dispatch. See developer/patients/audit/ for contract. | |
Fixed in PR #1443 (#1387). Identity::Facilities::WizardSetupService + SetupService now wrap dependent INSERTs in Shared::Tenancy::RlsContext.with(new_tenant.id). PR #1386 (frontend "New practice" wizard) coordinates separately. | |
Shared::Current#tenant= callback writes the GUC implicitly — coupling that caused #1387 | Mitigated by the explicit RlsContext API (PR #1443). Full callback removal + ~48 call-site migration deferred per the decouple plan. |
Fixed in PR #1444 (#1226). searchHospitals now calls /identity/facilities/search and maps specialties → specialities. | |
Fixed in PR #1445 (#1226). Clinical::PatientsController#create now back-links to Identity::Patient / Identity::User post-save. | |
| Fixed in PR #1446 (#1226). Document fetched with JWT, opened as a blob URL in a synchronously-opened popup. | |
| Audit log retention policy | #1338 item 2 — deferred (threshold-based; tables still low volume) |
| Patient-facing "who's seen my record" UI | #1338 item 3 — deferred Q2-Q3 |
Audit note field exposure — Option D (separate patient_visible_note schema column + CMS UX affordance) | Deferred follow-up to #1438. Option B (drop from wire) is the shipped fix. |
No patient-app frontend wiring for new /patient/appointment_orders endpoint yet | #1346 Phase 4 |
Public booking page doesn't yet detect existing Identity::User to skip OTP | #1346 Phase 3 |
9. References
- Developer guide: ../developer/patient/cross-tenant-appointments.md
- Brainstorm:
docs/brainstorms/2026-05-06-patient-cross-tenant-appointments-brainstorm.md - Plans:
docs/plans/2026-05-06-feat-clinic-federated-patient-search-plan.md,docs/plans/2026-05-06-feat-patient-app-authenticated-booking-plan.md - Patient::Domain QA: ./patient-domain-qa.md