mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
Patient Match
This commit is contained in:
@@ -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.
|
||||
|
||||
32
app/db/db.go
32
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
205
app/handlers/match.go
Normal file
205
app/handlers/match.go
Normal 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 (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)
|
||||
}
|
||||
152
app/handlers/match_proxy.go
Normal file
152
app/handlers/match_proxy.go
Normal 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
136
app/handlers/match_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
12
app/main.go
12
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)
|
||||
|
||||
40
app/middleware/apikey.go
Normal file
40
app/middleware/apikey.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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;">×</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
159
docs/patient-match-api.md
Normal 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 (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.
|
||||
Reference in New Issue
Block a user