chore: remove DB and binary from repo, add .gitignore to exclude artifacts

This commit is contained in:
2026-02-20 15:28:41 -05:00
parent 5d34942588
commit 63cb2b7b1c
25 changed files with 4376 additions and 421 deletions

257
app/handlers/auth.go Normal file
View File

@@ -0,0 +1,257 @@
// auth.go handles the OAuth2 authorization callback, token exchange,
// FHIR resource fetching, user upsert, and session creation.
//
// Flow (continued from launch.go):
// 1. EHR calls GET /auth-redirect?code=<auth_code>&state=<state_token>
// 2. Recover launch context from the state store (validates state, prevents CSRF).
// 3. Fetch the EHR's token endpoint from SMART discovery.
// 4. Exchange the authorization code for an access token.
// 5. Fetch the Patient FHIR resource using the access token.
// 6. Resolve the practitioner from the token response. The SMART spec allows
// the practitioner to appear in two places — we handle both:
// a. tokenResp.Practitioner — a bare FHIR ID (some EHRs)
// b. tokenResp.User — a relative reference "Practitioner/<id>" (SmartHealthIT)
// 7. Upsert both users into the database.
// 8. Create a server-side session for the HCP and set the session cookie.
// 9. Render the patient dashboard.
package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
// HandleAuthRedirect processes the SMART on FHIR authorization callback.
// GET /auth-redirect?code=<authorization_code>&state=<state_token>
func (h *Handler) HandleAuthRedirect(w http.ResponseWriter, r *http.Request) {
log.Printf("handlers: auth redirect query=%v", r.URL.Query())
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
h.renderError(w, http.StatusBadRequest, "Missing code or state parameter.")
return
}
// Recover and validate the launch context from the server-side state store.
// This is the CSRF protection — the state token is single-use and time-limited.
lc, ok := globalStateStore.get(state)
if !ok {
h.renderError(w, http.StatusBadRequest, "Invalid or expired state parameter.")
return
}
// Confirm the EHR is still registered (config could theoretically change).
ehrConfig := h.cfg.EHRByURL(lc.ISS)
if ehrConfig == nil {
h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.")
return
}
// Fetch the SMART discovery document to get the token endpoint.
smartCfg, err := fhir.GetSmartConfiguration(lc.ISS)
if err != nil {
log.Printf("handlers: SMART discovery failed for iss=%q: %v", lc.ISS, err)
h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration.")
return
}
if smartCfg.TokenEndpoint == "" {
h.renderError(w, http.StatusBadGateway, "SMART configuration missing token_endpoint.")
return
}
// Exchange the authorization code for an access token.
tokenResp, err := exchangeCode(
smartCfg.TokenEndpoint,
ehrConfig.ClientID,
ehrConfig.ClientSecret,
h.cfg.SMART.RedirectURL,
code,
)
if err != nil {
log.Printf("handlers: token exchange failed: %v", err)
h.renderError(w, http.StatusBadGateway, "Failed to exchange authorization code for token.")
return
}
if tokenResp.AccessToken == "" {
h.renderError(w, http.StatusBadGateway, "Token response missing access_token.")
return
}
if tokenResp.Patient == "" {
h.renderError(w, http.StatusBadGateway, "Token response missing patient context.")
return
}
log.Printf("handlers: token response patient=%q practitioner=%q user=%q",
tokenResp.Patient, tokenResp.Practitioner, tokenResp.User)
// Build a typed FHIR client for subsequent resource calls.
fhirClient := fhir.NewClient(lc.ISS, tokenResp.AccessToken)
// --- Fetch and upsert the Patient ---
patient, err := fhirClient.GetPatient(tokenResp.Patient)
if err != nil {
log.Printf("handlers: fetch Patient/%s failed: %v", tokenResp.Patient, err)
h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details.")
return
}
patientUser := fhir.ExtractUserFromPatient(patient, lc.ISS)
patientInternalID, err := h.store.UpsertUser(patientUser)
if err != nil {
log.Printf("handlers: upsert patient failed: %v", err)
h.renderError(w, http.StatusInternalServerError, "Failed to persist patient record.")
return
}
log.Printf("handlers: upserted patient fhir_id=%s internal_id=%s", patient.ID, patientInternalID)
// --- Resolve the practitioner FHIR ID ---
// The SMART spec allows the practitioner to be communicated in several ways:
// 1. tokenResp.Practitioner — a bare FHIR resource ID
// 2. tokenResp.User — a relative reference (e.g. "Practitioner/123")
// 3. id_token.fhirUser — a relative or absolute URL (OIDC standard)
//
// We check them in order of specificity.
practitionerFHIRID := tokenResp.Practitioner
if practitionerFHIRID == "" {
// Try the legacy "user" field.
practitionerFHIRID = parsePractitionerFromUserField(tokenResp.User)
}
if practitionerFHIRID == "" && tokenResp.IDToken != "" {
// Try the OIDC fhirUser claim.
fhirUserClaim := fhir.ParseFHIRUserFromIDToken(tokenResp.IDToken)
log.Printf("handlers: inspecting id_token fhirUser=%q", fhirUserClaim)
practitionerFHIRID = parsePractitionerFromUserField(fhirUserClaim)
}
// --- Resolve the Practitioner or fallback to Patient for the session ---
var sessionUserID string
var practitionerUser *models.User
if practitionerFHIRID != "" {
practitioner, err := fhirClient.GetPractitioner(practitionerFHIRID)
if err != nil {
// Non-fatal: log and continue.
log.Printf("handlers: fetch Practitioner/%s failed (non-fatal): %v", practitionerFHIRID, err)
} else {
practitionerUser = fhir.ExtractUserFromPractitioner(practitioner, lc.ISS)
practInternalID, err := h.store.UpsertUser(practitionerUser)
if err != nil {
log.Printf("handlers: upsert practitioner failed: %v", err)
h.renderError(w, http.StatusInternalServerError, "Failed to persist practitioner record.")
return
}
log.Printf("handlers: upserted practitioner fhir_id=%s internal_id=%s", practitioner.ID, practInternalID)
sessionUserID = practInternalID
}
}
// If no practitioner was resolved, fallback to the patient's identity to
// establish a session (common in patient-facing or testing flows).
if sessionUserID == "" {
log.Printf("handlers: no practitioner identity found — falling back to patient identity for session")
sessionUserID = patientInternalID
practitionerUser = patientUser // For the UI to show who is "logged in"
}
// --- Create session ---
if sessionUserID != "" {
sess, err := h.store.CreateSession(sessionUserID, tokenResp.Patient, tokenResp.AccessToken, tokenResp.IDToken, tokenResp.Scope, lc.ISS, 8*time.Hour)
if err != nil {
log.Printf("handlers: create session failed: %v", err)
h.renderError(w, http.StatusInternalServerError, "Failed to create session.")
return
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.ID,
Path: "/",
MaxAge: SessionTTL,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
log.Printf("handlers: session created id=%s for practitioner user_id=%s", sess.ID, sessionUserID)
}
// --- Redirect to the stable dashboard ---
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// parsePractitionerFromUserField extracts a bare Practitioner FHIR ID from a
// SMART "user" claim or fhirUser OIDC claim.
// The input may be:
// - A bare ID (if the context implies it): "123"
// - A relative reference: "Practitioner/123"
// - An absolute FHIR URL: "https://ehr.com/fhir/Practitioner/123"
// Returns an empty string if the value is not a Practitioner reference.
func parsePractitionerFromUserField(user string) string {
// If it's a URL, take the path part.
if strings.HasPrefix(user, "http") {
u, err := url.Parse(user)
if err == nil {
user = u.Path
}
}
// Remove leading slashes if any.
user = strings.TrimLeft(user, "/")
const prefix = "Practitioner/"
// We check for the prefix anywhere in the path to handle potential sub-paths.
if idx := strings.Index(user, prefix); idx != -1 {
return strings.TrimPrefix(user[idx:], prefix)
}
return ""
}
// exchangeCode performs the OAuth2 authorization_code token exchange.
// Returns an error on any network failure or non-200 HTTP status.
func exchangeCode(tokenEndpoint, clientID, clientSecret, redirectURI, code string) (*fhir.TokenResponse, error) {
formData := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
}
req, err := http.NewRequest(http.MethodPost, tokenEndpoint, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, clientSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read token response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp fhir.TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("decode token response: %w", err)
}
return &tokenResp, nil
}

89
app/handlers/dashboard.go Normal file
View File

@@ -0,0 +1,89 @@
package handlers
import (
"log"
"net/http"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
// HandleDashboard renders the stable patient dashboard.
// All clinical data is read from the local database; no live FHIR calls are
// made here. Use POST /dashboard/sync to refresh data from the EHR.
//
// GET /dashboard
func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
sess := middleware.SessionFromContext(r.Context())
practitionerUser := middleware.UserFromContext(r.Context())
if sess == nil || practitionerUser == nil {
h.handleUnauthorized(w, r)
return
}
ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID
// Fetch patient demographics from the FHIR server. This is a cheap single
// resource call and keeps the patient card always current.
fhirClient := fhir.NewClient(ehrURL, sess.AccessToken)
patient, err := fhirClient.GetPatient(patientID)
if err != nil {
log.Printf("handlers: dashboard fetch Patient/%s failed: %v", patientID, err)
h.renderError(w, http.StatusBadGateway, "Failed to fetch patient details from the FHIR server.")
return
}
patientUser := fhir.ExtractUserFromPatient(patient, ehrURL)
// Read clinical data from the local database.
observations, err := h.store.ListObservations(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err)
// Non-fatal; render with empty slice.
}
conditions, err := h.store.ListConditions(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListConditions Patient/%s: %v", patientID, err)
}
docRefs, err := h.store.ListDocumentReferences(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListDocumentReferences Patient/%s: %v", patientID, err)
}
latestSync, err := h.store.LatestSync(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard LatestSync Patient/%s: %v", patientID, err)
}
h.render(w, "dashboard.html", dashboardData{
Patient: patientUser,
Practitioner: practitionerUser,
RawPatient: patient,
Observations: observations,
Conditions: conditions,
DocumentReferences: docRefs,
LatestSync: latestSync,
Session: sess,
})
}
// dashboardData is the view model passed to the dashboard template.
type dashboardData struct {
Patient *models.User
Practitioner *models.User
RawPatient *fhir.Patient
Observations []models.Observation
Conditions []models.Condition
DocumentReferences []models.DocumentReference
LatestSync *models.PatientSync
Session *models.Session
}
// handleUnauthorized redirects to root for dashboard requests.
func (h *Handler) handleUnauthorized(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}

156
app/handlers/handler.go Normal file
View File

@@ -0,0 +1,156 @@
// Package handlers contains all HTTP handler implementations for the platform.
//
// Handler design:
// - All handlers are methods on *Handler, which aggregates all dependencies
// (store, config, templateFS). This avoids package-level globals and makes
// dependencies explicit and testable.
// - Handlers do not perform FHIR API calls directly; they delegate to the
// fhir package. This keeps HTTP concerns separate from FHIR protocol logic.
// - Templates are parsed per-render as a (base.html + page.html) pair.
// This is the correct Go html/template pattern for layout inheritance:
// a single global template.Set with multiple files all defining "content"
// blocks will have the last-parsed definition win, causing incorrect renders.
// Per-render parsing is cheap (microseconds) and completely correct.
// - Each handler file handles one logical concern:
// handler.go — shared Handler type and constructor
// launch.go — SMART EHR launch initiation
// auth.go — OAuth2 callback, token exchange, user upsert, session creation
// logout.go — session invalidation
package handlers
import (
"crypto/rand"
"encoding/hex"
"html/template"
"io/fs"
"log"
"net/http"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
)
const (
// SessionCookieName is the name of the HttpOnly session cookie.
SessionCookieName = "session_id"
// SessionTTL is how long a session remains valid after SMART launch.
SessionTTL = 8 * 60 * 60 // 8 hours in seconds
)
// Handler is the central handler struct. All HTTP handlers are methods on it.
// It holds all dependencies so they can be injected in tests.
type Handler struct {
store *db.Store
cfg *config.AppConfig
templateFS fs.FS
funcMap template.FuncMap
}
// New creates a Handler with all dependencies wired in.
// templateFS must be an fs.FS rooted so that "base.html", "dashboard.html",
// etc. are directly accessible (i.e. pass an fs.Sub of the embed.FS).
func New(store *db.Store, cfg *config.AppConfig, templateFS fs.FS, funcMap template.FuncMap) *Handler {
return &Handler{
store: store,
cfg: cfg,
templateFS: templateFS,
funcMap: funcMap,
}
}
// render parses base.html + the named page file and executes the combined
// template set, using the page filename as the entry point.
//
// Go's html/template block/define system works correctly when each page
// is parsed together with base.html in a fresh template.Template — the
// page's {{define "content"}} overrides the {{block "content"}} in base.html
// without conflicting with other pages' definitions.
func (h *Handler) render(w http.ResponseWriter, page string, data interface{}) {
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", page)
if err != nil {
log.Printf("handlers: parse template %q: %v", page, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
log.Printf("handlers: execute template %q: %v", page, err)
}
}
// renderError writes a clean HTML error page.
func (h *Handler) renderError(w http.ResponseWriter, code int, message string) {
data := struct {
Code int
Message string
}{code, message}
tmpl, err := template.New("").Funcs(h.funcMap).ParseFS(h.templateFS, "base.html", "error.html")
if err != nil {
log.Printf("handlers: parse error template: %v", err)
http.Error(w, message, code)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
log.Printf("handlers: execute error template: %v", err)
http.Error(w, message, code)
}
}
// generateState creates a cryptographically secure random state token.
// This replaces the naive iss+launchID concatenation in the original code.
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// templateFuncs returns the custom template function map.
// Defined here so it is available to both main.go (for wiring) and
// handler tests.
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatDate": func(s string) string {
if s == "" {
return "—"
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return s
}
return t.Format("January 2, 2006")
},
"formatDateTime": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.UTC().Format("Jan 2, 2006 15:04 UTC")
},
"derefFloat64": func(p *float64) float64 {
if p == nil {
return 0
}
return *p
},
"titleCase": func(s string) string {
if s == "" {
return "—"
}
if len(s) == 1 {
return string(s[0] - 32)
}
return string(s[0]-32) + s[1:]
},
"orDash": func(s string) string {
if s == "" {
return "—"
}
return s
},
}
}

177
app/handlers/launch.go Normal file
View File

@@ -0,0 +1,177 @@
// launch.go handles the SMART on FHIR EHR launch initiation sequence.
//
// Flow:
// 1. EHR calls GET /launch?iss=<fhir_base>&launch=<opaque_token>
// 2. This handler validates iss against the registered EHR list.
// 3. Fetches the SMART discovery document (.well-known/smart-configuration).
// 4. Generates a cryptographically secure state token.
// 5. Stores launch context (iss + launch token) in a short-lived in-memory
// map keyed by state, so the callback can recover context without
// encoding sensitive values in the state URL parameter.
// 6. Redirects the browser to the EHR's authorization_endpoint.
package handlers
import (
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
)
// launchState holds the OAuth2 launch context that must survive the
// browser redirect round-trip. It is keyed by a random state token.
type launchState struct {
ISS string
LaunchID string
StoredAt time.Time
UsedAt time.Time // Added to track when the state was first consumed.
}
// stateStore is a short-lived, in-memory map from state token → launchState.
// Entries are expired after 10 minutes to limit memory growth from abandoned
// launch flows. In a multi-instance deployment this should be replaced with
// a shared store (e.g., Redis).
type stateStore struct {
mu sync.Mutex
entries map[string]launchState
}
var globalStateStore = &stateStore{
entries: make(map[string]launchState),
}
// set stores a launch state entry.
func (ss *stateStore) set(token string, ls launchState) {
ss.mu.Lock()
defer ss.mu.Unlock()
// Opportunistically evict stale entries on every write.
ss.evict()
ss.entries[token] = ls
}
// get retrieves a state entry. Returns false if not found or expired.
// Implements a 10-second grace period for duplicate requests (common in some
// browsers/environments) after the first consumption.
func (ss *stateStore) get(token string) (launchState, bool) {
ss.mu.Lock()
defer ss.mu.Unlock()
ls, ok := ss.entries[token]
if !ok {
return launchState{}, false
}
// If already used more than 10 seconds ago, consider it fully consumed.
if !ls.UsedAt.IsZero() && time.Since(ls.UsedAt) > 10*time.Second {
delete(ss.entries, token)
return launchState{}, false
}
if time.Since(ls.StoredAt) > 10*time.Minute {
delete(ss.entries, token)
return launchState{}, false
}
// Mark as used but don't delete yet to allow for race conditions/double-requests.
if ls.UsedAt.IsZero() {
ls.UsedAt = time.Now()
ss.entries[token] = ls
}
return ls, true
}
// evict removes entries older than 10 minutes. Must be called with ss.mu held.
func (ss *stateStore) evict() {
cutoff := time.Now().Add(-10 * time.Minute)
for k, v := range ss.entries {
if v.StoredAt.Before(cutoff) {
delete(ss.entries, k)
}
}
}
// HandleRoot serves the application home page.
func (h *Handler) HandleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
h.renderError(w, http.StatusNotFound, "Page not found.")
return
}
h.render(w, "index.html", nil)
}
// HandleLaunch processes the SMART EHR launch initiation.
// GET /launch?iss=<fhir_base_url>&launch=<opaque_launch_token>
func (h *Handler) HandleLaunch(w http.ResponseWriter, r *http.Request) {
launchID := r.URL.Query().Get("launch")
iss := r.URL.Query().Get("iss")
log.Printf("handlers: launch request iss=%q launch=%q", iss, launchID)
if launchID == "" || iss == "" {
h.renderError(w, http.StatusBadRequest, "Missing required parameters: iss and launch.")
return
}
// Validate iss is a well-formed URI before doing anything with it.
if _, err := url.ParseRequestURI(iss); err != nil {
h.renderError(w, http.StatusBadRequest, "Invalid FHIR server URL (iss).")
return
}
// Confirm this EHR is registered.
ehrConfig := h.cfg.EHRByURL(iss)
if ehrConfig == nil {
log.Printf("handlers: unregistered EHR iss=%q", iss)
h.renderError(w, http.StatusBadRequest, "Unregistered FHIR server.")
return
}
// Fetch SMART discovery document.
smartCfg, err := fhir.GetSmartConfiguration(iss)
if err != nil {
log.Printf("handlers: SMART discovery failed for iss=%q: %v", iss, err)
h.renderError(w, http.StatusBadGateway, "Unable to fetch SMART configuration from FHIR server.")
return
}
if smartCfg.AuthorizationEndpoint == "" {
h.renderError(w, http.StatusBadGateway, "SMART configuration missing authorization_endpoint.")
return
}
// Generate a secure random state token.
state, err := generateState()
if err != nil {
log.Printf("handlers: state generation failed: %v", err)
h.renderError(w, http.StatusInternalServerError, "Internal error.")
return
}
// Store the launch context server-side, keyed by state.
globalStateStore.set(state, launchState{
ISS: strings.TrimRight(iss, "/"),
LaunchID: launchID,
StoredAt: time.Now(),
})
// Build the authorization URL.
scopes := strings.Join(h.cfg.SMART.Scopes, " ")
authURL := fmt.Sprintf(
"%s?response_type=code&client_id=%s&redirect_uri=%s&launch=%s&scope=%s&state=%s&aud=%s",
smartCfg.AuthorizationEndpoint,
url.QueryEscape(ehrConfig.ClientID),
url.QueryEscape(h.cfg.SMART.RedirectURL),
url.QueryEscape(launchID),
url.QueryEscape(scopes),
url.QueryEscape(state),
url.QueryEscape(iss),
)
log.Printf("handlers: redirecting to authorization endpoint for EHR %q", ehrConfig.Name)
http.Redirect(w, r, authURL, http.StatusFound)
}

32
app/handlers/logout.go Normal file
View File

@@ -0,0 +1,32 @@
// logout.go handles session invalidation and user logout.
package handlers
import (
"log"
"net/http"
)
// HandleLogout invalidates the current session and redirects to the home page.
// POST /logout
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookieName)
if err == nil && cookie.Value != "" {
if delErr := h.store.DeleteSession(cookie.Value); delErr != nil {
log.Printf("handlers: logout delete session %s: %v", cookie.Value, delErr)
} else {
log.Printf("handlers: logged out session %s", cookie.Value)
}
}
// Clear the cookie in the browser regardless of DB outcome.
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}

98
app/handlers/sync.go Normal file
View File

@@ -0,0 +1,98 @@
package handlers
import (
"log"
"net/http"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
)
// HandleSync performs a live FHIR pull for Observations, Conditions, and
// DocumentReferences for the session's patient, upserts all results into the
// database, records a PatientSync event, then redirects back to GET /dashboard.
//
// POST /dashboard/sync
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sess := middleware.SessionFromContext(r.Context())
if sess == nil {
h.handleUnauthorized(w, r)
return
}
ehrURL := sess.EHRURL
patientID := sess.PatientFHIRID
client := fhir.NewClient(ehrURL, sess.AccessToken)
// -----------------------------------------------------------------
// Fetch Observations
// -----------------------------------------------------------------
rawObs, err := client.GetObservations(patientID)
if err != nil {
log.Printf("handlers: sync GetObservations for Patient/%s: %v", patientID, err)
// Non-fatal; continue with whatever we got.
}
obsCount := 0
for i := range rawObs {
m := fhir.ExtractObservation(&rawObs[i], patientID, ehrURL)
if _, err := h.store.UpsertObservation(m); err != nil {
log.Printf("handlers: sync UpsertObservation fhir_id=%s: %v", m.FHIRID, err)
continue
}
obsCount++
}
// -----------------------------------------------------------------
// Fetch Conditions
// -----------------------------------------------------------------
rawConds, err := client.GetConditions(patientID)
if err != nil {
log.Printf("handlers: sync GetConditions for Patient/%s: %v", patientID, err)
}
condCount := 0
for i := range rawConds {
m := fhir.ExtractCondition(&rawConds[i], patientID, ehrURL)
if _, err := h.store.UpsertCondition(m); err != nil {
log.Printf("handlers: sync UpsertCondition fhir_id=%s: %v", m.FHIRID, err)
continue
}
condCount++
}
// -----------------------------------------------------------------
// Fetch DocumentReferences
// -----------------------------------------------------------------
rawDocs, err := client.GetDocumentReferences(patientID)
if err != nil {
log.Printf("handlers: sync GetDocumentReferences for Patient/%s: %v", patientID, err)
}
docCount := 0
for i := range rawDocs {
m := fhir.ExtractDocumentReference(&rawDocs[i], patientID, ehrURL)
if _, err := h.store.UpsertDocumentReference(m); err != nil {
log.Printf("handlers: sync UpsertDocumentReference fhir_id=%s: %v", m.FHIRID, err)
continue
}
docCount++
}
// -----------------------------------------------------------------
// Record the sync event
// -----------------------------------------------------------------
if _, err := h.store.RecordSync(patientID, ehrURL, obsCount, condCount, docCount); err != nil {
log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err)
}
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d",
patientID, obsCount, condCount, docCount)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}