← All posts

Build walkthrough · Regulated Industries

How We Built an EHR That Treats a Lead as a Lead

Inside the build: a dental EHR that tracks an inquiry from first contact to treatment in one system, not three.

Asaasin EngineeringPublished August 24, 20268 min read

In short

Every dental EHR on the market treats an inbound call as a chart the moment a name gets typed into it. We built the opposite for a dental sleep and airway medicine group: a unified practice EHR where the inquiry, its CRM lifecycle, and the clinical chart share one data model and one system, from first contact through treatment.

Key numbers

  • 1 record type spans the entire lifecycle: inquiry through treatment, not three disconnected systems stitched together
  • 6 end-to-end workflows shipped in the same schema, covering intake through the treatment handoff
  • RBAC and an audit baseline built into the data model from the first migration, not added later
  • Stack: Next.js, FastAPI, SQLModel, Alembic, an Auth/RBAC layer, and CI gating every merge

The problem every incumbent dental EHR ships with

Dental practice management software is built around the chart. That is fine for a practice that only sees patients who already scheduled a visit. It falls apart for a practice whose growth engine is an inbound inquiry: a phone call, a web form, a referral from another provider, asking about airway or sleep symptoms before anyone has decided this is a patient at all.

The incumbents treat that inquiry as a chart the instant someone opens a record for it. There is no lead stage, no lifecycle, no place to track a follow-up call or a screening result before intake becomes a patient of record. Practices patch the gap with a separate CRM or a spreadsheet, and the two systems never agree on where a person actually is. Revenue leaks in that gap: a promising inquiry stalls in the marketing tool while the clinical team has no visibility into it, or a screening result never makes it back into the intake pipeline that generated it.

Fixing that meant refusing the premise. An inquiry is not a lesser version of a chart. It is the same record, earlier in its life.

What we built: one data model, one system

The build is a unified practice EHR where lead management is native to the patient record rather than bolted on beside it. Inquiry, CRM lifecycle, and clinical chart live in the same schema. A record moves through stages, first contact, screening, active patient, in treatment, without ever crossing a system boundary or losing history in a handoff.

Two clinical paths run through the same record type: an airway screening path and a sleep screening path, because the practice sees both populations and they follow different clinical logic before converging on a treatment plan. Role-based access control governs who can see and edit each stage, since a front-desk intake coordinator has no clinical reason to see a completed sleep study, and a provider has no reason to touch billing fields on an inquiry that has not converted yet. An imaging pipeline handles cone-beam 3D scans and sleep-test data as first-class objects attached to the same record, not as files parked in a separate viewer.

Here is the shape of it end to end:

Patient / referral intake (Next.js) Provider workspace (Next.js) FastAPI backend one API, one schema Auth / RBAC layer role scoped to record stage Lifecycle engine inquiry -> CRM follow-up -> dual-path screening -> chart -> treatment Imaging pipeline CBCT 3D scan ingest, sleep-test data ingest, linked to same record Postgres SQLModel entities, Alembic migrations CI: typecheck, tests, migration check on every PR

One data model runs the whole path. The lifecycle engine and the imaging pipeline both write to the same Postgres instance through the same RBAC layer, so a provider opening a chart sees the same record the intake coordinator created weeks earlier, screening results and all.

The stack, and why each layer is doing real work

The frontend is Next.js on both surfaces, patient-facing intake and provider workspace, so the two share components and a build pipeline instead of drifting into two codebases. FastAPI backs both with one API. SQLModel gives us typed entities that double as Pydantic schemas for request and response validation, which matters when the same underlying record type has to render differently for an intake coordinator, a screening provider, and a billing role. Alembic tracks every schema change as a reviewed migration, which is the only honest way to evolve a live clinical schema without breaking a record mid-lifecycle.

A simplified illustration of that entity relationship. It shows the shape of the idea, not the client's schema:

from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship

class LifecycleStage(str, Enum):
 inquiry = "inquiry"
 crm_followup = "crm_followup"
 screening = "screening"
 active_patient = "active_patient"
 in_treatment = "in_treatment"
 closed = "closed"

class ScreeningPath(str, Enum):
 airway = "airway"
 sleep = "sleep"
 dual = "dual"

class PracticeRecord(SQLModel, table=True):
 id: Optional[int] = Field(default=None, primary_key=True)
 first_name: str
 last_name: str
 contact_source: str
 lifecycle_stage: LifecycleStage = Field(default=LifecycleStage.inquiry)
 screening_path: Optional[ScreeningPath] = None
 created_at: datetime = Field(default_factory=datetime.utcnow)
 updated_at: datetime = Field(default_factory=datetime.utcnow)

 lifecycle_events: list["LifecycleEvent"] = Relationship(back_populates="record")
 imaging_assets: list["ImagingAsset"] = Relationship(back_populates="record")
 chart: Optional["ClinicalChart"] = Relationship(back_populates="record")

class LifecycleEvent(SQLModel, table=True):
 id: Optional[int] = Field(default=None, primary_key=True)
 record_id: int = Field(foreign_key="practicerecord.id")
 stage: LifecycleStage
 actor_role: str
 note: Optional[str] = None
 occurred_at: datetime = Field(default_factory=datetime.utcnow)

 record: PracticeRecord = Relationship(back_populates="lifecycle_events")

class ImagingAsset(SQLModel, table=True):
 id: Optional[int] = Field(default=None, primary_key=True)
 record_id: int = Field(foreign_key="practicerecord.id")
 asset_type: str # "cbct_3d" or "sleep_test"
 storage_uri: str
 reviewed_by: Optional[str] = None
 reviewed_at: Optional[datetime] = None

class ClinicalChart(SQLModel, table=True):
 id: Optional[int] = Field(default=None, primary_key=True)
 record_id: int = Field(foreign_key="practicerecord.id", unique=True)
 diagnosis_notes: str = ""
 treatment_plan: str = ""

 record: PracticeRecord = Relationship(back_populates="chart")

PracticeRecord is the load-bearing choice here. It never gets replaced by a different table when an inquiry converts. It gets a new lifecycle_stage, a LifecycleEvent row logging who moved it and when, and eventually a ClinicalChart attached to the same id. Nothing gets re-keyed, nothing gets migrated between systems, because there was never a second system.

Schema changes went through Alembic the way any regulated build should handle them, as a reviewed, reversible migration rather than a manual ALTER TABLE run against production. Again as a simplified illustration:

"""add screening_path and lifecycle audit fields

Revision ID: 7f2a1c9d0abc
Revises: 4e91b6a3f012
Create Date: 2024-xx-xx
"""
from alembic import op
import sqlalchemy as sa

revision = "7f2a1c9d0abc"
down_revision = "4e91b6a3f012"

def upgrade() -> None:
 op.add_column(
 "practicerecord",
 sa.Column("screening_path", sa.String(length=16), nullable=True),
 )
 op.create_table(
 "lifecycleevent",
 sa.Column("id", sa.Integer(), primary_key=True),
 sa.Column("record_id", sa.Integer(), sa.ForeignKey("practicerecord.id"), nullable=False),
 sa.Column("stage", sa.String(length=32), nullable=False),
 sa.Column("actor_role", sa.String(length=32), nullable=False),
 sa.Column("note", sa.Text(), nullable=True),
 sa.Column("occurred_at", sa.DateTime(), nullable=False),
 )
 op.create_index("ix_lifecycleevent_record_id", "lifecycleevent", ["record_id"])

def downgrade() -> None:
 op.drop_index("ix_lifecycleevent_record_id", table_name="lifecycleevent")
 op.drop_table("lifecycleevent")
 op.drop_column("practicerecord", "screening_path")

Every migration ran through CI before it touched a shared environment: typecheck, the test suite against the migrated schema, and a check that the migration is reversible. That gate matters more here than on a typical SaaS product, because a botched schema change on a clinical record is not a rollback with an apology. It is a data-integrity incident on a HIPAA-adjacent system.

Dual-path screening and role-based access, in the same record

Airway and sleep patients present differently and follow different clinical protocols before they converge on a treatment plan, so the screening path is a property of the record rather than a separate module bolted alongside it. The two paths ask different things of a patient, which is a clinical decision the practice owns and not ours to publish, but both write to the same event trail and land on the same chart shape once a provider takes over.

RBAC is scoped to lifecycle stage, not just to role, which is the part most implementations skip. The same user can legitimately need a record at one stage and have no business reading it at another, so the check has to consider where the record is, not only who is asking. That scoping lives at the API layer and runs on every request, which is what makes an audit log meaningful later: every access is a role, a stage, and a timestamp, not a blanket "logged in" flag.

Imaging: cone-beam 3D scans and sleep-test data as chart objects

Cone-beam CT scans and sleep-test results are large, sensitive, and clinically load-bearing, so they needed to be first-class objects tied to the record rather than files dropped in a shared folder and linked by convention. The ImagingAsset table above is the simplified version of that: an asset type, a storage reference, and a review trail, foreign-keyed to the same PracticeRecord that carries the lifecycle history.

That matters for two reasons beyond convenience. First, a provider reviewing a CBCT scan is reviewing it against the same record where the screening path and treatment plan live, not cross-referencing a second system by patient name. Second, imaging review itself becomes an auditable event: who opened the scan, when, and what stage the record was in at the time. That is the same audit-trail discipline we describe for our security posture in general, and it is the kind of control a compliance-conscious buyer should ask any vendor to show, not just claim. Our guide to what HIPAA compliant software actually requires covers where audit controls sit inside the Security Rule and why a read is as loggable as a write.

How we verified it: the traceability matrix

A traceability matrix is a table that maps every requirement in the build brief to the code that satisfies it and the test that proves it. Each row is a line item: "inquiry converts to active patient without data loss," "sleep-test asset links to the correct record," "billing role cannot read clinical notes." Each row points at a merged pull request and a passing test. That is verification work a pod carries as part of the build rather than a separate QA engagement bolted on at the end.

That discipline matters more in a regulated build than a straightforward one. A features list in a deck is a claim. A traceability matrix is a way to check the claim against shipped, tested code, requirement by requirement, before anyone signs off. It is also the practical version of what an honest HIPAA posture looks like from a vendor: we do not claim a HIPAA certification, because there is no such certification to hold. We sign a business associate agreement on request and build to HIPAA-aligned controls, and a traceability matrix is one of the artifacts that lets a compliance reviewer see that the controls are real rather than promised. Our security page covers the rest of that posture, including how we handle a SOC 2 Type II report under NDA.

The outcome: one system, six workflows, no incumbent offers it

The result is one system running the full arc from first contact to treatment, which is the wedge no dental EHR incumbent ships. One record type carries a person through inquiry, CRM follow-up, dual-path screening, imaging, chart, and treatment, rather than handing them off between a marketing tool and a clinical record and losing history at the seam.

Six end-to-end workflows run on that same schema, spanning intake, follow-up, screening, imaging review, chart consolidation, and the treatment handoff. Each is a state machine over the same record, not a separate application bolted alongside it. RBAC and an audit baseline sit underneath all six, scoped to stage and role from the first migration rather than added as an afterthought once the practice asked about compliance. That is the difference between a practice management tool that happens to store patient data and a system built for a regulated clinical operation from its first schema. For the wider stack a practice has to get right around a record like this, infrastructure, PIMS integrations, and the compliance layer, see our overview of dental IT services.

The short version

An inquiry and a chart are the same record at different points in its life, and treating them as two systems is where dental practices lose both revenue and data integrity. We built a unified EHR on Next.js, FastAPI, SQLModel, and Alembic where one record type carries a person from first contact through treatment, with dual-path airway and sleep screening, RBAC scoped to lifecycle stage, and an imaging pipeline for CBCT and sleep-test data all sharing that same schema. Six end-to-end workflows run on it, every requirement checked against a traceability matrix before it counted as done, and the audit baseline was there from the first migration rather than added after someone asked about compliance.

Frequently asked questions

What does "HIPAA-aligned" mean if there is no HIPAA certification?
HIPAA has no certification body to grant one, so any vendor claiming to be "HIPAA certified" is overstating its position. The honest version, which is what we build to, is a signed business associate agreement plus controls aligned to HIPAA's technical and administrative safeguards: role-based access, audit logging, encryption in transit and at rest, and a documented breach-notification process. See our [guide to what HIPAA-compliant software actually requires](/blog/hipaa-compliant-software) for the full checklist.
Why treat an inquiry as part of the clinical record instead of keeping a separate CRM?
Because the gap between a marketing tool and a clinical record is where revenue and data both leak. A patient's screening interest, their referral source, and their eventual treatment plan are one continuous story, and splitting it across systems means someone has to reconcile it by hand, imperfectly, later.
What is a traceability matrix, in practical terms?
It is a table with one row per requirement in the build brief, each row pointing at the pull request and test that satisfy it. It turns "we built what was scoped" from a claim into something a compliance reviewer or a practice owner can check line by line against shipped code.
Does this replace existing dental practice management software entirely?
For a practice whose growth depends on inbound inquiries converting into patients, yes, because the value is in collapsing the marketing-to-chart gap that most practice management software leaves open. For a practice with a stable referral-only pipeline and no lead-conversion problem, a narrower build scoped to the clinical side alone may be the better fit. Our overview of [dental IT services](/blog/dental-it-services) covers what a modern practice stack needs before deciding which parts to rebuild.
How is a build like this staffed and delivered?
Work like this runs through a [pod](/pods): a lead engineer plus a bench, shipping weekly against a sprint roadmap, with the traceability matrix and CI gate as the verification layer throughout, rather than a single contractor working against a one-time statement of work.

Sources

Get in touch.

Thirty minutes to map your problem to a plan and a timeline. You will leave the call with scope, price, and a start date.

What happens on the call
01You describe the outcome you need.
02We map it to scope, price, and a start date.
03You decide whether to proceed to a free prototype.
Schedule a 30-minute call