chore: remove DB and binary from repo, add .gitignore to exclude artifacts

This commit is contained in:
2026-02-20 15:28:41 -05:00
parent 5d34942588
commit 63cb2b7b1c
25 changed files with 4376 additions and 421 deletions

340
app/db/clinical.go Normal file
View File

@@ -0,0 +1,340 @@
// clinical.go provides persistence operations for FHIR clinical resources:
// Observation, Condition, DocumentReference, and PatientSync.
//
// All upsert methods use (fhir_id, ehr_url) as the natural deduplication key,
// matching the same pattern used for users. On conflict the stored row is
// overwritten with the latest data from the EHR and synced_at is updated.
package db
import (
"fmt"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
"github.com/google/uuid"
)
// ---------------------------------------------------------------------------
// Observation
// ---------------------------------------------------------------------------
// UpsertObservation inserts or replaces an Observation record keyed on
// (fhir_id, ehr_url). Returns the internal UUID.
func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM observations WHERE fhir_id = ? AND ehr_url = ?`,
o.FHIRID, o.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE observations SET
patient_fhir_id = ?,
status = ?,
category = ?,
code_text = ?,
code_system = ?,
code_code = ?,
effective_date = ?,
value_quantity = ?,
value_unit = ?,
value_string = ?,
synced_at = ?
WHERE id = ?`,
o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode,
o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update observation %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO observations (
id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date,
value_quantity, value_unit, value_string, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category,
o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate,
o.ValueQuantity, o.ValueUnit, o.ValueString, now,
)
if err != nil {
return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err)
}
return id, nil
}
// ListObservations returns all Observations for the given patient, newest first.
func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observation, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category,
code_text, code_system, code_code, effective_date,
value_quantity, value_unit, value_string, synced_at
FROM observations
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY effective_date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list observations: %w", err)
}
defer rows.Close()
var out []models.Observation
for rows.Next() {
var o models.Observation
if err := rows.Scan(
&o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category,
&o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate,
&o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan observation: %w", err)
}
out = append(out, o)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Condition
// ---------------------------------------------------------------------------
// UpsertCondition inserts or updates a Condition record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertCondition(c *models.Condition) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM conditions WHERE fhir_id = ? AND ehr_url = ?`,
c.FHIRID, c.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE conditions SET
patient_fhir_id = ?,
clinical_status = ?,
verification_status = ?,
category = ?,
code_text = ?,
code_system = ?,
code_code = ?,
onset_date = ?,
recorded_date = ?,
synced_at = ?
WHERE id = ?`,
c.PatientFHIRID, c.ClinicalStatus, c.VerificationStatus,
c.Category, c.CodeText, c.CodeSystem, c.CodeCode,
c.OnsetDate, c.RecordedDate, now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update condition %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO conditions (
id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, category,
code_text, code_system, code_code,
onset_date, recorded_date, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, c.FHIRID, c.EHRURL, c.PatientFHIRID,
c.ClinicalStatus, c.VerificationStatus, c.Category,
c.CodeText, c.CodeSystem, c.CodeCode,
c.OnsetDate, c.RecordedDate, now,
)
if err != nil {
return "", fmt.Errorf("db: insert condition fhir_id=%s: %w", c.FHIRID, err)
}
return id, nil
}
// ListConditions returns all Conditions for the given patient, newest first.
func (s *Store) ListConditions(patientFHIRID, ehrURL string) ([]models.Condition, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, category,
code_text, code_system, code_code,
onset_date, recorded_date, synced_at
FROM conditions
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY recorded_date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list conditions: %w", err)
}
defer rows.Close()
var out []models.Condition
for rows.Next() {
var c models.Condition
if err := rows.Scan(
&c.ID, &c.FHIRID, &c.EHRURL, &c.PatientFHIRID,
&c.ClinicalStatus, &c.VerificationStatus, &c.Category,
&c.CodeText, &c.CodeSystem, &c.CodeCode,
&c.OnsetDate, &c.RecordedDate, &c.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan condition: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// DocumentReference
// ---------------------------------------------------------------------------
// UpsertDocumentReference inserts or updates a DocumentReference record.
func (s *Store) UpsertDocumentReference(d *models.DocumentReference) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM document_references WHERE fhir_id = ? AND ehr_url = ?`,
d.FHIRID, d.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE document_references SET
patient_fhir_id = ?,
status = ?,
doc_status = ?,
type_text = ?,
type_system = ?,
type_code = ?,
category = ?,
date = ?,
description = ?,
content_type = ?,
content_url = ?,
content_data = ?,
synced_at = ?
WHERE id = ?`,
d.PatientFHIRID, d.Status, d.DocStatus,
d.TypeText, d.TypeSystem, d.TypeCode,
d.Category, d.Date, d.Description,
d.ContentType, d.ContentURL, d.ContentData,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update document_reference %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO document_references (
id, fhir_id, ehr_url, patient_fhir_id,
status, doc_status, type_text, type_system, type_code,
category, date, description,
content_type, content_url, content_data, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, d.FHIRID, d.EHRURL, d.PatientFHIRID,
d.Status, d.DocStatus, d.TypeText, d.TypeSystem, d.TypeCode,
d.Category, d.Date, d.Description,
d.ContentType, d.ContentURL, d.ContentData, now,
)
if err != nil {
return "", fmt.Errorf("db: insert document_reference fhir_id=%s: %w", d.FHIRID, err)
}
return id, nil
}
// ListDocumentReferences returns all DocumentReferences for the given patient,
// newest first.
func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.DocumentReference, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
status, doc_status, type_text, type_system, type_code,
category, date, description,
content_type, content_url, content_data, synced_at
FROM document_references
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list document_references: %w", err)
}
defer rows.Close()
var out []models.DocumentReference
for rows.Next() {
var d models.DocumentReference
if err := rows.Scan(
&d.ID, &d.FHIRID, &d.EHRURL, &d.PatientFHIRID,
&d.Status, &d.DocStatus, &d.TypeText, &d.TypeSystem, &d.TypeCode,
&d.Category, &d.Date, &d.Description,
&d.ContentType, &d.ContentURL, &d.ContentData, &d.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan document_reference: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// PatientSync
// ---------------------------------------------------------------------------
// RecordSync inserts a new sync event for a patient.
func (s *Store) RecordSync(patientFHIRID, ehrURL string, obsCount, condCount, docCount int) (*models.PatientSync, error) {
ps := &models.PatientSync{
ID: uuid.NewString(),
PatientFHIRID: patientFHIRID,
EHRURL: ehrURL,
SyncedAt: time.Now().UTC(),
ObsCount: obsCount,
CondCount: condCount,
DocCount: docCount,
}
_, err := s.db.Exec(`
INSERT INTO patient_syncs (id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ps.ID, ps.PatientFHIRID, ps.EHRURL, ps.SyncedAt, ps.ObsCount, ps.CondCount, ps.DocCount,
)
if err != nil {
return nil, fmt.Errorf("db: record sync for patient %s: %w", patientFHIRID, err)
}
return ps, nil
}
// LatestSync retrieves the most recent sync event for a patient.
// Returns nil, nil when no sync has ever been performed.
func (s *Store) LatestSync(patientFHIRID, ehrURL string) (*models.PatientSync, error) {
ps := &models.PatientSync{}
err := s.db.QueryRow(`
SELECT id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count
FROM patient_syncs
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY synced_at DESC
LIMIT 1`,
patientFHIRID, ehrURL,
).Scan(
&ps.ID, &ps.PatientFHIRID, &ps.EHRURL, &ps.SyncedAt,
&ps.ObsCount, &ps.CondCount, &ps.DocCount,
)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, fmt.Errorf("db: latest sync for patient %s: %w", patientFHIRID, err)
}
return ps, nil
}

345
app/db/clinical_test.go Normal file
View File

@@ -0,0 +1,345 @@
package db_test
// clinical_test.go — tests for clinical resource persistence (Observation,
// Condition, DocumentReference, PatientSync).
import (
"testing"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
const (
testEHRURL = "https://ehr.example.com/fhir"
testPatientID = "patient-clinical-001"
)
// ---------------------------------------------------------------------------
// Observation tests
// ---------------------------------------------------------------------------
func TestUpsertObservation_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
qty := 98.6
obs := &models.Observation{
FHIRID: "obs-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "final",
Category: "vital-signs",
CodeText: "Body Temperature",
CodeSystem: "http://loinc.org",
CodeCode: "8310-5",
EffectiveDate: "2024-01-15",
ValueQuantity: &qty,
ValueUnit: "°F",
}
id1, err := store.UpsertObservation(obs)
if err != nil {
t.Fatalf("initial UpsertObservation: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
// Update: change value and status.
newQty := 99.1
obs.ValueQuantity = &newQty
obs.Status = "amended"
id2, err := store.UpsertObservation(obs)
if err != nil {
t.Fatalf("update UpsertObservation: %v", err)
}
// ID must be stable across upserts.
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
// Read back and confirm updated fields.
rows, err := store.ListObservations(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListObservations: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 observation, got %d", len(rows))
}
got := rows[0]
if got.Status != "amended" {
t.Errorf("Status: got %q, want %q", got.Status, "amended")
}
if got.ValueQuantity == nil || *got.ValueQuantity != 99.1 {
t.Errorf("ValueQuantity: got %v, want 99.1", got.ValueQuantity)
}
}
func TestUpsertObservation_TenantIsolation(t *testing.T) {
store := newTestStore(t)
makeObs := func(ehrURL string) *models.Observation {
return &models.Observation{
FHIRID: "obs-shared",
EHRURL: ehrURL,
PatientFHIRID: testPatientID,
Status: "final",
}
}
id1, err := store.UpsertObservation(makeObs("https://ehr-a.example.com/fhir"))
if err != nil {
t.Fatalf("upsert A: %v", err)
}
id2, err := store.UpsertObservation(makeObs("https://ehr-b.example.com/fhir"))
if err != nil {
t.Fatalf("upsert B: %v", err)
}
if id1 == id2 {
t.Error("expected different IDs for same fhir_id at different ehr_urls")
}
}
func TestListObservations_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListObservations("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListObservations: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0 rows, got %d", len(rows))
}
}
func TestListObservations_OrderedNewestFirst(t *testing.T) {
store := newTestStore(t)
for _, item := range []struct {
id string
date string
}{
{"obs-a", "2024-01-01"},
{"obs-b", "2024-06-15"},
{"obs-c", "2023-12-31"},
} {
_, err := store.UpsertObservation(&models.Observation{
FHIRID: item.id,
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "final",
EffectiveDate: item.date,
})
if err != nil {
t.Fatalf("UpsertObservation %s: %v", item.id, err)
}
}
rows, err := store.ListObservations(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListObservations: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Newest first: obs-b (Jun) > obs-a (Jan) > obs-c (Dec 2023)
if rows[0].FHIRID != "obs-b" {
t.Errorf("first row: got %q, want obs-b", rows[0].FHIRID)
}
if rows[2].FHIRID != "obs-c" {
t.Errorf("last row: got %q, want obs-c", rows[2].FHIRID)
}
}
// ---------------------------------------------------------------------------
// Condition tests
// ---------------------------------------------------------------------------
func TestUpsertCondition_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
cond := &models.Condition{
FHIRID: "cond-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
ClinicalStatus: "active",
VerificationStatus: "confirmed",
Category: "problem-list-item",
CodeText: "Hypertension",
CodeSystem: "http://snomed.info/sct",
CodeCode: "38341003",
OnsetDate: "2020-03-01",
RecordedDate: "2020-03-05",
}
id1, err := store.UpsertCondition(cond)
if err != nil {
t.Fatalf("initial UpsertCondition: %v", err)
}
// Update clinical status to resolved.
cond.ClinicalStatus = "resolved"
id2, err := store.UpsertCondition(cond)
if err != nil {
t.Fatalf("update UpsertCondition: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListConditions(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListConditions: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 condition, got %d", len(rows))
}
if rows[0].ClinicalStatus != "resolved" {
t.Errorf("ClinicalStatus: got %q, want resolved", rows[0].ClinicalStatus)
}
}
func TestListConditions_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListConditions("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListConditions: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
// ---------------------------------------------------------------------------
// DocumentReference tests
// ---------------------------------------------------------------------------
func TestUpsertDocumentReference_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
doc := &models.DocumentReference{
FHIRID: "doc-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "current",
DocStatus: "final",
TypeText: "Discharge Summary",
TypeSystem: "http://loinc.org",
TypeCode: "18842-5",
Date: "2024-02-10",
Description: "Hospital discharge summary",
ContentType: "text/plain",
ContentURL: "https://ehr.example.com/fhir/Binary/bin-001",
}
id1, err := store.UpsertDocumentReference(doc)
if err != nil {
t.Fatalf("UpsertDocumentReference: %v", err)
}
// Supersede the document.
doc.Status = "superseded"
id2, err := store.UpsertDocumentReference(doc)
if err != nil {
t.Fatalf("update UpsertDocumentReference: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListDocumentReferences(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListDocumentReferences: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 doc, got %d", len(rows))
}
if rows[0].Status != "superseded" {
t.Errorf("Status: got %q, want superseded", rows[0].Status)
}
}
func TestListDocumentReferences_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListDocumentReferences("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListDocumentReferences: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
// ---------------------------------------------------------------------------
// PatientSync tests
// ---------------------------------------------------------------------------
func TestRecordAndLatestSync(t *testing.T) {
store := newTestStore(t)
// No sync yet.
ps, err := store.LatestSync(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("LatestSync (empty): %v", err)
}
if ps != nil {
t.Errorf("expected nil before any sync, got %+v", ps)
}
// Record first sync.
recorded, err := store.RecordSync(testPatientID, testEHRURL, 10, 3, 1)
if err != nil {
t.Fatalf("RecordSync: %v", err)
}
if recorded.ID == "" {
t.Fatal("expected non-empty ID")
}
if recorded.ObsCount != 10 || recorded.CondCount != 3 || recorded.DocCount != 1 {
t.Errorf("counts wrong: %+v", recorded)
}
// LatestSync should now return that record.
latest, err := store.LatestSync(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("LatestSync: %v", err)
}
if latest == nil {
t.Fatal("expected non-nil latest sync")
}
if latest.ID != recorded.ID {
t.Errorf("ID mismatch: got %q, want %q", latest.ID, recorded.ID)
}
// Record a second sync with higher counts; LatestSync must return the newer one.
_, err = store.RecordSync(testPatientID, testEHRURL, 20, 5, 2)
if err != nil {
t.Fatalf("RecordSync second: %v", err)
}
latest2, err := store.LatestSync(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("LatestSync second: %v", err)
}
if latest2.ObsCount != 20 {
t.Errorf("expected ObsCount 20 from latest, got %d", latest2.ObsCount)
}
}
func TestLatestSync_TenantIsolation(t *testing.T) {
store := newTestStore(t)
_, err := store.RecordSync(testPatientID, "https://ehr-a.example.com/fhir", 5, 1, 0)
if err != nil {
t.Fatalf("RecordSync EHR-A: %v", err)
}
// Querying for a different EHR URL should return nil.
ps, err := store.LatestSync(testPatientID, "https://ehr-b.example.com/fhir")
if err != nil {
t.Fatalf("LatestSync EHR-B: %v", err)
}
if ps != nil {
t.Errorf("expected nil for different EHR URL, got %+v", ps)
}
}

421
app/db/db.go Normal file
View File

@@ -0,0 +1,421 @@
// Package db provides the SQLite-backed persistence layer for the platform.
//
// Architecture notes:
// - Uses the standard database/sql interface with a pure-Go SQLite driver
// (modernc.org/sqlite), requiring no CGO.
// - Schema versioning is handled via the schema_migrations table, enabling
// forward-only incremental migrations as the platform grows.
// - All public functions accept a *Store receiver, keeping the DB handle
// encapsulated and allowing easy substitution (e.g., for testing with
// an in-memory SQLite instance).
// - Upsert semantics for users use (fhir_id, ehr_url) as the natural key,
// preventing duplicates across EHR tenants while allowing the same FHIR
// ID to exist at different servers.
package db
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
"github.com/google/uuid"
_ "modernc.org/sqlite" // Register the sqlite driver under the name "sqlite".
)
// Store wraps a database/sql.DB and exposes all persistence operations
// for the platform. It is the single point of DB access — no raw *sql.DB
// handles should escape this package.
type Store struct {
db *sql.DB
}
// New opens (or creates) the SQLite database at the given path, applies
// all pending migrations, and returns a ready-to-use Store.
//
// Use path ":memory:" in tests to get a disposable in-memory database.
func New(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("db: open: %w", err)
}
// SQLite is file-based; a small pool is sufficient.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("db: migrate: %w", err)
}
return s, nil
}
// Close releases the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// ---------------------------------------------------------------------------
// Schema migrations
// ---------------------------------------------------------------------------
// migration represents a single, versioned, forward-only DDL statement.
// New tables and columns must be added as new migrations — never alter
// existing ones, to preserve upgrade safety.
type migration struct {
version int
sql string
}
var migrations = []migration{
{
version: 1,
sql: `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
fhir_resource_type TEXT NOT NULL,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
role TEXT NOT NULL,
first_name TEXT NOT NULL DEFAULT '',
middle_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
dob TEXT NOT NULL DEFAULT '',
gender TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token TEXT NOT NULL DEFAULT '',
ehr_url TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_fhir ON users(fhir_id, ehr_url);
`,
},
{
version: 2,
sql: `
ALTER TABLE sessions ADD COLUMN patient_fhir_id TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN id_token TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT '';
`,
},
{
version: 3,
sql: `
CREATE TABLE IF NOT EXISTS observations (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
code_text TEXT NOT NULL DEFAULT '',
code_system TEXT NOT NULL DEFAULT '',
code_code TEXT NOT NULL DEFAULT '',
effective_date TEXT NOT NULL DEFAULT '',
value_quantity REAL,
value_unit TEXT NOT NULL DEFAULT '',
value_string TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS conditions (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
clinical_status TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
code_text TEXT NOT NULL DEFAULT '',
code_system TEXT NOT NULL DEFAULT '',
code_code TEXT NOT NULL DEFAULT '',
onset_date TEXT NOT NULL DEFAULT '',
recorded_date TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS document_references (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
doc_status TEXT NOT NULL DEFAULT '',
type_text TEXT NOT NULL DEFAULT '',
type_system TEXT NOT NULL DEFAULT '',
type_code TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT '',
content_url TEXT NOT NULL DEFAULT '',
content_data TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE TABLE IF NOT EXISTS patient_syncs (
id TEXT PRIMARY KEY,
patient_fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
obs_count INTEGER NOT NULL DEFAULT 0,
cond_count INTEGER NOT NULL DEFAULT 0,
doc_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_observations_patient ON observations(patient_fhir_id, ehr_url);
CREATE INDEX IF NOT EXISTS idx_conditions_patient ON conditions(patient_fhir_id, ehr_url);
CREATE INDEX IF NOT EXISTS idx_docrefs_patient ON document_references(patient_fhir_id, ehr_url);
CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url);
`,
},
// Future migrations: append new entries here with incrementing version numbers.
// Example:
// {
// version: 2,
// sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`,
// },
}
// migrate applies any migrations that have not yet been run, in order.
func (s *Store) migrate() error {
// Enable WAL mode for better concurrent read performance.
if _, err := s.db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
return fmt.Errorf("set WAL mode: %w", err)
}
// Enable foreign key enforcement (off by default in SQLite).
if _, err := s.db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
// Bootstrap the migrations table if it doesn't exist yet.
if _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`); err != nil {
return fmt.Errorf("bootstrap schema_migrations: %w", err)
}
for _, m := range migrations {
var count int
row := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version)
if err := row.Scan(&count); err != nil {
return fmt.Errorf("check migration v%d: %w", m.version, err)
}
if count > 0 {
continue // Already applied.
}
if _, err := s.db.Exec(m.sql); err != nil {
return fmt.Errorf("apply migration v%d: %w", m.version, err)
}
if _, err := s.db.Exec(`INSERT INTO schema_migrations(version) VALUES(?)`, m.version); err != nil {
return fmt.Errorf("record migration v%d: %w", m.version, err)
}
log.Printf("db: applied migration v%d", m.version)
}
return nil
}
// ---------------------------------------------------------------------------
// User operations
// ---------------------------------------------------------------------------
// UpsertUser inserts a new user or updates the demographics of an existing one,
// matched on the (fhir_id, ehr_url) natural key.
//
// Returns the user's internal UUID (which may be the existing one if the user
// already existed).
func (s *Store) UpsertUser(u *models.User) (string, error) {
now := time.Now().UTC()
// Check if the user already exists to preserve the original created_at
// and internal ID.
var existingID string
err := s.db.QueryRow(
`SELECT id FROM users WHERE fhir_id = ? AND ehr_url = ?`,
u.FHIRID, u.EHRURL,
).Scan(&existingID)
if err == nil {
// User exists — update demographics but preserve ID and created_at.
_, err = s.db.Exec(`
UPDATE users SET
first_name = ?,
middle_name = ?,
last_name = ?,
dob = ?,
gender = ?,
email = ?,
fhir_resource_type = ?,
role = ?,
updated_at = ?
WHERE id = ?`,
u.FirstName, u.MiddleName, u.LastName,
u.DOB, u.Gender, u.Email,
u.FHIRResourceType, string(u.Role),
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update user %s: %w", existingID, err)
}
return existingID, nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("db: lookup user (%s, %s): %w", u.FHIRID, u.EHRURL, err)
}
// New user — generate a fresh internal UUID.
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO users (
id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role),
u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email,
now, now,
)
if err != nil {
return "", fmt.Errorf("db: insert user (fhir_id=%s): %w", u.FHIRID, err)
}
return id, nil
}
// GetUserByFHIRID retrieves a user by their FHIR ID and originating EHR URL.
// Returns sql.ErrNoRows if no matching user is found.
func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
created_at, updated_at
FROM users WHERE fhir_id = ? AND ehr_url = ?`,
fhirID, ehrURL,
).Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// GetUserByID retrieves a user by their internal UUID primary key.
func (s *Store) GetUserByID(id string) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
created_at, updated_at
FROM users WHERE id = ?`, id,
).Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// ---------------------------------------------------------------------------
// Session operations
// ---------------------------------------------------------------------------
// CreateSession creates a new authenticated session for the given user,
// storing the FHIR access token and EHR context. Sessions expire after
// the provided duration from now.
func (s *Store) CreateSession(userID, patientFHIRID, accessToken, idToken, scope, ehrURL string, ttl time.Duration) (*models.Session, error) {
now := time.Now().UTC()
sess := &models.Session{
ID: uuid.NewString(),
UserID: userID,
PatientFHIRID: patientFHIRID,
AccessToken: accessToken,
IDToken: idToken,
Scope: scope,
EHRURL: ehrURL,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
_, err := s.db.Exec(`
INSERT INTO sessions (id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sess.ID, sess.UserID, sess.PatientFHIRID, sess.AccessToken, sess.IDToken, sess.Scope, sess.EHRURL,
sess.CreatedAt, sess.ExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("db: create session for user %s: %w", userID, err)
}
return sess, nil
}
// GetSession retrieves a session by its token ID.
// Returns sql.ErrNoRows if the session does not exist.
func (s *Store) GetSession(id string) (*models.Session, error) {
sess := &models.Session{}
err := s.db.QueryRow(`
SELECT id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at
FROM sessions WHERE id = ?`, id,
).Scan(
&sess.ID, &sess.UserID, &sess.PatientFHIRID, &sess.AccessToken, &sess.IDToken, &sess.Scope, &sess.EHRURL,
&sess.CreatedAt, &sess.ExpiresAt,
)
if err != nil {
return nil, err
}
return sess, nil
}
// DeleteSession removes a session by its token ID. Used during logout.
func (s *Store) DeleteSession(id string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("db: delete session %s: %w", id, err)
}
return nil
}
// DeleteExpiredSessions removes all sessions whose expiry time has passed.
// This should be called periodically (e.g., on startup or via a background
// goroutine) to keep the sessions table lean.
func (s *Store) DeleteExpiredSessions() (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("db: purge expired sessions: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}

256
app/db/db_test.go Normal file
View File

@@ -0,0 +1,256 @@
package db_test
import (
"database/sql"
"testing"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
// newTestStore creates a disposable in-memory SQLite store for testing.
func newTestStore(t *testing.T) *db.Store {
t.Helper()
store, err := db.New(":memory:")
if err != nil {
t.Fatalf("newTestStore: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// ---------------------------------------------------------------------------
// User tests
// ---------------------------------------------------------------------------
func TestUpsertUser_NewUser(t *testing.T) {
store := newTestStore(t)
u := &models.User{
FHIRResourceType: "Patient",
FHIRID: "patient-abc",
EHRURL: "https://ehr.example.com/fhir",
Role: models.RolePatient,
FirstName: "Alice",
MiddleName: "Marie",
LastName: "Smith",
DOB: "1990-04-22",
Gender: "female",
Email: "alice@example.com",
}
id, err := store.UpsertUser(u)
if err != nil {
t.Fatalf("UpsertUser: %v", err)
}
if id == "" {
t.Fatal("expected non-empty ID for new user")
}
// Fetch back and verify.
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
if err != nil {
t.Fatalf("GetUserByFHIRID: %v", err)
}
if got.FirstName != "Alice" {
t.Errorf("FirstName: got %q, want %q", got.FirstName, "Alice")
}
if got.Role != models.RolePatient {
t.Errorf("Role: got %q, want %q", got.Role, models.RolePatient)
}
if got.Email != "alice@example.com" {
t.Errorf("Email: got %q, want %q", got.Email, "alice@example.com")
}
}
func TestUpsertUser_UpdateExisting(t *testing.T) {
store := newTestStore(t)
u := &models.User{
FHIRResourceType: "Patient",
FHIRID: "patient-xyz",
EHRURL: "https://ehr.example.com/fhir",
Role: models.RolePatient,
FirstName: "Bob",
LastName: "Jones",
DOB: "1985-01-01",
Gender: "male",
}
id1, err := store.UpsertUser(u)
if err != nil {
t.Fatalf("initial UpsertUser: %v", err)
}
// Update demographics.
u.Email = "bob.updated@example.com"
u.FirstName = "Robert"
id2, err := store.UpsertUser(u)
if err != nil {
t.Fatalf("update UpsertUser: %v", err)
}
// Internal ID must remain stable across upserts.
if id1 != id2 {
t.Errorf("ID changed on upsert: got %q, was %q", id2, id1)
}
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
if err != nil {
t.Fatalf("GetUserByFHIRID after update: %v", err)
}
if got.FirstName != "Robert" {
t.Errorf("updated FirstName: got %q, want %q", got.FirstName, "Robert")
}
if got.Email != "bob.updated@example.com" {
t.Errorf("updated Email: got %q, want %q", got.Email, "bob.updated@example.com")
}
}
func TestGetUserByFHIRID_NotFound(t *testing.T) {
store := newTestStore(t)
_, err := store.GetUserByFHIRID("nonexistent", "https://ehr.example.com/fhir")
if err != sql.ErrNoRows {
t.Errorf("expected sql.ErrNoRows, got %v", err)
}
}
func TestUpsertUser_TenantIsolation(t *testing.T) {
// The same FHIR ID at two different EHR URLs must produce two separate records.
store := newTestStore(t)
makeUser := func(ehrURL string) *models.User {
return &models.User{
FHIRResourceType: "Patient",
FHIRID: "shared-fhir-id",
EHRURL: ehrURL,
Role: models.RolePatient,
FirstName: "Carol",
LastName: "Tenant",
}
}
id1, err := store.UpsertUser(makeUser("https://ehr-a.example.com/fhir"))
if err != nil {
t.Fatalf("upsert EHR-A: %v", err)
}
id2, err := store.UpsertUser(makeUser("https://ehr-b.example.com/fhir"))
if err != nil {
t.Fatalf("upsert EHR-B: %v", err)
}
if id1 == id2 {
t.Error("expected separate internal IDs for same FHIR ID at different EHR URLs")
}
}
// ---------------------------------------------------------------------------
// Session tests
// ---------------------------------------------------------------------------
func TestCreateAndGetSession(t *testing.T) {
store := newTestStore(t)
// Create a practitioner user first.
practUser := &models.User{
FHIRResourceType: "Practitioner",
FHIRID: "pract-001",
EHRURL: "https://ehr.example.com/fhir",
Role: models.RolePractitioner,
FirstName: "Dr. Emily",
LastName: "Chen",
}
userID, err := store.UpsertUser(practUser)
if err != nil {
t.Fatalf("UpsertUser practitioner: %v", err)
}
// Create a session.
sess, err := store.CreateSession(userID, "patient-001", "access-token-xyz", "id-token-abc", "openid profile", "https://ehr.example.com/fhir", 8*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if sess.ID == "" {
t.Fatal("expected non-empty session ID")
}
// Retrieve the session.
got, err := store.GetSession(sess.ID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.UserID != userID {
t.Errorf("UserID: got %q, want %q", got.UserID, userID)
}
if got.AccessToken != "access-token-xyz" {
t.Errorf("AccessToken: got %q, want %q", got.AccessToken, "access-token-xyz")
}
if got.ExpiresAt.Before(time.Now()) {
t.Error("session should not be expired immediately after creation")
}
}
func TestDeleteSession(t *testing.T) {
store := newTestStore(t)
userID, err := store.UpsertUser(&models.User{
FHIRResourceType: "Practitioner",
FHIRID: "pract-delete",
EHRURL: "https://ehr.example.com/fhir",
Role: models.RolePractitioner,
FirstName: "Test",
LastName: "Delete",
})
if err != nil {
t.Fatalf("UpsertUser: %v", err)
}
sess, err := store.CreateSession(userID, "pat", "tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if err := store.DeleteSession(sess.ID); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
_, err = store.GetSession(sess.ID)
if err != sql.ErrNoRows {
t.Errorf("expected sql.ErrNoRows after deletion, got %v", err)
}
}
func TestDeleteExpiredSessions(t *testing.T) {
store := newTestStore(t)
userID, err := store.UpsertUser(&models.User{
FHIRResourceType: "Practitioner",
FHIRID: "pract-expire",
EHRURL: "https://ehr.example.com/fhir",
Role: models.RolePractitioner,
FirstName: "Expire",
LastName: "Test",
})
if err != nil {
t.Fatalf("UpsertUser: %v", err)
}
// Create one valid and one already-expired session.
_, err = store.CreateSession(userID, "pat", "valid-tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
if err != nil {
t.Fatalf("CreateSession (valid): %v", err)
}
_, err = store.CreateSession(userID, "pat", "expired-tok", "id", "scope", "https://ehr.example.com/fhir", -1*time.Second) // already past
if err != nil {
t.Fatalf("CreateSession (expired): %v", err)
}
n, err := store.DeleteExpiredSessions()
if err != nil {
t.Fatalf("DeleteExpiredSessions: %v", err)
}
if n != 1 {
t.Errorf("deleted %d expired sessions, want 1", n)
}
}