mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 19:56:17 -04:00
Patient Match
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user