mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
360 lines
10 KiB
Go
360 lines
10 KiB
Go
// 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"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
|
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private helpers used by both template funcs and buildClinicalSummary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// filterObsByCategory returns observations whose category matches cat
|
|
// using a normalised (lowercase, spaces→hyphens) comparison.
|
|
func filterObsByCategory(obs []models.Observation, cat string) []models.Observation {
|
|
want := strings.ToLower(strings.ReplaceAll(cat, " ", "-"))
|
|
var out []models.Observation
|
|
for _, o := range obs {
|
|
got := strings.ToLower(strings.ReplaceAll(o.Category, " ", "-"))
|
|
if got == want {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// latestObsPerCode returns the most-recent observation per LOINC code (or code
|
|
// text when code is absent), sorted by code text for stable display.
|
|
func latestObsPerCode(obs []models.Observation) []models.Observation {
|
|
latest := make(map[string]models.Observation)
|
|
for _, o := range obs {
|
|
key := o.CodeCode
|
|
if key == "" {
|
|
key = o.CodeText
|
|
}
|
|
if existing, ok := latest[key]; !ok || o.EffectiveDate > existing.EffectiveDate {
|
|
latest[key] = o
|
|
}
|
|
}
|
|
out := make([]models.Observation, 0, len(latest))
|
|
for _, o := range latest {
|
|
out = append(out, o)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CodeText < out[j].CodeText })
|
|
return out
|
|
}
|
|
|
|
// isAbnormalInterp returns true for interpretation codes that indicate an
|
|
// out-of-range or critical result.
|
|
func isAbnormalInterp(interp string) bool {
|
|
switch strings.ToUpper(strings.TrimSpace(interp)) {
|
|
case "H", "HH", "L", "LL", "A", "AA", "HIGH", "LOW", "ABNORMAL", "CRITICAL":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 "—"
|
|
}
|
|
// Try full datetime first (FHIR dateTime), then plain date.
|
|
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} {
|
|
t, err := time.Parse(layout, s)
|
|
if err == nil {
|
|
return t.Format("Jan 2, 2006")
|
|
}
|
|
}
|
|
return s
|
|
},
|
|
"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
|
|
},
|
|
// groupByCategory is kept for backward compatibility.
|
|
"groupByCategory": func(obs []models.Observation) map[string][]models.Observation {
|
|
m := make(map[string][]models.Observation)
|
|
for _, o := range obs {
|
|
cat := o.Category
|
|
if cat == "" {
|
|
cat = "other"
|
|
}
|
|
m[cat] = append(m[cat], o)
|
|
}
|
|
return m
|
|
},
|
|
"hasCriticalAllergies": func(allergies []models.AllergyIntolerance) bool {
|
|
for _, a := range allergies {
|
|
if a.Criticality == "high" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
// --- T1.2 Vitals/Labs ---
|
|
"filterObsByCategory": func(obs []models.Observation, cat string) []models.Observation {
|
|
return filterObsByCategory(obs, cat)
|
|
},
|
|
"latestObPerCode": func(obs []models.Observation) []models.Observation {
|
|
return latestObsPerCode(obs)
|
|
},
|
|
"isAbnormal": func(interp string) bool {
|
|
return isAbnormalInterp(interp)
|
|
},
|
|
// --- T1.3 Medication history ---
|
|
"filterMedsByStatus": func(meds []models.MedicationRequest, status string) []models.MedicationRequest {
|
|
if status == "" || status == "all" {
|
|
return meds
|
|
}
|
|
var out []models.MedicationRequest
|
|
for _, m := range meds {
|
|
if strings.EqualFold(m.Status, status) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
},
|
|
// --- T1.1 Demographics ---
|
|
"calculateAge": func(dob string) string {
|
|
if dob == "" {
|
|
return ""
|
|
}
|
|
t, err := time.Parse("2006-01-02", dob)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
now := time.Now()
|
|
years := now.Year() - t.Year()
|
|
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
|
|
years--
|
|
}
|
|
return fmt.Sprintf("%d", years)
|
|
},
|
|
"primaryPhone": func(p *fhir.Patient) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
for _, tc := range p.Telecom {
|
|
if tc.System == "phone" && tc.Value != "" {
|
|
return tc.Value
|
|
}
|
|
}
|
|
return ""
|
|
},
|
|
"primaryAddress": func(p *fhir.Patient) string {
|
|
if p == nil || len(p.Address) == 0 {
|
|
return ""
|
|
}
|
|
addr := p.Address[0]
|
|
var parts []string
|
|
if len(addr.Line) > 0 {
|
|
parts = append(parts, addr.Line[0])
|
|
}
|
|
if addr.City != "" {
|
|
parts = append(parts, addr.City)
|
|
}
|
|
if addr.State != "" {
|
|
parts = append(parts, addr.State)
|
|
}
|
|
if addr.PostalCode != "" {
|
|
parts = append(parts, addr.PostalCode)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
},
|
|
"usRace": func(p *fhir.Patient) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return fhir.ExtractUSCoreRaceText(p)
|
|
},
|
|
"usEthnicity": func(p *fhir.Patient) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return fhir.ExtractUSCoreEthnicityText(p)
|
|
},
|
|
// --- T2.3 Encounters ---
|
|
"encounterClassBadge": func(class string) string {
|
|
switch strings.ToUpper(class) {
|
|
case "AMB":
|
|
return "badge-info"
|
|
case "EMER":
|
|
return "badge-danger"
|
|
case "IMP", "INPATIENT":
|
|
return "badge-warning"
|
|
default:
|
|
return "badge-neutral"
|
|
}
|
|
},
|
|
"encounterClassLabel": func(class string) string {
|
|
switch strings.ToUpper(class) {
|
|
case "AMB":
|
|
return "Ambulatory"
|
|
case "EMER":
|
|
return "Emergency"
|
|
case "IMP":
|
|
return "Inpatient"
|
|
case "VR":
|
|
return "Virtual"
|
|
default:
|
|
if class == "" {
|
|
return "Visit"
|
|
}
|
|
return class
|
|
}
|
|
},
|
|
"split": func(s, sep string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(s, sep)
|
|
},
|
|
"min": func(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
}
|
|
}
|