// 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/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 } // 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) return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc}, 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) 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 { Status string `json:"status"` Service string `json:"service"` Version string `json:"version"` Database string `json:"database"` Scans store.ScanStatus `json:"scans"` Time string `json:"time"` } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := healthResponse{ Status: "ok", Service: "compass-teams-nhs", Version: s.cfg.Version, Database: "ok", Time: time.Now().UTC().Format(time.RFC3339), } code := http.StatusOK if err := s.store.Ping(r.Context()); err != nil { 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 }