Files
compass_teams_nhs/internal/web/server.go
Sam Rolfe f6be17fe4f Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:01:30 +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.After(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)
}
}