Dentolize · DHS (NPHIES) Integration Walkthrough
On this pageTermsEnumsData modelMigrationsGraphQL surfaceCoding tablesProtected fields

Glossary & Data Model

Terms

NPHIESNational Platform for Health Information Exchange Services. Saudi Arabia's national exchange through which providers and payers transact eligibility, pre-authorization and claims.

DHS — the Motalabate Health platform that Dentolize actually talks to. It fronts NPHIES. Throughout the code and UI, "DHS" is the integration; "NPHIES" is the identifier scheme.

NPHIES code / provider code — identifies a place of care. In Dentolize this lives on Branch.nphiesCode, not on the company. Every eligibility check and approval is filed under one branch's code, sent as the OrganizationCode header.

Payer — the insurer. Identified in requests by InsuranceCompany.nphiesCode.

NPHIES insurer idInsuranceCompany.nphiesInsuranceCompanyId, distinct from nphiesCode. Part of the composite unique [companyId, nphiesInsuranceCompanyId] used to recognise an insurer returned by discovery. It cannot be edited after creationeditInsuranceCompany accepts only nphiesCode (schema.graphql:703-704).

Client secret — the clinic's DHS credential, exchanged at /api/Login for a bearer token. Stored encrypted; only a ****#### mask is ever returned.

Coverage — one insurance arrangement for one person: insurer + policy + class + limits + dates. Discovery may return several.

Eligibility check — a point-in-time answer to "is this person covered right now?" Recorded as a DHSEligibilityCheck; the latest is pointed at by Patient.currentEligibilityId.

Pre-authorization (pre-auth / approval) — asking the payer to commit, before treatment, to covering specific services. Recorded as a DHSApproval. Not the same as a claim.

Approval number — the payer's identifier for a pre-auth. Also stamped onto each linked Operation.preAuth.

Iqama — Saudi residency permit. Its number is a 10-digit identifier starting with 2 (national IDs start with 1). In the DHS mappings iqama and residency are treated as the same thing.

SCFHS — Saudi Commission for Health Specialties. The practitioner's licence number, required on every pre-auth.

Deductible rate — the share the patient pays. Dentolize's insurancePercentage is the inverse: a 20% deductible becomes 80% cover (DHSCheckInsurance.utils.ts:24-33). Returned by DHS as a string.

Operation — Dentolize's term for a single planned or performed treatment on the chart. What becomes a "service line" in a pre-auth.

insuranceDiscount: false — the "not subject to insurance" flag on an operation. Such operations are excluded from DHS submission, client- and server-side.


Enums

DHSApprovalStatus

schema.prisma:5602 · enums.graphql:1304 — identical in both.

ValueMeaning
DRAFTCreated locally, never sent (the Prisma default)
PENDINGWith the payer
APPROVEDPayer will cover it
PARTIALLY_APPROVEDSome services approved — not cancellable
DENIEDDeclined (distinct payer code)
REJECTEDDeclined
CANCELEDWithdrawn by the clinic
ERRORUpstream error, or an unrecognised payer status

EligibilityStatus

schema.prisma:5613ELIGIBLE, NON_ELIGIBLE. There is no "unknown" state; anything not explicitly eligible is stored as NON_ELIGIBLE.

Permission — DHS values

schema.prisma:6348-6356 · enums.graphql:622-630:

VIEW_DHS_INTEGRATION   EDIT_DHS_INTEGRATION   VIEW_DHS_APPROVALS
CHECK_DHS_INSURANCE    CHECK_DHS_ELIGIBILITY  CREATE_DHS_APPROVAL
CANCEL_DHS_APPROVAL    CREATE_MANUAL_DHS_APPROVAL   GET_DHS_APPROVAL
Orphaned values. Migration 20260615152553_dhs_permissions added MANAGE_DHS_INTEGRATION, MANAGE_DHS_APPROVALS and CHECK_INSURANCE to the Postgres enum. They were superseded a day later by the nine above. Postgres cannot drop enum values, so they persist in the database type while being absent from schema.prisma and enums.graphql. A repo-wide grep for MANAGE_DHS returns zero hits. The PR description still names them.

Data model

DHSIntegrationschema.prisma:5541

One per company (companyId @unique). Holds the encrypted credential.

FieldTypeNotes
idStringuuid
companyIdString@unique, cascade delete
clientSecretCiphertextBytes?AES-256-GCM
clientSecretIvBytes?12 random bytes per encryption
clientSecretAuthTagBytes?
secretUpdatedAtDateTime@default(now())

No plaintext column exists. The GraphQL type exposes only hasSecret, clientSecretMask and secretUpdatedAt (types.graphql:5468-5476).

DHSEligibilityCheckschema.prisma:5555

FieldTypeNotes
patientIdStringcascade
companyIdStringadded by the 20260507 migration
statusEligibilityStatus@default(NON_ELIGIBLE)
checkedAtDateTime@default(now())
responseDateDateTime?from the payer when supplied
responsePayloadJsonrequired in Prisma, nullable in GraphQL
insuranceCompanyIdString?cascade
currentForPatient?back-relation of Patient.currentEligibility

Indexes: [patientId], [companyId, patientId].

DHSApprovalschema.prisma:5575

FieldTypeNotes
operationsOperation[]the treatments this covers
patientIdStringcascade
companyIdStringdenormalised for tenant indexing
approvalNumberString?the payer's identifier
statusDHSApprovalStatus@default(DRAFT)
requestPayloadJson?attachments already '[redacted]'
responsePayloadJson?verbatim payer response
canceledAt / canceledByIdDateTime? / String?audit only
responseDateDateTime?
manualUpdateByIdString?source of truth for "manually updated"

Indexes: [patientId], [companyId, status], [status, updatedAt] — the last one serves the cron's PENDING + orderBy: updatedAt scan.

Two load-bearing comments in the schema:

  • :5586status === 'CANCELED' is the source of truth for "canceled?"; canceledAt/By

carry audit information the status does not.

  • :5591manualUpdateById != null is the source of truth for "manually updated?"

DHS fields on existing models

ModelFieldsLine
CompanydhsIntegration, dhsApprovals, dhsEligibilityChecks:263-265
UsercanceledDHSApprovals, manualUpdatedDHSApprovals:675-676
PatientcurrentEligibilityId @unique (SET NULL), currentEligibility, dhsEligibilityChecks, dhsApprovals:965-968
BranchnphiesCode String?:1104
InsuranceCompanynphiesCode, nphiesInsuranceCompanyId, eligibilityChecks, @@unique([companyId, nphiesInsuranceCompanyId]):1281, :1301-1310
OperationdhsApproval, dhsApprovalId String? (SET NULL):2334-2335

InsurancePolicy and PolicyClass have no DHS fields at all. They connect to DHS only indirectly, through InsuranceCompany.nphiesInsuranceCompanyId and the ResolveInsuranceCoverage response type. Their identity keys matter for resolution: InsurancePolicy.@@unique([insuranceCompanyId, policyNumber]) (:1349) and PolicyClass.@@unique([insurancePolicyId, name]) (:1383).


Migrations

Eight touch DHS.

MigrationWhat it does
20260506145429_add_unique_constraints_to_insurance_and_patientCreates both enums, the three tables, and all the DHS fields on existing models. Original DHSApproval carried isCanceled and isManualUpdate booleans.
20260507183115_add_companyid_to_dhs_modelsadd-nullable → backfill from patient.companyId → SET NOT NULL, with an orphan guard that RAISE EXCEPTIONs rather than coercing (lines 25–38). Adds the three tenant indexes.
20260508120000_drop_dhs_redundant_status_flagsBackfills status='CANCELED' where isCanceled, then drops both boolean columns. No down-migration.
20260609102732_add_insurance_company_to_eligibility_checkAdds DHSEligibilityCheck.insuranceCompanyId, FK ON DELETE SET NULL.
20260614090102_add_insurance_company_to_eligibility_checkSame directory name, later timestamp. Recreates the FK as ON DELETE CASCADE.
20260615152553_dhs_permissionsAdds the three coarse permissions later abandoned.
20260616100525_add_dhs_permissionsAdds the nine that are actually used.
20260616145425_refactor_dhs_permissionsIntentional no-op — comment-only. Its original SQL re-added an existing column and broke migrate deploy on fresh databases; kept to preserve ordering.

GraphQL surface

Queries — schema.graphql:597-604

getDHSClientSecret: GetDHSClientSecretResponse!
dhsBranches: DHSBranchesResponse!
getInsuranceCompanyDhsInsuranceHierarchy(input: GetDhsInsuranceHierarchyInput!): GetDhsInsuranceHierarchyResponse!
resolveInsuranceCoverages(input: ResolveInsuranceCoverageInput!): ResolveInsuranceCoverageResponse!
getDHSApprovals(input: GetDHSApprovalsInput!): GetDHSApprovalsResponse!
totalDHSApprovals(input: TotalDHSApprovalsInput!): Int!
dhsApprovalDetails(dhsApproval: ID!): DHSApproval!

Mutations — schema.graphql:1378-1390

dhsAuthentication: DHSAuthenticationResponse!
saveDHSIntegration(input: SaveDHSIntegrationInput!): SaveDHSIntegrationResponse!
testDHSConnection: TestDHSConnectionResponse!
rotateDHSClientSecret(newSecret: String!): RotateDHSClientSecretResponse!
updateBranchesNphiesCodes(input: UpdateBranchesNphiesCodesInput!): UpdateBranchesNphiesCodesResponse!
checkInsuranceEligibility(input: CheckInsuranceEligibilityInput!): CheckInsuranceEligibilityResponse!
dhsCheckEligibility(input: DHSCheckEligibilityInput!): DHSCheckEligibilityResponse!
updatePatientDetails(input: UpdatePatientDetailsInput!): updatePatientDetailsResponse!
dhsApprovalSubmission(input: DHSApprovalSubmissionInput!): DHSApprovalSubmissionResponse!
dhsGetApproval(input: DHSGetApprovalInput!): DHSGetApprovalResponse!
dhsCancelApproval(input: DHSCancelApprovalInput!): DHSCancelApprovalResponse!
dhsManualUpdateOperations(input: DHSManualUpdateOperationsInput!): DHSManualUpdateOperationsResponse!

Note rotateDHSClientSecret and dhsApprovalDetails take bare scalars rather than input objects, unlike everything else.

Key response types

TypeLineShape
DHSApprovaltypes.graphql:5186Full row minus companyId, which is deliberately not exposed
DHSIntegration:5468hasSecret, clientSecretMask, secretUpdatedAt — never the bytes
DHSInsuranceCoverage:550822 nullable String fields; maxLimit and deductibleRate are strings, not numbers
InsuranceEntityResolution:5540{ exists: Boolean!, id: String, nphiesCode: String }
ResolveInsuranceCoverage:5546The three InsuranceEntityResolutions — the only place policy/class meet DHS
DHSEligibilityCheck:5581No companyId; insuranceCompanyId as a raw id, not an object
DHSCancelApprovalResponse:5618Still exposes isCanceled, now derived from status

Outbound DHS endpoints

PurposeEndpoint
AuthenticatePOST ${DHS_AUTH_URL}/api/Login
Discover insurancePOST ${DHS_ELIGIBILITY_URL}/api/v1/Eligibility/CheckInsurance
Check eligibilityPOST ${DHS_ELIGIBILITY_URL}/api/v2/Eligibility/Checkeligibility
Submit pre-authPOST ${DHS_PREAUTH_URL}/api/v1/Preauth/SubmitApprovalRequest
Poll pre-authPOST ${DHS_PREAUTH_URL}/api/v1/Preauth/GetApprovalResponse
Cancel pre-authPOST ${DHS_PREAUTH_URL}/api/v1/Preauth/CancelApproval

All carry Authorization: Bearer <token> and OrganizationCode: <branch.nphiesCode>.


Coding tables

Identifier type → systemTypeId (Check Insurance)

DHSIdentifierMappings.ts:76-96. There is no code 4; unknown → 1.

CodeTypes
1national_id, iqama, residency
2visa, visitor_id
3passport
5driver_license, border_number, displaced_person, other, OTHER

Identifier type → eligibility / approval code

DHSIdentifierMappings.ts:22-43. Unknown → 7, logged, never thrown.

CodeTypes
1iqama, residency
2passport
3national_id
4visa, visitor_id
5mrn
6border_number
7displaced_person, driver_license, other, OTHER

DHS type → legacy patient type

DHSIdentifierMappings.ts:50-73, applied before updatePatientDetails. Pass-through: national_id, passport, driver_license, residency, OTHER. Mapped: other → OTHER, iqama → residency, visa | border_number | displaced_person → OTHER. Unmapped → OTHER, logged.

Other codings

DHSCheckEligibility.utils.ts (duplicated verbatim in DHSApprovalSubmission.utils.ts:167-198):

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, else 14:112-123

Hardcoded in the eligibility payload: subscriberRelated: 6 (Self), fallback NPHIES code 'INS-FHIRss', fallback DOB '2000-01-01', fallback names 'Unknown', fallback policy holder 'Holder'.

Hardcoded in the approval payload: vatPercentage: 15, deductible: 0, durationOfDrugSupply: 1, estimatedLengthOfStay: 1, specialityCode: '08.26', specialityName: 'General', exactly one visit dated now.


Protected fields

packages/server/src/utils/dhsFieldGuards.js:

SetFieldsLocked when
PREAUTH_PROTECTED_FIELDSpreAuthAlways, every status
GENERAL_PROTECTED_FIELDStooth, price, amount, doctorId, createdAt, diagnosisAny status except CANCELED

Arrays are compared order-insensitively as multisets; Dates by getTime(); only fields present in the incoming payload are checked.