The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:
GET /capabilities the routing topics the Hub reads, project "compass_nhs"
POST /ask one short, read-only answer about a child's school work
POST /notify accept the contract's notification shape, deliver via Apprise
All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".
/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.
Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.
/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.
Live proof on .13 (deploy/verify-phase8.sh):
/health 200, ok true
/capabilities 200, project compass_nhs; unsigned 401
/ask 200, the sentence above, duration 1ms
/notify 400 on a bad level (auth proven, nothing sent), then 200 with
a real delivery to Sam on email and WhatsApp
The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1461 lines
45 KiB
Go
1461 lines
45 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("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
|
|
}
|
|
|
|
// ---------- 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
|
|
}
|