Patient Domain — Mobile App Integration Guide
Audience: Mobile app developers integrating the standalone patient client (React Native, Expo, or a React PWA) with the
Patient::backend.Scope: How to actually wire a mobile client to the Patient:: API — auth, request patterns, offline sync, file uploads, error handling. Not a spec — for the full endpoint reference see API reference.
Status: Backend is complete and deployed to staging. This guide is the frontend team's starting point.
Table of contents
- Prerequisites
- API client setup
- Authentication
- Response envelope
- Patient-reported resources (CRUD)
- Source provenance — setting it right
- Soft-delete and restore UX
- Device sync — bulk vitals endpoint
- Offline queue pattern
- Document uploads
- Clinical-read façade
- Error handling
- Caching with React Query
- Migrating from legacy endpoints
- Testing the integration
- Performance tips
1. Prerequisites
| What | Where |
|---|---|
| Base URL | https://api.kulcare.com/patient (prod) · https://api.staging.kulcare.com/patient (staging) · http://localhost:3000/patient (dev) |
| Auth | Identity JWT — same token the app already uses for login. No tenant header required. |
| Content type | application/json for CRUD; multipart/form-data for document uploads |
| Clock skew tolerance | None enforced by the API — client clock should be reasonably accurate for timestamps |
Dependencies (React Native / Expo)
{
"dependencies": {
"axios": "^1.6.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-persist-client": "^5.0.0",
"@react-native-async-storage/async-storage": "^1.21.0",
"expo-file-system": "~16.0.0",
"expo-document-picker": "~11.0.0",
"jwt-decode": "^4.0.0"
}
}Browser React equivalents: swap AsyncStorage → localStorage, expo-file-system → File API, expo-document-picker → <input type="file">.
2. API client setup
Single axios instance with a request interceptor for the bearer token and a response interceptor for the consistent error envelope.
// lib/api-client.ts
import axios, { AxiosError } from 'axios';
import { getAccessToken, refreshAccessToken } from './auth';
const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
export const api = axios.create({
baseURL: `${BASE_URL}/patient`,
timeout: 15_000,
headers: { 'Content-Type': 'application/json' },
});
api.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Refresh-on-401 with single-flight to avoid stampedes
let refreshPromise: Promise<string> | null = null;
api.interceptors.response.use(
(r) => r,
async (error: AxiosError) => {
const original = error.config as typeof error.config & { _retried?: boolean };
if (error.response?.status === 401 && !original?._retried) {
original._retried = true;
refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null; });
const newToken = await refreshPromise;
original.headers.Authorization = `Bearer ${newToken}`;
return api.request(original);
}
return Promise.reject(error);
}
);Typed response helpers
// lib/api-types.ts
export interface ApiResponse<T> {
data: T;
meta: {
request_id: string;
total?: number;
page?: number;
limit?: number;
pages?: number;
fan_out?: number;
};
}
export interface ApiError {
data: null;
error: {
code: string;
message: string;
details?: Array<{ field: string | null; message: string }>;
};
meta: { request_id: string };
}3. Authentication
The Patient:: surface is user-scoped. Send the identity JWT as Authorization: Bearer <token> and nothing else — no tenant header, no session cookie. The backend will reject requests without a valid token with 401.
Token lifecycle
| Event | Action |
|---|---|
| App launch | Read stored token, decode expiry (jwt-decode), refresh if within 5 min of expiry |
| 401 from API | Attempt refresh once; on second 401 → log out and redirect to login |
| Logout | Clear stored token (both access and refresh), clear React Query cache, cancel pending mutations |
Storage
// lib/auth.ts (RN)
import AsyncStorage from '@react-native-async-storage/async-storage';
const ACCESS = '@kulcare/access_token';
const REFRESH = '@kulcare/refresh_token';
export const getAccessToken = () => AsyncStorage.getItem(ACCESS);
export const setTokens = async (access: string, refresh: string) => {
await AsyncStorage.multiSet([[ACCESS, access], [REFRESH, refresh]]);
};
export const clearTokens = () => AsyncStorage.multiRemove([ACCESS, REFRESH]);Security note: JWTs in AsyncStorage are vulnerable if the device is rooted. Consider expo-secure-store (uses iOS Keychain / Android Keystore) for production builds.
4. Response envelope
Every successful response follows { data, meta }; every error follows { data: null, error: { code, message, details }, meta }.
// Helper to unwrap either shape
export async function apiCall<T>(fn: () => Promise<{ data: ApiResponse<T> }>): Promise<T> {
try {
const response = await fn();
return response.data.data;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data?.error) {
throw new PatientApiError(err.response.data as ApiError, err.response.status);
}
throw err;
}
}Typed error class
export class PatientApiError extends Error {
code: string;
status: number;
fieldErrors: Record<string, string>;
constructor(body: ApiError, status: number) {
super(body.error.message);
this.code = body.error.code;
this.status = status;
this.fieldErrors = {};
body.error.details?.forEach((d) => {
if (d.field) this.fieldErrors[d.field] = d.message;
});
}
}5. Patient-reported resources (CRUD)
All 10 resources share the same CRUD surface. Build a generic hook once, reuse everywhere.
// lib/patient-resources.ts
import { api, apiCall, ApiResponse } from './api-client';
export const patientResource = <T, CreateInput, UpdateInput>(resource: string, rootKey: string) => ({
list: (params?: Record<string, unknown>) =>
apiCall<T[]>(() => api.get(`/${resource}`, { params })),
show: (id: string) =>
apiCall<T>(() => api.get(`/${resource}/${id}`)),
create: (input: CreateInput) =>
apiCall<T>(() => api.post(`/${resource}`, { [rootKey]: input })),
update: (id: string, input: UpdateInput) =>
apiCall<T>(() => api.patch(`/${resource}/${id}`, { [rootKey]: input })),
destroy: (id: string) =>
api.delete(`/${resource}/${id}`), // returns 204, no body
restore: (id: string) =>
apiCall<T>(() => api.post(`/${resource}/${id}/restore`)),
});Usage per resource
// lib/vitals.ts
import { patientResource } from './patient-resources';
export interface Vital {
id: string;
vital_type: 'blood_pressure' | 'heart_rate' | 'temperature' | 'weight' | 'height'
| 'blood_glucose' | 'spo2' | 'respiratory_rate' | 'pain_score' | 'bmi';
value: Record<string, number>;
unit: string | null;
measurement_type: 'fasting' | 'post_meal' | 'random' | 'pre_exercise' | 'post_exercise' | null;
occurred_at: string;
recorded_at: string; // alias of occurred_at
source: Source;
source_reference: SourceReference | null;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface VitalCreate {
vital_type: Vital['vital_type'];
value: Record<string, number>;
unit?: string;
measurement_type?: Vital['measurement_type'];
occurred_at?: string;
recorded_at?: string; // backend accepts either
source?: Source;
source_reference?: SourceReference;
}
export const vitalsApi = patientResource<Vital, VitalCreate, Partial<VitalCreate>>(
'vitals', 'vital'
);Same shape works for symptoms, medications, allergies, conditions, immunizations, procedures, health_notes. family_history_entries uses family_history_entry as the root key. Documents need a separate client for multipart (§10).
Query params — list filters
All list endpoints support:
| Param | Type | Notes |
|---|---|---|
limit | 1..200 | Default 50 (vitals/clinical) or 25 (shared base) |
page | 1..1000 | 1-indexed |
include_deleted | boolean | Include soft-deleted rows |
Plus resource-specific filters (see API reference). Common ones:
- Vitals:
vital_type,from,to - Symptoms:
severity,body_area,name(ILIKE) - Medications:
is_active,drug_name(ILIKE) - Conditions:
status,is_chronic,name
6. Source provenance — setting it right
Every row carries a source and optional source_reference. The client is responsible for setting these correctly.
Decision tree
Is the patient typing it manually?
├── Yes → source: 'self_reported', source_reference: null
└── No
├── From a connected device (Apple Health / Fitbit / BP cuff)?
│ └── Yes → source: 'device', source_reference: {
│ type: 'device', vendor: '<apple_health|fitbit|...>',
│ sample_id: '<vendor-unique-id>'
│ }
├── Patient tapping "save to my records" on a clinical record?
│ └── Yes → source: 'clinical_imported', source_reference: {
│ type: 'clinical.observations', id: '<uuid>',
│ tenant_id: '<uuid>'
│ }
└── Imported from an external health record (ABDM, Apple Health export, pharmacy)?
└── Yes → source: 'external', source_reference: {
type: 'abdm', abha_id: '...' | type: 'pharmacy_receipt', ...
}Important rules
source_referenceis required whensource != 'self_reported'. Submitting without it returns422.source: 'clinical_imported'is explicit user action only. Don't build a "sync clinical records into patient diary" feature — it would break the architecture's single-source-of-truth principle. See ADR-002.- Device sync entries must include a
sample_idthat's unique per vendor. The backend uses(user_id, vendor, sample_id)as the idempotency key; without it, retries will duplicate.
"Save to my records" flow
// User taps 'Save to my records' on a clinical observation shown in the timeline
const importClinicalVital = async (clinical: ClinicalObservation) => {
return vitalsApi.create({
vital_type: clinical.vital_type,
value: clinical.value,
occurred_at: clinical.effective_date_time,
source: 'clinical_imported',
source_reference: {
type: 'clinical.observations',
id: clinical.id,
tenant_id: clinical.tenant_id,
},
});
};If the user taps it twice, the second call returns 422 due to the partial unique index — surface this as a friendly "already saved" message, not a destructive error.
7. Soft-delete and restore UX
The backend soft-deletes on DELETE and exposes a POST /:id/restore. Build the UX to match:
- Delete action → immediate optimistic remove from the list, call
DELETE. On 204, confirm; on error, reinsert. - Undo toast → show for ~5s with a Restore button that calls
POST /:id/restore. - Trash view (optional) → list with
?include_deleted=trueand a Restore-per-row control.
const deleteVitalWithUndo = (id: string) => {
vitalsApi.destroy(id);
showToast('Vital deleted', {
action: { label: 'Undo', handler: () => vitalsApi.restore(id) },
duration: 5000,
});
};The backend keeps tombstoned rows indefinitely (hard-delete is an out-of-scope follow-up). Don't build UX around "permanent delete" yet.
8. Device sync — bulk vitals endpoint
POST /patient/vitals/bulk is designed for retry-safe device sync. Rows with a duplicate (source_reference.vendor, source_reference.sample_id) tuple are skipped at the database level.
Request shape
interface BulkVitalsRequest {
vitals: VitalCreate[]; // each row must have source: 'device' with a sample_id
}
interface BulkVitalsResponse {
created_count: number;
skipped_count: number; // duplicates rejected by the unique index
}Idiomatic sync flow (Apple Health / HealthKit example)
import AppleHealth from 'react-native-health';
async function syncVitals(since: Date) {
// 1. Pull samples from the device SDK
const hrSamples = await AppleHealth.getHeartRateSamples({ startDate: since });
// 2. Map to Patient:: bulk payload — one sample = one row
const rows = hrSamples.map((s) => ({
vital_type: 'heart_rate' as const,
value: { value: s.value },
unit: 'bpm',
occurred_at: s.startDate,
source: 'device' as const,
source_reference: {
type: 'device',
vendor: 'apple_health',
sample_id: s.uuid, // HealthKit UUID is vendor-unique
},
}));
if (rows.length === 0) return { created: 0, skipped: 0 };
// 3. POST to /patient/vitals/bulk — safe to retry
const res = await apiCall<BulkVitalsResponse>(() =>
api.post('/vitals/bulk', { vitals: rows })
);
return { created: res.created_count, skipped: res.skipped_count };
}Batching
Backend accepts batches of any size, but practical limits apply:
| Batch size | Typical latency | Recommended? |
|---|---|---|
| 1–50 rows | <100ms | Ideal |
| 50–200 rows | ~200ms | Fine |
| 200–1000 rows | ~1s | OK for cold-sync — split otherwise |
| >1000 rows | >1s | Split into chunks of ~500 |
Whole-batch rejection
If any row fails validation, the entire batch is rejected with 422 and the error.details[].field will read row[N]. The service rejects atomically so the client gets a clear diff. If a row is invalid:
- Inspect
error.detailsforrow[N]entries. - Either fix the row and retry the whole batch, or drop the bad rows and retry.
Never submit the same batch with "I'll just ignore errors and try again" logic — you'll get stuck in a loop.
9. Offline queue pattern
Mobile networks drop. The bulk endpoint's idempotency contract is what makes offline-first feasible.
Queue in AsyncStorage
// lib/offline-queue.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
const QUEUE_KEY = '@kulcare/pending_vitals';
export async function enqueueVital(vital: VitalCreate) {
const queue = JSON.parse((await AsyncStorage.getItem(QUEUE_KEY)) ?? '[]');
queue.push(vital);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export async function flushQueue() {
const { isConnected } = await NetInfo.fetch();
if (!isConnected) return;
const queue: VitalCreate[] = JSON.parse((await AsyncStorage.getItem(QUEUE_KEY)) ?? '[]');
if (queue.length === 0) return;
try {
await api.post('/vitals/bulk', { vitals: queue });
// On success, clear — even if some were skipped, they're now in the DB.
await AsyncStorage.removeItem(QUEUE_KEY);
} catch (err) {
// Transient error — keep queue for next try. For 422 (bad row), drop bad
// rows and retry; see §8.
}
}
// Wire up a listener to flush on reconnect
NetInfo.addEventListener((state) => {
if (state.isConnected) flushQueue();
});Why this works offline-first:
- Every queued row carries
source_reference.sample_id(vendor-unique), so a double flush is a no-op via the partial unique index. - Re-submitting after app crash / force-quit is safe.
- No server-side dedupe state to track.
Self-reported entries (not from a device)
For patient-typed rows that don't naturally have a sample_id, generate one client-side so offline retries stay idempotent:
import { v4 as uuid } from 'uuid';
const manualVital: VitalCreate = {
vital_type: 'blood_pressure',
value: { systolic: 120, diastolic: 80 },
occurred_at: new Date().toISOString(),
source: 'device', // treat manual entry as a pseudo-device for idempotency
source_reference: {
type: 'device',
vendor: 'manual_entry',
sample_id: uuid(), // stable per-entry id
},
};Alternative: submit with source: 'self_reported' — but then you lose offline retry idempotency. Use the pseudo-device pattern if offline is a real concern.
10. Document uploads
POST /patient/documents accepts multipart/form-data. Use native file-picker APIs on each platform.
React Native (Expo)
import * as DocumentPicker from 'expo-document-picker';
async function uploadDocument(title: string, documentType: string) {
const picked = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
multiple: false,
});
if (picked.canceled) return;
const file = picked.assets[0];
const formData = new FormData();
formData.append('document[title]', title);
formData.append('document[document_type]', documentType);
formData.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType ?? 'application/octet-stream',
} as any);
const { data } = await api.post<ApiResponse<PatientDocument>>(
'/documents',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
return data.data; // includes file_url
}Downloading
The blueprint response includes data.file_url when a file is attached — a signed URL that points to /rails/active_storage/blobs/.... Use it directly with Image / fetch / Linking.openURL.
const { file_url } = document;
<Image source={{ uri: file_url, headers: { Authorization: `Bearer ${token}` } }} />;Caching
ActiveStorage signed URLs have a short TTL (~5–10 minutes). Don't persist them — re-fetch the document when needed. For offline viewing, download the blob into FileSystem once and reuse the local path.
import * as FileSystem from 'expo-file-system';
async function cacheDocument(doc: PatientDocument) {
const cachePath = `${FileSystem.cacheDirectory}patient-docs/${doc.id}`;
const info = await FileSystem.getInfoAsync(cachePath);
if (info.exists) return cachePath;
// Re-fetch with a fresh URL
const fresh = await apiCall<PatientDocument>(() => api.get(`/documents/${doc.id}`));
const download = await FileSystem.downloadAsync(fresh.file_url!, cachePath);
return download.uri;
}11. Clinical-read façade
Reading clinician-authored data (what the patient's doctors wrote about them) happens exclusively through /patient/clinical/*. Never call clinical.* endpoints directly from the app — this is an architectural invariant.
Per-resource list
type ClinicalResourceType =
| 'conditions' | 'prescriptions' | 'allergy_intolerances'
| 'observations' | 'encounters' | 'appointments' | 'documents';
interface ClinicalRecord<T = unknown> {
id: string;
type: ClinicalResourceType;
tenant_id: string;
occurred_at: string | null;
data: T;
}
export const clinicalApi = {
list: (type: ClinicalResourceType, params?: Record<string, unknown>) =>
apiCall<ClinicalRecord[]>(() => api.get(`/clinical/${type}`, { params })),
show: (type: ClinicalResourceType, id: string) =>
apiCall<ClinicalRecord>(() => api.get(`/clinical/${type}/${id}`)),
timeline: (params?: { limit?: number }) =>
apiCall<{ timeline: TimelineEntry[]; facilities: FacilitySummary[] }>(() =>
api.get('/clinical/timeline', { params })
),
};
// Shortcut hooks
export const useConditions = () => useQuery({
queryKey: ['clinical', 'conditions'],
queryFn: () => clinicalApi.list('conditions'),
});Timeline
Single call that merges all 7 clinical resource types sorted by date DESC. Use for the "home" screen of the patient app.
const { data } = useQuery({
queryKey: ['clinical', 'timeline'],
queryFn: () => clinicalApi.timeline({ limit: 50 }),
staleTime: 60_000, // clinical data changes rarely; 1-minute stale is fine
});
data?.timeline.map((entry) => {
// entry.type: 'encounter' | 'prescription' | 'condition' | ...
// entry.data: resource-specific shape
// entry.tenant_id: which clinic authored it
});Fan-out budget
The backend caps cross-tenant iteration at 10 tenants per request. For patients with more than 10 clinic records, the response reflects only the first 10. Check meta.fan_out against the known tenant count and surface a warning if they diverge.
if (meta.fan_out === 10 && facilities.length > 10) {
showBanner('Some clinics may not be included. Contact support.');
}This cap is rarely hit in practice (typical patient: 2–5 tenants). A future release will add pagination across tenants.
Empty state
A new patient who hasn't visited any clinic returns { timeline: [], facilities: [] } with 200. Render a "no medical history yet" state — don't treat empty as an error.
12. Error handling
HTTP codes
| Code | Meaning | Client action |
|---|---|---|
200 / 201 / 204 | Success | — |
400 | Malformed request | Bug — log and surface generic error |
401 | Missing / invalid / expired token | Refresh once; on second 401, log out |
404 | Not found or belongs to another user (the API does not leak existence) | Redirect to list / show empty state |
422 | Validation failed | Show per-field errors from error.details |
5xx | Server error | Retry with exponential backoff (3 attempts, 1s→2s→4s) |
Field-level errors (422)
try {
await vitalsApi.create(input);
} catch (err) {
if (err instanceof PatientApiError && err.status === 422) {
// err.fieldErrors: { vital_type: 'is not included in the list', ... }
setFieldErrors(err.fieldErrors);
return;
}
showToast(err.message);
}Retry policy
Only retry idempotent operations. For non-idempotent ones (POST create without a client-generated dedupe key), retry causes duplicates.
| Operation | Retryable? |
|---|---|
GET anything | Yes |
DELETE (already soft) | Yes — re-delete is a no-op at app level |
POST /:id/restore | Yes |
POST /vitals/bulk | Yes — DB-enforced idempotency |
POST create (no client UUID) | No — will duplicate |
POST create (with client-gen source_reference.sample_id) | Yes |
PATCH /:id | Yes |
13. Caching with React Query
React Query pairs well with this API — every endpoint returns a consistent envelope and the data model has clean invalidation boundaries.
Query keys
// Standardize on a factory so invalidation is consistent
export const patientKeys = {
all: ['patient'] as const,
resources: (resource: string) => [...patientKeys.all, resource] as const,
resourceList: (resource: string, filters?: Record<string, unknown>) =>
[...patientKeys.resources(resource), 'list', filters ?? {}] as const,
resourceDetail: (resource: string, id: string) =>
[...patientKeys.resources(resource), 'detail', id] as const,
clinical: ['patient', 'clinical'] as const,
clinicalList: (type: string, filters?: Record<string, unknown>) =>
[...patientKeys.clinical, type, 'list', filters ?? {}] as const,
clinicalDetail: (type: string, id: string) =>
[...patientKeys.clinical, type, 'detail', id] as const,
timeline: () => [...patientKeys.clinical, 'timeline'] as const,
};Invalidation on mutation
const queryClient = useQueryClient();
const createVital = useMutation({
mutationFn: vitalsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: patientKeys.resources('vitals') });
},
});
const deleteVital = useMutation({
mutationFn: vitalsApi.destroy,
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: patientKeys.resources('vitals') });
// Optionally optimistic update — remove from the list cache immediately
},
});Stale times by data kind
| Data | Staleness | Reason |
|---|---|---|
| Patient-reported list / detail | 30s | Patient may edit frequently |
| Clinical list / detail | 5 min | Changes only when a clinic adds a record |
| Clinical timeline | 5 min | Same |
| Documents list | 60s | File URLs short-lived; re-fetch is cheap |
| User profile | 10 min | Rarely changes |
Offline persistence
Use @tanstack/query-persist-client with AsyncStorage to rehydrate caches on app launch. Keep the persisted store separate from the write queue (§9) — read caches and write queues have different semantics.
14. Migrating from legacy endpoints
If the app already talks to the legacy /api/v1/patient/* or /patients/:id/* surface, here's the mapping:
Patient-reported (writes)
| Legacy | New |
|---|---|
GET/POST /patients/:id/problems | GET/POST /patient/conditions |
GET/POST /patients/:id/medicines | GET/POST /patient/medications |
GET/POST /patients/:id/vitals | GET/POST /patient/vitals |
GET/POST /patients/:id/patient_symptoms | GET/POST /patient/symptoms |
GET /patients/:id/vitals_trends | GET /patient/vitals?vital_type=X&from=Y&to=Z (chart the response client-side) |
GET/POST /api/v1/patient/health-records/documents | GET/POST /patient/documents |
| (didn't exist) | GET/POST /patient/allergies |
| (didn't exist) | GET/POST /patient/immunizations |
| (didn't exist) | GET/POST /patient/family_history_entries |
| (didn't exist) | GET/POST /patient/procedures |
| (didn't exist) | GET/POST /patient/health_notes |
| (didn't exist) | POST /patient/vitals/bulk |
Clinical (reads)
| Legacy | New |
|---|---|
GET /patients/:id/prescriptions | GET /patient/clinical/prescriptions |
GET /patients/appointments | GET /patient/appointments (served by Patient::Appointments::ListService). NOTE (2026-05-08): /patient/clinical/appointments removed — LHR is retrospective only. See docs/brainstorms/2026-05-06-patient-cross-tenant-appointments-brainstorm.md. |
GET /patients/patient_dashboard (aggregate) | GET /patient/clinical/timeline |
| (mixed endpoints) | GET /patient/clinical/conditions, .../observations, .../encounters, etc. |
Request shape changes
- All payloads are wrapped in a resource key:
{ "vital": { ... } }, not{ ... }flat. - IDs are UUIDs (strings), not integers.
- Timestamps are ISO 8601 strings in UTC, not epoch seconds.
- Response envelopes are always
{ data, meta }.
Auth changes
- No more Devise session cookies. Pure Bearer JWT.
- No tenant header on patient requests.
- JWT refresh works exactly as the legacy identity auth already does.
Cutover strategy
- Dual-run period — add the new client alongside the old one, with a feature flag per endpoint.
- Per-feature flip — medications first (simplest), then vitals, then documents, then clinical timeline.
- Remove legacy calls — grep for
/api/v1/patient/and/patients/:id/after each feature ships. - Backend deprecation — after zero traffic on legacy routes for N weeks, backend removes them.
15. Testing the integration
Local setup
# Backend on localhost:3000
cd backend && bin/rails s
# Mobile app pointing at local backend
EXPO_PUBLIC_API_URL=http://localhost:3000 pnpm startUse ngrok http 3000 if you need to test from a physical device against a local backend.
Test users
Ask the backend team for two staging test users — you need at least two to verify ownership isolation. Or create them via Rails console:
Identity::User.create!(email: 'mobile-test-a@example.com', first_name: 'A', last_name: 'Tester')Generate a JWT via the identity API or via the dev auth helper.
Key manual scenarios
See the QA guide for the full matrix. For mobile specifically, prioritize:
- Offline → create vitals → airplane mode → reconnect → verify queue flushes and rows appear
- Force-quit during a bulk sync → relaunch → verify queue resumes
- Background the app → 10 minutes later → foreground → token refresh happens silently
- Delete a row → undo within 5s → row returns
- Upload a PDF → view it → force-quit → relaunch → view it again (re-fetch signed URL)
- Cross-user: log in as user B, try to access a known id of user A via deep-linking → expect empty state, never a record
16. Performance tips
| Tip | Why |
|---|---|
| Batch vitals; don't POST one at a time. | Each POST is a round-trip; one bulk is one round-trip. |
Paginate clinical lists aggressively (limit=20 or less). | Cross-tenant iteration has per-tenant DB overhead. |
| Cache the timeline for 5 min. | It's the heaviest endpoint (7 resource types × N tenants). |
| Show skeleton loaders while clinical data loads. | Fan-out over 5 tenants can hit ~300ms P95. |
| Defer doc uploads until WiFi (app hint, user-overridable). | Multi-MB uploads over cellular burn data. |
| Pre-fetch the timeline on app foreground. | Patient landing screen feels instant even with 3 tenants. |