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…
' + + '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 += '| Field | '; + html += 'HRS (Local) | '; + html += 'Rimidi (Remote) | '; + html += 'Match | '; + html += '
|---|---|---|---|
| ' + fieldLabels[key] + ' | '; + html += '' + escapeHtml(localVal) + ' | '; + html += '' + escapeHtml(remoteVal) + ' | '; + html += '' + icon + ' | '; + html += '