Files
compass_teams_nhs/internal/web/server.go
Sam Rolfe f56dbb46ac Phase 6 follow-up: today-and-future events, one write per school-wide event
Two corrections found while preparing the first live run:

- A scan that runs late in the day still records today's school events; the
  candidate window now starts at the local start of day instead of "now".
- The Compass week calendar is school-wide, so one event is stored against each
  child. Write it once, with no child prefix in the title, and record the same
  Home Assistant event id on every child's item. A per-child event still keeps
  the child's name.

Also report pending events as events, not rows, in `school calendar status`.
Tests cover the window and the de-duplication.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:05:33 +11:00

874 lines
25 KiB
Go

// Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard
// (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail
// screen, and the Delete actions (FR-7, FR-10, FR-12).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"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
}
// 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)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, 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/{id}/delete", s.requireAuth(s.handleSummaryDelete))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
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
Recipient string
Created string
Sent string
Draft bool
Text string
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Scan scanStatusView
Calendar calendarBannerView
Tabs []childTab
Child childPanel
HasChild bool
Summaries []summaryView
ServerUp string
ErrMsg 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),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
}
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
}
}
summaries, err := s.store.ListSummaries(ctx, 10)
if err != nil {
s.log.Error("list summaries", "err", err)
}
for _, sm := range summaries {
v := summaryView{
ID: sm.ID,
Recipient: sm.Recipient,
Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"),
Text: sm.Text,
Draft: sm.SentAt == nil,
}
if sm.SentAt != nil {
v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04")
} else {
v.Sent = "draft"
}
data.Summaries = append(data.Summaries, v)
}
s.render(w, r, "dashboard.html", data)
}
// 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
}
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
}
items, err := s.store.ItemsForChild(ctx, childID)
if err != nil {
s.log.Error("items for child", "child", child.Name, "err", err)
http.Error(w, "could not read the items", http.StatusInternalServerError)
return
}
text := dashboard.Render(child.Name, dashboard.Classify(items, time.Now(), s.cfg.Location), s.cfg.Location)
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))
}
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.DeleteSummary(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)
}
}