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

Permissions, Tenancy & Rollout

Business view

DHS touches money, patient identity and a national health exchange. Three separate controls decide whether any given click is allowed:

  1. Is the feature on for this clinic? A feature flag, off for everyone except the beta

cohort.

  1. Does this user have the right permission? Nine new, deliberately granular

permissions, configured per permission group.

  1. Does this record belong to this clinic? Tenant rules, enforced server-side even for

users who pass the first two.

The nine permissions

They appear as a new DHS Settings tab when editing a permission group (Settings → Permission Groups → group):

PermissionLets the user…
View DHS ApprovalsSee the Approvals tab and the approvals log
Check DHS InsuranceRun insurance discovery; also gates Check Status
Check DHS EligibilityRun eligibility checks
Create DHS ApprovalSubmit a pre-auth
Create Manual DHS ApprovalHand-reconcile an approval
Cancel DHS ApprovalWithdraw a pre-auth
Get DHS ApprovalRetry a failed approval poll

Two more live on the Settings tab, under a DHS Integration row:

PermissionLets the user…
View DHS IntegrationSee the Settings → Integrations → DHS tab
Edit DHS IntegrationSave / rotate the client secret, set branch NPHIES codes

For that row the View-Created, Add and Delete checkboxes are deliberately disabled — only View and Edit make sense for a singleton integration record.

Sensible defaults to recommend

Dentolize ships no DHS permissions enabled on any seeded group. A reasonable starting point:

RoleSuggested grants
Owner / ManagerAll nine, plus View + Edit DHS Integration
ReceptionistCheck DHS Insurance, Check DHS Eligibility, View DHS Approvals
DoctorCreate DHS Approval, View DHS Approvals, Get DHS Approval
AccountantView DHS Approvals, Create Manual DHS Approval
Nobody by defaultCancel DHS Approval, Edit DHS Integration

The rollout flag

FEATURE_DHS_INTEGRATION is seeded active: true with the positive rule Rule('isBeta', 'EQUALS', true). In plain terms: the feature is live only for companies flagged as beta. Turning it on for a clinic is a matter of flipping that company's beta flag (or editing the rule), not a code deploy.

When the flag is off, every DHS GraphQL operation is refused, every DHS UI surface is hidden, the background job skips the company entirely, and — importantly — the invoicing guards do not block anything. A non-beta clinic behaves exactly as it did before this PR.


Technical view

Permission constants

packages/prisma/schema.prisma:6348-6356 and enums.graphql:622-630, identical sets:

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
Documented discrepancy. The PR description names MANAGE_DHS_INTEGRATION, MANAGE_DHS_APPROVALS and CHECK_INSURANCE. Those were added to the Postgres enum by migration 20260615152553_dhs_permissions and then superseded by the nine above in 20260616100525_add_dhs_permissions. Because Postgres cannot drop enum values, they still exist 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 is out of date; the code is correct.

Front-end mapping: packages/clinic-mobile/src/shared/utils/getUserPermissions.js:459-468 (shared by web and mobile). Note getDhsApprovals is backed by the singular GET_DHS_APPROVAL constant.

Permission-group UI: settings/Groups/tableData.js:471-479 (the seven-row DhsDataSource, exported at :1133), rendered as its own tab at settings/Groups/Group.js:251. The DHS_INTEGRATION row lives in the Settings data source at tableData.js:126-129, with View-Created / Add / Delete disabled at :515, :529, :562.

Shield wiring

packages/server/src/permissions/permissions.js. Every DHS field uses the same shape:

and(
  chain(isAuthenticated, hasPermission(X)[, tenantRule]),
  hasFeatureFlag('FEATURE_DHS_INTEGRATION')
)

Queries (:2727-2734):

OperationPermission
getDHSClientSecret, dhsBranchesVIEW_DHS_INTEGRATION
getInsuranceCompanyDhsInsuranceHierarchy, resolveInsuranceCoveragesCHECK_DHS_INSURANCE
getDHSApprovals, totalDHSApprovals, dhsApprovalDetailsVIEW_DHS_APPROVALS

Mutations (:2770-2781):

OperationPermissionTenant rule
saveDHSIntegration, testDHSConnection, rotateDHSClientSecret, updateBranchesNphiesCodes, dhsAuthenticationEDIT_DHS_INTEGRATION
dhsApprovalSubmissionCREATE_DHS_APPROVALisSameCompanyAsDhsInputPatient
dhsCancelApprovalCANCEL_DHS_APPROVALisSameCompanyAsDhsApprovalId
dhsGetApprovalGET_DHS_APPROVALisSameCompanyAsDhsApprovalId
dhsManualUpdateOperationsCREATE_MANUAL_DHS_APPROVALisSameCompanyAsDhsManualOperations
checkInsuranceEligibilityCHECK_DHS_INSURANCE
dhsCheckEligibilityCHECK_DHS_ELIGIBILITY
updatePatientDetailsEDIT_PATIENTS_DETAILS + isSameCompanyAsPatientno feature-flag wrapper (:2782)

The move out of the public patientApp namespace into the authenticated clinic namespace — called out in the PR's security-review section — is what these entries represent.

Tenant rules

packages/server/src/permissions/rules.js:

RuleWhat it comparesLine
isSameCompanyAsDhsInputPatientpatient.companyId via patientCache:1927-1933
isSameCompanyAsDhsApprovalIddHSApproval.patient.companyId:1935-1945
isSameCompanyAsDhsManualOperationsEvery operation.branch.companyId:1947-1957

All three return true when the relevant id is absent — they are permissive on absence, not fail-closed. The resolvers compensate: dhsGetApproval.js:30-31 and dhsCancelApproval.js:29-37 both add an explicit !dhsApprovalId guard whose comment explains that Prisma drops undefined filters, which would otherwise degrade the lookup to "any approval in this company".

isSameCompanyAsDhsManualOperations checks the branch's company, not operation.companyId, and its .every does not verify that all requested ids were found — a nonexistent id silently drops out of the result set. The resolver's own count assertion (dhsManualUpdateOperations.js:46-51) is the real guard.

Note: the read side (getDHSApprovals, dhsApprovalDetails) has no shield-level tenant rule. Scoping is enforced inside the resolvers — getDHSApprovals.js:16-17 always includes AND: [{ companyId }], and dhsApprovalDetails.js:24-26 compares approval.companyId after an unscoped findUnique and throws 'Not Authorised'.

Defence in depth: layered gating

Even with the flag and permission satisfied, most patient-facing surfaces require a third condition — the selected branch has an NPHIES code:

SurfaceFile:line
Chart → Approvals tabChartTabs.js:130
Patient-profile buttonsPatientProfile.js:147
Patient-form insurance linkPatientCommonFields.js:737
Chart footer submissionChartTableFooter.js:407
Status popover interactivityDHSStatusPopover.js:47,53,135
Chart-table status tagsChartTable.js:319
Invoice expanded rowInvoiceExpendedRow.js:274

This is why, on a freshly flagged clinic, none of the patient-side DHS controls appear until step 2 of the setup wizard has been completed. It was reproduced exactly on the branch sandbox: setting a branch NPHIES code made Check Eligibility, Check Insurance, the Approvals tab and GET Approval all appear at once.

Feature-flag enforcement — three independent places

The flag is genuinely enforced, not a decorative seed row:

  1. GraphQL shield — all 18 DHS operations, via hasFeatureFlag

(rules.js:174-193), which loads the flag row and evaluates positive/negative rules against the caller's company. All rejection causes collapse to the same 'Feature not enabled' message, deliberately (comment :169-170).

  1. The cron — a global active check at dhsApprovalsCron.js:68-75, then per-company

rule evaluation at :124-145.

  1. Resolver-side guardisDhsFeatureActiveForCompany inside

assertNoPendingDhsApproval (dhsGuards.js:6,12-28,51), so that flag-off never blocks invoicing.

All three share evaluateFeatureFlagRules and the same company field set { id, country, isBeta, isVIP, tier, createdAt }. rules.js:165-167 explains why the company is loaded directly rather than via companyCache — the cache omits those fields.

Seed: packages/server/src/generateServerData/seedFeatureFlags.js:39-45, upserted by key at :50-54.

The seed description says "off by default", but the row is seeded active: true with a beta-only positive rule. The gating is the isBeta rule, not the active bit — worth knowing before someone flips active expecting it to be the master switch.

Client-side, useFeatureFlag('FEATURE_DHS_INTEGRATION') appears in ~20 files across clinic-web and clinic-web-canary. That is presentation only; the server is the authority.

Environment prerequisites

VariableIn .env.example?Failure mode if missing
DHS_AUTH_URLYes (:36-40)Process throws at import (dhsConfig.js:1-5)
DHS_PREAUTH_URLYesSame
DHS_ELIGIBILITY_URLYesSame
ENCRYPTION_MASTER_KEYYes (:47-51)Process throws at import (encryption.js:6-8)
S3_BUCKET_AWSNovalidateAttachmentUrl.js:9-11 throws at import
S3_REGION_AWSNoSame

All hardcoded staging-*.motalabatech.health URLs were replaced by these variables, as the PR describes. required() also strips one trailing slash (dhsConfig.js:4).