Services & API Client
The SPA never calls fetch directly from a component. Everything goes through a service obtained from services/index.js.
Factory
ui/src/services/index.js:
export function createApiServices({ mode, baseURL, getAccessToken }) {
const client = new HttpClient({
baseURL,
defaultHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
if (mode === 'mock') {
return {
auth: new MockAuthService(),
patients: new MockPatientsService(),
appointments: new MockAppointmentsService(),
invoices: new MockInvoicesService(),
schedules: new MockSchedulesService(),
pharmacy: new MockPharmacyService(),
};
}
return {
auth: new HttpAuthService(client, getAccessToken),
patients: new HttpPatientsService(client, getAccessToken),
appointments: new HttpAppointmentsService(client, getAccessToken),
invoices: new HttpInvoicesService(client, getAccessToken),
schedules: new HttpSchedulesService(client, getAccessToken),
pharmacy: new HttpPharmacyService(client, getAccessToken),
};
}
Called once at app bootstrap in lib/api.js:
export const services = createApiServices({
mode: process.env.REACT_APP_API_MODE || 'http',
baseURL: resolveBaseUrl(),
getAccessToken: () => localStorage.getItem('access_token'),
});
Components then import services from lib/api.
HttpClient
ui/src/services/core/httpClient.js — a tiny fetch wrapper:
class HttpClient {
constructor({ baseURL, defaultHeaders }) { … }
get (path, { headers, query } = {}) { … }
post (path, body, { headers } = {}) { … }
put (path, body, { headers } = {}) { … }
patch (path, body, { headers } = {}) { … }
delete(path, { headers } = {}) { … }
}
Responsibilities:
- Builds the URL from
baseURL + pathwith URL-encoded query strings. - Merges
defaultHeaderswith per-call headers (includingAuthorization: Bearer <token>injected by service classes). - Parses JSON response; on non-2xx throws an Error object with
.statusand.data.
apiResult
services/core/apiResult.js wraps raw responses in a consistent envelope:
{ ok: true, data: <value> }
{ ok: false, error: { status, message, errors? } }
Hooks like usePatients pattern-match on result.ok instead of using try/catch.
HTTP service adapters
services/adapters/http/* — each class adapts the raw client to a domain surface.
HttpAuthService
login(payload) // POST /auth/login
logout() // returns { success: true } (no backend endpoint yet)
HttpPatientsService
list({ tenantId }) // GET /tenant/{tenantId}/pets
create({ tenantId, payload }) // POST /tenant/{tenantId}/patients/onboard
update({ tenantId, patientId, payload }) // PATCH /tenant/{tenantId}/patients/{patientId}
listClients({ tenantId }) // GET /tenant/{tenantId}/clients
HttpAppointmentsService
list({ tenantId }) // GET /tenant/{tenantId}/appointments
create({ tenantId, payload }) // POST /tenant/{tenantId}/appointments
updateStatus({ tenantId, appointmentId, status }) // PATCH /tenant/{tenantId}/appointments/{id}
HttpInvoicesService
list({ tenantId }) // GET /tenant/{tenantId}/invoices
create({ tenantId, payload }) // POST /tenant/{tenantId}/invoices
recordPayment({ tenantId, invoiceId, payment }) // POST /tenant/{tenantId}/invoices/{id}/payments
HttpSchedulesService
Stub today; mirrors the same shape.
HttpPharmacyService
Powers the Inventory module for every tenant type — clinic, pharmacy
and retail. All methods take { tenantId, ... } and return the standard
{ ok, data } envelope.
// Catalog
listMedicines({ tenantId, filters }) // GET /tenant/{tenantId}/medicines
getMedicineById({ tenantId, medicineId }) // GET /tenant/{tenantId}/medicines/{id}
createMedicine({ tenantId, payload }) // POST /tenant/{tenantId}/medicines
updateMedicine({ tenantId, medicineId, payload })// PATCH /tenant/{tenantId}/medicines/{id}
// Dashboards / alerts
summary({ tenantId }) // GET /tenant/{tenantId}/medicines/summary
lowStockAlerts({ tenantId }) // GET /tenant/{tenantId}/medicines/alerts/low-stock
expiryAlerts({ tenantId, days }) // GET /tenant/{tenantId}/medicines/alerts/expiry
// Stock movements
stockMovements({ tenantId, filters }) // GET /tenant/{tenantId}/stock-movements
movementsForMedicine({ tenantId, medicineId }) // GET /tenant/{tenantId}/medicines/{id}/stock-movements
addStockMovement({ tenantId, payload }) // POST /tenant/{tenantId}/stock-movements
updateStock({ tenantId, medicineId, quantity, type, ... }) // convenience wrapper
The matching MockPharmacyService exposes identical signatures so the two
adapters are swappable via REACT_APP_API_MODE.
Mock adapters
services/adapters/mock/* store their state in mockDb.js — a plain in-memory object loaded with sample data from services/mockData.js. They implement the same surface as the HTTP adapters so components are unchanged.
Use it for:
- Local UI development with no backend running.
- Storybook-like visual iterations.
- Fast Jest tests.
Enable via .env.local:
REACT_APP_API_MODE=mock
Normalizers
ui/src/lib/normalizers.js converts Laravel's snake_case payloads into UI-ready objects:
normalizeUser(rawUser) // → { id, name, email, role, accountType, tenantId }
normalizeTenant(rawTenant) // → { id, name, logo, phone, email, subscription, status }
normalizePatient(rawPet) // → { id, clientId, name, species, breed, age, weight, owner, ownerEmail, status, lastVisit, medicalConditions[], allergies[], raw }
normalizeAppointment(rawAppt) // → { id, patientId, patientName, doctor, date, time, type, notes, status, raw }
normalizeInvoice(rawInv) // → { id, invoiceNumber, patientId, patientName, service, date, dueDate, amount, paidAmount, balance, status, invoiceType, paymentHistory[], items[], raw }
Every normalized object also carries a raw field with the untouched API payload — useful when a component occasionally needs something the normalizer didn't expose.
Adding a new endpoint
- Add or extend the Laravel controller and route.
- In
services/adapters/http/<domain>.service.js, add the method calling the rightclient.{verb}(path, body). - In
services/adapters/mock/<domain>.service.js, add the matching method againstmockDb. - If the response shape is new, add a normalizer in
lib/normalizers.js. - Consume from a hook or component.