mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
350 lines
12 KiB
Go
350 lines
12 KiB
Go
// 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 (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)
|
||
}
|
||
|
||
// 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)
|
||
}
|