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

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:


<rect width="900" height="640" fill="#0C0C14"/>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#8B7CF6"/>
</marker>
</defs>

<rect x="40" y="30" width="230" height="70" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="155" y="58" fill="#F4F2FF" font-size="14" text-anchor="middle">Patient / referral</text>
<text x="155" y="76" fill="#F4F2FF" font-size="14" text-anchor="middle">intake (Next.js)</text>

<rect x="630" y="30" width="230" height="70" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="745" y="58" fill="#F4F2FF" font-size="14" text-anchor="middle">Provider workspace</text>
<text x="745" y="76" fill="#F4F2FF" font-size="14" text-anchor="middle">(Next.js)</text>

<line x1="155" y1="100" x2="400" y2="155" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="745" y1="100" x2="500" y2="155" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>

<rect x="335" y="150" width="230" height="60" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="175" fill="#F4F2FF" font-size="14" text-anchor="middle">FastAPI backend</text>
<text x="450" y="193" fill="#F4F2FF" font-size="14" text-anchor="middle">one API, one schema</text>

<line x1="450" y1="210" x2="450" y2="240" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>

<rect x="335" y="240" width="230" height="60" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="265" fill="#F4F2FF" font-size="14" text-anchor="middle">Auth / RBAC layer</text>
<text x="450" y="283" fill="#F4F2FF" font-size="14" text-anchor="middle">role scoped to record stage</text>

<line x1="400" y1="300" x2="250" y2="330" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="500" y1="300" x2="660" y2="330" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>

<rect x="60" y="330" width="380" height="110" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="250" y="358" fill="#F4F2FF" font-size="14" text-anchor="middle">Lifecycle engine</text>
<text x="250" y="378" fill="#F4F2FF" font-size="13" text-anchor="middle">inquiry -&gt; CRM follow-up -&gt;</text>
<text x="250" y="396" fill="#F4F2FF" font-size="13" text-anchor="middle">dual-path screening -&gt;</text>
<text x="250" y="414" fill="#F4F2FF" font-size="13" text-anchor="middle">chart -&gt; treatment</text>

<rect x="470" y="330" width="380" height="110" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="660" y="358" fill="#F4F2FF" font-size="14" text-anchor="middle">Imaging pipeline</text>
<text x="660" y="378" fill="#F4F2FF" font-size="13" text-anchor="middle">CBCT 3D scan ingest,</text>
<text x="660" y="396" fill="#F4F2FF" font-size="13" text-anchor="middle">sleep-test data ingest,</text>
<text x="660" y="414" fill="#F4F2FF" font-size="13" text-anchor="middle">linked to same record</text>

<line x1="250" y1="440" x2="350" y2="470" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="660" y1="440" x2="550" y2="470" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>

<rect x="250" y="470" width="400" height="70" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="497" fill="#F4F2FF" font-size="14" text-anchor="middle">Postgres</text>
<text x="450" y="515" fill="#F4F2FF" font-size="13" text-anchor="middle">SQLModel entities, Alembic migrations</text>

<line x1="450" y1="540" x2="450" y2="570" stroke="#8B7CF6" stroke-width="2" marker-end="url(#arrow)"/>

<rect x="250" y="570" width="400" height="50" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="600" fill="#F4F2FF" font-size="13" text-anchor="middle">CI: typecheck, tests, migration check on every PR</text>


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:

```python
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:

```python
"""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](/blog/hipaa-compliant-software) 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](/pods) 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](/security) 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](/blog/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.