- internal/dashboard: classify items into overdue / due today / due this week /
no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
including the NO SCAN / SCAN FAILED / healthy banner behaviour.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
704 lines
20 KiB
Go
704 lines
20 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/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 /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" }
|
|
|
|
// ---------- 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
|
|
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),
|
|
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
|
|
ServerUp string
|
|
}
|
|
|
|
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
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
|
|
}
|
|
child, err := s.store.ChildByID(ctx, it.ChildID)
|
|
if err != nil || child == nil {
|
|
child = &store.Child{ID: it.ChildID, Name: it.Child}
|
|
}
|
|
|
|
s.render(w, r, "item.html", itemData{
|
|
Title: it.Title,
|
|
Username: s.cfg.AdminUsername,
|
|
CSRF: s.auth.EnsureCSRF(w, r),
|
|
Version: s.cfg.Version,
|
|
AppEnv: s.cfg.AppEnv,
|
|
Item: itemViews([]store.Item{*it}, s.cfg.Location)[0],
|
|
Child: *child,
|
|
Scan: s.scanStatus(ctx),
|
|
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|