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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user