Dentolize · DHS (NPHIES) Integration Walkthrough
On this pageBusiness viewTechnical view

Eligibility Checks

Business view

Knowing who insures a patient is not the same as knowing their cover is live today. Policies lapse mid-year, employers switch insurers, dependants get dropped. An eligibility check is the clinic asking the payer, in real time: "is this person covered right now?"

In Dentolize this is the Check Eligibility button on the patient profile. The result becomes a small coloured tag next to the button — green Eligible or red Non-Eligible — with the timestamp of the last check on hover. The clinic can re-check at any time.

The "Fill Missing Fields" step

NPHIES will not answer an eligibility question unless it gets a complete picture of the patient: legal name, date of birth, gender, marital status, occupation, identifier type and number, plus the policy number and coverage class. Real patient records rarely have all twelve.

So the button does a pre-flight. If anything is missing, a Fill Missing Fields modal opens with everything the record already knows greyed out and read-only, and only the gaps editable. If nothing is missing, the check runs immediately with no modal at all.

This is a genuinely important behaviour to understand, and it surprises people:

The patient record is updated first, and the eligibility check runs second. If you fill in Marital Status and Occupation and press OK, those values are saved to the patient permanently — even if the eligibility call then fails.

This was confirmed live on the branch sandbox: filling in "Married" and "Doctor" persisted both to the patient profile, while the eligibility call itself failed because the sandbox has no DHS credential. That is by design (the clinic keeps the data-quality improvement), but support and training need to say it out loud.

Three visual states

StateWhat the user sees
Patient has no insurance companyA grey Unavailable tag, tooltip explaining insurance is missing. No button.
Insured, never checkedThe Check Eligibility button, primary blue.
Insured, checkedA green or red status tag, plus a Re-check Eligibility button in a muted style.

When embedded in the Check Insurance review step with no saved patient yet, an info banner explains the check is preview-only.

The result

A modal shows payer name, provider name, policy number, policy holder, coverage type, inception / expiry / request dates, response date, payer reference number, and any remarks the payer returned.


Technical view

The mutation

dhsCheckEligibility (packages/server/src/resolvers/mutations/DHS/dhsCheckEligibility.js:15-183)

Input (inputs.graphql:971-978):

input DHSCheckEligibilityInput {
  payer: PayerInput!            # { nphiesCode }
  patient: PatientInput!        # name, DOB, gender, marital, occupation, identifier
  coverage: CoverageInput!      # membershipNumber, policyNumber, policyHolderName, className
  branchId: String!
  patientId: String             # optional — controls whether anything is persisted
  insuranceCompanyId: String
}

Sequence:

  1. Session company guard, then tenant-scoped branch + nphiesCode check (:19-46) — same

shape as checkInsuranceEligibility.

  1. Inline dhsAuthentication for a bearer token (:48-56).
  2. POST ${DHS_ELIGIBILITY_URL}/api/v2/Eligibility/Checkeligibility (note v2, and the

lower-case e in Checkeligibility) with Authorization and OrganizationCode headers (:92-102).

  1. If the body carries succeeded === false, return success:false with **no database

write at all** (:107-114).

Status derivation

responseData?.data?.eligibilityCode?.code?.toLowerCase() === 'eligible'
  ? 'ELIGIBLE' : 'NON_ELIGIBLE'

(:116) — anything else, including a missing field, is NON_ELIGIBLE. The enum has only these two values (schema.prisma:5613-5616); there is no "unknown" state in the database.

The front end is slightly more permissive: parseEligibilityResponse (DHSCheckEligibility.utils.ts:199-201) treats either 'eligible' or 'active', read from either payerResponse.status or eligibilityCode.code, as eligible. So the badge and the stored row can in principle disagree for an 'active' response.

Persistence

Everything after step 4 is conditional on input.patientId being supplied (:120):

  1. Re-verify tenancy with patient.findFirst({ where: { id: patientId, companyId } })

(:121-126). If the patient is not found, all writes are silently skipped (:128) and the mutation still returns success:true.

  1. dHSEligibilityCheck.create with companyId, patientId, resolved

insuranceCompanyId, status, the whole upstream body as responsePayload, and responseDate taken from data.requestHeader.submissionDateTime when present, else now (:129-145).

  1. patient.update setting currentEligibilityId to the new row, plus insuranceNumber

when the payer returned a non-blank coverage.membershipNumber (:149-158).

Steps 2 and 3 are not wrapped in a $transaction. If the patient update fails, an orphan DHSEligibilityCheck row is left behind and the mutation reports success:false even though the check itself succeeded.

Patient.currentEligibilityId is @unique with a SET NULL delete rule (schema.prisma:965-966), so it points at exactly one check at a time — the "current" answer the UI renders.

Derived, not stored, UI state

useDHSCheckEligibility.ts:67-83activeEligibility is computed on every render, never kept in state:

  1. Prefer localResult (this session's optimistic result) if its insuranceCompanyId

matches the patient's current insurer.

  1. Otherwise fall back to patient.currentEligibility under the same match rule.
  2. Otherwise null.

The insurer-match rule is what makes the badge disappear when the patient's insurance is changed: a green Eligible from Bupa must not keep showing after the patient is moved to Tawuniya. The same guard appears in PatientProfile.js:91-93 and in the Check Insurance review step (ReviewStep.tsx:85-89).

On success the hook also writes a PatientEligibilityUpdate fragment into the Apollo cache (:127-145), minting a temporary id temp-<ts>-<rand> when the server did not return one.

The pre-flight gate

handleCheckClick (useDHSCheckEligibility.ts:187-232) checks twelve fields: insuranceCompany.id, insuranceCompany.nphiesCode, insurancePolicy.policyNumber, policyClass.name, nationalId, job, marital, gender, identifierType, birthDate, firstName, lastName. Any one missing opens the modal.

handleModalSubmit (:281-307) is where the "update first" behaviour lives:

validateFields()
  → if enablePatientUpdate: updatePatient()  ← persists, aborts on failure
  → else if onPatientDataChange: push values to host without persisting
  → performCheck()

Field-level validation

Every field the patient already has is disabled — isFieldDisabled returns true for any non-empty trimmed value (DHSCheckEligibility.utils.ts:125-127). identifierType is the exception: it locks only when both the type and the national ID are already set (DHSCheckEligibility.tsx:179).

National-ID validation is country-aware, driven by user.company.country (DHSCheckEligibility.tsx:196-245):

CountryRule
SA, types national_id / iqama / residency / border_numberExactly 10 digits
SA, national_idMust start with 1
SA, iqama / residencyMust start with 2
EGExactly 14 digits; first char 2 or 3; chars 3–5 a valid month; 5–7 a valid day

The legacy patient form carries the matching Saudi rule at packages/clinic-web/src/components/common/formFields/NationalIdField.js:52.

Payload coding

buildEligibilityPayload (DHSCheckEligibility.utils.ts:143-181) throws 'Branch ID is missing' when branchId is falsy (:144) — caught by performCheck into a toast.

Coding tables, all with silent defaults:

FieldMappingLine
Gendermale=0, female=1, default 0:94-97
Marital statussingle=1, married=2, divorced=3, widowed=4, separated=5, default 1:101-110
Occupationhealthcare=7, military=8, unemployed=9, retired=10, student=11, everything else=14:112-123
Identifier typemapIdentifierTypeToEligibility, unknown → 7DHSIdentifierMappings.ts:22-43

Hardcoded values: subscriberRelated: 6 (Self), fallback NPHIES code 'INS-FHIRss', fallback birth date '2000-01-01', fallback names 'Unknown', fallback policy holder 'Holder'.

Writing back to the patient

updatePatientDetails (packages/server/src/resolvers/mutations/DHS/updatePatientDetails.js:12-98) is the mutation the modal calls first. It is the security-hardened rewrite mentioned in the PR description:

  • Explicit allow-list via lodash/pick (:21-37), not a rest-spread: gender,

nationality, birthDate, firstName, lastName, phoneNumber, email, nationalId, identifierType, firstNameE, lastNameE, marital, job.

  • Tenant-scoped write: patient.updateMany({ where: { id, companyId } }), throwing when

count !== 1 (:72-79).

  • Future birth dates rejected (:39-45).
  • Identifier types are normalized, never rejected: iqama → residency;

visa | border_number | displaced_person → OTHER; anything unrecognised → OTHER with a log.warn (:52-66).

Two things to know about it:

  1. Three fields declared on UpdatePatientDetailsInputpolicyNumber, coverageType,

nphiesCode — are not on the allow-list and are silently discarded (inputs.graphql:911-924 vs :21-37).

  1. It is the only DHS-namespaced mutation not gated by FEATURE_DHS_INTEGRATION

(permissions.js:2782). It is guarded by EDIT_PATIENTS_DETAILS + isSameCompanyAsPatient instead, because it is really a general patient-edit mutation that happens to live in the DHS/ folder.

Every failure — including "no valid fields" and "not in your company" — collapses to "Update failed. Please try again." (:90-97); the real cause only reaches the log.