Phase 7: notify and send via Apprise (Email + WhatsApp)

The morning briefs go out through the house Apprise API on `.35:8210`: one
brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service
names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an
address, a phone number or an SMTP password (AGENTS.md §10 rule 3).

- internal/apprise: keyed and stateless client, tag filtering, and error
  redaction so nothing sensitive reaches a log, a row or a screen.
- internal/brief: builds the per-boy and the combined brief, delivers on email
  and WhatsApp, records every attempt in `notifications` (migration 004 adds
  scope, channel, tag, ok and error) and fails loudly.
- Send panel (§5 screen 4): recipients, channels, an exact preview, Send now,
  plus the Send action on a stored draft (FR-10). The dashboard shows a
  Morning briefs banner and the Sends list.
- POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule
  calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring
  without messaging the family.
- scripts/morning-brief.sh: scan both children, then send. It is the n8n job;
  deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily).
- FR-12: a failed scan is never "nothing due" -- the brief says so, and the run
  exits non-zero. A failed delivery is recorded with no sent_at.
- The long no-due-date tail is capped in the sent brief so it stays brief
  (§10 rule 6); overdue work is never capped.

Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and
sent_at, and the endpoint answers 401/200/207 as documented. Evidence in
docs/phase-7-notify-notes.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:28:01 +11:00
parent da57efad73
commit 09639c5125
31 changed files with 4266 additions and 169 deletions

View File

@@ -1,11 +1,12 @@
// Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard
// (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail
// screen, and the Delete actions (FR-7, FR-10, FR-12).
// 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"
@@ -13,11 +14,14 @@ import (
"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"
@@ -37,6 +41,7 @@ type Server struct {
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
brief *brief.Service
}
// New builds a Server and parses the embedded templates.
@@ -50,7 +55,9 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
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)
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc}, nil
}
// Handler returns the fully wrapped HTTP handler.
@@ -67,9 +74,15 @@ func (s *Server) Handler() http.Handler {
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)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
@@ -380,27 +393,82 @@ type childTab struct {
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
Tabs []childTab
Child childPanel
HasChild bool
Summaries []summaryView
ServerUp string
ErrMsg string
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()
@@ -418,9 +486,19 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
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 {
@@ -466,22 +544,32 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
}
summaries, err := s.store.ListSummaries(ctx, 10)
data.Send = s.sendPanel(ctx, children)
notifications, err := s.store.RecentNotifications(ctx, 12)
if err != nil {
s.log.Error("list summaries", "err", err)
s.log.Error("list notifications", "err", err)
}
for _, sm := range summaries {
for _, n := range notifications {
v := summaryView{
ID: sm.ID,
Recipient: sm.Recipient,
Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"),
Text: sm.Text,
Draft: sm.SentAt == nil,
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,
}
if sm.SentAt != nil {
v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04")
} else {
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)
}
@@ -489,6 +577,103 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
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)
@@ -777,6 +962,33 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
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)
@@ -787,13 +999,12 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
items, err := s.store.ItemsForChild(ctx, childID)
text, _, err := s.brief.ChildrenText(ctx, *child)
if err != nil {
s.log.Error("items for child", "child", child.Name, "err", err)
s.log.Error("build brief", "child", child.Name, "err", err)
http.Error(w, "could not read the items", http.StatusInternalServerError)
return
}
text := dashboard.Render(child.Name, dashboard.Classify(items, time.Now(), s.cfg.Location), s.cfg.Location)
id, err := s.store.CreateSummary(ctx, child.Name, text)
if err != nil {
s.log.Error("create summary", "child", child.Name, "err", err)
@@ -804,6 +1015,348 @@ func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
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()
@@ -821,7 +1374,7 @@ func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if _, err := s.store.DeleteSummary(ctx, id); err != nil {
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
@@ -871,3 +1424,17 @@ func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name str
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
}

View File

@@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -32,11 +33,21 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
return newTestServerCalendar(t, config.CalendarConfig{})
return newTestServerFull(t, config.CalendarConfig{}, config.AppriseConfig{})
}
// newTestServerCalendar is newTestServer with a Home Assistant calendar target.
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, cal, config.AppriseConfig{})
}
// newTestServerNotify is newTestServer with an Apprise gateway and token.
func newTestServerNotify(t *testing.T, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, config.CalendarConfig{}, ap)
}
// newTestServerFull builds the handler with both integrations configured.
func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
@@ -65,6 +76,7 @@ func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.S
Version: "test",
Location: loc,
Calendar: cal,
Apprise: ap,
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil {
@@ -283,7 +295,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("build status = %d, want 303", resp.StatusCode)
}
summaries, err := st.ListSummaries(ctx, 10)
summaries, err := st.RecentNotifications(ctx, 10)
if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %d, %v", len(summaries), err)
}
@@ -301,7 +313,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
if resp2.StatusCode != http.StatusSeeOther {
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
}
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil {
if got, _ := st.NotificationByID(ctx, summaries[0].ID); got != nil {
t.Error("summary survived delete")
}
}
@@ -498,3 +510,357 @@ func TestItemCalendarPushNeedsCSRF(t *testing.T) {
t.Errorf("a tokenless push recorded a write: %+v", summary)
}
}
// ---------- Phase 7: Send panel and the 6:00am endpoint ----------
// testAppriseConfig is the recipient and channel set the tests use.
func testAppriseConfig(url, key, token string) config.AppriseConfig {
return config.AppriseConfig{
BaseURL: url,
Key: key,
Timeout: 2 * time.Second,
Channels: []string{config.ChannelEmail, config.ChannelWhatsApp},
Recipients: []config.Recipient{
{Name: "Harry", TagBase: "harry"},
{Name: "Finn", TagBase: "finn"},
{Name: "Jo", TagBase: "joanna"},
{Name: "Sam", TagBase: "sam"},
},
Parents: []string{"Jo", "Sam"},
NotifyToken: token,
}
}
// fakeAppriseServer answers every notification with the given status and records
// the tags it was asked to notify.
func fakeAppriseServer(t *testing.T, status int) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var tags []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Tag []string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
tags = append(tags, strings.Join(body.Tag, ","))
mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, &tags
}
// TestSendPanelShowsPreviewAndSends proves the Send panel (§5 screen 4): the
// page previews the brief, choosing a recipient and channel sends it, and the
// delivery is recorded against the right tag (FR-9, FR-10).
func TestSendPanelShowsPreviewAndSends(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Send a brief") {
t.Fatalf("Send panel missing: %s", snippet(page))
}
if !strings.Contains(page, "OVERDUE") && !strings.Contains(page, "Harry —") {
t.Errorf("preview missing from the Send panel: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("send status = %d, want 303", resp.StatusCode)
}
if len(*tags) != 1 || (*tags)[0] != "harry_email" {
t.Fatalf("apprise tags = %#v, want [harry_email]", *tags)
}
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 1 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
got := rows[0]
if got.Scope != "Harry" || got.Recipient != "Harry" || got.Channel != "email" || !got.OK || got.SentAt == nil {
t.Errorf("row = %+v", got)
}
if !strings.Contains(got.Text, "Harry") {
t.Errorf("stored brief = %q", got.Text)
}
// The dashboard reports the send back, and the banner is healthy.
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "delivered") {
t.Errorf("dashboard does not report the send: %s", snippet(page))
}
}
// TestSendPanelIsLoudWithoutApprise proves a missing gateway is shown, and the
// send is refused instead of being reported as sent (FR-12).
func TestSendPanelIsLoudWithoutApprise(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "NOTIFICATIONS NOT CONFIGURED") {
t.Errorf("missing banner: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 with the failure reported", resp.StatusCode)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a refused send must not be recorded as delivered: %+v", rows)
}
}
// TestSendReportsChannelFailure proves a failed channel is recorded and shown,
// never reported as a success (FR-12).
func TestSendReportsChannelFailure(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email", "whatsapp"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
for _, n := range rows {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("row = %+v, want a recorded failure", n)
}
}
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "SEND FAILED") {
t.Errorf("dashboard does not show the failure: %s", snippet(page))
}
}
// TestMorningEndpoint covers the endpoint n8n calls at 6:00am: it refuses a bad
// token, refuses to run unconfigured, and delivers one brief per boy plus the
// combined brief, reporting failures as 207 (FR-9, FR-12, FR-14).
func TestMorningEndpoint(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
post := func(auth string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
if auth != "" {
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
resp := post("wrong")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token status = %d, want 401", resp.StatusCode)
}
resp.Body.Close()
// A healthy morning: both children scanned, every channel answers 200.
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
OK bool `json:"ok"`
Channels []string `json:"channels"`
ScansFailed []string `json:"scans_failed"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if len(body.ScansFailed) != 0 {
t.Errorf("scans_failed = %#v, want none", body.ScansFailed)
}
if len(*tags) != 8 {
t.Errorf("deliveries = %d, want 8 (2 boys + 2 parents, 2 channels)", len(*tags))
}
// The combined brief goes to both parents.
var combined *struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
}
for i := range body.Sends {
if body.Sends[i].Scope == "combined" {
combined = &body.Sends[i]
}
}
if combined == nil || len(combined.Recipients) != 2 || combined.Delivered != 4 {
t.Errorf("combined send = %+v", combined)
}
rows, err := st.RecentNotifications(ctx, 20)
if err != nil || len(rows) != 8 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
// A failed scan still sends the briefs, but the run is not green (FR-12).
harry, _ := st.ChildByName(ctx, "Harry")
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
t.Fatalf("record failed scan: %v", err)
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("failed-scan status = %d, want 207", resp.StatusCode)
}
var after struct {
OK bool `json:"ok"`
ScansFailed []string `json:"scans_failed"`
}
if err := json.NewDecoder(resp.Body).Decode(&after); err != nil {
t.Fatalf("decode: %v", err)
}
if after.OK || len(after.ScansFailed) != 1 || after.ScansFailed[0] != "Harry" {
t.Errorf("after a failed scan = %+v", after)
}
}
// TestMorningEndpointDisabled proves the endpoint is off, not silently
// accepting, when the token is unset.
func TestMorningEndpointDisabled(t *testing.T) {
ts, _ := newTestServerNotify(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""))
resp, err := http.Post(ts.URL+"/notify/morning", "application/json", nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
// TestMorningEndpointFailsLoudly proves a failed delivery is a 207, so the n8n
// execution is not green when a brief did not go out (FR-12).
func TestMorningEndpointFailsLoudly(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, _ := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("status = %d, want 207", resp.StatusCode)
}
}
// TestMorningEndpointDryRun proves the n8n wiring can be checked without
// messaging the family: the same build path, no delivery, nothing recorded.
func TestMorningEndpointDryRun(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning?dry_run=1&channels=email,whatsapp", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
DryRun bool `json:"dry_run"`
OK bool `json:"ok"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string `json:"recipients"`
Text string `json:"text"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.DryRun || !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if body.Sends[2].Scope != "combined" || len(body.Sends[2].Recipients) != 2 {
t.Errorf("combined plan = %+v", body.Sends[2])
}
if body.Sends[0].Text == "" {
t.Error("dry run should return the brief text")
}
if len(*tags) != 0 {
t.Errorf("a dry run must not call Apprise: %#v", *tags)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a dry run must not record deliveries: %+v", rows)
}
}

View File

@@ -423,7 +423,8 @@ a:hover { text-decoration: underline; }
}
.item__subject { color: var(--ink-secondary); }
.item__due { color: var(--ink-secondary); }
.item__actions { margin: 0; flex: 0 0 auto; }
.item__actions { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin: 0; flex: 0 0 auto; justify-content: flex-end; }
.item__actions form { margin: 0; }
.tag {
display: inline-flex;
@@ -507,3 +508,29 @@ a:hover { text-decoration: underline; }
.item__actions .button-utility { width: 100%; }
.child__head { flex-direction: column; }
}
/* ---------- Send panel (Phase 7: recipients, preview, send) ---------- */
.send-card {
display: grid;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.send-card__summary {
cursor: pointer;
font: var(--type-body-md);
font-weight: 600;
color: var(--ink);
}
.send-card__form { display: grid; gap: var(--space-md); margin-top: var(--space-sm); }
.send-card__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.send-card__hint { font: var(--type-caption); color: var(--ink-muted); }
.check-row { display: flex; flex-wrap: wrap; gap: var(--space-sm) var(--space-lg); }
.check { display: inline-flex; align-items: center; gap: var(--space-xxs); font: var(--type-body-sm); }
.brief-wrap { display: grid; gap: var(--space-xxs); }
.brief-wrap > summary { cursor: pointer; }

View File

@@ -34,6 +34,7 @@
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
{{if .Flash}}<p class="flash flash--{{$.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
{{template "scan-status.html" .Scan}}
@@ -41,6 +42,22 @@
{{template "calendar-status.html" .Calendar}}
<section class="scan scan--{{.Notify.Level}}" aria-labelledby="notify-title">
<div class="scan__head">
<h2 id="notify-title" class="heading-2">Morning briefs</h2>
<span class="category-pill scan__pill">
{{if eq .Notify.Level "ok"}}sending
{{else if eq .Notify.Level "error"}}failed
{{else if eq .Notify.Level "warn"}}attention
{{else}}off{{end}}
</span>
</div>
<p class="scan__message">{{.Notify.Message}}</p>
{{if or (gt .Notify.Sent 0) (gt .Notify.Failed 0)}}
<p class="scan__meta">{{.Notify.Sent}} delivered · {{.Notify.Failed}} failed{{if .Notify.LastAt}} · last attempt {{.Notify.LastAt}}{{end}}</p>
{{end}}
</section>
{{if .Tabs}}
<nav class="child-tabs" aria-label="Children">
{{range .Tabs}}
@@ -126,26 +143,91 @@
</div>
{{end}}
{{if .Summaries}}
<section class="section" aria-labelledby="summaries-title">
{{if .Send.Scopes}}
<section class="section" id="send-panel" aria-labelledby="send-title">
<div class="section__head">
<h2 id="summaries-title" class="heading-2">Briefs</h2>
<h2 id="send-title" class="heading-2">Send a brief</h2>
<span class="category-pill">Apprise</span>
</div>
{{if not .Send.Enabled}}
<p class="flash flash--error" role="alert">
NOTIFICATIONS NOT CONFIGURED — nothing can be sent. Set {{range $i, $m := .Send.Missing}}{{if $i}}, {{end}}{{$m}}{{end}}.
</p>
{{else}}
{{range .Send.Scopes}}
<details class="send-card">
<summary class="send-card__summary">{{.Label}}</summary>
<form method="post" action="/summaries/send" class="send-card__form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Key}}">
<fieldset class="field">
<legend class="field__label">Recipients</legend>
<div class="check-row">
{{range .Recipients}}
<label class="check"><input type="checkbox" name="recipient" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Name}}</label>
{{end}}
</div>
</fieldset>
<fieldset class="field">
<legend class="field__label">Channels</legend>
<div class="check-row">
{{range $.Send.Channels}}
<label class="check"><input type="checkbox" name="channel" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</fieldset>
{{if .Warning}}<p class="flash flash--error" role="alert">{{.Warning}}</p>{{end}}
<div class="send-card__actions">
<button class="button-utility" type="submit">Send now</button>
<span class="send-card__hint">The preview is exactly what is sent. Overdue first, lists only.</span>
</div>
<details class="brief-wrap" open>
<summary class="send-card__summary">preview ({{len .Preview}} bytes)</summary>
<pre class="brief">{{.Preview}}</pre>
</details>
</form>
</details>
{{end}}
{{end}}
</section>
{{end}}
{{if .Summaries}}
<section class="section" aria-labelledby="sends-title">
<div class="section__head">
<h2 id="sends-title" class="heading-2">Sends</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
</div>
<ul class="item-list">
{{range .Summaries}}
<li class="item" id="summary-{{.ID}}">
<li class="item{{if .Failed}} item--overdue{{end}}" id="summary-{{.ID}}">
<div class="item__body">
<p class="item__title">{{.Recipient}} · {{.Sent}}</p>
<p class="item__meta"><span class="item__subject">built {{.Created}}</span></p>
<pre class="brief">{{.Text}}</pre>
<p class="item__title">{{.Recipient}} · {{.Sent}}{{if .Channel}} · {{.Channel}}{{end}}</p>
<p class="item__meta">
<span class="tag tag--type">{{.Scope}}</span>
{{if .Tag}}<span class="item__subject">{{.Tag}}</span>{{end}}
{{if .Draft}}<span class="item__subject">built {{.Created}}</span>{{end}}
</p>
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<details class="brief-wrap">
<summary class="send-card__summary">brief text ({{len .Text}} bytes)</summary>
<pre class="brief">{{.Text}}</pre>
</details>
</div>
<div class="item__actions">
{{if .Draft}}
<form method="post" action="/summaries/{{.ID}}/send">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility" type="submit">Send</button>
</form>
{{end}}
<form method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief and its delivery record?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</div>
<form class="item__actions" method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li>
{{end}}
</ul>