Patient Portal (بوابة المريض)
The Patient Portal provides a self-service interface for patients to view their released lab results. Authentication uses a token-based system with MRN and date of birth -- no Sanctum or user account required.
Authentication Flow
API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
POST | /api/lis/portal/login | Authenticate with MRN + DOB | none |
POST | /api/lis/portal/link-access | Open a session from a direct-link token | none |
GET | /api/lis/portal/requests | List requests with per-test status | portal token |
GET | /api/lis/portal/results | List released results | portal token |
GET | /api/lis/portal/results/{id} | Get result details | portal token |
POST | /api/lis/portal/logout | Invalidate token | portal token |
POST | /api/lis/patients/{id}/regenerate-portal-link | Issue a fresh direct-link token | staff auth |
Direct-Link Access
Every patient has a permanent portal_link_token (auto-generated on creation). POST /api/lis/portal/link-access with { "link_token": "..." } opens a 24-hour portal session without an MRN or date of birth — the link itself is the credential, so a QR code on the receipt or a WhatsApp link drops the patient straight into the portal. The response shape matches /portal/login (token, expires_at, patient).
The token is exposed as portal_link_token on the patient resource (GET /api/lis/patients/{id}) so staff/frontend can render the QR. POST /api/lis/patients/{id}/regenerate-portal-link issues a fresh token and invalidates the previous link. Anyone holding a valid link can view that patient's results — this capability-URL model is intentional; regenerate the token if a link is mis-shared.
The message body for the portal's "Send via WhatsApp" action is a company-level setting, lis.portal_whatsapp_message (managed via GET/PUT /api/lis/settings). It supports three placeholders the frontend substitutes: {patient} (patient name), {lab} (lab name) and {link} (the portal link). Default: Hello {patient}, you can view your lab results from {lab} here: {link}.
Requests with Per-Test Status
GET /api/lis/portal/requests returns the patient's lab requests, each with the status of every ordered test — not only released ones. A test is either released (carries value, result_text, normal_min/normal_max, abnormal_flag) or pending. A non-released test never carries a value or reference range — the backend strips them, so reading the API directly cannot reveal an unreleased result. Each request also reports all_released (computed over every leaf test, panel members included).
Every test row carries its lab section ({name, name_en}) and its code.
Panels are nested. A panel test is returned as a single row with is_panel: true and its member tests under members[]; the members are not repeated at the top level. The panel row is a container (no value); its status is released only when every member is released. A member investigation is nested under its panel only when that panel is itself ordered in the same request — otherwise it appears as a standalone test. Each member carries the full set of test fields (name, name_en, code, section, unit, status, and — when released — value/ranges/abnormal_flag).
The patient object returned by /portal/login and /portal/link-access includes id, mrn, name, date_of_birth and gender (the latter two for the lab-report header).
Request / Response Examples
Login
curl -X POST /api/lis/portal/login \
-H "Content-Type: application/json" \
-d '{
"mrn": "MRN-2026-0001",
"date_of_birth": "1990-05-15"
}'final response = await dio.post('/api/lis/portal/login', data: {
'mrn': 'MRN-2026-0001',
'date_of_birth': '1990-05-15',
});
// Store response.data['token'] for subsequent requestsResponse 200 OK
{
"token": "a1b2c3d4e5f6...64-char-random-string",
"expires_at": "2026-03-02T10:00:00.000000Z",
"patient": {
"id": 1,
"mrn": "MRN-2026-0001",
"name": "Ahmed Mohamed"
}
}List Results
curl -G /api/lis/portal/results \
-H "Authorization: Bearer {portal_token}"final response = await dio.get('/api/lis/portal/results',
options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);Response 200 OK
{
"data": [
{
"id": 42,
"investigation_name": "Glucose, Fasting",
"result_value": "95.5",
"result_text": null,
"unit": "mg/dL",
"abnormal_flag": {"value": "normal", "label": "Normal"},
"normal_min": "70.0000",
"normal_max": "100.0000",
"request_number": "REQ-2026-0001",
"released_at": "2026-03-01T14:30:00.000000Z"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 25,
"total": 1
}
}Show Result Detail
curl /api/lis/portal/results/42 \
-H "Authorization: Bearer {portal_token}"final response = await dio.get('/api/lis/portal/results/42',
options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);Response 200 OK
{
"data": {
"id": 42,
"investigation_name": "Glucose, Fasting",
"result_value": "95.5",
"result_text": null,
"unit": "mg/dL",
"abnormal_flag": {"value": "normal", "label": "Normal"},
"normal_min": "70.0000",
"normal_max": "100.0000",
"critical_low": "40.0000",
"critical_high": "500.0000",
"request_number": "REQ-2026-0001",
"requested_at": "2026-03-01T08:00:00.000000Z",
"released_at": "2026-03-01T14:30:00.000000Z"
}
}Logout
curl -X POST /api/lis/portal/logout \
-H "Authorization: Bearer {portal_token}"final response = await dio.post('/api/lis/portal/logout',
options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);Business Rules
- Token-based auth -- The portal does not use Sanctum. It uses a custom SHA-256 hashed token stored on the patient record.
- 24-hour expiry -- Portal tokens expire after 24 hours. The patient must log in again.
- Released only -- Only results with status
releasedare visible to patients. - Patient isolation -- A patient can only view their own results. The token resolves to a specific patient.
- Login credentials -- MRN (Medical Record Number) and date of birth are required. The patient must be active.
- Single token -- Each login replaces any existing token. Only one active session per patient.
- Localization -- Investigation names are returned in the language specified by the
Accept-Languageheader.