Skip to main content

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 + path with URL-encoded query strings.
  • Merges defaultHeaders with per-call headers (including Authorization: Bearer <token> injected by service classes).
  • Parses JSON response; on non-2xx throws an Error object with .status and .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

  1. Add or extend the Laravel controller and route.
  2. In services/adapters/http/<domain>.service.js, add the method calling the right client.{verb}(path, body).
  3. In services/adapters/mock/<domain>.service.js, add the matching method against mockDb.
  4. If the response shape is new, add a normalizer in lib/normalizers.js.
  5. Consume from a hook or component.