Skip to content

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

MethodEndpointDescriptionAuth
POST/api/lis/portal/loginAuthenticate with MRN + DOBnone
POST/api/lis/portal/link-accessOpen a session from a direct-link tokennone
GET/api/lis/portal/requestsList requests with per-test statusportal token
GET/api/lis/portal/resultsList released resultsportal token
GET/api/lis/portal/results/{id}Get result detailsportal token
POST/api/lis/portal/logoutInvalidate tokenportal token
POST/api/lis/patients/{id}/regenerate-portal-linkIssue a fresh direct-link tokenstaff auth

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

bash
curl -X POST /api/lis/portal/login \
  -H "Content-Type: application/json" \
  -d '{
    "mrn": "MRN-2026-0001",
    "date_of_birth": "1990-05-15"
  }'
dart
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 requests

Response 200 OK

json
{
  "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

bash
curl -G /api/lis/portal/results \
  -H "Authorization: Bearer {portal_token}"
dart
final response = await dio.get('/api/lis/portal/results',
  options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);

Response 200 OK

json
{
  "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

bash
curl /api/lis/portal/results/42 \
  -H "Authorization: Bearer {portal_token}"
dart
final response = await dio.get('/api/lis/portal/results/42',
  options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);

Response 200 OK

json
{
  "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

bash
curl -X POST /api/lis/portal/logout \
  -H "Authorization: Bearer {portal_token}"
dart
final response = await dio.post('/api/lis/portal/logout',
  options: Options(headers: {'Authorization': 'Bearer $portalToken'}),
);

Business Rules

  1. Token-based auth -- The portal does not use Sanctum. It uses a custom SHA-256 hashed token stored on the patient record.
  2. 24-hour expiry -- Portal tokens expire after 24 hours. The patient must log in again.
  3. Released only -- Only results with status released are visible to patients.
  4. Patient isolation -- A patient can only view their own results. The token resolves to a specific patient.
  5. Login credentials -- MRN (Medical Record Number) and date of birth are required. The patient must be active.
  6. Single token -- Each login replaces any existing token. Only one active session per patient.
  7. Localization -- Investigation names are returned in the language specified by the Accept-Language header.

Moon ERP API Documentation