mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 19:56:17 -04:00
chore: remove DB and binary from repo, add .gitignore to exclude artifacts
This commit is contained in:
421
app/db/db.go
Normal file
421
app/db/db.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user