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:
247
internal/apprise/apprise.go
Normal file
247
internal/apprise/apprise.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Package apprise delivers notifications through the house Apprise API on
|
||||
// `.35:8210` (AGENTS.md §8). Phase 7 uses it for the morning briefs: one brief
|
||||
// per boy, plus one combined brief to Jo and Sam (FR-9, FR-14).
|
||||
//
|
||||
// The Apprise API holds every destination in a stored configuration, selected
|
||||
// by tag. The school service therefore never holds an email address, a phone
|
||||
// number or an SMTP password: it only names a tag such as "harry_email"
|
||||
// (AGENTS.md §10 rule 3). The API shape it speaks is:
|
||||
//
|
||||
// POST {base}/notify/{key} JSON {"tag":["harry_email"],"title","body","type"}
|
||||
// POST {base}/notify JSON {"urls":[...],"title","body","type"} (stateless)
|
||||
//
|
||||
// A failure is returned as an error with the redacted server text, so the
|
||||
// caller can record it and show it loudly (FR-12). Nothing is retried here.
|
||||
package apprise
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Notification classes understood by Apprise.
|
||||
const (
|
||||
TypeInfo = "info"
|
||||
TypeSuccess = "success"
|
||||
TypeWarning = "warning"
|
||||
TypeFailure = "failure"
|
||||
)
|
||||
|
||||
// Result is the outcome of one delivery attempt.
|
||||
type Result struct {
|
||||
// Status is the HTTP status the Apprise API returned (0 when the request
|
||||
// never completed).
|
||||
Status int
|
||||
// Body is the redacted response text, kept short for the log.
|
||||
Body string
|
||||
// Duration is how long the attempt took.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// OK reports whether Apprise accepted the notification for every service.
|
||||
func (r Result) OK() bool { return r.Status >= 200 && r.Status < 300 }
|
||||
|
||||
// Client posts notifications to one Apprise API server.
|
||||
type Client struct {
|
||||
// BaseURL is the Apprise API root, for example http://192.168.20.35:8210.
|
||||
BaseURL string
|
||||
// Key names a stored Apprise configuration. Empty means stateless mode,
|
||||
// which requires URLs on every send.
|
||||
Key string
|
||||
// Token is sent as a bearer token when the Apprise API is protected.
|
||||
Token string
|
||||
// HTTP is the transport. A nil value means a client with a 15s timeout.
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New builds a client. A zero timeout defaults to fifteen seconds: the morning
|
||||
// brief waits on a real SMTP or WhatsApp gateway, so a short timeout would
|
||||
// report a false failure.
|
||||
func New(baseURL, key, token string, timeout time.Duration) *Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
Key: strings.TrimSpace(key),
|
||||
Token: strings.TrimSpace(token),
|
||||
HTTP: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether a delivery can be attempted. A nil client is not
|
||||
// configured, so callers never branch on nil.
|
||||
func (c *Client) Configured() bool {
|
||||
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
|
||||
return false
|
||||
}
|
||||
return true // the base URL is set; a key or explicit URLs are checked on send
|
||||
}
|
||||
|
||||
// notifyBody is the Apprise API request body. `tag` selects a subset of the
|
||||
// stored configuration; `urls` is the stateless alternative.
|
||||
type notifyBody struct {
|
||||
URLs []string `json:"urls,omitempty"`
|
||||
Tag []string `json:"tag,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// SendTags delivers one notification to every stored destination that carries
|
||||
// one of the given tags. An empty tag list is refused: sending the whole stored
|
||||
// configuration would hit destinations that are configured for other purposes
|
||||
// (and the empty WhatsApp placeholders), so the failure would be ambiguous.
|
||||
func (c *Client) SendTags(ctx context.Context, tags []string, title, body, notifyType string) (Result, error) {
|
||||
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
|
||||
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
|
||||
}
|
||||
if c.Key == "" {
|
||||
return Result{}, fmt.Errorf("apprise key is not set: set SCHOOL_APPRISE_KEY")
|
||||
}
|
||||
clean := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
clean = append(clean, t)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return Result{}, fmt.Errorf("apprise tag is empty: nothing would be delivered")
|
||||
}
|
||||
return c.post(ctx, c.BaseURL+"/notify/"+c.Key, notifyBody{Tag: clean, Title: title, Body: body, Type: notifyType})
|
||||
}
|
||||
|
||||
// SendURLs delivers one notification to an explicit stateless destination list.
|
||||
// It is the fallback when no stored configuration is set.
|
||||
func (c *Client) SendURLs(ctx context.Context, urls []string, title, body, notifyType string) (Result, error) {
|
||||
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
|
||||
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return Result{}, fmt.Errorf("apprise destination list is empty")
|
||||
}
|
||||
return c.post(ctx, c.BaseURL+"/notify", notifyBody{URLs: urls, Title: title, Body: body, Type: notifyType})
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, url string, payload notifyBody) (Result, error) {
|
||||
if payload.Type == "" {
|
||||
payload.Type = TypeInfo
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("encode apprise request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("build apprise request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
httpClient := c.HTTP
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
// A transport error is wrapped by net/http; it never echoes a secret.
|
||||
return Result{Duration: time.Since(started)}, fmt.Errorf("post apprise: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
|
||||
res := Result{Status: resp.StatusCode, Body: Truncate(Redact(strings.TrimSpace(string(body))), 300), Duration: time.Since(started)}
|
||||
if !res.OK() {
|
||||
msg := fmt.Sprintf("apprise returned status %d", resp.StatusCode)
|
||||
if res.Body != "" {
|
||||
msg += ": " + res.Body
|
||||
}
|
||||
return res, fmt.Errorf("post apprise: %s", msg)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Redact removes anything that must never reach a log, a database row or a
|
||||
// screen: email addresses, phone numbers, Apprise keys and long tokens.
|
||||
// Apprise echoes the failing destination in its error text, so this runs on
|
||||
// every response before the text is stored (AGENTS.md §10 rule 3).
|
||||
func Redact(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); {
|
||||
c := s[i]
|
||||
if isEmailStart(c) {
|
||||
if j := emailEnd(s, i); j > i {
|
||||
b.WriteString("<redacted-address>")
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
}
|
||||
if isDigit(c) {
|
||||
// A run of seven or more digits is a phone number or a token.
|
||||
j := i
|
||||
for j < len(s) && (isDigit(s[j]) || s[j] == '-' || s[j] == ' ') && j-i < 24 {
|
||||
j++
|
||||
}
|
||||
run := strings.NewReplacer("-", "", " ", "").Replace(s[i:j])
|
||||
if len(run) >= 7 {
|
||||
b.WriteString("<redacted-number>")
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isEmailStart(c byte) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c)
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
|
||||
// emailEnd returns the index just past an address starting at i, or i when the
|
||||
// text at i is not an address.
|
||||
func emailEnd(s string, i int) int {
|
||||
j := i
|
||||
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '_' || s[j] == '%' || s[j] == '+' || s[j] == '-') {
|
||||
j++
|
||||
}
|
||||
if j >= len(s) || s[j] != '@' {
|
||||
return i
|
||||
}
|
||||
j++
|
||||
host := j
|
||||
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '-') {
|
||||
j++
|
||||
}
|
||||
if j == host || !strings.Contains(s[host:j], ".") {
|
||||
return i
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// Truncate shortens s to at most n bytes, marking that it was cut.
|
||||
func Truncate(s string, n int) string {
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
cut := s[:n]
|
||||
if i := strings.LastIndexAny(cut, " \n"); i > n/2 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
return cut + "… (truncated)"
|
||||
}
|
||||
148
internal/apprise/apprise_test.go
Normal file
148
internal/apprise/apprise_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package apprise
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSendTagsUsesKeyTagAndBearer proves the request shape the house Apprise
|
||||
// API expects: POST /notify/{key} with the tag list and the bearer token.
|
||||
func TestSendTagsUsesKeyTagAndBearer(t *testing.T) {
|
||||
var (
|
||||
path, auth string
|
||||
body notifyBody
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
auth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "abc123", "secret-token", 2*time.Second)
|
||||
res, err := c.SendTags(context.Background(), []string{"harry_email", "harry_whatsapp"}, "Harry — school brief", "OVERDUE\n- Essay", TypeInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if !res.OK() || res.Status != http.StatusOK {
|
||||
t.Errorf("result = %+v, want 200", res)
|
||||
}
|
||||
if path != "/notify/abc123" {
|
||||
t.Errorf("path = %q, want /notify/abc123", path)
|
||||
}
|
||||
if auth != "Bearer secret-token" {
|
||||
t.Errorf("auth = %q, want the bearer token", auth)
|
||||
}
|
||||
if len(body.Tag) != 2 || body.Tag[0] != "harry_email" || body.Tag[1] != "harry_whatsapp" {
|
||||
t.Errorf("tag = %#v, want both tags", body.Tag)
|
||||
}
|
||||
if body.Title != "Harry — school brief" || body.Body != "OVERDUE\n- Essay" || body.Type != TypeInfo {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
if body.URLs != nil {
|
||||
t.Errorf("urls = %#v, want none in keyed mode", body.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTagsFailsLoudlyAndRedacts proves a 424 is an error and that the
|
||||
// echoed address and phone number never reach the caller (AGENTS.md §10 rule 3).
|
||||
func TestSendTagsFailsLoudlyAndRedacts(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusFailedDependency)
|
||||
_, _ = w.Write([]byte(`{"error":"One or more notifications not sent. Failed to send Email to person@example.com and WhatsApp to 61400000000"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "abc123", "", 2*time.Second)
|
||||
res, err := c.SendTags(context.Background(), []string{"harry_email"}, "t", "b", TypeInfo)
|
||||
if err == nil {
|
||||
t.Fatal("want an error on 424")
|
||||
}
|
||||
if res.Status != http.StatusFailedDependency {
|
||||
t.Errorf("status = %d, want 424", res.Status)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"person", "example.com", "61400000000"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Errorf("error leaks %q: %s", leak, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "424") {
|
||||
t.Errorf("error should carry the status: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTagsRefusesEmptyTag proves a send without a tag is refused: sending
|
||||
// the whole stored configuration would hit destinations that are configured for
|
||||
// other purposes, so the result would be ambiguous.
|
||||
func TestSendTagsRefusesEmptyTag(t *testing.T) {
|
||||
c := New("http://127.0.0.1:1", "abc123", "", time.Second)
|
||||
if _, err := c.SendTags(context.Background(), []string{"", " "}, "t", "b", TypeInfo); err == nil {
|
||||
t.Fatal("want an error for an empty tag list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTagsNeedsKey proves stateless mode is explicit: without a key the
|
||||
// caller must use SendURLs, so a missing key never silently sends nowhere.
|
||||
func TestSendTagsNeedsKey(t *testing.T) {
|
||||
c := New("http://127.0.0.1:1", "", "", time.Second)
|
||||
if _, err := c.SendTags(context.Background(), []string{"harry_email"}, "t", "b", TypeInfo); err == nil {
|
||||
t.Fatal("want an error without a key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendURLsStateless covers the fallback shape.
|
||||
func TestSendURLsStateless(t *testing.T) {
|
||||
var body notifyBody
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "", "", 2*time.Second)
|
||||
if _, err := c.SendURLs(context.Background(), []string{"mailto://user:pass@example.com"}, "t", "b", TypeWarning); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if len(body.URLs) != 1 || body.Type != TypeWarning {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"plain text", "plain text"},
|
||||
{"Failed to send Email to sam@example.com", "Failed to send Email to <redacted-address>"},
|
||||
{"phone 61412345678", "phone <redacted-number>"},
|
||||
{"short 12 stays", "short 12 stays"},
|
||||
{"two: a@b.co and c@d.co", "two: <redacted-address> and <redacted-address>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Redact(c.in); got != c.want {
|
||||
t.Errorf("Redact(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := Truncate("short", 10); got != "short" {
|
||||
t.Errorf("Truncate short = %q", got)
|
||||
}
|
||||
long := strings.Repeat("word ", 40)
|
||||
got := Truncate(long, 40)
|
||||
if len(got) > 60 || !strings.HasSuffix(got, "(truncated)") {
|
||||
t.Errorf("Truncate long = %q", got)
|
||||
}
|
||||
}
|
||||
401
internal/brief/brief.go
Normal file
401
internal/brief/brief.go
Normal file
@@ -0,0 +1,401 @@
|
||||
// Package brief builds and sends the morning briefs through the house Apprise
|
||||
// API (AGENTS.md §4 FR-9, FR-10, FR-12, FR-14, §11 Phase 7).
|
||||
//
|
||||
// One brief goes to each boy — what is on today, overdue work, pending work and
|
||||
// the school notices — and one combined brief goes to Jo and Sam. Every
|
||||
// delivery attempt is recorded in `notifications`, one row per recipient per
|
||||
// channel, so a failed send is visible rather than silent (FR-12, FR-13).
|
||||
//
|
||||
// The service never holds a destination: it names an Apprise tag, for example
|
||||
// "harry_email", and the Apprise configuration on `.35` holds the address
|
||||
// (AGENTS.md §10 rule 3).
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// ScopeCombined is the scope of the single brief to Jo and Sam.
|
||||
const ScopeCombined = "combined"
|
||||
|
||||
// Service builds and delivers briefs. Every field is required except Log, which
|
||||
// defaults to the standard logger.
|
||||
type Service struct {
|
||||
Store *store.Store
|
||||
Client *apprise.Client
|
||||
Cfg config.AppriseConfig
|
||||
Loc *time.Location
|
||||
Log *slog.Logger
|
||||
// Now is injectable so tests can pin the clock.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New builds a Service.
|
||||
func New(st *store.Store, client *apprise.Client, cfg config.AppriseConfig, loc *time.Location, log *slog.Logger) *Service {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{Store: st, Client: client, Cfg: cfg, Loc: loc, Log: log, Now: time.Now}
|
||||
}
|
||||
|
||||
// Delivery is one send attempt: one recipient, one channel, one Apprise tag.
|
||||
type Delivery struct {
|
||||
Recipient string
|
||||
Channel string
|
||||
Tag string
|
||||
OK bool
|
||||
Status int
|
||||
Error string
|
||||
// ID is the `notifications` row for this attempt.
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Send is one brief and everything that happened to it.
|
||||
type Send struct {
|
||||
// Scope is the child name, or ScopeCombined.
|
||||
Scope string
|
||||
// Title is the message subject.
|
||||
Title string
|
||||
// Text is the brief itself.
|
||||
Text string
|
||||
// Recipients are the names the brief went to, in order.
|
||||
Recipients []string
|
||||
// Deliveries is one entry per recipient per channel.
|
||||
Deliveries []Delivery
|
||||
// ScanFailed lists the children whose last scan did not complete, so the
|
||||
// caller can exit loudly (FR-12).
|
||||
ScanFailed []string
|
||||
}
|
||||
|
||||
// Failed is the number of delivery attempts that did not deliver.
|
||||
func (s Send) Failed() int {
|
||||
n := 0
|
||||
for _, d := range s.Deliveries {
|
||||
if !d.OK {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Delivered is the number of delivery attempts that delivered.
|
||||
func (s Send) Delivered() int { return len(s.Deliveries) - s.Failed() }
|
||||
|
||||
// OK reports whether every attempt delivered and no scan was broken.
|
||||
func (s Send) OK() bool { return s.Failed() == 0 && len(s.ScanFailed) == 0 }
|
||||
|
||||
// SentOK reports whether every delivery attempt delivered, ignoring the scan
|
||||
// state (which is reported separately).
|
||||
func (s Send) SentOK() bool { return s.Failed() == 0 }
|
||||
|
||||
// FirstError is the first redacted delivery error, or "" when nothing failed.
|
||||
func (s Send) FirstError() string {
|
||||
for _, d := range s.Deliveries {
|
||||
if !d.OK {
|
||||
return d.Error
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChildrenText returns one child's brief and whether that child's last scan
|
||||
// completed. A failed or missing scan never reads as "nothing due": the brief
|
||||
// opens with an explicit warning (FR-12).
|
||||
func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, bool, error) {
|
||||
items, err := s.Store.ItemsForChild(ctx, child.ID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("items for %s: %w", child.Name, err)
|
||||
}
|
||||
buckets := dashboard.Classify(items, s.Now(), s.Loc)
|
||||
text := dashboard.RenderWithLimits(child.Name, buckets, s.Loc, dashboard.MorningLimits)
|
||||
|
||||
sc, err := s.Store.ScanSummaryForChild(ctx, child.ID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("scan summary for %s: %w", child.Name, err)
|
||||
}
|
||||
notice := s.scanNotice(child.Name, sc)
|
||||
if notice == "" {
|
||||
return text, true, nil
|
||||
}
|
||||
return notice + "\n" + text, false, nil
|
||||
}
|
||||
|
||||
// CombinedText returns the single brief for Jo and Sam — both boys in one
|
||||
// message — and the children whose scan did not complete.
|
||||
func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) {
|
||||
parts := make([]dashboard.Combined, 0, len(children))
|
||||
var failed []string
|
||||
var notices []string
|
||||
for _, c := range children {
|
||||
items, err := s.Store.ItemsForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("items for %s: %w", c.Name, err)
|
||||
}
|
||||
parts = append(parts, dashboard.Combined{
|
||||
Child: c.Name,
|
||||
Buckets: dashboard.Classify(items, s.Now(), s.Loc),
|
||||
})
|
||||
sc, err := s.Store.ScanSummaryForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("scan summary for %s: %w", c.Name, err)
|
||||
}
|
||||
if notice := s.scanNotice(c.Name, sc); notice != "" {
|
||||
notices = append(notices, notice)
|
||||
failed = append(failed, c.Name)
|
||||
}
|
||||
}
|
||||
text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits)
|
||||
if len(notices) > 0 {
|
||||
text = strings.Join(notices, "\n") + "\n\n" + text
|
||||
}
|
||||
return text, failed, nil
|
||||
}
|
||||
|
||||
// scanNotice is the FR-12 warning for one child, or "" when the last scan
|
||||
// completed.
|
||||
func (s *Service) scanNotice(child string, sc store.ScanStatus) string {
|
||||
switch {
|
||||
case !sc.EverRun:
|
||||
return fmt.Sprintf("%s — BRIEF UNAVAILABLE: no scan has run yet. Due work is UNKNOWN, not “nothing due”.", child)
|
||||
case sc.Last != nil && !sc.Last.OK:
|
||||
detail := strings.TrimSpace(sc.Last.Error)
|
||||
when := sc.Last.StartedAt.In(s.Loc).Format("Mon 02 Jan 15:04")
|
||||
msg := fmt.Sprintf("%s — SCAN FAILED at %s", child, when)
|
||||
if detail != "" {
|
||||
msg += " (" + detail + ")"
|
||||
}
|
||||
return msg + ". Due work is UNKNOWN, not “nothing due”."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// RecipientsFor resolves names to configured Apprise recipients, and fails
|
||||
// loudly when a name is not configured.
|
||||
func (s *Service) RecipientsFor(names []string) ([]config.Recipient, error) {
|
||||
out := make([]config.Recipient, 0, len(names))
|
||||
for _, name := range names {
|
||||
r, ok := s.Cfg.RecipientByName(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipient %q is not configured (SCHOOL_APPRISE_TAGS)", name)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChildRecipients returns the configured recipients that are children, matched
|
||||
// by name against the children table.
|
||||
func (s *Service) ChildRecipients(child store.Child) ([]config.Recipient, error) {
|
||||
return s.RecipientsFor([]string{child.Name})
|
||||
}
|
||||
|
||||
// ParentRecipients returns the recipients of the combined brief (FR-14).
|
||||
func (s *Service) ParentRecipients() ([]config.Recipient, string, error) {
|
||||
parents := s.Cfg.ParentRecipients()
|
||||
if len(parents) == 0 {
|
||||
return nil, "", fmt.Errorf("no parents configured for the combined brief (SCHOOL_APPRISE_PARENTS)")
|
||||
}
|
||||
names := make([]string, 0, len(parents))
|
||||
for _, p := range parents {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
return parents, strings.Join(names, "+"), nil
|
||||
}
|
||||
|
||||
// Deliver sends one brief to the given recipients on the given channels and
|
||||
// records every attempt. It never returns an error for a failed delivery: the
|
||||
// failure is in the returned Send, recorded in the database, and the caller is
|
||||
// responsible for going loud (FR-12).
|
||||
func (s *Service) Deliver(ctx context.Context, scope, title, text string, recipients []config.Recipient, channels []string) (Send, error) {
|
||||
out := Send{Scope: scope, Title: title, Text: text}
|
||||
if len(recipients) == 0 {
|
||||
return out, fmt.Errorf("no recipients for scope %q", scope)
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return out, fmt.Errorf("no channels requested for scope %q", scope)
|
||||
}
|
||||
if s.Client == nil || !s.Client.Configured() {
|
||||
return out, fmt.Errorf("apprise is not configured: set %s",
|
||||
strings.Join(s.Cfg.Missing(), ", "))
|
||||
}
|
||||
|
||||
now := s.Now()
|
||||
for _, r := range recipients {
|
||||
out.Recipients = append(out.Recipients, r.Name)
|
||||
for _, ch := range channels {
|
||||
tag := s.Cfg.ChannelTag(r, ch)
|
||||
d := Delivery{Recipient: r.Name, Channel: ch, Tag: tag}
|
||||
|
||||
res, err := s.Client.SendTags(ctx, []string{tag}, title, text, apprise.TypeInfo)
|
||||
d.Status = res.Status
|
||||
d.OK = err == nil
|
||||
if err != nil {
|
||||
// apprise.Redact has already run on the response text, so no
|
||||
// address or phone number reaches the log or the row.
|
||||
d.Error = apprise.Truncate(err.Error(), 300)
|
||||
}
|
||||
sentAt := &now
|
||||
if !d.OK {
|
||||
sentAt = nil
|
||||
}
|
||||
id, recErr := s.Store.RecordNotification(ctx, store.Notification{
|
||||
Scope: scope,
|
||||
Recipient: r.Name,
|
||||
Channel: ch,
|
||||
Tag: tag,
|
||||
Text: text,
|
||||
OK: d.OK,
|
||||
Error: d.Error,
|
||||
SentAt: sentAt,
|
||||
})
|
||||
if recErr != nil {
|
||||
return out, recErr
|
||||
}
|
||||
d.ID = id
|
||||
if d.OK {
|
||||
s.Log.Info("brief delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status)
|
||||
} else {
|
||||
s.Log.Error("brief NOT delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status, "err", d.Error)
|
||||
}
|
||||
out.Deliveries = append(out.Deliveries, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// planned is one brief that is ready to go: the send, plus the resolved
|
||||
// Apprise recipients it would go to.
|
||||
type planned struct {
|
||||
Send
|
||||
recipients []config.Recipient
|
||||
}
|
||||
|
||||
// Morning builds and sends the whole morning run: one brief per boy, then one
|
||||
// combined brief to Jo and Sam (FR-9, FR-14). The briefs always go out, even
|
||||
// when a scan failed, because the failure notice is the message that matters
|
||||
// most; ScanFailed tells the caller to exit loudly.
|
||||
func (s *Service) Morning(ctx context.Context, channels []string) ([]Send, error) {
|
||||
plans, err := s.plan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sends := make([]Send, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
send, err := s.Deliver(ctx, p.Scope, p.Title, p.Text, p.recipients, channels)
|
||||
if err != nil {
|
||||
return sends, err
|
||||
}
|
||||
send.ScanFailed = p.ScanFailed
|
||||
sends = append(sends, send)
|
||||
}
|
||||
return sends, nil
|
||||
}
|
||||
|
||||
// Plan builds every morning brief without sending anything. It is the build
|
||||
// path Morning uses, exposed for the machine endpoint's dry run so the n8n
|
||||
// wiring can be checked without messaging the family.
|
||||
func (s *Service) Plan(ctx context.Context) ([]Send, error) {
|
||||
plans, err := s.plan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Send, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
out = append(out, p.Send)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plan builds the per-boy briefs and the combined brief, without sending.
|
||||
func (s *Service) plan(ctx context.Context) ([]planned, error) {
|
||||
children, err := s.Store.ListChildren(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list children: %w", err)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, fmt.Errorf("no children on file: nothing to brief")
|
||||
}
|
||||
|
||||
var out []planned
|
||||
for _, c := range children {
|
||||
text, scanOK, err := s.ChildrenText(ctx, c)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
recipients, err := s.ChildRecipients(c)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
p := planned{
|
||||
Send: Send{
|
||||
Scope: c.Name,
|
||||
Title: fmt.Sprintf("%s — school brief %s", c.Name, s.Now().In(s.Loc).Format("Mon 02 Jan")),
|
||||
Text: text,
|
||||
Recipients: []string{c.Name},
|
||||
},
|
||||
recipients: recipients,
|
||||
}
|
||||
if !scanOK {
|
||||
p.ScanFailed = []string{c.Name}
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
parents, _, err := s.ParentRecipients()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
text, failed, err := s.CombinedText(ctx, children)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
names := make([]string, 0, len(parents))
|
||||
for _, r := range parents {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
out = append(out, planned{
|
||||
Send: Send{
|
||||
Scope: ScopeCombined,
|
||||
Title: fmt.Sprintf("School brief — %s %s", combinedNames(children), s.Now().In(s.Loc).Format("Mon 02 Jan")),
|
||||
Text: text,
|
||||
Recipients: names,
|
||||
ScanFailed: failed,
|
||||
},
|
||||
recipients: parents,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func combinedNames(children []store.Child) string {
|
||||
names := make([]string, 0, len(children))
|
||||
for _, c := range children {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
switch len(names) {
|
||||
case 0:
|
||||
return "school"
|
||||
case 1:
|
||||
return names[0]
|
||||
case 2:
|
||||
return names[0] + " and " + names[1]
|
||||
default:
|
||||
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// CombinedNames is the exported form of the display label for the combined
|
||||
// brief, used by the Send panel.
|
||||
func CombinedNames(children []store.Child) string { return combinedNames(children) }
|
||||
334
internal/brief/brief_test.go
Normal file
334
internal/brief/brief_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// fakeApprise records every notification request and answers with the status
|
||||
// the test chooses, so a delivery and a failure are both provable.
|
||||
type fakeApprise struct {
|
||||
mu sync.Mutex
|
||||
tags []string
|
||||
titles []string
|
||||
bodies []string
|
||||
tagFail map[string]int // tag -> status
|
||||
fallback int
|
||||
}
|
||||
|
||||
func (f *fakeApprise) server(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Tag []string `json:"tag"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
f.tags = append(f.tags, strings.Join(body.Tag, ","))
|
||||
f.titles = append(f.titles, body.Title)
|
||||
f.bodies = append(f.bodies, body.Body)
|
||||
status := f.fallback
|
||||
for _, tag := range body.Tag {
|
||||
if s, ok := f.tagFail[tag]; ok {
|
||||
status = s
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if status != http.StatusOK {
|
||||
_, _ = w.Write([]byte(`{"error":"One or more notifications not sent"}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func (f *fakeApprise) calls() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.tags...)
|
||||
}
|
||||
|
||||
// newService builds a brief.Service against a fresh database and the fake
|
||||
// gateway.
|
||||
func newService(t *testing.T, url string, seed func(*store.Store)) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
t.Fatalf("location: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
if err := st.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := st.SeedChildren(ctx, "Harry", "Finn"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if seed != nil {
|
||||
seed(st)
|
||||
}
|
||||
cfg := config.AppriseConfig{
|
||||
BaseURL: url,
|
||||
Key: "test-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"},
|
||||
}
|
||||
client := apprise.New(url, cfg.Key, "", cfg.Timeout)
|
||||
svc := New(st, client, cfg, loc, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
svc.Now = func() time.Time { return time.Date(2026, 10, 16, 6, 0, 0, 0, loc) }
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// seedItems stores one overdue task per child and a successful scan, so the
|
||||
// briefs are not empty.
|
||||
func seedItems(st *store.Store) {
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
|
||||
Source: "compass", Type: "task", Subject: "English", Title: "Essay",
|
||||
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "h-essay",
|
||||
}}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{{
|
||||
Source: "compass", Type: "task", Subject: "Science", Title: "Lab report",
|
||||
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "f-lab",
|
||||
}}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := st.RecordScan(ctx, &finn.ID, 4, 1, true, ""); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMorningSendsPerBoyAndCombined proves FR-9 and FR-14: one brief per boy,
|
||||
// one combined brief to Jo and Sam, one recorded row per recipient per channel.
|
||||
func TestMorningSendsPerBoyAndCombined(t *testing.T) {
|
||||
fake := &fakeApprise{}
|
||||
srv := fake.server(t)
|
||||
svc, st := newService(t, srv.URL, seedItems)
|
||||
|
||||
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
|
||||
if err != nil {
|
||||
t.Fatalf("morning: %v", err)
|
||||
}
|
||||
if len(sends) != 3 {
|
||||
t.Fatalf("sends = %d, want Harry, Finn and combined", len(sends))
|
||||
}
|
||||
for _, s := range sends {
|
||||
if !s.OK() {
|
||||
t.Errorf("send %s not OK: %+v", s.Scope, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels.
|
||||
if got := len(sends[0].Deliveries); got != 2 {
|
||||
t.Errorf("Harry deliveries = %d, want 2", got)
|
||||
}
|
||||
combined := sends[2]
|
||||
if combined.Scope != ScopeCombined || len(combined.Deliveries) != 4 {
|
||||
t.Fatalf("combined = %+v", combined)
|
||||
}
|
||||
recipients := map[string]bool{}
|
||||
for _, d := range combined.Deliveries {
|
||||
recipients[d.Recipient] = true
|
||||
}
|
||||
if !recipients["Jo"] || !recipients["Sam"] {
|
||||
t.Errorf("combined recipients = %#v", recipients)
|
||||
}
|
||||
if len(combined.Recipients) != 2 {
|
||||
t.Errorf("combined recipient names = %#v", combined.Recipients)
|
||||
}
|
||||
|
||||
// The tags are the configured per-channel names, and the combined text
|
||||
// carries both boys, overdue first.
|
||||
want := map[string]bool{
|
||||
"harry_email": true, "harry_whatsapp": true,
|
||||
"finn_email": true, "finn_whatsapp": true,
|
||||
"joanna_email": true, "joanna_whatsapp": true,
|
||||
"sam_email": true, "sam_whatsapp": true,
|
||||
}
|
||||
calls := fake.calls()
|
||||
if len(calls) != 8 {
|
||||
t.Fatalf("calls = %#v, want 8", calls)
|
||||
}
|
||||
for _, c := range calls {
|
||||
if !want[c] {
|
||||
t.Errorf("unexpected tag %q", c)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(combined.Text, "OVERDUE") ||
|
||||
!strings.Contains(combined.Text, "Harry: English: Essay") ||
|
||||
!strings.Contains(combined.Text, "Finn: Science: Lab report") {
|
||||
t.Errorf("combined text = %q", combined.Text)
|
||||
}
|
||||
|
||||
rows, err := st.RecentNotifications(context.Background(), 20)
|
||||
if err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
if len(rows) != 8 {
|
||||
t.Fatalf("rows = %d, want 8", len(rows))
|
||||
}
|
||||
for _, n := range rows {
|
||||
if !n.OK || n.SentAt == nil || n.Channel == "" || n.Tag == "" {
|
||||
t.Errorf("row = %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliveryFailureIsRecordedAndLoud proves a failed channel is recorded with
|
||||
// its error and no sent_at, and that the send is not reported as OK (FR-12).
|
||||
func TestDeliveryFailureIsRecordedAndLoud(t *testing.T) {
|
||||
fake := &fakeApprise{tagFail: map[string]int{
|
||||
"harry_whatsapp": http.StatusFailedDependency,
|
||||
"finn_whatsapp": http.StatusFailedDependency,
|
||||
"joanna_whatsapp": http.StatusFailedDependency,
|
||||
"sam_whatsapp": http.StatusFailedDependency,
|
||||
}}
|
||||
srv := fake.server(t)
|
||||
svc, st := newService(t, srv.URL, seedItems)
|
||||
|
||||
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
|
||||
if err != nil {
|
||||
t.Fatalf("morning: %v", err)
|
||||
}
|
||||
for _, s := range sends {
|
||||
if s.SentOK() {
|
||||
t.Errorf("%s reported OK despite the WhatsApp failures", s.Scope)
|
||||
}
|
||||
wantFailed := 1 // the boy's own WhatsApp tag
|
||||
if s.Scope == ScopeCombined {
|
||||
wantFailed = 2 // Jo and Sam
|
||||
}
|
||||
if s.Failed() != wantFailed {
|
||||
t.Errorf("%s failed = %d, want %d (one per WhatsApp recipient)", s.Scope, s.Failed(), wantFailed)
|
||||
}
|
||||
if s.FirstError() == "" {
|
||||
t.Errorf("%s has no recorded error", s.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
status, err := st.NotificationSummary(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if status.Sent != 4 || status.Failed != 4 {
|
||||
t.Errorf("summary = %+v, want 4 sent and 4 failed", status)
|
||||
}
|
||||
rows, _ := st.RecentNotifications(context.Background(), 20)
|
||||
for _, n := range rows {
|
||||
if n.Channel == config.ChannelWhatsApp {
|
||||
if n.OK || n.SentAt != nil || n.Error == "" {
|
||||
t.Errorf("whatsapp row = %+v, want a loud failure", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedScanIsNeverNothingDue proves FR-12: a child whose last scan failed
|
||||
// gets an explicit warning instead of a clean, empty brief.
|
||||
func TestFailedScanIsNeverNothingDue(t *testing.T) {
|
||||
fake := &fakeApprise{}
|
||||
srv := fake.server(t)
|
||||
svc, _ := newService(t, srv.URL, func(st *store.Store) {
|
||||
seedItems(st)
|
||||
harry, _ := st.ChildByName(context.Background(), "Harry")
|
||||
if _, err := st.RecordScan(context.Background(), &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail})
|
||||
if err != nil {
|
||||
t.Fatalf("morning: %v", err)
|
||||
}
|
||||
if len(sends[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" {
|
||||
t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed)
|
||||
}
|
||||
if !strings.Contains(sends[0].Text, "SCAN FAILED") {
|
||||
t.Errorf("brief does not say the scan failed: %q", sends[0].Text)
|
||||
}
|
||||
if !strings.Contains(sends[0].Text, "UNKNOWN") {
|
||||
t.Errorf("brief does not say due work is unknown: %q", sends[0].Text)
|
||||
}
|
||||
if !strings.Contains(sends[2].Text, "SCAN FAILED") {
|
||||
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text)
|
||||
}
|
||||
if sends[0].OK() {
|
||||
t.Error("a scan failure must make the run not OK")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoScanYetIsLoud proves a child that has never been scanned is never shown
|
||||
// as "nothing due".
|
||||
func TestNoScanYetIsLoud(t *testing.T) {
|
||||
fake := &fakeApprise{}
|
||||
srv := fake.server(t)
|
||||
svc, st := newService(t, srv.URL, nil)
|
||||
|
||||
harry, _ := st.ChildByName(context.Background(), "Harry")
|
||||
text, ok, err := svc.ChildrenText(context.Background(), *harry)
|
||||
if err != nil {
|
||||
t.Fatalf("brief: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("a child with no scan must not be reported as OK")
|
||||
}
|
||||
if !strings.Contains(text, "no scan has run yet") {
|
||||
t.Errorf("brief = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnconfiguredRecipientFails proves a name that is not configured fails
|
||||
// loudly instead of sending somewhere unexpected.
|
||||
func TestUnconfiguredRecipientFails(t *testing.T) {
|
||||
fake := &fakeApprise{}
|
||||
srv := fake.server(t)
|
||||
svc, _ := newService(t, srv.URL, seedItems)
|
||||
|
||||
if _, err := svc.RecipientsFor([]string{"Nana"}); err == nil {
|
||||
t.Fatal("want an error for an unknown recipient")
|
||||
}
|
||||
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", nil, []string{config.ChannelEmail}); err == nil {
|
||||
t.Fatal("want an error for no recipients")
|
||||
}
|
||||
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", []config.Recipient{{Name: "Harry", TagBase: "harry"}}, nil); err == nil {
|
||||
t.Fatal("want an error for no channels")
|
||||
}
|
||||
}
|
||||
127
internal/config/apprise_test.go
Normal file
127
internal/config/apprise_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clearAppriseEnv removes every Apprise variable so a load starts from the
|
||||
// defaults, not from the developer's environment.
|
||||
func clearAppriseEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"SCHOOL_APPRISE_URL", "SCHOOL_APPRISE_KEY", "SCHOOL_APPRISE_TOKEN",
|
||||
"SCHOOL_APPRISE_TIMEOUT", "SCHOOL_APPRISE_CHANNELS", "SCHOOL_APPRISE_TAGS",
|
||||
"SCHOOL_APPRISE_PARENTS", "SCHOOL_NOTIFY_TOKEN",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppriseDefaults(t *testing.T) {
|
||||
clearAppriseEnv(t)
|
||||
|
||||
cfg, err := loadApprise()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.BaseURL != DefaultAppriseURL {
|
||||
t.Errorf("url = %q, want %q", cfg.BaseURL, DefaultAppriseURL)
|
||||
}
|
||||
if cfg.Enabled() {
|
||||
t.Error("Enabled = true without a key")
|
||||
}
|
||||
if got := strings.Join(cfg.Missing(), ","); got != "SCHOOL_APPRISE_KEY" {
|
||||
t.Errorf("missing = %q", got)
|
||||
}
|
||||
if cfg.Timeout != 15*time.Second {
|
||||
t.Errorf("timeout = %v", cfg.Timeout)
|
||||
}
|
||||
if len(cfg.Channels) != 2 || cfg.Channels[0] != ChannelEmail || cfg.Channels[1] != ChannelWhatsApp {
|
||||
t.Errorf("channels = %#v", cfg.Channels)
|
||||
}
|
||||
// The family recipients are the default, so a missing env value cannot
|
||||
// silently send the briefs nowhere.
|
||||
names := make([]string, 0, len(cfg.Recipients))
|
||||
for _, r := range cfg.Recipients {
|
||||
names = append(names, r.Name+"="+r.TagBase)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != DefaultAppriseTags {
|
||||
t.Errorf("recipients = %q, want %q", got, DefaultAppriseTags)
|
||||
}
|
||||
harry, ok := cfg.RecipientByName("harry")
|
||||
if !ok {
|
||||
t.Fatal("Harry is not configured")
|
||||
}
|
||||
if got := cfg.ChannelTag(harry, ChannelWhatsApp); got != "harry_whatsapp" {
|
||||
t.Errorf("whatsapp tag = %q", got)
|
||||
}
|
||||
if got := cfg.ChannelTag(harry, ChannelEmail); got != "harry_email" {
|
||||
t.Errorf("email tag = %q", got)
|
||||
}
|
||||
if got := cfg.ParentRecipients(); len(got) != 2 || got[0].Name != "Jo" || got[1].Name != "Sam" {
|
||||
t.Errorf("parents = %#v", got)
|
||||
}
|
||||
if cfg.IsParent("Finn") {
|
||||
t.Error("Finn is not a parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppriseCustom(t *testing.T) {
|
||||
clearAppriseEnv(t)
|
||||
t.Setenv("SCHOOL_APPRISE_URL", "http://example.test:9000/")
|
||||
t.Setenv("SCHOOL_APPRISE_KEY", "abc")
|
||||
t.Setenv("SCHOOL_APPRISE_TIMEOUT", "3s")
|
||||
t.Setenv("SCHOOL_APPRISE_CHANNELS", "email")
|
||||
t.Setenv("SCHOOL_APPRISE_TAGS", "Harry=h")
|
||||
t.Setenv("SCHOOL_APPRISE_PARENTS", "Harry")
|
||||
t.Setenv("SCHOOL_NOTIFY_TOKEN", "n8n-token")
|
||||
|
||||
cfg, err := loadApprise()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !cfg.Enabled() {
|
||||
t.Error("Enabled = false with a key")
|
||||
}
|
||||
if cfg.BaseURL != "http://example.test:9000" {
|
||||
t.Errorf("url = %q", cfg.BaseURL)
|
||||
}
|
||||
if cfg.Timeout != 3*time.Second {
|
||||
t.Errorf("timeout = %v", cfg.Timeout)
|
||||
}
|
||||
if len(cfg.Channels) != 1 || cfg.Channels[0] != ChannelEmail {
|
||||
t.Errorf("channels = %#v", cfg.Channels)
|
||||
}
|
||||
if cfg.NotifyToken != "n8n-token" {
|
||||
t.Errorf("notify token = %q", cfg.NotifyToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAppriseRejectsBadInput proves a typo fails at start-up instead of at
|
||||
// 6:00am, when nobody is watching (FR-12).
|
||||
func TestLoadAppriseRejectsBadInput(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T){
|
||||
"unknown channel": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_CHANNELS", "sms") },
|
||||
"unknown parent": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_PARENTS", "Nana") },
|
||||
"bad tag entry": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_TAGS", "Harry") },
|
||||
}
|
||||
for name, set := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clearAppriseEnv(t)
|
||||
set(t)
|
||||
if _, err := loadApprise(); err == nil {
|
||||
t.Fatal("want an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidChannels(t *testing.T) {
|
||||
c := AppriseConfig{Channels: []string{ChannelEmail, "sms", ChannelWhatsApp}}
|
||||
ok, bad := c.ValidChannels()
|
||||
if len(ok) != 2 || len(bad) != 1 || bad[0] != "sms" {
|
||||
t.Errorf("ok = %#v bad = %#v", ok, bad)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ type Config struct {
|
||||
// Calendar is the Home Assistant calendar target for important school
|
||||
// events (AGENTS.md §4 FR-8).
|
||||
Calendar CalendarConfig
|
||||
// Apprise is the notification gateway for the morning briefs (FR-9,
|
||||
// FR-14).
|
||||
Apprise AppriseConfig
|
||||
}
|
||||
|
||||
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes
|
||||
@@ -91,6 +94,137 @@ func (c CalendarConfig) Missing() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// AppriseConfig points at the house Apprise API on `.35:8210` (AGENTS.md §8)
|
||||
// and names the destinations by tag. The school service never holds an email
|
||||
// address, a phone number or an SMTP password; the Apprise API holds those and
|
||||
// selects them by tag (AGENTS.md §10 rule 3).
|
||||
//
|
||||
// Environment variables:
|
||||
//
|
||||
// SCHOOL_APPRISE_URL API base, default http://192.168.20.35:8210
|
||||
// SCHOOL_APPRISE_KEY stored configuration key (required to send)
|
||||
// SCHOOL_APPRISE_TOKEN optional bearer token
|
||||
// SCHOOL_APPRISE_TIMEOUT per-request timeout, default 15s
|
||||
// SCHOOL_APPRISE_CHANNELS channels to use, default "email,whatsapp"
|
||||
// SCHOOL_APPRISE_TAGS recipient=tag-base list, default
|
||||
// "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
|
||||
// SCHOOL_APPRISE_PARENTS who gets the combined brief, default "Jo,Sam"
|
||||
// SCHOOL_NOTIFY_TOKEN bearer token for POST /notify/morning (n8n)
|
||||
//
|
||||
// A channel tag is the recipient's tag base plus "_email" or "_whatsapp",
|
||||
// which is the naming the house Apprise configuration already uses.
|
||||
type AppriseConfig struct {
|
||||
BaseURL string
|
||||
// Key names the stored Apprise configuration that holds every destination.
|
||||
Key string
|
||||
// Token is an optional bearer token when the Apprise API is protected.
|
||||
Token string
|
||||
// Timeout bounds one delivery attempt.
|
||||
Timeout time.Duration
|
||||
// Channels are the channels the morning brief uses, in order. Each entry is
|
||||
// "email" or "whatsapp".
|
||||
Channels []string
|
||||
// Recipients are the people a brief can be sent to, in order.
|
||||
Recipients []Recipient
|
||||
// Parents are the recipient names that receive the combined brief.
|
||||
Parents []string
|
||||
// NotifyToken guards POST /notify/morning, the endpoint n8n calls.
|
||||
NotifyToken string
|
||||
}
|
||||
|
||||
// Recipient is one person a brief can go to. TagBase is the Apprise tag prefix,
|
||||
// for example "harry" for the tags harry_email and harry_whatsapp.
|
||||
type Recipient struct {
|
||||
Name string
|
||||
TagBase string
|
||||
}
|
||||
|
||||
// The channels Phase 7 delivers on (AGENTS.md §13 decision 8).
|
||||
const (
|
||||
ChannelEmail = "email"
|
||||
ChannelWhatsApp = "whatsapp"
|
||||
)
|
||||
|
||||
// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
|
||||
const DefaultAppriseURL = "http://192.168.20.35:8210"
|
||||
|
||||
// DefaultAppriseTags is the recipient list already present in the house
|
||||
// Apprise configuration: each boy, and both parents.
|
||||
const DefaultAppriseTags = "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
|
||||
|
||||
// DefaultAppriseParents is who receives the combined brief (FR-14).
|
||||
const DefaultAppriseParents = "Jo,Sam"
|
||||
|
||||
// Enabled reports whether a brief can be delivered at all.
|
||||
func (c AppriseConfig) Enabled() bool {
|
||||
return c.BaseURL != "" && c.Key != ""
|
||||
}
|
||||
|
||||
// Missing lists the environment variables that are still unset, in the order
|
||||
// the operator should set them. An empty result means the gateway is usable.
|
||||
func (c AppriseConfig) Missing() []string {
|
||||
var out []string
|
||||
if c.BaseURL == "" {
|
||||
out = append(out, "SCHOOL_APPRISE_URL")
|
||||
}
|
||||
if c.Key == "" {
|
||||
out = append(out, "SCHOOL_APPRISE_KEY")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecipientByName returns the configured recipient with the given name.
|
||||
func (c AppriseConfig) RecipientByName(name string) (Recipient, bool) {
|
||||
for _, r := range c.Recipients {
|
||||
if strings.EqualFold(r.Name, strings.TrimSpace(name)) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return Recipient{}, false
|
||||
}
|
||||
|
||||
// ChannelTag is the Apprise tag for one recipient and channel, for example
|
||||
// "harry_email".
|
||||
func (c AppriseConfig) ChannelTag(r Recipient, channel string) string {
|
||||
return r.TagBase + "_" + strings.ToLower(strings.TrimSpace(channel))
|
||||
}
|
||||
|
||||
// IsParent reports whether the recipient receives the combined brief.
|
||||
func (c AppriseConfig) IsParent(name string) bool {
|
||||
for _, p := range c.Parents {
|
||||
if strings.EqualFold(strings.TrimSpace(p), strings.TrimSpace(name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParentRecipients returns the configured parents, in order.
|
||||
func (c AppriseConfig) ParentRecipients() []Recipient {
|
||||
out := make([]Recipient, 0, len(c.Parents))
|
||||
for _, name := range c.Parents {
|
||||
if r, ok := c.RecipientByName(name); ok {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidChannels returns the requested channels that are configured, in order,
|
||||
// and the ones that are not.
|
||||
func (c AppriseConfig) ValidChannels() ([]string, []string) {
|
||||
var ok, bad []string
|
||||
for _, ch := range c.Channels {
|
||||
switch ch {
|
||||
case ChannelEmail, ChannelWhatsApp:
|
||||
ok = append(ok, ch)
|
||||
default:
|
||||
bad = append(bad, ch)
|
||||
}
|
||||
}
|
||||
return ok, bad
|
||||
}
|
||||
|
||||
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
|
||||
const DefaultTimezone = "Australia/Sydney"
|
||||
|
||||
@@ -137,6 +271,11 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg.Location = loc
|
||||
cfg.Calendar = loadCalendar()
|
||||
apprise, err := loadApprise()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Apprise = apprise
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -148,21 +287,95 @@ type DBConfig struct {
|
||||
DBPath string
|
||||
Location *time.Location
|
||||
Calendar CalendarConfig
|
||||
Apprise AppriseConfig
|
||||
}
|
||||
|
||||
// LoadDB reads the database path and the family timezone only.
|
||||
// LoadDB reads the database path, the family timezone, the calendar target and
|
||||
// the notification gateway only.
|
||||
func LoadDB() (*DBConfig, error) {
|
||||
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
|
||||
}
|
||||
apprise, err := loadApprise()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DBConfig{
|
||||
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
|
||||
Location: loc,
|
||||
Calendar: loadCalendar(),
|
||||
Apprise: apprise,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadApprise reads the Apprise gateway settings. Like the calendar target it
|
||||
// never fails on an empty value: the service boots before the gateway is
|
||||
// configured, and every send then fails loudly instead of silently.
|
||||
func loadApprise() (AppriseConfig, error) {
|
||||
c := AppriseConfig{
|
||||
BaseURL: strings.TrimRight(env("SCHOOL_APPRISE_URL", DefaultAppriseURL), "/"),
|
||||
Key: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_KEY")),
|
||||
Token: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TOKEN")),
|
||||
Timeout: 15 * time.Second,
|
||||
Channels: splitList(env("SCHOOL_APPRISE_CHANNELS", ChannelEmail+","+ChannelWhatsApp)),
|
||||
Parents: splitList(env("SCHOOL_APPRISE_PARENTS", DefaultAppriseParents)),
|
||||
NotifyToken: strings.TrimSpace(os.Getenv("SCHOOL_NOTIFY_TOKEN")),
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TIMEOUT")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
c.Timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
recipients, err := parseRecipients(env("SCHOOL_APPRISE_TAGS", DefaultAppriseTags))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("SCHOOL_APPRISE_TAGS: %w", err)
|
||||
}
|
||||
c.Recipients = recipients
|
||||
|
||||
for _, p := range c.Parents {
|
||||
if _, ok := c.RecipientByName(p); !ok {
|
||||
return c, fmt.Errorf("SCHOOL_APPRISE_PARENTS: %q is not in SCHOOL_APPRISE_TAGS", p)
|
||||
}
|
||||
}
|
||||
if _, bad := c.ValidChannels(); len(bad) > 0 {
|
||||
return c, fmt.Errorf("SCHOOL_APPRISE_CHANNELS: unknown channel(s) %s", strings.Join(bad, ", "))
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// parseRecipients reads the "Name=tagbase,Name=tagbase" recipient list. The tag
|
||||
// base names the Apprise destinations, so a name that is wrong fails at send
|
||||
// time with the Apprise error, not here.
|
||||
func parseRecipients(v string) ([]Recipient, error) {
|
||||
var out []Recipient
|
||||
for _, entry := range splitList(v) {
|
||||
name, tag, found := strings.Cut(entry, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
tag = strings.TrimSpace(tag)
|
||||
if !found || name == "" || tag == "" {
|
||||
return nil, fmt.Errorf("expected Name=tag entries, got %q", entry)
|
||||
}
|
||||
out = append(out, Recipient{Name: name, TagBase: tag})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no recipients configured")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitList splits a comma list and drops empty entries.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadCalendar reads the Home Assistant calendar target. It never fails: an
|
||||
// empty value simply leaves the target disabled, so the service boots before
|
||||
// the calendar is configured and the calendar commands fail loudly instead.
|
||||
|
||||
@@ -178,29 +178,50 @@ func startOfDay(t time.Time, loc *time.Location) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// Limits caps a section so a brief stays brief (AGENTS.md §10 rule 6). Overdue
|
||||
// work is never capped; pending work without a due date is the long tail, so it
|
||||
// is the one that is summarised.
|
||||
type Limits struct {
|
||||
// NoDue is the most no-due-date items a brief lists (0 means no cap).
|
||||
NoDue int
|
||||
}
|
||||
|
||||
// MorningLimits is the cap the per-boy and combined morning briefs use. The
|
||||
// dashboard and the preview always show the full lists.
|
||||
var MorningLimits = Limits{NoDue: 5}
|
||||
|
||||
// Render writes one child's brief as plain text: overdue first, then due today,
|
||||
// due this week, no due date and messages (FR-9, FR-14, AGENTS.md §4 rule 6).
|
||||
func Render(child string, b Buckets, loc *time.Location) string {
|
||||
return RenderWithLimits(child, b, loc, Limits{})
|
||||
}
|
||||
|
||||
// RenderWithLimits is Render with a cap on the long-tail sections.
|
||||
func RenderWithLimits(child string, b Buckets, loc *time.Location, lim Limits) string {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "%s — %s.\n", child, b.Headline())
|
||||
|
||||
writeSection(&sb, "OVERDUE", b.Overdue, loc)
|
||||
writeSection(&sb, "DUE TODAY", b.DueToday, loc)
|
||||
writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc)
|
||||
writeSection(&sb, "NO DUE DATE", b.NoDue, loc)
|
||||
writeSection(&sb, "OVERDUE", b.Overdue, loc, 0)
|
||||
writeSection(&sb, "DUE TODAY", b.DueToday, loc, 0)
|
||||
writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc, 0)
|
||||
writeSection(&sb, "NO DUE DATE", b.NoDue, loc, lim.NoDue)
|
||||
writeMessages(&sb, b.Messages, loc)
|
||||
return strings.TrimRight(sb.String(), "\n") + "\n"
|
||||
}
|
||||
|
||||
func writeSection(sb *strings.Builder, title string, items []store.Item, loc *time.Location) {
|
||||
func writeSection(sb *strings.Builder, title string, items []store.Item, loc *time.Location, limit int) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "\n%s\n", title)
|
||||
for _, it := range items {
|
||||
for i, it := range items {
|
||||
if limit > 0 && i >= limit {
|
||||
fmt.Fprintf(sb, "- … and %d more with no due date (see the dashboard)\n", len(items)-limit)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(sb, "- %s\n", line(it, loc))
|
||||
}
|
||||
}
|
||||
@@ -219,6 +240,110 @@ func writeMessages(sb *strings.Builder, items []store.Item, loc *time.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// Combined is one child's share of the combined brief.
|
||||
type Combined struct {
|
||||
Child string
|
||||
Buckets Buckets
|
||||
}
|
||||
|
||||
// RenderCombined writes the single brief for Jo and Sam (FR-14): both boys in
|
||||
// one message, overdue first, lists only (AGENTS.md §10 rule 6). Every line is
|
||||
// prefixed with the child's name so a parent never has to guess whose work it
|
||||
// is. It never claims "nothing due" by itself: the caller adds the scan warning
|
||||
// when a scan failed (FR-12).
|
||||
func RenderCombined(parts []Combined, loc *time.Location) string {
|
||||
return RenderCombinedWithLimits(parts, loc, Limits{})
|
||||
}
|
||||
|
||||
// RenderCombinedWithLimits is RenderCombined with a cap on the long tail.
|
||||
func RenderCombinedWithLimits(parts []Combined, loc *time.Location, lim Limits) string {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
names := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
names = append(names, p.Child)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "%s — %s.\n", joinNames(names), combinedHeadline(parts))
|
||||
|
||||
writeCombined(&sb, "OVERDUE", parts, loc, 0, func(b Buckets) []store.Item { return b.Overdue })
|
||||
writeCombined(&sb, "DUE TODAY", parts, loc, 0, func(b Buckets) []store.Item { return b.DueToday })
|
||||
writeCombined(&sb, "DUE THIS WEEK", parts, loc, 0, func(b Buckets) []store.Item { return b.DueWeek })
|
||||
writeCombined(&sb, "NO DUE DATE", parts, loc, lim.NoDue, func(b Buckets) []store.Item { return b.NoDue })
|
||||
writeCombinedMessages(&sb, parts, loc)
|
||||
return strings.TrimRight(sb.String(), "\n") + "\n"
|
||||
}
|
||||
|
||||
// joinNames renders "Harry and Finn" for any number of children.
|
||||
func joinNames(names []string) string {
|
||||
switch len(names) {
|
||||
case 0:
|
||||
return "School brief"
|
||||
case 1:
|
||||
return names[0]
|
||||
case 2:
|
||||
return names[0] + " and " + names[1]
|
||||
default:
|
||||
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// combinedHeadline counts both boys in one line, so the whole family sees the
|
||||
// size of the day before the lists.
|
||||
func combinedHeadline(parts []Combined) string {
|
||||
var overdue, today, week, messages int
|
||||
for _, p := range parts {
|
||||
overdue += len(p.Buckets.Overdue)
|
||||
today += len(p.Buckets.DueToday)
|
||||
week += len(p.Buckets.DueWeek)
|
||||
messages += len(p.Buckets.Messages)
|
||||
}
|
||||
return fmt.Sprintf("%d overdue · %d due today · %d this week · %d message(s)",
|
||||
overdue, today, week, messages)
|
||||
}
|
||||
|
||||
func writeCombined(sb *strings.Builder, title string, parts []Combined, loc *time.Location, limit int, pick func(Buckets) []store.Item) {
|
||||
var lines []string
|
||||
for _, p := range parts {
|
||||
for _, it := range pick(p.Buckets) {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s\n", p.Child, line(it, loc)))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "\n%s\n", title)
|
||||
for i, l := range lines {
|
||||
if limit > 0 && i >= limit {
|
||||
fmt.Fprintf(sb, "- … and %d more with no due date (see the dashboard)\n", len(lines)-limit)
|
||||
break
|
||||
}
|
||||
sb.WriteString(l)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCombinedMessages(sb *strings.Builder, parts []Combined, loc *time.Location) {
|
||||
var lines []string
|
||||
for _, p := range parts {
|
||||
for _, it := range p.Buckets.Messages {
|
||||
when := ""
|
||||
if it.DueAt != nil {
|
||||
when = " (" + it.DueAt.In(loc).Format("Mon 02 Jan 15:04") + ")"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s%s\n", p.Child, it.Title, when))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
sb.WriteString("\nMESSAGES\n")
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l)
|
||||
}
|
||||
}
|
||||
|
||||
// line renders one actionable item for the brief.
|
||||
func line(it store.Item, loc *time.Location) string {
|
||||
subject := strings.TrimSpace(it.Subject)
|
||||
|
||||
@@ -167,3 +167,96 @@ func equal(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestRenderCombinedBothBoys proves FR-14: the combined brief for Jo and Sam
|
||||
// carries both boys, overdue first, and every line names the child.
|
||||
func TestRenderCombinedBothBoys(t *testing.T) {
|
||||
loc := sydney(t)
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
||||
|
||||
harry := Classify([]store.Item{
|
||||
{Type: "task", Title: "Essay", Subject: "English", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
|
||||
{Type: "task", Title: "Worksheet", Subject: "Maths", DueAt: due(t, loc, "2026-10-15 15:00")},
|
||||
{Type: "message", Title: "Sports day", DueAt: due(t, loc, "2026-10-14 07:00")},
|
||||
}, now, loc)
|
||||
finn := Classify([]store.Item{
|
||||
{Type: "task", Title: "Lab report", Subject: "Science", DueAt: due(t, loc, "2026-10-09 09:00"), Body: "Status: Overdue"},
|
||||
}, now, loc)
|
||||
|
||||
text := RenderCombined([]Combined{
|
||||
{Child: "Harry", Buckets: harry},
|
||||
{Child: "Finn", Buckets: finn},
|
||||
}, loc)
|
||||
|
||||
if !strings.HasPrefix(text, "Harry and Finn — 2 overdue · 1 due today · 0 this week · 1 message(s).\n") {
|
||||
t.Errorf("headline = %q", firstLineOf(text))
|
||||
}
|
||||
overdue := strings.Index(text, "OVERDUE")
|
||||
today := strings.Index(text, "DUE TODAY")
|
||||
if overdue < 0 || today < 0 || overdue > today {
|
||||
t.Errorf("overdue must come first:\n%s", text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"- Harry: English: Essay (due Sat 10 Oct 09:00) [overdue]",
|
||||
"- Finn: Science: Lab report (due Fri 09 Oct 09:00) [overdue]",
|
||||
"- Harry: Maths: Worksheet (due Thu 15 Oct 15:00)",
|
||||
"MESSAGES",
|
||||
"- Harry: Sports day (Wed 14 Oct 07:00)",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("combined brief is missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderCombinedSingleChild proves one child's brief still renders a
|
||||
// sensible combined message.
|
||||
func TestRenderCombinedSingleChild(t *testing.T) {
|
||||
loc := sydney(t)
|
||||
text := RenderCombined([]Combined{{Child: "Harry"}}, loc)
|
||||
if !strings.HasPrefix(text, "Harry — 0 overdue") {
|
||||
t.Errorf("text = %q", text)
|
||||
}
|
||||
if strings.Contains(text, "OVERDUE") {
|
||||
t.Errorf("an empty brief must not show an OVERDUE heading: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestRenderWithLimitsKeepsTheBriefBrief proves the long tail is summarised, so
|
||||
// the morning message stays a brief (AGENTS.md §10 rule 6) without hiding work.
|
||||
func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) {
|
||||
loc := sydney(t)
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
||||
|
||||
items := make([]store.Item, 0, 8)
|
||||
for _, title := range []string{"A", "B", "C", "D", "E", "F", "G", "H"} {
|
||||
items = append(items, store.Item{Type: "task", Title: "Pending " + title, Body: "Status: Pending"})
|
||||
}
|
||||
items = append(items, store.Item{
|
||||
Type: "task", Title: "Essay", Subject: "English",
|
||||
DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue",
|
||||
})
|
||||
|
||||
b := Classify(items, now, loc)
|
||||
limited := RenderWithLimits("Harry", b, loc, Limits{NoDue: 3})
|
||||
if !strings.Contains(limited, "… and 5 more with no due date") {
|
||||
t.Errorf("the long tail is not summarised:\n%s", limited)
|
||||
}
|
||||
if strings.Contains(limited, "Pending D") {
|
||||
t.Errorf("the cap did not apply:\n%s", limited)
|
||||
}
|
||||
// Overdue work is never hidden by a cap.
|
||||
if !strings.Contains(limited, "English: Essay") {
|
||||
t.Errorf("overdue work went missing:\n%s", limited)
|
||||
}
|
||||
if full := Render("Harry", b, loc); !strings.Contains(full, "Pending H") {
|
||||
t.Errorf("the full render must keep every item:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the read/write helpers the Phase 5 dashboard needs:
|
||||
@@ -131,93 +130,43 @@ func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanSta
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Summary is one stored brief (AGENTS.md §7 notifications): either a draft
|
||||
// (SentAt nil) or a delivered brief. It is the summary half of FR-10.
|
||||
type Summary struct {
|
||||
ID int64
|
||||
Recipient string
|
||||
Text string
|
||||
SentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
// Draft briefs (FR-10). A brief is a row in `notifications`; the delivery log
|
||||
// and the per-channel rows live in notify.go. CreateDraft stores a brief that
|
||||
// has not been sent yet, so the Send panel can preview it first.
|
||||
|
||||
// CreateSummary stores a brief for one recipient as a draft (sent_at NULL). It
|
||||
// is CreateDraft with the scope defaulted to the recipient.
|
||||
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
|
||||
return s.CreateDraft(ctx, recipient, recipient, text)
|
||||
}
|
||||
|
||||
// CreateSummary stores a brief for one recipient. sent_at stays NULL for a
|
||||
// draft; Phase 7 sets it when the brief is sent.
|
||||
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
|
||||
// CreateDraft stores a brief that has not been sent yet. scope is the child
|
||||
// name, or "combined" for the brief to Jo and Sam (FR-9, FR-14).
|
||||
func (s *Store) CreateDraft(ctx context.Context, scope, recipient, text string) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`,
|
||||
recipient, text)
|
||||
`INSERT INTO notifications (scope, recipient, channel, summary, sent_at)
|
||||
VALUES (?, ?, '', ?, NULL)`,
|
||||
scope, recipient, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListSummaries returns recent briefs, newest first.
|
||||
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at
|
||||
FROM notifications ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Summary
|
||||
for rows.Next() {
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := rows.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan summary: %w", err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
out = append(out, sm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SummaryByID returns one brief, or nil when it does not exist.
|
||||
func (s *Store) SummaryByID(ctx context.Context, id int64) (*Summary, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at FROM notifications WHERE id = ?`, id)
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := row.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("summary %d: %w", id, err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
return &sm, nil
|
||||
}
|
||||
|
||||
// DeleteSummary removes one stored brief. It returns the number of rows removed.
|
||||
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
|
||||
// DeleteNotification removes one brief or delivery row. It returns the number
|
||||
// of rows removed.
|
||||
func (s *Store) DeleteNotification(ctx context.Context, id int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
return 0, fmt.Errorf("delete notification %d: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
return 0, fmt.Errorf("delete notification %d: %w", id, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -83,22 +83,22 @@ func TestSummariesRoundTrip(t *testing.T) {
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("create: %v id=%d", err, id)
|
||||
}
|
||||
list, err := st.ListSummaries(ctx, 10)
|
||||
list, err := st.RecentNotifications(ctx, 10)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
if list[0].Recipient != "Harry" || list[0].SentAt != nil {
|
||||
if list[0].Recipient != "Harry" || list[0].SentAt != nil || list[0].Channel != "" {
|
||||
t.Errorf("summary = %+v, want draft for Harry", list[0])
|
||||
}
|
||||
got, err := st.SummaryByID(ctx, id)
|
||||
got, err := st.NotificationByID(ctx, id)
|
||||
if err != nil || got == nil || got.Text != list[0].Text {
|
||||
t.Fatalf("by id: %v %+v", err, got)
|
||||
}
|
||||
n, err := st.DeleteSummary(ctx, id)
|
||||
n, err := st.DeleteNotification(ctx, id)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("delete = %d, %v", n, err)
|
||||
}
|
||||
if got, _ := st.SummaryByID(ctx, id); got != nil {
|
||||
if got, _ := st.NotificationByID(ctx, id); got != nil {
|
||||
t.Error("summary survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
20
internal/store/migrations/004_notifications_send.sql
Normal file
20
internal/store/migrations/004_notifications_send.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Migration 004 — Phase 7 notify and send (AGENTS.md §4 FR-9/FR-13/FR-14,
|
||||
-- §11 Phase 7).
|
||||
--
|
||||
-- `notifications` becomes the delivery log: one row per brief per recipient per
|
||||
-- channel. The brief text is the `summary` column (it was already there), and
|
||||
-- the new columns record where the row belongs (scope), which channel and
|
||||
-- Apprise tag were used, whether the delivery succeeded, and the redacted error
|
||||
-- when it did not. A failed send is therefore visible and loud instead of being
|
||||
-- lost (FR-12).
|
||||
--
|
||||
-- A draft (built but not sent) is a row with an empty `channel`, ok = 0 and
|
||||
-- sent_at NULL, which is what the Phase 5 dashboard already showed.
|
||||
|
||||
ALTER TABLE notifications ADD COLUMN scope TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN channel TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN tag TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN ok INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE notifications ADD COLUMN error TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_scope ON notifications(scope, created_at);
|
||||
169
internal/store/notify.go
Normal file
169
internal/store/notify.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the Phase 7 delivery helpers (AGENTS.md §4 FR-9, FR-13,
|
||||
// FR-14): one row in `notifications` per brief per recipient per channel, the
|
||||
// draft that the Send panel previews, and the summary the dashboard uses to
|
||||
// show a failed send loudly (FR-12).
|
||||
|
||||
// Notification is one brief row: either a draft, or one delivery attempt on one
|
||||
// channel. It is both the stored summary (FR-10) and the send log (FR-13).
|
||||
type Notification struct {
|
||||
ID int64
|
||||
// Scope is the child name, or "combined" for the brief to Jo and Sam.
|
||||
Scope string
|
||||
// Recipient is the person the brief is for, for example "Harry" or "Jo+Sam".
|
||||
Recipient string
|
||||
// Channel is "email", "whatsapp", or "" for a draft.
|
||||
Channel string
|
||||
// Tag is the Apprise tag the delivery used, for example "harry_email".
|
||||
Tag string
|
||||
// Text is the brief itself (the `summary` column).
|
||||
Text string
|
||||
// OK reports whether the delivery succeeded.
|
||||
OK bool
|
||||
// Error is the redacted failure text, empty on success.
|
||||
Error string
|
||||
// SentAt is when the brief was delivered, or nil for a draft or a failure.
|
||||
SentAt *time.Time
|
||||
// CreatedAt is when the row was written.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// notificationSelect is the shared column list for a notification row.
|
||||
const notificationSelect = `SELECT id, COALESCE(scope, ''), recipient, COALESCE(channel, ''), COALESCE(tag, ''),
|
||||
summary, ok, COALESCE(error, ''), sent_at, created_at FROM notifications`
|
||||
|
||||
func scanNotification(r rowScanner) (Notification, error) {
|
||||
var n Notification
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := r.Scan(&n.ID, &n.Scope, &n.Recipient, &n.Channel, &n.Tag,
|
||||
&n.Text, &n.OK, &n.Error, &sent, &created); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
n.SentAt = &t
|
||||
}
|
||||
n.CreatedAt = parseTime(created)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RecordNotification writes one brief or one delivery attempt. sent_at is NULL
|
||||
// while the delivery failed, so a failure can never be read as "delivered".
|
||||
func (s *Store) RecordNotification(ctx context.Context, n Notification) (int64, error) {
|
||||
var sentAt any
|
||||
if n.SentAt != nil {
|
||||
sentAt = n.SentAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (scope, recipient, channel, tag, summary, ok, error, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Scope, n.Recipient, n.Channel, n.Tag, n.Text, n.OK, n.Error, sentAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// MarkSummarySent stamps a stored draft as sent and records which channel and
|
||||
// tag delivered it.
|
||||
func (s *Store) MarkSummarySent(ctx context.Context, id int64, channel, tag string, ok bool, errText string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE notifications SET sent_at = ?, channel = ?, tag = ?, ok = ?, error = ? WHERE id = ?`,
|
||||
at.UTC().Format(time.RFC3339), channel, tag, ok, errText, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark summary %d sent: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecentNotifications returns the newest brief rows, newest first. limit <= 0
|
||||
// means twenty.
|
||||
func (s *Store) RecentNotifications(ctx context.Context, limit int) ([]Notification, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
notificationSelect+` ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Notification
|
||||
for rows.Next() {
|
||||
n, err := scanNotification(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NotificationStatus counts the delivery attempts so the dashboard can show a
|
||||
// failed send loudly (FR-12).
|
||||
type NotificationStatus struct {
|
||||
// Total is every delivery attempt on record.
|
||||
Total int
|
||||
// Failed is the attempts that did not deliver.
|
||||
Failed int
|
||||
// Sent is the attempts that delivered.
|
||||
Sent int
|
||||
// Drafts is the stored briefs that were never sent.
|
||||
Drafts int
|
||||
// Last is the newest attempt, or nil.
|
||||
Last *Notification
|
||||
}
|
||||
|
||||
// EverRun reports whether any delivery has been attempted.
|
||||
func (st NotificationStatus) EverRun() bool { return st.Total > 0 }
|
||||
|
||||
// NotificationSummary reads the delivery log summary.
|
||||
func (s *Store) NotificationSummary(ctx context.Context) (NotificationStatus, error) {
|
||||
var st NotificationStatus
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN ok = 0 AND channel <> '' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN channel = '' THEN 1 ELSE 0 END), 0)
|
||||
FROM notifications`).
|
||||
Scan(&st.Total, &st.Failed, &st.Sent, &st.Drafts); err != nil {
|
||||
return st, fmt.Errorf("notification summary: %w", err)
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, notificationSelect+` ORDER BY id DESC LIMIT 1`)
|
||||
n, err := scanNotification(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("last notification: %w", err)
|
||||
}
|
||||
st.Last = &n
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// NotificationByID returns one brief row, or nil when it does not exist.
|
||||
func (s *Store) NotificationByID(ctx context.Context, id int64) (*Notification, error) {
|
||||
row := s.db.QueryRowContext(ctx, notificationSelect+` WHERE id = ?`, id)
|
||||
n, err := scanNotification(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notification %d: %w", id, err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
127
internal/store/notify_test.go
Normal file
127
internal/store/notify_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNotificationDeliveryLog covers the Phase 7 delivery row: a delivered
|
||||
// brief records the channel, the tag and sent_at, and a failure records the
|
||||
// error without a sent_at, so a failure can never read as delivered (FR-12).
|
||||
func TestNotificationDeliveryLog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
sent := time.Date(2026, 10, 16, 6, 0, 0, 0, time.UTC)
|
||||
okID, err := st.RecordNotification(ctx, Notification{
|
||||
Scope: "Harry", Recipient: "Harry", Channel: "email", Tag: "harry_email",
|
||||
Text: "Harry — 1 overdue.", OK: true, SentAt: &sent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("record ok: %v", err)
|
||||
}
|
||||
if _, err := st.RecordNotification(ctx, Notification{
|
||||
Scope: "Harry", Recipient: "Harry", Channel: "whatsapp", Tag: "harry_whatsapp",
|
||||
Text: "Harry — 1 overdue.", OK: false, Error: "apprise returned status 424",
|
||||
}); err != nil {
|
||||
t.Fatalf("record fail: %v", err)
|
||||
}
|
||||
|
||||
got, err := st.NotificationByID(ctx, okID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("by id: %v %v", got, err)
|
||||
}
|
||||
if !got.OK || got.Channel != "email" || got.Tag != "harry_email" || got.SentAt == nil {
|
||||
t.Errorf("row = %+v", got)
|
||||
}
|
||||
|
||||
rows, err := st.RecentNotifications(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("recent = %d, %v", len(rows), err)
|
||||
}
|
||||
var failed *Notification
|
||||
for i := range rows {
|
||||
if !rows[i].OK {
|
||||
failed = &rows[i]
|
||||
}
|
||||
}
|
||||
if failed == nil {
|
||||
t.Fatal("the failed delivery row is missing")
|
||||
}
|
||||
if failed.SentAt != nil {
|
||||
t.Error("a failed delivery must not carry sent_at")
|
||||
}
|
||||
if failed.Error == "" {
|
||||
t.Error("a failed delivery must record its error")
|
||||
}
|
||||
|
||||
st2, err := st.NotificationSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if st2.Total != 2 || st2.Sent != 1 || st2.Failed != 1 || st2.Drafts != 0 {
|
||||
t.Errorf("summary = %+v", st2)
|
||||
}
|
||||
if st2.Last == nil || st2.Last.Tag != "harry_whatsapp" {
|
||||
t.Errorf("last = %+v", st2.Last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraftAndMarkSent covers the Send action on a stored brief (FR-10): a
|
||||
// draft starts unsent and is stamped only when a channel delivered.
|
||||
func TestDraftAndMarkSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
id, err := st.CreateDraft(ctx, "combined", "Jo+Sam", "School brief — Harry and Finn")
|
||||
if err != nil {
|
||||
t.Fatalf("draft: %v", err)
|
||||
}
|
||||
draft, err := st.NotificationByID(ctx, id)
|
||||
if err != nil || draft == nil {
|
||||
t.Fatalf("by id: %v %v", draft, err)
|
||||
}
|
||||
if draft.Scope != "combined" || draft.Channel != "" || draft.OK || draft.SentAt != nil {
|
||||
t.Errorf("draft = %+v", draft)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 10, 16, 6, 1, 0, 0, time.UTC)
|
||||
if err := st.MarkSummarySent(ctx, id, "email", "joanna_email", true, "", at); err != nil {
|
||||
t.Fatalf("mark sent: %v", err)
|
||||
}
|
||||
got, _ := st.NotificationByID(ctx, id)
|
||||
if got == nil || !got.OK || got.SentAt == nil || got.Channel != "email" {
|
||||
t.Errorf("after send = %+v", got)
|
||||
}
|
||||
|
||||
st2, _ := st.NotificationSummary(ctx)
|
||||
if st2.Drafts != 0 || st2.Sent != 1 {
|
||||
t.Errorf("summary = %+v", st2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationSummaryCountsFailures proves a failed send is visible in the
|
||||
// summary the dashboard shows (FR-12).
|
||||
func TestNotificationSummaryCountsFailures(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
if _, err := st.RecordNotification(ctx, Notification{
|
||||
Scope: "combined", Recipient: "Jo", Channel: "whatsapp", Tag: "joanna_whatsapp",
|
||||
Text: "brief", Error: "apprise returned status 424: <redacted>",
|
||||
}); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
status, err := st.NotificationSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if status.Failed != 1 || status.Sent != 0 || !status.EverRun() {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
if !strings.Contains(status.Last.Error, "424") {
|
||||
t.Errorf("last error = %q", status.Last.Error)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user