Files
compass_teams_nhs/internal/web/server.go
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:47:32 +11:00

1461 lines
45 KiB
Go

// Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, the server-rendered HTMX dashboard, the Send
// panel and the machine endpoint the 6:00am n8n schedule calls (AGENTS.md §5,
// §9, §11 Phase 7).
package web
import (
"context"
"crypto/subtle"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/brief"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/hub"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
brief *brief.Service
answerer *hub.Answerer
apprise *apprise.Client
}
// New builds a Server and parses the embedded templates.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"clock": func() string { return time.Now().Format("15:04:05 MST") },
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout)
svc := brief.New(st, client, cfg.Apprise, cfg.Location, log)
answerer := hub.NewAnswerer(st, cfg.Location)
answerer.SetParents(cfg.Apprise.Parents...)
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc,
answerer: answerer, apprise: client}, nil
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
mux.HandleFunc("POST /summaries/send", s.requireAuth(s.handleSendBrief))
mux.HandleFunc("POST /summaries/{id}/send", s.requireAuth(s.handleSummarySend))
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
// The endpoint the 6:00am n8n schedule calls (AGENTS.md §11 Phase 7). It is
// machine-to-machine: a bearer token, not the dashboard session.
mux.HandleFunc("POST /notify/morning", s.handleMorningNotify)
// The project side of the frozen Hub contract (AGENTS.md §4 FR-11, §11
// Phase 8): GET /capabilities, read-only POST /ask, POST /notify. All three
// need the shared Hub bearer token (SCHOOL_HUB_TOKEN).
mux.HandleFunc("GET /capabilities", s.handleCapabilities)
mux.HandleFunc("POST /ask", s.handleAsk)
mux.HandleFunc("POST /notify", s.handleNotify)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- health ----------
type healthResponse struct {
// OK mirrors the frozen contract's GET /health shape ({"ok": true}) while
// keeping the richer fields this service has always returned.
OK bool `json:"ok"`
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Scans store.ScanStatus `json:"scans"`
Hub bool `json:"hub_api"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
OK: true,
Status: "ok",
Service: "compass-teams-nhs",
Version: s.cfg.Version,
Database: "ok",
Hub: s.cfg.HubConfigured(),
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.OK = false
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
if st, err := s.store.ScanSummary(r.Context()); err == nil {
resp.Scans = st
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
// ---------- login ----------
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// ---------- fail-loudly scan banner (FR-12) ----------
// scanStatusView is the fail-loudly view of scan health. It is shared by the
// dashboard, the item detail page and the HTMX partial. The template never
// renders an empty day as "nothing due".
type scanStatusView struct {
store.ScanStatus
// Level is "none", "error", "ok" or "warn".
Level string
Message string
LastAt string
}
// scanStatusFor builds the banner from a scan summary. The caller supplies the
// summary so the dashboard can show the global view and the per-child view.
func (s *Server) scanStatusFor(st store.ScanStatus, err error) scanStatusView {
if err != nil {
s.log.Error("scan summary", "err", err)
return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()}
}
v := scanStatusView{ScanStatus: st}
if !st.EverRun {
v.Level = "warn"
v.Message = "NO SCAN HAS RUN YET — do not read this as “nothing due”."
return v
}
if st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN."
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
return v
}
v.Level = "ok"
v.Message = "Last scan completed."
if st.Last != nil {
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
}
return v
}
func (s *Server) scanStatus(ctx context.Context) scanStatusView {
st, err := s.store.ScanSummary(ctx)
return s.scanStatusFor(st, err)
}
// scanOK reports whether the banner is healthy, so an empty section may say
// "nothing due" instead of "unknown".
func (v scanStatusView) OK() bool { return v.Level == "ok" }
// ---------- fail-loudly calendar banner (FR-8, FR-12) ----------
// calendarBannerView is the fail-loudly view of the Home Assistant calendar
// target. A missing target or a failed write is shown explicitly, so the
// family never assumes an event was written when it was not.
type calendarBannerView struct {
// Level is "off", "warn", "error" or "ok".
Level string
Message string
Entity string
LastAt string
Written int
Failed int
}
// OK reports whether the calendar target is healthy.
func (v calendarBannerView) OK() bool { return v.Level == "ok" }
func (s *Server) calendarBanner(ctx context.Context) calendarBannerView {
cc := s.cfg.Calendar
if !cc.Enabled() {
return calendarBannerView{
Level: "warn",
Message: "CALENDAR NOT CONFIGURED — important school events are not being written. Set " + strings.Join(cc.Missing(), ", ") + ".",
}
}
v := calendarBannerView{Entity: cc.EntityID}
st, err := s.store.CalendarPushSummary(ctx)
if err != nil {
s.log.Error("calendar push summary", "err", err)
v.Level = "error"
v.Message = "Could not read the calendar log: " + err.Error()
return v
}
v.Written = st.Total - st.Failed
v.Failed = st.Failed
if st.Last != nil {
v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
if st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = "CALENDAR WRITE FAILED — the last write to " + cc.EntityID + " did not complete."
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
return v
}
if !st.EverRun {
v.Level = "warn"
v.Message = "No calendar write has run yet for " + cc.EntityID + "."
return v
}
v.Level = "ok"
v.Message = "Last calendar write completed."
return v
}
// ---------- dashboard (FR-6, FR-7) ----------
type itemView struct {
store.Item
DueLabel string
StatusLabel string
TypeLabel string
BodyLines []string
}
type sectionView struct {
Key string
Title string
Tone string
Empty string
Items []itemView
}
type childPanel struct {
ID int64
Name string
Year string
Headline string
Overdue int
Scan scanStatusView
LaterCount int
DoneCount int
Sections []sectionView
}
type childTab struct {
ID int64
Name string
Overdue int
Active bool
}
type summaryView struct {
ID int64
Scope string
Recipient string
Channel string
Tag string
Created string
Sent string
Draft bool
Failed bool
Error string
Text string
}
// notifyBannerView is the fail-loudly view of the Apprise gateway (FR-12).
type notifyBannerView struct {
// Level is "off", "warn", "error" or "ok".
Level string
Message string
Sent int
Failed int
LastAt string
}
// OK reports whether the last delivery attempt succeeded.
func (v notifyBannerView) OK() bool { return v.Level == "ok" }
// channelOption is one tick box in the Send panel.
type channelOption struct {
Name string
Label string
Checked bool
}
// recipientOption is one recipient tick box in the Send panel.
type recipientOption struct {
Name string
Tag string
Checked bool
}
// sendScopeView is one brief the Send panel can build, preview and send:
// a child's own brief, or the combined brief to Jo and Sam.
type sendScopeView struct {
Key string
Label string
Preview string
Warning string
Recipients []recipientOption
}
// sendPanelView is the Send panel (AGENTS.md §5 screen 4): choose recipients,
// preview the brief, send.
type sendPanelView struct {
Enabled bool
Missing []string
Channels []channelOption
Scopes []sendScopeView
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Scan scanStatusView
Calendar calendarBannerView
Notify notifyBannerView
Tabs []childTab
Child childPanel
HasChild bool
Summaries []summaryView
Send sendPanelView
ServerUp string
ErrMsg string
Flash string
FlashLevel string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
loc := s.cfg.Location
data := dashboardData{
Title: "What is due",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Scan: s.scanStatus(ctx),
Calendar: s.calendarBanner(ctx),
Notify: s.notifyBanner(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
}
// A send reports back on the dashboard: what went out, or what failed.
if msg := strings.TrimSpace(r.URL.Query().Get("msg")); msg != "" {
data.Flash = msg
data.FlashLevel = "ok"
}
if level := strings.TrimSpace(r.URL.Query().Get("level")); level == "error" {
data.FlashLevel = "error"
data.ErrMsg = ""
}
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
data.ErrMsg = "Could not read the children: " + err.Error()
}
// Which tab? ?child=ID, else the first child.
selected := int64(0)
if v, err := strconv.ParseInt(r.URL.Query().Get("child"), 10, 64); err == nil {
selected = v
}
found := false
for _, c := range children {
if selected == 0 || c.ID == selected {
if !found {
selected = c.ID
found = true
}
}
}
if !found && len(children) > 0 {
selected = children[0].ID
found = true
}
overdueByChild := map[int64]int{}
for _, c := range children {
items, err := s.store.ItemsForChild(ctx, c.ID)
if err != nil {
s.log.Error("items for child", "child", c.Name, "err", err)
continue
}
overdueByChild[c.ID] = len(dashboard.Classify(items, time.Now(), loc).Overdue)
}
for _, c := range children {
tab := childTab{ID: c.ID, Name: c.Name, Overdue: overdueByChild[c.ID], Active: c.ID == selected}
data.Tabs = append(data.Tabs, tab)
if c.ID == selected {
data.Child = s.buildChildPanel(ctx, c, loc)
data.HasChild = true
}
}
data.Send = s.sendPanel(ctx, children)
notifications, err := s.store.RecentNotifications(ctx, 12)
if err != nil {
s.log.Error("list notifications", "err", err)
}
for _, n := range notifications {
v := summaryView{
ID: n.ID,
Scope: n.Scope,
Recipient: n.Recipient,
Channel: n.Channel,
Tag: n.Tag,
Created: n.CreatedAt.In(loc).Format("Mon 02 Jan 15:04"),
Text: n.Text,
Draft: n.Channel == "",
Failed: n.Channel != "" && !n.OK,
Error: n.Error,
}
switch {
case n.Channel == "":
v.Sent = "draft"
case n.SentAt != nil:
v.Sent = n.SentAt.In(loc).Format("Mon 02 Jan 15:04")
default:
v.Sent = "failed"
}
data.Summaries = append(data.Summaries, v)
}
s.render(w, r, "dashboard.html", data)
}
// notifyBanner is the fail-loudly banner for the Apprise gateway (FR-12).
func (s *Server) notifyBanner(ctx context.Context) notifyBannerView {
ac := s.cfg.Apprise
if !ac.Enabled() {
return notifyBannerView{
Level: "warn",
Message: "NOTIFICATIONS NOT CONFIGURED — the morning briefs cannot be sent. Set " + strings.Join(ac.Missing(), ", ") + ".",
}
}
st, err := s.store.NotificationSummary(ctx)
if err != nil {
s.log.Error("notification summary", "err", err)
return notifyBannerView{Level: "error", Message: "Could not read the delivery log: " + err.Error()}
}
v := notifyBannerView{Sent: st.Sent, Failed: st.Failed}
if st.Last != nil {
v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
if st.Failed > 0 && st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = fmt.Sprintf("SEND FAILED — the last brief delivery did not complete (%s/%s). It was NOT delivered.",
st.Last.Recipient, channelLabel(st.Last.Channel))
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
return v
}
if !st.EverRun() || st.Sent == 0 {
v.Level = "warn"
v.Message = "No brief has been sent yet."
return v
}
v.Level = "ok"
v.Message = fmt.Sprintf("%d delivery attempt(s) delivered, %d failed.", st.Sent, st.Failed)
return v
}
// sendPanel builds the Send panel (AGENTS.md §5 screen 4): one card per brief
// that can go out, each with a preview, the recipients and the channels.
func (s *Server) sendPanel(ctx context.Context, children []store.Child) sendPanelView {
ac := s.cfg.Apprise
p := sendPanelView{Enabled: ac.Enabled(), Missing: ac.Missing()}
for _, ch := range ac.Channels {
p.Channels = append(p.Channels, channelOption{Name: ch, Label: channelLabel(ch), Checked: true})
}
parents := ac.ParentRecipients()
for _, c := range children {
scope := sendScopeView{Key: strconv.FormatInt(c.ID, 10), Label: c.Name}
text, scanOK, err := s.brief.ChildrenText(ctx, c)
if err != nil {
s.log.Error("brief preview", "child", c.Name, "err", err)
scope.Warning = "Could not build the brief: " + err.Error()
}
scope.Preview = text
if !scanOK {
scope.Warning = "The last scan for " + c.Name + " did not complete. The brief says so and must not be read as “nothing due” (FR-12)."
}
if r, ok := ac.RecipientByName(c.Name); ok {
scope.Recipients = append(scope.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail), Checked: true})
}
for _, r := range parents {
scope.Recipients = append(scope.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail)})
}
p.Scopes = append(p.Scopes, scope)
}
combined := sendScopeView{Key: brief.ScopeCombined, Label: brief.CombinedNames(children) + " (combined)"}
text, failed, err := s.brief.CombinedText(ctx, children)
if err != nil {
s.log.Error("combined brief preview", "err", err)
combined.Warning = "Could not build the combined brief: " + err.Error()
}
combined.Preview = text
if len(failed) > 0 {
combined.Warning = "The last scan did not complete for " + strings.Join(failed, ", ") + ". The brief says so (FR-12)."
}
for _, r := range parents {
combined.Recipients = append(combined.Recipients, recipientOption{Name: r.Name, Tag: ac.ChannelTag(r, config.ChannelEmail), Checked: true})
}
p.Scopes = append(p.Scopes, combined)
return p
}
func channelLabel(ch string) string {
switch ch {
case config.ChannelEmail:
return "Email"
case config.ChannelWhatsApp:
return "WhatsApp"
case "":
return "draft"
default:
return ch
}
}
// buildChildPanel classifies one child's items and prepares the sections.
func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel {
items, err := s.store.ItemsForChild(ctx, c.ID)
if err != nil {
s.log.Error("items for child", "child", c.Name, "err", err)
}
b := dashboard.Classify(items, time.Now(), loc)
sc, scErr := s.store.ScanSummaryForChild(ctx, c.ID)
scan := s.scanStatusFor(sc, scErr)
p := childPanel{
ID: c.ID,
Name: c.Name,
Year: c.Year,
Headline: b.Headline(),
Overdue: len(b.Overdue),
Scan: scan,
LaterCount: len(b.Later),
DoneCount: len(b.Done),
}
known := scan.OK()
p.Sections = []sectionView{
{Key: "overdue", Title: "Overdue", Tone: "danger", Items: itemViews(b.Overdue, loc),
Empty: emptyText(known, "Nothing overdue.")},
{Key: "today", Title: "Due today", Items: itemViews(b.DueToday, loc),
Empty: emptyText(known, "Nothing due today.")},
{Key: "week", Title: "Due this week", Items: itemViews(b.DueWeek, loc),
Empty: emptyText(known, "Nothing else due this week.")},
}
if len(b.NoDue) > 0 {
p.Sections = append(p.Sections, sectionView{Key: "nodue", Title: "No due date",
Items: itemViews(b.NoDue, loc)})
}
p.Sections = append(p.Sections, sectionView{Key: "messages", Title: "Messages",
Items: itemViews(b.Messages, loc), Empty: emptyText(known, "No messages.")})
return p
}
// emptyText never turns a failed or missing scan into "nothing due" (FR-12).
func emptyText(scanKnown bool, ok string) string {
if scanKnown {
return ok
}
return "Unknown — the last scan did not complete."
}
func itemViews(items []store.Item, loc *time.Location) []itemView {
out := make([]itemView, 0, len(items))
for _, it := range items {
v := itemView{Item: it, TypeLabel: typeLabel(it.Type)}
if it.DueAt != nil {
v.DueLabel = it.DueAt.In(loc).Format("Mon 02 Jan 15:04")
}
if st := dashboard.Status(it.Body); st != "" && st != "pending" {
v.StatusLabel = strings.ToUpper(st[:1]) + st[1:]
}
if strings.TrimSpace(it.Body) != "" {
v.BodyLines = strings.Split(strings.TrimSpace(it.Body), "\n")
}
out = append(out, v)
}
return out
}
func typeLabel(t string) string {
switch t {
case "task":
return "Task"
case "event":
return "Event"
case "message":
return "Message"
default:
return t
}
}
// ---------- item detail + delete (FR-10, §5 screen 3) ----------
type itemData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Item itemView
Child store.Child
Scan scanStatusView
Calendar calendarItemView
Flash string
FlashLevel string
ServerUp string
}
// calendarItemView is the calendar state of one item on the item screen.
type calendarItemView struct {
Configured bool
Entity string
Eligible bool
InCalendar bool
UID string
PushedAt string
}
// itemCalendarView describes whether one item can go to the calendar and
// whether it is already there. An important, future Compass event is eligible.
func (s *Server) itemCalendarView(it store.CalendarItem, now time.Time) calendarItemView {
v := calendarItemView{
Configured: s.cfg.Calendar.Enabled(),
Entity: s.cfg.Calendar.EntityID,
InCalendar: it.HAEventUID != "",
UID: it.HAEventUID,
}
v.Eligible = it.Type == "event" && it.Source == "compass" && it.Importance == "high" &&
it.DueAt != nil && !it.DueAt.Before(calendar.StartOfDay(now))
if it.HAPushedAt != nil {
v.PushedAt = it.HAPushedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
return v
}
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
s.renderItemDetail(w, r, id, "", "")
}
// renderItemDetail renders the item screen, optionally with a calendar flash.
func (s *Server) renderItemDetail(w http.ResponseWriter, r *http.Request, id int64, flash, flashLevel string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ci, err := s.store.CalendarItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if ci == nil {
http.NotFound(w, r)
return
}
child, err := s.store.ChildByID(ctx, ci.ChildID)
if err != nil || child == nil {
child = &store.Child{ID: ci.ChildID, Name: ci.Child}
}
s.render(w, r, "item.html", itemData{
Title: ci.Title,
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Item: itemViews([]store.Item{ci.Item}, s.cfg.Location)[0],
Child: *child,
Scan: s.scanStatus(ctx),
Calendar: s.itemCalendarView(*ci, time.Now()),
Flash: flash,
FlashLevel: flashLevel,
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
})
}
// handleItemCalendarPush writes one item to Sam's Home Assistant calendar. The
// school stays read-only: the only write is to the calendar (AGENTS.md §10
// rule 1). A failure is shown on the page and recorded in calendar_pushes.
func (s *Server) handleItemCalendarPush(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("calendar push rejected: bad csrf token", "path", r.URL.Path)
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
ci, err := s.store.CalendarItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if ci == nil {
http.NotFound(w, r)
return
}
if missing := s.cfg.Calendar.Missing(); len(missing) > 0 {
s.renderItemDetail(w, r, id,
"Calendar is not configured. Set "+strings.Join(missing, ", ")+".", "error")
return
}
client, err := calendar.New(s.cfg.Calendar, s.log)
if err != nil {
s.log.Error("calendar client", "err", err)
s.renderItemDetail(w, r, id, "Calendar is not usable: "+err.Error(), "error")
return
}
res, err := calendar.Sync(ctx, s.store, client,
calendar.Options{ItemID: &id, Now: time.Now().In(s.cfg.Location)}, s.log)
if err != nil {
s.log.Error("calendar push", "item", id, "err", err)
s.renderItemDetail(w, r, id, "Calendar write failed: "+err.Error(), "error")
return
}
switch {
case res.Failed > 0:
msg := "Calendar write failed."
if len(res.Errors) > 0 {
msg = "Calendar write failed: " + strings.Join(res.Errors, "; ")
}
s.log.Error("calendar push failed", "item", id, "child", ci.Child, "errors", res.Errors)
s.renderItemDetail(w, r, id, msg, "error")
case res.Created == 0 && res.Skipped > 0:
s.renderItemDetail(w, r, id, "Already in the calendar ("+s.cfg.Calendar.EntityID+").", "ok")
case res.Created == 0:
s.renderItemDetail(w, r, id,
"Nothing to write: only important, future school events go to the calendar.", "error")
default:
s.log.Info("calendar event written", "item", id, "child", ci.Child, "title", ci.Title, "entity", s.cfg.Calendar.EntityID)
s.renderItemDetail(w, r, id, "Added to "+s.cfg.Calendar.EntityID+" on Sam's Home Assistant calendar.", "ok")
}
}
func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("delete rejected: bad csrf token", "path", r.URL.Path)
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
it, err := s.store.ItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if it == nil {
http.NotFound(w, r)
return
}
if _, err := s.store.DeleteItemByID(ctx, id); err != nil {
s.log.Error("delete item", "id", id, "err", err)
http.Error(w, "could not delete the item", http.StatusInternalServerError)
return
}
s.log.Info("item deleted", "id", id, "child", it.Child, "title", it.Title)
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(it.ChildID, 10))
}
func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
// The combined brief is built from both children (FR-14); a child brief is
// built from that child's items only.
if scope := strings.TrimSpace(r.PostFormValue("scope")); strings.EqualFold(scope, brief.ScopeCombined) {
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
http.Error(w, "could not read the children", http.StatusInternalServerError)
return
}
text, _, err := s.brief.CombinedText(ctx, children)
if err != nil {
s.log.Error("combined brief", "err", err)
http.Error(w, "could not build the combined brief", http.StatusInternalServerError)
return
}
id, err := s.store.CreateDraft(ctx, brief.ScopeCombined, brief.CombinedNames(children), text)
if err != nil {
s.log.Error("create combined draft", "err", err)
http.Error(w, "could not store the summary", http.StatusInternalServerError)
return
}
s.log.Info("combined summary built", "id", id)
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape("Combined brief built as a draft."))
return
}
childID, err := strconv.ParseInt(r.PostFormValue("child"), 10, 64)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
child, err := s.store.ChildByID(ctx, childID)
if err != nil || child == nil {
http.NotFound(w, r)
return
}
text, _, err := s.brief.ChildrenText(ctx, *child)
if err != nil {
s.log.Error("build brief", "child", child.Name, "err", err)
http.Error(w, "could not read the items", http.StatusInternalServerError)
return
}
id, err := s.store.CreateSummary(ctx, child.Name, text)
if err != nil {
s.log.Error("create summary", "child", child.Name, "err", err)
http.Error(w, "could not store the summary", http.StatusInternalServerError)
return
}
s.log.Info("summary built", "id", id, "recipient", child.Name)
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(childID, 10))
}
// ---------- Send panel (FR-9, FR-10, §5 screen 4) ----------
// handleSendBrief builds the brief for the chosen scope and delivers it to the
// chosen recipients on the chosen channels. Every attempt is recorded, so a
// failure is visible on the page and in `notifications` (FR-12).
func (s *Server) handleSendBrief(w http.ResponseWriter, r *http.Request) {
// Sending waits on SMTP and the WhatsApp gateway, so it is allowed longer
// than a page render but is still bounded.
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
scope := strings.TrimSpace(r.PostFormValue("scope"))
if scope == "" {
http.Error(w, "choose a brief to send", http.StatusBadRequest)
return
}
channels := r.PostForm["channel"]
if len(channels) == 0 {
channels = s.cfg.Apprise.Channels
}
text, childID, label, err := s.briefText(ctx, scope)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
recipients, err := s.recipientsForScope(ctx, label, childID, r.PostForm["recipient"])
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
title := "School brief — " + label
send, err := s.brief.Deliver(ctx, label, title, text, recipients, channels)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
s.log.Info("brief sent from the Send panel", "scope", label, "delivered", send.Delivered(), "failed", send.Failed())
msg := describeSend(send)
if send.Failed() > 0 || len(send.ScanFailed) > 0 {
s.redirectSendError(w, r, msg)
return
}
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape(msg))
}
// handleSummarySend sends one stored brief (a draft) as it is. It is the Send
// action on a summary (FR-10).
func (s *Server) handleSummarySend(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
n, err := s.store.NotificationByID(ctx, id)
if err != nil || n == nil {
http.NotFound(w, r)
return
}
scope := n.Scope
if scope == "" {
scope = n.Recipient
}
var childID int64
if !strings.EqualFold(scope, brief.ScopeCombined) {
if c, err := s.store.ChildByName(ctx, scope); err == nil && c != nil {
childID = c.ID
}
}
channels := r.PostForm["channel"]
if len(channels) == 0 {
channels = s.cfg.Apprise.Channels
}
recipients, err := s.recipientsForScope(ctx, scope, childID, r.PostForm["recipient"])
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
send, err := s.brief.Deliver(ctx, scope, "School brief — "+scope, n.Text, recipients, channels)
if err != nil {
s.redirectSendError(w, r, err.Error())
return
}
// The stored brief is stamped only when every channel delivered, so a row is
// never marked sent after a failure (FR-12).
tag, channel := "", ""
if len(send.Deliveries) > 0 {
tag, channel = send.Deliveries[0].Tag, send.Deliveries[0].Channel
}
if err := s.store.MarkSummarySent(ctx, n.ID, channel, tag, send.SentOK(), send.FirstError(), s.brief.Now()); err != nil {
s.log.Error("mark summary sent", "id", n.ID, "err", err)
}
msg := describeSend(send)
if !send.SentOK() {
s.redirectSendError(w, r, msg)
return
}
s.redirectAfterWrite(w, r, "/?msg="+url.QueryEscape(msg))
}
// briefText builds the brief text for a scope. The scope is either
// "combined" or a child id; the returned label is the stored scope name
// ("combined" or the child's name), so the delivery log never shows a bare id.
func (s *Server) briefText(ctx context.Context, scope string) (string, int64, string, error) {
if strings.EqualFold(scope, brief.ScopeCombined) {
children, err := s.store.ListChildren(ctx)
if err != nil {
return "", 0, "", err
}
text, _, err := s.brief.CombinedText(ctx, children)
return text, 0, brief.ScopeCombined, err
}
id, err := strconv.ParseInt(scope, 10, 64)
if err != nil {
return "", 0, "", fmt.Errorf("unknown brief %q", scope)
}
child, err := s.store.ChildByID(ctx, id)
if err != nil || child == nil {
return "", 0, "", fmt.Errorf("unknown child %d", id)
}
text, _, err := s.brief.ChildrenText(ctx, *child)
return text, child.ID, child.Name, err
}
// recipientsForScope resolves the tick boxes: the explicit list when given,
// otherwise the child, otherwise the parents (FR-14).
func (s *Server) recipientsForScope(ctx context.Context, scope string, childID int64, names []string) ([]config.Recipient, error) {
if len(names) > 0 {
return s.brief.RecipientsFor(names)
}
if strings.EqualFold(scope, brief.ScopeCombined) || childID == 0 {
parents, _, err := s.brief.ParentRecipients()
return parents, err
}
child, err := s.store.ChildByID(ctx, childID)
if err != nil || child == nil {
return nil, fmt.Errorf("unknown child %d", childID)
}
return s.brief.ChildRecipients(*child)
}
// describeSend is the one-line result shown on the dashboard.
func describeSend(send brief.Send) string {
parts := make([]string, 0, len(send.Deliveries))
for _, d := range send.Deliveries {
if d.OK {
parts = append(parts, fmt.Sprintf("%s %s: sent", d.Recipient, channelLabel(d.Channel)))
continue
}
parts = append(parts, fmt.Sprintf("%s %s: FAILED (%s)", d.Recipient, channelLabel(d.Channel), apprise.Truncate(d.Error, 140)))
}
msg := fmt.Sprintf("%s: %d delivered, %d failed", scopeDisplay(send.Scope), send.Delivered(), send.Failed())
if len(parts) > 0 {
msg += " — " + strings.Join(parts, "; ")
}
if len(send.ScanFailed) > 0 {
msg += fmt.Sprintf(" — scan failed for %s, so due work is UNKNOWN", strings.Join(send.ScanFailed, ", "))
}
return msg
}
func scopeDisplay(scope string) string {
if strings.EqualFold(scope, brief.ScopeCombined) {
return "combined"
}
return scope
}
// redirectSendError sends the send result back to the dashboard as an error, so
// a failed delivery is never a silent success.
func (s *Server) redirectSendError(w http.ResponseWriter, r *http.Request, msg string) {
s.log.Error("brief send reported a failure", "err", msg)
s.redirectAfterWrite(w, r, "/?level=error&err="+url.QueryEscape(msg))
}
// ---------- the 6:00am endpoint n8n calls (§11 Phase 7) ----------
// morningResponse is the machine-readable result of POST /notify/morning.
type morningResponse struct {
OK bool `json:"ok"`
At string `json:"at"`
DryRun bool `json:"dry_run,omitempty"`
Channels []string `json:"channels"`
ScansFailed []string `json:"scans_failed,omitempty"`
Sends []morningSend `json:"sends"`
Error string `json:"error,omitempty"`
}
type morningSend struct {
Scope string `json:"scope"`
Title string `json:"title,omitempty"`
Recipients []string `json:"recipients"`
Delivered int `json:"delivered"`
Failed int `json:"failed"`
Deliveries []morningDelivery `json:"deliveries"`
// Text is the brief itself; it is filled in only by a dry run, so the n8n
// wiring can be checked without messaging the family.
Text string `json:"text,omitempty"`
}
type morningDelivery struct {
Recipient string `json:"recipient"`
Channel string `json:"channel"`
Tag string `json:"tag"`
OK bool `json:"ok"`
Status int `json:"status"`
Error string `json:"error,omitempty"`
}
// handleMorningNotify builds and sends every morning brief. n8n calls it at
// 6:00am with the shared bearer token (SCHOOL_NOTIFY_TOKEN). It never needs the
// dashboard session, and it fails loudly: a delivery failure returns 207 with
// the failure in the body, an internal error returns 500.
func (s *Server) handleMorningNotify(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(s.cfg.Apprise.NotifyToken)
if token == "" {
s.writeJSON(w, http.StatusServiceUnavailable, morningResponse{
Error: "the morning endpoint is disabled: set SCHOOL_NOTIFY_TOKEN",
})
return
}
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
s.writeJSON(w, http.StatusUnauthorized, morningResponse{Error: "missing or bad bearer token"})
return
}
dryRun := strings.TrimSpace(r.URL.Query().Get("dry_run")) != ""
channels := s.cfg.Apprise.Channels
if v := strings.TrimSpace(r.URL.Query().Get("channels")); v != "" {
channels = nil
for _, ch := range strings.Split(v, ",") {
if ch = strings.TrimSpace(ch); ch != "" {
channels = append(channels, ch)
}
}
}
// A brief of many recipients on two channels needs longer than one send.
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
defer cancel()
if dryRun {
plans, err := s.brief.Plan(ctx)
if err != nil {
s.log.Error("morning dry run", "err", err)
s.writeJSON(w, http.StatusInternalServerError, morningResponse{Error: err.Error()})
return
}
resp := morningResponse{OK: true, DryRun: true, Channels: channels,
At: time.Now().In(s.cfg.Location).Format(time.RFC3339)}
for _, p := range plans {
resp.Sends = append(resp.Sends, morningSend{
Scope: p.Scope, Title: p.Title, Recipients: p.Recipients, Text: p.Text,
})
resp.ScansFailed = append(resp.ScansFailed, p.ScanFailed...)
}
resp.ScansFailed = dedupe(resp.ScansFailed)
if len(resp.ScansFailed) > 0 {
resp.OK = false
}
s.log.Info("morning dry run", "sends", len(resp.Sends), "scans_failed", strings.Join(resp.ScansFailed, ","))
s.writeJSON(w, http.StatusOK, resp)
return
}
sends, err := s.brief.Morning(ctx, channels)
if err != nil {
s.log.Error("morning brief", "err", err)
s.writeJSON(w, http.StatusInternalServerError, morningResponse{Error: err.Error()})
return
}
resp := morningResponse{
OK: true,
At: time.Now().In(s.cfg.Location).Format(time.RFC3339),
Channels: channels,
}
status := http.StatusOK
for _, send := range sends {
ms := morningSend{Scope: send.Scope, Recipients: send.Recipients, Delivered: send.Delivered(), Failed: send.Failed()}
for _, d := range send.Deliveries {
ms.Deliveries = append(ms.Deliveries, morningDelivery{
Recipient: d.Recipient, Channel: d.Channel, Tag: d.Tag, OK: d.OK, Status: d.Status, Error: d.Error,
})
}
resp.Sends = append(resp.Sends, ms)
resp.ScansFailed = append(resp.ScansFailed, send.ScanFailed...)
if send.Failed() > 0 {
status = http.StatusMultiStatus
}
}
if len(resp.ScansFailed) > 0 {
// A child can appear in both its own brief and the combined brief, so
// the summary list is deduplicated.
resp.ScansFailed = dedupe(resp.ScansFailed)
status = http.StatusMultiStatus
}
if status != http.StatusOK {
resp.OK = false
resp.Error = "one or more briefs did not deliver, or a scan did not complete"
}
s.log.Info("morning run", "status", status, "sends", len(resp.Sends), "scans_failed", strings.Join(resp.ScansFailed, ","))
s.writeJSON(w, status, resp)
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
s.log.Error("write json", "err", err)
}
}
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.store.DeleteNotification(ctx, id); err != nil {
s.log.Error("delete summary", "id", id, "err", err)
http.Error(w, "could not delete the summary", http.StatusInternalServerError)
return
}
s.log.Info("summary deleted", "id", id)
s.redirectAfterWrite(w, r, "/")
}
// redirectAfterWrite answers an HTMX request with 204 so the element can be
// removed, and a plain form post with a redirect to the dashboard.
func (s *Server) redirectAfterWrite(w http.ResponseWriter, r *http.Request, to string) {
if r.Header.Get("HX-Request") != "" {
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func pathID(r *http.Request, name string) (int64, bool) {
v, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// ---------- partials ----------
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "scan-status.html", s.scanStatus(ctx))
}
func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// dedupe keeps the first occurrence of each name, in order.
func dedupe(names []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(names))
for _, n := range names {
if seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}