diff --git a/app/config/config.go b/app/config/config.go index 138bc54..cbdeefa 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -33,6 +33,18 @@ type AppConfig struct { // PatientMatchRemoteAPIKey is the API key sent in the X-Api-Key header // when calling the remote (Rimidi/Provider) patient match API. PatientMatchRemoteAPIKey string + + // RimidiAppID is the identifier for the Rimidi app instance. + // Used when storing confirmed patient matches. + RimidiAppID string + + // RimidiCGMAPIURL is the base URL for the Rimidi CGM preview API. + // e.g., "http://localhost:2222/cshub/privateadmin/patients" + RimidiCGMAPIURL string + + // RimidiInternalAPIKey is the API key used to authenticate with the Rimidi + // CGM API (INTERNAL_APP_SYNC_KEY from Provider settings). + RimidiInternalAPIKey string } // ServerConfig holds HTTP server settings. diff --git a/app/db/db.go b/app/db/db.go index a9c54af..c3db317 100644 --- a/app/db/db.go +++ b/app/db/db.go @@ -314,6 +314,25 @@ var migrations = []migration{ ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT ''; `, }, + { + version: 10, + sql: ` + CREATE TABLE IF NOT EXISTS patient_matches ( + id TEXT PRIMARY KEY, + hrs_patient_fhir_id TEXT NOT NULL, + hrs_ehr_url TEXT NOT NULL, + rimidi_app_id TEXT NOT NULL, + rimidi_patient_pk TEXT NOT NULL, + rimidi_patient_ref TEXT NOT NULL, + confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, rimidi_patient_pk) + ); + + CREATE INDEX IF NOT EXISTS idx_patient_matches_fhir ON patient_matches(hrs_patient_fhir_id, hrs_ehr_url); + `, + }, } // migrate applies any migrations that have not yet been run, in order. @@ -597,3 +616,128 @@ func (s *Store) DeleteExpiredSessions() (int64, error) { n, _ := res.RowsAffected() return n, nil } + +// --------------------------------------------------------------------------- +// Patient match operations +// --------------------------------------------------------------------------- + +// UpsertPatientMatch inserts a new patient match or updates an existing one. +// The natural key is (hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, rimidi_patient_pk). +func (s *Store) UpsertPatientMatch(match *models.PatientMatch) error { + now := time.Now().UTC() + + // Check if match already exists + var existingID string + err := s.db.QueryRow( + `SELECT id FROM patient_matches + WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ? AND rimidi_app_id = ? AND rimidi_patient_pk = ?`, + match.HRSPatientFHIRID, match.HRSEHRURL, match.RimidiAppID, match.RimidiPatientPK, + ).Scan(&existingID) + + if err == nil { + // Match exists — update it + _, err = s.db.Exec(` + UPDATE patient_matches SET + rimidi_patient_ref = ?, + confirmed_at = ?, + updated_at = ? + WHERE id = ?`, + match.RimidiPatientRef, now, now, existingID, + ) + if err != nil { + return fmt.Errorf("db: update patient match %s: %w", existingID, err) + } + match.ID = existingID + match.ConfirmedAt = now + match.UpdatedAt = now + return nil + } + + if err != sql.ErrNoRows { + return fmt.Errorf("db: lookup patient match: %w", err) + } + + // New match — generate a fresh internal UUID + match.ID = uuid.NewString() + match.ConfirmedAt = now + match.CreatedAt = now + match.UpdatedAt = now + + _, err = s.db.Exec(` + INSERT INTO patient_matches ( + id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, + rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + match.ID, match.HRSPatientFHIRID, match.HRSEHRURL, match.RimidiAppID, + match.RimidiPatientPK, match.RimidiPatientRef, match.ConfirmedAt, match.CreatedAt, match.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("db: insert patient match: %w", err) + } + return nil +} + +// GetPatientMatchByFHIRID retrieves the most recent confirmed match for a patient +// by their FHIR ID and EHR URL. Returns sql.ErrNoRows if no match is found. +func (s *Store) GetPatientMatchByFHIRID(fhirID, ehrURL string) (*models.PatientMatch, error) { + m := &models.PatientMatch{} + err := s.db.QueryRow(` + SELECT id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, + rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at + FROM patient_matches + WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ? + ORDER BY confirmed_at DESC + LIMIT 1`, + fhirID, ehrURL, + ).Scan( + &m.ID, &m.HRSPatientFHIRID, &m.HRSEHRURL, &m.RimidiAppID, + &m.RimidiPatientPK, &m.RimidiPatientRef, &m.ConfirmedAt, &m.CreatedAt, &m.UpdatedAt, + ) + if err != nil { + return nil, err + } + return m, nil +} + +// ListPatientMatchesByFHIRID retrieves all confirmed matches for a patient +// by their FHIR ID and EHR URL, ordered by most recent first. +func (s *Store) ListPatientMatchesByFHIRID(fhirID, ehrURL string) ([]models.PatientMatch, error) { + rows, err := s.db.Query(` + SELECT id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, + rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at + FROM patient_matches + WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ? + ORDER BY confirmed_at DESC`, + fhirID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list patient matches: %w", err) + } + defer rows.Close() + + var matches []models.PatientMatch + for rows.Next() { + var m models.PatientMatch + if err := rows.Scan( + &m.ID, &m.HRSPatientFHIRID, &m.HRSEHRURL, &m.RimidiAppID, + &m.RimidiPatientPK, &m.RimidiPatientRef, &m.ConfirmedAt, &m.CreatedAt, &m.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan patient match: %w", err) + } + matches = append(matches, m) + } + return matches, rows.Err() +} + +// DeletePatientMatch removes the confirmed match for a patient. +func (s *Store) DeletePatientMatch(fhirID, ehrURL string) error { + _, err := s.db.Exec(` + DELETE FROM patient_matches + WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ?`, + fhirID, ehrURL, + ) + if err != nil { + return fmt.Errorf("db: delete patient match: %w", err) + } + return nil +} diff --git a/app/handlers/cgm.go b/app/handlers/cgm.go new file mode 100644 index 0000000..cdbd373 --- /dev/null +++ b/app/handlers/cgm.go @@ -0,0 +1,125 @@ +// Package handlers — cgm.go implements the CGM preview proxy endpoint. +// +// GET /api/cgm-preview?patient_id= +// +// This endpoint proxies requests to the Rimidi Provider CGM API to display +// CGM data for a patient that has been matched between HRS and Rimidi. +package handlers + +import ( + "encoding/json" + "io" + "log" + "net/http" + "time" + + "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" +) + +// proxyTimeout is the HTTP client timeout for the outbound CGM API call. +const cgmProxyTimeout = 15 * time.Second + +// HandleCGMPreview handles GET /api/cgm-preview requests. +// It requires a valid session and looks up the confirmed match for the patient, +// then proxies the request to the Rimidi Provider CGM API. +func (h *Handler) HandleCGMPreview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + 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 + } + + // ── Look up confirmed match for this patient ─────────────────── + match, err := h.store.GetPatientMatchByFHIRID(patientFHIRID, sess.EHRURL) + if err != nil { + log.Printf("handlers: cgm-preview GetPatientMatchByFHIRID(%s, %s): %v", patientFHIRID, sess.EHRURL, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "No confirmed patient match found. Please confirm a match first.", + }) + return + } + + // ── Build Rimidi CGM API URL ─────────────────────────────────── + rimidiURL := h.cfg.RimidiCGMAPIURL + rimidiKey := h.cfg.RimidiInternalAPIKey + + if rimidiURL == "" || rimidiKey == "" { + log.Printf("handlers: cgm-preview Rimidi CGM API not configured") + http.Error(w, `{"error":"CGM API not configured"}`, http.StatusServiceUnavailable) + return + } + + // Construct the full URL: {base_url}/{patient_pk}/cgm-preview/ + cgmURL := rimidiURL + "/" + match.RimidiPatientPK + "/cgm-preview/" + + // ── Call Rimidi CGM API ──────────────────────────────────────── + client := &http.Client{Timeout: cgmProxyTimeout} + req, err := http.NewRequest(http.MethodGet, cgmURL, nil) + if err != nil { + log.Printf("handlers: cgm-preview new request: %v", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + req.Header.Set("X-Api-Key", rimidiKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("handlers: cgm-preview remote call failed: %v", err) + http.Error(w, `{"error":"could not reach Rimidi CGM API"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("handlers: cgm-preview read response: %v", err) + http.Error(w, `{"error":"failed to read remote response"}`, http.StatusBadGateway) + return + } + + if resp.StatusCode != http.StatusOK { + bodyLen := len(respBody) + if bodyLen > 500 { + bodyLen = 500 + } + log.Printf("handlers: cgm-preview remote returned %d: %s", resp.StatusCode, string(respBody[:bodyLen])) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(respBody) + return + } + + // ── Parse and forward the response ────────────────────────────── + var cgmData map[string]interface{} + if err := json.Unmarshal(respBody, &cgmData); err != nil { + log.Printf("handlers: cgm-preview unmarshal response: %v", err) + http.Error(w, `{"error":"invalid response from Rimidi CGM API"}`, http.StatusBadGateway) + return + } + + // Reports now come with presigned S3 URLs, no transformation needed + + // Forward the response as-is + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(cgmData) +} diff --git a/app/handlers/cgm_report_proxy.go b/app/handlers/cgm_report_proxy.go new file mode 100644 index 0000000..16a8fd1 --- /dev/null +++ b/app/handlers/cgm_report_proxy.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "io" + "log" + "net/http" + "time" +) + +const cgmReportProxyTimeout = 30 * time.Second + +// HandleCGMReportProxy proxies CGM report requests (PDFs and images) from Rimidi Provider. +// This allows the browser to access reports without needing to handle API keys directly. +func (h *Handler) HandleCGMReportProxy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get the target URL from query parameter + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + http.Error(w, "Missing 'url' query parameter", http.StatusBadRequest) + return + } + + // Validate that the URL is from the configured Rimidi CGM API + rimidiBaseURL := h.cfg.RimidiCGMAPIURL + if rimidiBaseURL == "" { + http.Error(w, "CGM API not configured", http.StatusServiceUnavailable) + return + } + + // Extract base URL (remove /{patient_pk}/cgm-preview/) + baseURL := rimidiBaseURL + if len(baseURL) > 20 { + // Remove trailing path components to get base URL + lastSlash := -1 + for i := len(baseURL) - 1; i >= 0; i-- { + if baseURL[i] == '/' { + lastSlash = i + if i > 0 && baseURL[i-1] != '/' { + break + } + } + } + if lastSlash > 0 { + baseURL = baseURL[:lastSlash] + } + } + // Remove /privateadmin/patients if present + if len(baseURL) > 20 && baseURL[len(baseURL)-20:] == "/privateadmin/patients" { + baseURL = baseURL[:len(baseURL)-20] + } + // Add /cshub if not present + if len(baseURL) < 7 || baseURL[len(baseURL)-7:] != "/cshub" { + baseURL = baseURL + "/cshub" + } + + // Check if target URL starts with the expected base + expectedPrefix := baseURL + "/api/cgm-report-proxy/" + if len(targetURL) < len(expectedPrefix) || targetURL[:len(expectedPrefix)] != expectedPrefix { + log.Printf("handlers: cgm-report-proxy invalid URL prefix: %s (expected: %s)", targetURL, expectedPrefix) + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + // Call Rimidi Provider API with internal API key + client := &http.Client{Timeout: cgmReportProxyTimeout} + req, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + log.Printf("handlers: cgm-report-proxy new request: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + req.Header.Set("X-Api-Key", h.cfg.RimidiInternalAPIKey) + + resp, err := client.Do(req) + if err != nil { + log.Printf("handlers: cgm-report-proxy remote call failed: %v", err) + http.Error(w, "Could not fetch report", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Set status code + w.WriteHeader(resp.StatusCode) + + // Copy response body + if _, err := io.Copy(w, resp.Body); err != nil { + log.Printf("handlers: cgm-report-proxy copy response: %v", err) + return + } +} diff --git a/app/handlers/dashboard.go b/app/handlers/dashboard.go index c541b24..03db163 100644 --- a/app/handlers/dashboard.go +++ b/app/handlers/dashboard.go @@ -1,8 +1,11 @@ package handlers import ( + "encoding/json" + "io" "log" "net/http" + "strings" "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" @@ -151,6 +154,16 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { summary := buildClinicalSummary(observations, conditions, medications) synced := r.URL.Query().Get("synced") == "true" + // ── Check for confirmed Rimidi patient match ──────────────────── + var rimidiMatch *models.PatientMatch + var rimidiPatientKey string + match, err := h.store.GetPatientMatchByFHIRID(patientID, ehrURL) + if err == nil { + rimidiMatch = match + // Fetch the signed patient key from Rimidi + rimidiPatientKey = h.fetchRimidiPatientKey(match.RimidiPatientPK) + } + h.render(w, "dashboard.html", dashboardData{ Patient: patientUser, Practitioner: practitionerUser, @@ -167,6 +180,8 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { LatestSync: latestSync, Session: sess, Synced: synced, + RimidiMatch: rimidiMatch, + RimidiPatientKey: rimidiPatientKey, }) } @@ -187,6 +202,75 @@ type dashboardData struct { LatestSync *models.PatientSync Session *models.Session Synced bool + RimidiMatch *models.PatientMatch + RimidiPatientKey string +} + +// fetchRimidiPatientKey fetches the signed patient key from Rimidi Provider API. +// Returns empty string if the fetch fails (non-blocking). +func (h *Handler) fetchRimidiPatientKey(patientPK string) string { + if patientPK == "" { + return "" + } + + // Build the Rimidi patient key API URL + // PatientMatchRemoteURL is like "http://localhost:2222/cshub/api/patient-match/" + rimidiBaseURL := h.cfg.PatientMatchRemoteURL + if rimidiBaseURL == "" { + return "" + } + + // Replace "/api/patient-match/" with "/api/patient-key/{pk}/" + keyURL := strings.Replace(rimidiBaseURL, "/api/patient-match/", "/api/patient-key/"+patientPK+"/", 1) + log.Printf("handlers: fetchRimidiPatientKey constructing URL: %s (from base: %s)", keyURL, rimidiBaseURL) + + // Call Rimidi API to get signed patient key + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, keyURL, nil) + if err != nil { + log.Printf("handlers: fetchRimidiPatientKey new request: %v", err) + return "" + } + // Use internal API key (same as CGM API) for consistency + req.Header.Set("X-Api-Key", h.cfg.RimidiInternalAPIKey) + + resp, err := client.Do(req) + if err != nil { + log.Printf("handlers: fetchRimidiPatientKey remote call failed: %v", err) + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("handlers: fetchRimidiPatientKey read response: %v", err) + return "" + } + + if resp.StatusCode != http.StatusOK { + bodyPreview := "" + if len(body) > 0 { + if len(body) > 200 { + bodyPreview = string(body[:200]) + } else { + bodyPreview = string(body) + } + } + log.Printf("handlers: fetchRimidiPatientKey remote returned %d for URL %s, body: %s", resp.StatusCode, keyURL, bodyPreview) + return "" + } + + var keyData map[string]interface{} + if err := json.Unmarshal(body, &keyData); err != nil { + log.Printf("handlers: fetchRimidiPatientKey unmarshal response: %v", err) + return "" + } + + if patientKey, ok := keyData["patient_key"].(string); ok { + return patientKey + } + + return "" } // handleUnauthorized redirects to root for dashboard requests. diff --git a/app/handlers/match.go b/app/handlers/match.go index a741297..d7158f4 100644 --- a/app/handlers/match.go +++ b/app/handlers/match.go @@ -13,8 +13,11 @@ import ( "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. @@ -203,3 +206,144 @@ func (h *Handler) HandlePatientMatch(w http.ResponseWriter, r *http.Request) { 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) +} diff --git a/app/main.go b/app/main.go index a83cee6..9cb5c6d 100644 --- a/app/main.go +++ b/app/main.go @@ -53,6 +53,13 @@ func main() { // Remote (Rimidi/Provider) patient match integration. PatientMatchRemoteURL: "http://localhost:2222/cshub/api/patient-match/", PatientMatchRemoteAPIKey: "pMaTcH.XkR9wQzL5vJ3nT7hB2fY8dU4mA6sC1eP0gW", + + // Rimidi app identifier for patient matches. + RimidiAppID: "demo-app", + + // Rimidi CGM API integration. + RimidiCGMAPIURL: "http://localhost:2222/cshub/privateadmin/patients", + RimidiInternalAPIKey: "PYRXAcHV.ltOJRxYjoSrBNKXZNhu5fWalWXJesvbx", } // ------------------------------------------------------------------------- @@ -112,6 +119,16 @@ func main() { // Session-required API — "Find in Rimidi" proxy for the dashboard UI. mux.Handle("/api/patient-match-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatientMatchProxy))) + // Session-required API — Patient match confirmation. + mux.Handle("/api/patient-match/confirm", sessionMW.RequireSession(http.HandlerFunc(h.HandleConfirmMatch))) + + // Session-required API — Patient match unlink. + mux.Handle("/api/patient-match/unlink", sessionMW.RequireSession(http.HandlerFunc(h.HandleUnlinkMatch))) + + // Session-required API — CGM preview proxy. + mux.Handle("/api/cgm-preview", sessionMW.RequireSession(http.HandlerFunc(h.HandleCGMPreview))) + mux.Handle("/api/cgm-report-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandleCGMReportProxy))) + // 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/models/models.go b/app/models/models.go index 76e55d2..1f4482f 100644 --- a/app/models/models.go +++ b/app/models/models.go @@ -258,3 +258,16 @@ type PatientSync struct { CondCount int `json:"cond_count" db:"cond_count"` DocCount int `json:"doc_count" db:"doc_count"` } + +// PatientMatch records a confirmed patient match between HRS (FHIR Sandbox) and Rimidi Provider. +type PatientMatch struct { + ID string `json:"id" db:"id"` + HRSPatientFHIRID string `json:"hrs_patient_fhir_id" db:"hrs_patient_fhir_id"` + HRSEHRURL string `json:"hrs_ehr_url" db:"hrs_ehr_url"` + RimidiAppID string `json:"rimidi_app_id" db:"rimidi_app_id"` + RimidiPatientPK string `json:"rimidi_patient_pk" db:"rimidi_patient_pk"` + RimidiPatientRef string `json:"rimidi_patient_ref" db:"rimidi_patient_ref"` + ConfirmedAt time.Time `json:"confirmed_at" db:"confirmed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 68a58bf..c016f97 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -177,6 +177,31 @@ tr:hover td { background-color: #FAFAFB; } } .tab-btn:hover { color: var(--text-main); } .tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); } +.tab-btn.cgm-tab { + color: #e91e63; + border-bottom: 3px solid transparent; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +.tab-btn.cgm-tab:hover { + color: #c2185b; + border-bottom-color: rgba(233, 30, 99, 0.4); +} +.tab-btn.cgm-tab.active { + color: #e91e63; + border-bottom-color: #e91e63; + font-weight: 600; + position: relative; +} +.tab-btn.cgm-tab.active::after { + content: ''; + position: absolute; + bottom: -3px; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #e91e63 0%, #f06292 50%, #e91e63 100%); + border-radius: 2px 2px 0 0; +} .tab-content { display: none; animation: fadeIn 0.3s ease; } .tab-content.active { display: block; } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index aa20bf2..bf6c2a0 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -63,10 +63,28 @@ Refresh Chart + {{if .RimidiMatch}} + {{if .RimidiPatientKey}} + + + Launch in Rimidi + + {{else}} + + {{end}} + + {{else}} + {{end}} Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}} @@ -99,6 +117,9 @@
+ {{if .RimidiMatch}} + + {{end}} @@ -161,6 +182,13 @@
+ {{/* ---- CGM Tab ---- */}} + {{if .RimidiMatch}} +
+
+
+ {{end}} + {{/* ---- Vitals Tab ---- */}}
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}} @@ -461,6 +489,15 @@ function openTab(evt, tabName) { var target = document.getElementById(tabName); setTimeout(() => target.classList.add("active"), 10); evt.currentTarget.classList.add("active"); + + // Load CGM data when CGM tab is opened + if (tabName === 'cgm') { + var cgmContent = document.getElementById('cgmTabContent'); + if (cgmContent && !cgmContent.dataset.loaded) { + var patientFHIRID = '{{.Patient.FHIRID}}'; + loadCGMData(patientFHIRID); + } + } } function onSyncClick() { @@ -501,7 +538,7 @@ function findInRimidi(patientFHIRID) { return resp.json(); }) .then(function(data) { - renderRimidiMatchResults(body, data); + renderRimidiMatchResults(body, data, patientFHIRID); }) .catch(function(err) { body.innerHTML = @@ -520,21 +557,25 @@ function escapeHtml(s) { return d.innerHTML; } -function renderRimidiMatchResults(container, data) { +function renderRimidiMatchResults(container, data, patientFHIRID) { var matches = data.matches || []; var local = data.local_patient || {}; + patientFHIRID = patientFHIRID || ''; if (matches.length === 0) { container.innerHTML = - '
' + - 'No matching patients found in the Rimidi system.
'; + '
' + + '' + + '

No matching patients found in the Rimidi system.

'; return; } - var html = '
'; - html += '

' + - 'Found ' + matches.length + ' potential match' + + var html = '

'; + html += '
'; + html += '

' + + 'Found ' + matches.length + ' potential match' + (matches.length > 1 ? 'es' : '') + ' in Rimidi.

'; + html += '
'; var fieldLabels = { first_name: 'First Name', last_name: 'Last Name', @@ -545,21 +586,26 @@ function renderRimidiMatchResults(container, data) { 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 borderColor = m.score >= 4 ? 'var(--success)' : (m.score >= 3 ? 'var(--warning)' : 'var(--accent-blue)'); var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue'); + var matchQuality = m.score >= 4 ? 'High' : (m.score >= 3 ? 'Medium' : 'Low'); - html += '
'; - html += '
'; - html += 'Match #' + (i + 1) + ''; - html += '' + m.score + '/5 fields match'; + html += '
'; + html += '
'; + html += '
'; + html += 'Match #' + (i + 1) + ''; + html += '' + m.score + '/5 fields match'; + html += '(' + matchQuality + ' confidence)'; + html += '
'; html += '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; + html += '
'; + html += '
FieldHRS (Local)Rimidi (Remote)Match
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; html += ''; for (var j = 0; j < fieldOrder.length; j++) { @@ -568,42 +614,661 @@ function renderRimidiMatchResults(container, data) { var localVal = local[key] || '—'; var remoteVal = fld.value || '—'; var isMatch = fld.match === true; - var icon = isMatch - ? '' - : ''; - var rowBg = isMatch ? '' : 'background:rgba(240,173,78,0.1);'; + + // Enhanced visual indicators + var matchIcon = isMatch + ? '' + : ''; + + var rowStyle = isMatch + ? 'background:var(--success-bg);border-left:3px solid var(--success);' + : 'background:var(--warning-bg);border-left:3px solid var(--warning);'; + + var matchBadge = isMatch + ? 'Match' + : 'Mismatch'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; html += ''; } - html += '
FieldHRS (Local)Rimidi (Remote)Status
' + fieldLabels[key] + '' + escapeHtml(localVal) + '' + escapeHtml(remoteVal) + '' + icon + '
' + fieldLabels[key] + '' + escapeHtml(localVal) + '' + escapeHtml(remoteVal) + '' + matchBadge + '
'; + html += ''; + html += '
'; + + // Add Confirm Match button + var patientPK = m.patient_pk || m.patient_ref; + html += '
'; + html += ''; + html += '
'; + html += '
'; } html += '
'; container.innerHTML = html; } + +function confirmMatch(patientFHIRID, rimidiPatientRef, rimidiPatientPK, matchIndex) { + var btn = document.getElementById('confirm-btn-' + matchIndex); + if (!btn) return; + + var originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Confirming...'; + + fetch('/api/patient-match/confirm?patient_id=' + encodeURIComponent(patientFHIRID), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rimidi_patient_ref: rimidiPatientRef, + rimidi_patient_pk: rimidiPatientPK + }) + }) + .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) { + if (data.success) { + // Show success message + var body = document.getElementById('rimidiMatchBody'); + body.innerHTML = + '
' + + '
' + + '

Match Confirmed!

' + + '

The patient match has been successfully confirmed.

' + + '' + + '
'; + } else { + throw new Error(data.message || 'Confirmation failed'); + } + }) + .catch(function(err) { + btn.disabled = false; + btn.innerHTML = originalText; + alert('Error confirming match: ' + err.message); + }); +} -