Files
FHIR-Sandbox/app/handlers/match.go

350 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
"github.com/google/uuid"
)
// 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)
}
// confirmMatchRequest is the inbound JSON shape for a match confirmation.
type confirmMatchRequest struct {
RimidiPatientRef string `json:"rimidi_patient_ref"`
RimidiPatientPK string `json:"rimidi_patient_pk"`
}
// confirmMatchResponse is the response shape for match confirmation.
type confirmMatchResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Match *models.PatientMatch `json:"match,omitempty"`
}
// HandleConfirmMatch processes POST /api/patient-match/confirm requests.
// It requires a valid session and stores the confirmed match in the database.
func (h *Handler) HandleConfirmMatch(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
}
// ── Decode request body ─────────────────────────────────────────
var req confirmMatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
return
}
// ── Validate required fields ────────────────────────────────────
if strings.TrimSpace(req.RimidiPatientRef) == "" {
http.Error(w, `{"error":"rimidi_patient_ref is required"}`, http.StatusBadRequest)
return
}
if strings.TrimSpace(req.RimidiPatientPK) == "" {
http.Error(w, `{"error":"rimidi_patient_pk is required"}`, http.StatusBadRequest)
return
}
// ── Get Rimidi app ID from config ──────────────────────────────
rimidiAppID := h.cfg.RimidiAppID
if rimidiAppID == "" {
rimidiAppID = "demo-app" // Default for demo
}
// ── Create patient match record ─────────────────────────────────
match := &models.PatientMatch{
ID: uuid.NewString(),
HRSPatientFHIRID: patientFHIRID,
HRSEHRURL: sess.EHRURL,
RimidiAppID: rimidiAppID,
RimidiPatientPK: req.RimidiPatientPK,
RimidiPatientRef: req.RimidiPatientRef,
ConfirmedAt: time.Now().UTC(),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
// ── Store match in database ────────────────────────────────────
if err := h.store.UpsertPatientMatch(match); err != nil {
log.Printf("handlers: confirm-match UpsertPatientMatch failed: %v", err)
http.Error(w, `{"error":"failed to store match"}`, http.StatusInternalServerError)
return
}
log.Printf("handlers: confirmed patient match: hrs_patient=%s rimidi_pk=%s", patientFHIRID, req.RimidiPatientPK)
// ── Write success response ─────────────────────────────────────
resp := confirmMatchResponse{
Success: true,
Message: "Patient match confirmed successfully",
Match: match,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
// HandleUnlinkMatch processes DELETE /api/patient-match/unlink requests.
// It requires a valid session and removes the confirmed match for the patient.
func (h *Handler) HandleUnlinkMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete && 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 query param or session ────────────────
patientFHIRID := r.URL.Query().Get("patient_id")
if patientFHIRID == "" {
patientFHIRID = sess.PatientFHIRID
}
if patientFHIRID == "" {
http.Error(w, `{"error":"no patient in context"}`, http.StatusBadRequest)
return
}
// ── Delete the match ──────────────────────────────────────────
err := h.store.DeletePatientMatch(patientFHIRID, sess.EHRURL)
if err != nil {
log.Printf("handlers: unlink-match DeletePatientMatch(%s, %s): %v", patientFHIRID, sess.EHRURL, err)
http.Error(w, `{"error":"failed to unlink patient"}`, http.StatusInternalServerError)
return
}
log.Printf("handlers: unlinked patient match: hrs_patient=%s", patientFHIRID)
// ── Write success response ─────────────────────────────────────
resp := map[string]interface{}{
"success": true,
"message": "Patient unlinked successfully",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}