Kulcare Docs
DeveloperPatient domain

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

  1. Prerequisites
  2. API client setup
  3. Authentication
  4. Response envelope
  5. Patient-reported resources (CRUD)
  6. Source provenance — setting it right
  7. Soft-delete and restore UX
  8. Device sync — bulk vitals endpoint
  9. Offline queue pattern
  10. Document uploads
  11. Clinical-read façade
  12. Error handling
  13. Caching with React Query
  14. Migrating from legacy endpoints
  15. Testing the integration
  16. Performance tips

1. Prerequisites

WhatWhere
Base URLhttps://api.kulcare.com/patient (prod) · https://api.staging.kulcare.com/patient (staging) · http://localhost:3000/patient (dev)
AuthIdentity JWT — same token the app already uses for login. No tenant header required.
Content typeapplication/json for CRUD; multipart/form-data for document uploads
Clock skew toleranceNone 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

EventAction
App launchRead stored token, decode expiry (jwt-decode), refresh if within 5 min of expiry
401 from APIAttempt refresh once; on second 401 → log out and redirect to login
LogoutClear 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:

ParamTypeNotes
limit1..200Default 50 (vitals/clinical) or 25 (shared base)
page1..10001-indexed
include_deletedbooleanInclude 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_reference is required when source != 'self_reported'. Submitting without it returns 422.
  • 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_id that'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:

  1. Delete action → immediate optimistic remove from the list, call DELETE. On 204, confirm; on error, reinsert.
  2. Undo toast → show for ~5s with a Restore button that calls POST /:id/restore.
  3. Trash view (optional) → list with ?include_deleted=true and 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 sizeTypical latencyRecommended?
1–50 rows<100msIdeal
50–200 rows~200msFine
200–1000 rows~1sOK for cold-sync — split otherwise
>1000 rows>1sSplit 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:

  1. Inspect error.details for row[N] entries.
  2. 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

CodeMeaningClient action
200 / 201 / 204Success—
400Malformed requestBug — log and surface generic error
401Missing / invalid / expired tokenRefresh once; on second 401, log out
404Not found or belongs to another user (the API does not leak existence)Redirect to list / show empty state
422Validation failedShow per-field errors from error.details
5xxServer errorRetry 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.

OperationRetryable?
GET anythingYes
DELETE (already soft)Yes — re-delete is a no-op at app level
POST /:id/restoreYes
POST /vitals/bulkYes — DB-enforced idempotency
POST create (no client UUID)No — will duplicate
POST create (with client-gen source_reference.sample_id)Yes
PATCH /:idYes

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

DataStalenessReason
Patient-reported list / detail30sPatient may edit frequently
Clinical list / detail5 minChanges only when a clinic adds a record
Clinical timeline5 minSame
Documents list60sFile URLs short-lived; re-fetch is cheap
User profile10 minRarely 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)

LegacyNew
GET/POST /patients/:id/problemsGET/POST /patient/conditions
GET/POST /patients/:id/medicinesGET/POST /patient/medications
GET/POST /patients/:id/vitalsGET/POST /patient/vitals
GET/POST /patients/:id/patient_symptomsGET/POST /patient/symptoms
GET /patients/:id/vitals_trendsGET /patient/vitals?vital_type=X&from=Y&to=Z (chart the response client-side)
GET/POST /api/v1/patient/health-records/documentsGET/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)

LegacyNew
GET /patients/:id/prescriptionsGET /patient/clinical/prescriptions
GET /patients/appointmentsGET /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

  1. Dual-run period — add the new client alongside the old one, with a feature flag per endpoint.
  2. Per-feature flip — medications first (simplest), then vitals, then documents, then clinical timeline.
  3. Remove legacy calls — grep for /api/v1/patient/ and /patients/:id/ after each feature ships.
  4. 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 start

Use 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

TipWhy
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.

References

On this page