Patient Match

This commit is contained in:
2026-02-24 00:26:55 -05:00
parent c25b6e0c80
commit 51115c9a64
9 changed files with 884 additions and 0 deletions

View File

@@ -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.

View File

@@ -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
// ---------------------------------------------------------------------------

205
app/handlers/match.go Normal file
View File

@@ -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 (05) 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)
}

152
app/handlers/match_proxy.go Normal file
View File

@@ -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
}

136
app/handlers/match_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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)

40
app/middleware/apikey.go Normal file
View File

@@ -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)
})
}

View File

@@ -63,6 +63,10 @@
Refresh Chart
</button>
</form>
<button class="btn btn-outline" type="button" onclick="findInRimidi('{{.Patient.FHIRID}}')" title="Search for this patient in the Rimidi system">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Find in Rimidi
</button>
<span class="text-xs text-muted">
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
</span>
@@ -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 =
'<div style="text-align:center;padding:40px;">' +
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
'<p style="margin-top:12px;color:var(--color-text-muted);">Searching Rimidi…</p></div>';
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 =
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
});
}
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 =
'<div style="padding:20px;text-align:center;color:var(--color-text-muted);">' +
'No matching patients found in the Rimidi system.</div>';
return;
}
var html = '<div style="padding:16px;">';
html += '<p style="margin-bottom:16px;color:var(--color-text-muted);">' +
'Found <strong>' + matches.length + '</strong> potential match' +
(matches.length > 1 ? 'es' : '') + ' in Rimidi.</p>';
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 += '<div class="panel" style="margin-bottom:16px;border-left:4px solid ' + borderColor + ';padding:0;">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--color-border-soft);">';
html += '<strong>Match #' + (i + 1) + '</strong>';
html += '<span class="badge ' + badgeClass + '">' + m.score + '/5 fields match</span>';
html += '</div>';
html += '<table style="width:100%;border-collapse:collapse;">';
html += '<thead><tr style="background:var(--color-bg-panel);">';
html += '<th style="padding:8px 12px;text-align:left;width:25%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Field</th>';
html += '<th style="padding:8px 12px;text-align:left;width:30%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">HRS (Local)</th>';
html += '<th style="padding:8px 12px;text-align:left;width:30%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Rimidi (Remote)</th>';
html += '<th style="padding:8px 12px;text-align:center;width:15%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Match</th>';
html += '</tr></thead><tbody>';
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
? '<span style="color:var(--color-success);font-size:16px;">✓</span>'
: '<span style="color:var(--color-danger);font-size:16px;">✗</span>';
var rowBg = isMatch ? '' : 'background:rgba(240,173,78,0.1);';
html += '<tr style="border-top:1px solid var(--color-border-soft);' + rowBg + '">';
html += '<td style="padding:8px 12px;font-weight:600;">' + fieldLabels[key] + '</td>';
html += '<td style="padding:8px 12px;">' + escapeHtml(localVal) + '</td>';
html += '<td style="padding:8px 12px;">' + escapeHtml(remoteVal) + '</td>';
html += '<td style="padding:8px 12px;text-align:center;">' + icon + '</td>';
html += '</tr>';
}
html += '</tbody></table></div>';
}
html += '</div>';
container.innerHTML = html;
}
</script>
<!-- Rimidi Patient Match Modal Overlay -->
<div id="rimidiMatchOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
<div style="background:var(--color-bg-main);border-radius:12px;width:90%;max-width:700px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--color-border-soft);">
<h3 style="margin:0;font-size:18px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Find Patient in Rimidi
</h3>
<button onclick="closeRimidiMatch()" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--color-text-muted);padding:0 4px;">&times;</button>
</div>
<div id="rimidiMatchBody" style="overflow-y:auto;flex:1;">
</div>
<div style="padding:12px 20px;border-top:1px solid var(--color-border-soft);text-align:right;">
<button class="btn btn-secondary" onclick="closeRimidiMatch()">Close</button>
</div>
</div>
</div>
{{end}}

159
docs/patient-match-api.md Normal file
View File

@@ -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 (25) |
| `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 (23 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 (34 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 (23 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" (34 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" (23 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.