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