diff --git a/app/config/config.go b/app/config/config.go index 2e18fc6..138bc54 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -21,6 +21,18 @@ type AppConfig struct { // DBPath is the file path for the SQLite database. // Use ":memory:" for in-process testing. DBPath string + + // PatientMatchAPIKey is the pre-shared key that external systems must + // provide in the X-Api-Key header when calling POST /api/patient-match. + PatientMatchAPIKey string + + // PatientMatchRemoteURL is the URL of the remote (Rimidi/Provider) patient + // match API that the proxy handler calls on behalf of the logged-in user. + PatientMatchRemoteURL string + + // PatientMatchRemoteAPIKey is the API key sent in the X-Api-Key header + // when calling the remote (Rimidi/Provider) patient match API. + PatientMatchRemoteAPIKey string } // ServerConfig holds HTTP server settings. diff --git a/app/db/db.go b/app/db/db.go index 075d246..a9c54af 100644 --- a/app/db/db.go +++ b/app/db/db.go @@ -495,6 +495,38 @@ func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, return users, rows.Err() } +// ListAllPatients returns every user with role='patient' across all EHR +// tenants. Used by the patient-match API which needs to compare against +// the full patient population. +func (s *Store) ListAllPatients() ([]models.User, error) { + rows, err := s.db.Query(` + SELECT id, fhir_resource_type, fhir_id, ehr_url, role, + first_name, middle_name, last_name, mrn, dob, gender, email, + created_at, updated_at + FROM users WHERE role = ? + ORDER BY last_name ASC, first_name ASC`, + string(models.RolePatient), + ) + if err != nil { + return nil, fmt.Errorf("db: list all patients: %w", err) + } + defer rows.Close() + + var users []models.User + for rows.Next() { + var u models.User + if err := rows.Scan( + &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, + &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan patient: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + // --------------------------------------------------------------------------- // Session operations // --------------------------------------------------------------------------- diff --git a/app/handlers/match.go b/app/handlers/match.go new file mode 100644 index 0000000..a741297 --- /dev/null +++ b/app/handlers/match.go @@ -0,0 +1,205 @@ +// Package handlers — match.go implements the patient match API endpoint. +// +// POST /api/patient-match +// +// This endpoint accepts demographic identifiers (first_name, last_name, +// email, dob, sex) and returns a list of patients in the FHIR Sandbox +// that match ≥ 2 fields exactly. Auth is via X-Api-Key header. +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "sort" + "strings" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/models" +) + +// patientMatchRequest is the inbound JSON shape for a match query. +type patientMatchRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + DOB string `json:"dob"` + Sex string `json:"sex"` +} + +// fieldResult describes whether a single field matched and what the +// remote system's value is. +type fieldResult struct { + Value string `json:"value"` + Match bool `json:"match"` +} + +// matchResult is one potential patient match in the response. +type matchResult struct { + PatientRef string `json:"patient_ref"` + Score int `json:"score"` + Fields map[string]fieldResult `json:"fields"` +} + +// patientMatchResponse is the top-level response shape. +type patientMatchResponse struct { + SourceSystem string `json:"source_system"` + Matches []matchResult `json:"matches"` +} + +// Minimum number of exact field matches required to include a patient. +const minMatchScore = 2 + +// sexNormMap normalises FHIR/Provider gender codes → canonical M/F/O/U. +var sexNormMap = map[string]string{ + "m": "M", + "f": "F", + "o": "O", + "u": "U", + "male": "M", + "female": "F", + "other": "O", + "unknown": "U", +} + +// normName lowercases and trims a name string. +func normName(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// normSex normalises a sex/gender value to M/F/O/U. +func normSex(s string) string { + v, ok := sexNormMap[strings.ToLower(strings.TrimSpace(s))] + if !ok { + return "" + } + return v +} + +// normDOB trims and returns the DOB string (expected YYYY-MM-DD). +func normDOB(s string) string { return strings.TrimSpace(s) } + +// computeMatchScore compares normalised criteria against a candidate user. +// Returns the score (0–5) and per-field report. +func computeMatchScore(criteria, candidate map[string]string) (int, map[string]fieldResult) { + fields := make(map[string]fieldResult, 5) + score := 0 + + for _, f := range []string{"first_name", "last_name", "email", "dob", "sex"} { + cVal := criteria[f] + pVal := candidate[f] + + isMatch := cVal != "" && pVal != "" && cVal == pVal + if isMatch { + score++ + } + fields[f] = fieldResult{Value: pVal, Match: isMatch} + } + return score, fields +} + +// normaliseUser converts a User model into a normalised string map. +func normaliseUser(u *models.User) map[string]string { + return map[string]string{ + "first_name": normName(u.FirstName), + "last_name": normName(u.LastName), + "email": normName(u.Email), + "dob": normDOB(u.DOB), + "sex": normSex(u.Gender), + } +} + +// normaliseCriteria converts a match request into a normalised string map. +func normaliseCriteria(req *patientMatchRequest) map[string]string { + return map[string]string{ + "first_name": normName(req.FirstName), + "last_name": normName(req.LastName), + "email": normName(req.Email), + "dob": normDOB(req.DOB), + "sex": normSex(req.Sex), + } +} + +// HandlePatientMatch processes POST /api/patient-match requests. +func (h *Handler) HandlePatientMatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + // ── Decode request body ───────────────────────────────────────── + var req patientMatchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest) + return + } + + // ── Validate required fields ──────────────────────────────────── + var missing []string + if strings.TrimSpace(req.FirstName) == "" { + missing = append(missing, "first_name") + } + if strings.TrimSpace(req.LastName) == "" { + missing = append(missing, "last_name") + } + if strings.TrimSpace(req.DOB) == "" { + missing = append(missing, "dob") + } + if strings.TrimSpace(req.Sex) == "" { + missing = append(missing, "sex") + } + if len(missing) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing required fields: " + strings.Join(missing, ", "), + }) + return + } + + criteria := normaliseCriteria(&req) + + // ── Load all patients from the database ───────────────────────── + patients, err := h.store.ListAllPatients() + if err != nil { + log.Printf("handlers: HandlePatientMatch ListAllPatients failed: %v", err) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + return + } + + // ── Match loop ────────────────────────────────────────────────── + var matches []matchResult + for i := range patients { + candidate := normaliseUser(&patients[i]) + score, fields := computeMatchScore(criteria, candidate) + + if score >= minMatchScore { + matches = append(matches, matchResult{ + PatientRef: patients[i].ID, // internal UUID — opaque to caller + Score: score, + Fields: fields, + }) + } + } + + // Sort by score descending, then patient_ref for deterministic order. + sort.Slice(matches, func(i, j int) bool { + if matches[i].Score != matches[j].Score { + return matches[i].Score > matches[j].Score + } + return matches[i].PatientRef < matches[j].PatientRef + }) + + log.Printf("handlers: patient-match candidates=%d matches=%d", len(patients), len(matches)) + + // ── Write response ────────────────────────────────────────────── + resp := patientMatchResponse{ + SourceSystem: "hrs", + Matches: matches, + } + // Ensure matches is never null in JSON + if resp.Matches == nil { + resp.Matches = []matchResult{} + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} diff --git a/app/handlers/match_proxy.go b/app/handlers/match_proxy.go new file mode 100644 index 0000000..08de687 --- /dev/null +++ b/app/handlers/match_proxy.go @@ -0,0 +1,152 @@ +// Package handlers — match_proxy.go implements the browser-facing +// "Find in Rimidi" proxy endpoint. +// +// POST /api/patient-match-proxy +// +// This endpoint is session-protected. It reads the current patient's +// demographics from the local database, calls the remote Rimidi patient +// match API, and returns the response to the browser. +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" +) + +// proxyTimeout is the HTTP client timeout for the outbound match call. +const proxyTimeout = 10 * time.Second + +// matchProxyResponse wraps the remote response and adds the local patient's +// demographics so the UI can render a side-by-side diff. +type matchProxyResponse struct { + SourceSystem string `json:"source_system"` + Matches []matchResult `json:"matches"` + LocalPatient map[string]string `json:"local_patient"` + Raw map[string]interface{} `json:"-"` // internal only +} + +// HandlePatientMatchProxy handles POST /api/patient-match-proxy. +// It requires a valid session and reads the patient from session context. +func (h *Handler) HandlePatientMatchProxy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + sess := middleware.SessionFromContext(r.Context()) + if sess == nil { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // ── Resolve patient from the session or query param ───────────── + patientFHIRID := sess.PatientFHIRID + if override := r.URL.Query().Get("patient_id"); override != "" { + patientFHIRID = override + } + + if patientFHIRID == "" { + http.Error(w, `{"error":"no patient in context"}`, http.StatusBadRequest) + return + } + + // Fetch patient demographics from the local database. + patient, err := h.store.GetUserByFHIRID(patientFHIRID, sess.EHRURL) + if err != nil { + log.Printf("handlers: match-proxy GetUserByFHIRID(%s): %v", patientFHIRID, err) + http.Error(w, `{"error":"patient not found in local database"}`, http.StatusNotFound) + return + } + + // ── Build outbound payload ────────────────────────────────────── + payload := patientMatchRequest{ + FirstName: patient.FirstName, + LastName: patient.LastName, + Email: patient.Email, + DOB: patient.DOB, + Sex: patient.Gender, + } + + body, err := json.Marshal(payload) + if err != nil { + log.Printf("handlers: match-proxy marshal payload: %v", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + // ── Call remote Rimidi match API ───────────────────────────────── + remoteURL := h.cfg.PatientMatchRemoteURL + remoteKey := h.cfg.PatientMatchRemoteAPIKey + + if remoteURL == "" || remoteKey == "" { + log.Printf("handlers: match-proxy remote URL/key not configured") + http.Error(w, `{"error":"remote patient match not configured"}`, http.StatusServiceUnavailable) + return + } + + client := &http.Client{Timeout: proxyTimeout} + req, err := http.NewRequest(http.MethodPost, remoteURL, bytes.NewReader(body)) + if err != nil { + log.Printf("handlers: match-proxy new request: %v", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", remoteKey) + + resp, err := client.Do(req) + if err != nil { + log.Printf("handlers: match-proxy remote call failed: %v", err) + http.Error(w, `{"error":"could not reach remote system"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("handlers: match-proxy read response: %v", err) + http.Error(w, `{"error":"failed to read remote response"}`, http.StatusBadGateway) + return + } + + if resp.StatusCode != http.StatusOK { + log.Printf("handlers: match-proxy remote returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + http.Error(w, fmt.Sprintf(`{"error":"remote system returned HTTP %d"}`, resp.StatusCode), http.StatusBadGateway) + return + } + + // ── Parse remote response and augment with local demographics ─── + var remoteData map[string]interface{} + if err := json.Unmarshal(respBody, &remoteData); err != nil { + log.Printf("handlers: match-proxy unmarshal response: %v", err) + http.Error(w, `{"error":"invalid response from remote system"}`, http.StatusBadGateway) + return + } + + // Attach local patient demographics for side-by-side diff rendering. + remoteData["local_patient"] = map[string]string{ + "first_name": patient.FirstName, + "last_name": patient.LastName, + "email": patient.Email, + "dob": patient.DOB, + "sex": patient.Gender, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(remoteData) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/app/handlers/match_test.go b/app/handlers/match_test.go new file mode 100644 index 0000000..517fe79 --- /dev/null +++ b/app/handlers/match_test.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "testing" +) + +func TestNormName(t *testing.T) { + tests := []struct { + input, want string + }{ + {" Jane ", "jane"}, + {"SMITH", "smith"}, + {"", ""}, + {" Bob ", "bob"}, + } + for _, tt := range tests { + got := normName(tt.input) + if got != tt.want { + t.Errorf("normName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNormSex(t *testing.T) { + tests := []struct { + input, want string + }{ + {"M", "M"}, + {"F", "F"}, + {"m", "M"}, + {"male", "M"}, + {"female", "F"}, + {"other", "O"}, + {"unknown", "U"}, + {"MALE", "M"}, + {"X", ""}, + {"", ""}, + } + for _, tt := range tests { + got := normSex(tt.input) + if got != tt.want { + t.Errorf("normSex(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestComputeMatchScore_AllMatch(t *testing.T) { + a := map[string]string{ + "first_name": "jane", + "last_name": "smith", + "email": "j@test.com", + "dob": "1990-04-22", + "sex": "F", + } + score, fields := computeMatchScore(a, a) + if score != 5 { + t.Errorf("expected score=5, got %d", score) + } + for k, f := range fields { + if !f.Match { + t.Errorf("expected field %q to match", k) + } + } +} + +func TestComputeMatchScore_NoneMatch(t *testing.T) { + a := map[string]string{ + "first_name": "jane", + "last_name": "smith", + "email": "j@test.com", + "dob": "1990-04-22", + "sex": "F", + } + b := map[string]string{ + "first_name": "bob", + "last_name": "jones", + "email": "b@test.com", + "dob": "1985-01-01", + "sex": "M", + } + score, _ := computeMatchScore(a, b) + if score != 0 { + t.Errorf("expected score=0, got %d", score) + } +} + +func TestComputeMatchScore_EmptyDoesNotMatch(t *testing.T) { + a := map[string]string{ + "first_name": "jane", + "last_name": "smith", + "email": "", + "dob": "", + "sex": "", + } + b := map[string]string{ + "first_name": "jane", + "last_name": "smith", + "email": "", + "dob": "", + "sex": "", + } + score, _ := computeMatchScore(a, b) + if score != 2 { + t.Errorf("expected score=2 (only name fields), got %d", score) + } +} + +func TestComputeMatchScore_PartialMatch(t *testing.T) { + a := map[string]string{ + "first_name": "jane", + "last_name": "smith", + "email": "a@test.com", + "dob": "1990-04-22", + "sex": "F", + } + b := map[string]string{ + "first_name": "john", + "last_name": "smith", + "email": "b@test.com", + "dob": "1990-04-22", + "sex": "M", + } + score, fields := computeMatchScore(a, b) + if score != 2 { + t.Errorf("expected score=2 (last_name + dob), got %d", score) + } + if !fields["last_name"].Match { + t.Error("expected last_name to match") + } + if !fields["dob"].Match { + t.Error("expected dob to match") + } + if fields["first_name"].Match { + t.Error("expected first_name to NOT match") + } +} diff --git a/app/main.go b/app/main.go index baa3c0c..a83cee6 100644 --- a/app/main.go +++ b/app/main.go @@ -48,6 +48,11 @@ func main() { }, // Add additional EHR configurations here as needed. }, + PatientMatchAPIKey: "hRsMatch.Yk4mN8wQ2xR7vJ3pT5hB9fU1dA6sC0eL", + + // Remote (Rimidi/Provider) patient match integration. + PatientMatchRemoteURL: "http://localhost:2222/cshub/api/patient-match/", + PatientMatchRemoteAPIKey: "pMaTcH.XkR9wQzL5vJ3nT7hB2fY8dU4mA6sC1eP0gW", } // ------------------------------------------------------------------------- @@ -94,12 +99,19 @@ func main() { mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("app/static")))) + // API routes — protected by API key middleware (machine-to-machine). + apiKeyMW := middleware.NewAPIKeyMiddleware(cfg.PatientMatchAPIKey) + mux.Handle("/api/patient-match", apiKeyMW.Wrap(http.HandlerFunc(h.HandlePatientMatch))) + // Session-required routes — wrapped with the hard-gate middleware. mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard))) mux.Handle("/dashboard/sync", sessionMW.RequireSession(http.HandlerFunc(h.HandleSync))) mux.Handle("/patients", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatients))) mux.Handle("/logout", sessionMW.RequireSession(http.HandlerFunc(h.HandleLogout))) + // Session-required API — "Find in Rimidi" proxy for the dashboard UI. + mux.Handle("/api/patient-match-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatientMatchProxy))) + // Apply the soft session loader to every request so templates can always // read the current user from context. root := sessionMW.LoadSession(mux) diff --git a/app/middleware/apikey.go b/app/middleware/apikey.go new file mode 100644 index 0000000..3b92e4e --- /dev/null +++ b/app/middleware/apikey.go @@ -0,0 +1,40 @@ +// Package middleware — apikey.go provides API key authentication for +// machine-to-machine endpoints (e.g., /api/patient-match). +// +// The middleware reads the X-Api-Key header and compares it against +// a configured expected value. It is applied only to /api/* routes +// via the route registration in main.go. +package middleware + +import ( + "net/http" +) + +// APIKeyMiddleware validates the X-Api-Key header on incoming requests. +type APIKeyMiddleware struct { + expectedKey string +} + +// NewAPIKeyMiddleware creates a middleware that gates requests behind +// the given API key. +func NewAPIKeyMiddleware(expectedKey string) *APIKeyMiddleware { + return &APIKeyMiddleware{expectedKey: expectedKey} +} + +// Wrap returns an http.Handler that checks for a valid API key before +// delegating to the wrapped handler. Returns 401 if the key is missing +// and 403 if the key is incorrect. +func (m *APIKeyMiddleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-Api-Key") + if key == "" { + http.Error(w, `{"error":"missing API key"}`, http.StatusUnauthorized) + return + } + if key != m.expectedKey { + http.Error(w, `{"error":"invalid API key"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 78773a7..aa20bf2 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -63,6 +63,10 @@ Refresh Chart + Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}} @@ -469,5 +473,137 @@ function onSyncClick() { // Helper for splitting scopes string function split(s, sep) { return s.split(sep); } + +/* ── "Find in Rimidi" match modal ────────────────────────────────── */ + +function findInRimidi(patientFHIRID) { + var overlay = document.getElementById('rimidiMatchOverlay'); + var body = document.getElementById('rimidiMatchBody'); + overlay.style.display = 'flex'; + body.innerHTML = + '
' + + '
' + + '

Searching Rimidi…

'; + + var url = '/api/patient-match-proxy'; + if (patientFHIRID) { + url += '?patient_id=' + encodeURIComponent(patientFHIRID); + } + + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(function(resp) { + if (!resp.ok) { + return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); }); + } + return resp.json(); + }) + .then(function(data) { + renderRimidiMatchResults(body, data); + }) + .catch(function(err) { + body.innerHTML = + '
' + + 'Error: ' + escapeHtml(err.message) + '
'; + }); +} + +function closeRimidiMatch() { + document.getElementById('rimidiMatchOverlay').style.display = 'none'; +} + +function escapeHtml(s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +function renderRimidiMatchResults(container, data) { + var matches = data.matches || []; + var local = data.local_patient || {}; + + if (matches.length === 0) { + container.innerHTML = + '
' + + 'No matching patients found in the Rimidi system.
'; + return; + } + + var html = '
'; + html += '

' + + 'Found ' + matches.length + ' potential match' + + (matches.length > 1 ? 'es' : '') + ' in Rimidi.

'; + + var fieldLabels = { + first_name: 'First Name', last_name: 'Last Name', + email: 'Email', dob: 'Date of Birth', sex: 'Sex' + }; + var fieldOrder = ['first_name', 'last_name', 'email', 'dob', 'sex']; + + for (var i = 0; i < matches.length; i++) { + var m = matches[i]; + var f = m.fields || {}; + var borderColor = m.score >= 4 ? 'var(--color-success)' : (m.score >= 3 ? 'var(--color-warning, #f0ad4e)' : 'var(--color-brand)'); + var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue'); + + html += '
'; + html += '
'; + html += 'Match #' + (i + 1) + ''; + html += '' + m.score + '/5 fields match'; + html += '
'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + for (var j = 0; j < fieldOrder.length; j++) { + var key = fieldOrder[j]; + var fld = f[key] || {}; + var localVal = local[key] || '—'; + var remoteVal = fld.value || '—'; + var isMatch = fld.match === true; + var icon = isMatch + ? '' + : ''; + var rowBg = isMatch ? '' : 'background:rgba(240,173,78,0.1);'; + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + + html += '
FieldHRS (Local)Rimidi (Remote)Match
' + fieldLabels[key] + '' + escapeHtml(localVal) + '' + escapeHtml(remoteVal) + '' + icon + '
'; + } + + html += '
'; + container.innerHTML = html; +} + + + {{end}} diff --git a/docs/patient-match-api.md b/docs/patient-match-api.md new file mode 100644 index 0000000..eb0495f --- /dev/null +++ b/docs/patient-match-api.md @@ -0,0 +1,159 @@ +# Bidirectional Patient-Matching API — Rimidi ↔ HRS (FHIR Sandbox) + +## Overview + +Rimidi was acquired by HRS, another healthcare company. Both platforms need to +discover overlapping patients so clinical staff in either system can see which +patients also exist in the other. This document defines the shared API contract, +matching algorithm, auth model, and phased rollout plan. + +For the **demo** the FHIR Sandbox plays the role of HRS. + +--- + +## Shared JSON Contract + +Both systems expose `POST /api/patient-match/` behind API-key auth. + +### Request + +```json +{ + "first_name": "Jane", + "last_name": "Smith", + "email": "jane.smith@email.com", + "dob": "1990-04-22", + "sex": "F" +} +``` + +| Field | Type | Required | Notes | +|--------------|--------|----------|----------------------------------------| +| `first_name` | string | yes | Will be lowercased & trimmed | +| `last_name` | string | yes | Will be lowercased & trimmed | +| `email` | string | no | Will be lowercased & trimmed | +| `dob` | string | yes | ISO 8601 `YYYY-MM-DD` | +| `sex` | string | yes | Normalized: `M`/`F`/`O`/`U` | + +### Response + +```json +{ + "source_system": "rimidi", + "matches": [ + { + "patient_ref": "opaque-signed-token", + "score": 4, + "fields": { + "first_name": { "value": "Jane", "match": true }, + "last_name": { "value": "Smith", "match": true }, + "email": { "value": "jane.smith@email.com", "match": true }, + "dob": { "value": "1990-04-22", "match": true }, + "sex": { "value": "F", "match": false } + } + } + ] +} +``` + +| Field | Notes | +|----------------------------|-------------------------------------------------| +| `source_system` | `"rimidi"` or `"hrs"` — identifies the responder | +| `matches[].patient_ref` | Opaque token — never exposes raw DB PK | +| `matches[].score` | Count of exactly matching fields (2–5) | +| `matches[].fields.*.value` | The **remote** system's value for this field | +| `matches[].fields.*.match` | Whether the field matched exactly | + +### Rules + +- Only patients with **≥ 2 exact field matches** are returned. +- Comparisons are **case-insensitive, whitespace-stripped**. +- Sex is normalized before comparison: + - Provider: `"M"` / `"F"` → canonical `"M"` / `"F"` / `"O"` / `"U"` + - Sandbox: `"male"` / `"female"` / `"other"` / `"unknown"` → `"M"` / `"F"` / `"O"` / `"U"` +- DOB is always ISO 8601 `"YYYY-MM-DD"`. +- Results are sorted by `score` descending. +- `patient_ref` is an HMAC-signed / Django-signed token of the internal PK. + +--- + +## Auth Strategy + +Both systems validate a **pre-shared API key** in the `X-Api-Key` header. + +- **Provider** adds `PATIENT_MATCH_API_KEY` to Django settings. +- **FHIR Sandbox** adds a `PatientMatchAPIKey` config field and an API-key + middleware that applies to `/api/*` routes only. +- Keys are **directional** — each system holds the key for the *other* system. + +--- + +## Field Mapping + +| Provider (`RimidiUser`) | FHIR Sandbox (`User`) | Normalization | +|----------------------------|------------------------|------------------------| +| `first_name` (encrypted) | `first_name` | lowercase + trim | +| `last_name` (encrypted) | `last_name` | lowercase + trim | +| `email` (encrypted) | `email` | lowercase + trim | +| `birth_date` (encrypted) | `dob` (string) | both → `YYYY-MM-DD` | +| `sex` (`"M"` / `"F"`) | `gender` (FHIR codes) | both → `M/F/O/U` | + +--- + +## Critical Constraint: Provider PII Encryption + +All matchable demographic fields in Provider (`first_name`, `last_name`, `email`, +`birth_date`, `sex`) are **AES-encrypted** at the column level via +`django-encrypted-model-fields`. No SQL-level filtering is possible. + +Matching must be done in **Python application memory**: load all patients for the +provider, decrypt them via Django ORM, and compare. This works for typical +provider panels (hundreds to low-thousands of patients). + +--- + +## Phased Rollout + +### Phase 1 — Contract, Auth & Field Normalization (2–3 days) + +- Lock the shared JSON schema (this document). +- Add `PATIENT_MATCH_API_KEY` to Provider settings (cs_hub app). +- Add API-key middleware + stub handler in FHIR Sandbox. +- Build field normalization utilities in both systems (unit-testable). + +### Phase 2 — Provider Patient Match API (3–4 days) + +- Full `POST /cshub/api/patient-match/` endpoint in `cs_hub`. +- In-memory matching loop (decrypt all patients for the provider, compare). +- Opaque `patient_ref` via Django `TimestampSigner`. +- Unit tests for matching logic, normalization, and auth. + +### Phase 3 — FHIR Sandbox Patient Match API (2–3 days) + +- `POST /api/patient-match` endpoint in Sandbox. +- `ListAllPatients()` DB query for matching. +- API-key middleware. +- Unit tests. + +### Phase 4 — Provider UI: "Find in HRS" (3–4 days) + +- Proxy endpoint `POST /cshub/api/patient-match-proxy/` (session-auth, calls Sandbox). +- "Find in HRS" button on patient chart. +- Match diff modal showing per-field comparison. + +### Phase 5 — Sandbox UI: "Find in Rimidi" (2–3 days) + +- Proxy handler (session-required, calls Provider). +- "Find in Rimidi" button on dashboard. +- Diff panel with field-level comparison. + +--- + +## Future Considerations + +1. **Deterministic hash columns** for scale (SHA-256 of lowercased fields) to + avoid full-table decrypt at Provider panels > 5k patients. +2. **Multi-instance Rimidi** — HRS fans out to multiple Rimidi deployments. + `org_key` + per-instance API key handles this. +3. **Patient linking** — a future `PatientCrossReference` model to explicitly + link records after a match is confirmed by a human.