CombinedText now applies OrderChildren itself, so the CLI preview, the dashboard and the Hub render Finn's block then Harry's exactly like the email. Previously `notify preview --combined` showed the DB order (Harry first). Adds TestCombinedTextKeepsBriefOrder with a reversed input.
437 lines
14 KiB
Go
437 lines
14 KiB
Go
// 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"
|
|
"sort"
|
|
"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/decode"
|
|
"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)
|
|
dec, err := s.decoder(ctx)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
text := dashboard.RenderBrief(child.Name, buckets, dec, s.Loc)
|
|
|
|
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
|
|
}
|
|
|
|
// decoder loads the editable codes map and returns the lesson-title decoder the
|
|
// brief uses (FR-17). An unknown code renders raw, never blank.
|
|
func (s *Service) decoder(ctx context.Context) (func(string) string, error) {
|
|
codes, err := s.Store.Codes(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode map: %w", err)
|
|
}
|
|
return func(title string) string { return decode.Period(title, codes) }, 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) {
|
|
// Finn's block first, then Harry's (FR-9). Enforce the order here, not only in
|
|
// the send plan, so every caller — the email, the CLI preview, the dashboard
|
|
// and the Hub — renders the combined brief in the same order as the email.
|
|
children = OrderChildren(children)
|
|
parts := make([]dashboard.CombinedBrief, 0, len(children))
|
|
var failed []string
|
|
var notices []string
|
|
dec, err := s.decoder(ctx)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
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.CombinedBrief{
|
|
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.RenderCombinedBrief(parts, dec, s.Loc)
|
|
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")
|
|
}
|
|
// Finn's block first, then Harry's: the required order (FR-9).
|
|
children = OrderChildren(children)
|
|
|
|
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
|
|
}
|
|
|
|
// OrderChildren returns the children in brief order: Finn's block first, then
|
|
// Harry's (FR-9). The order is by name, which puts Finn before Harry; it is
|
|
// shared by the send path and the dry-run build so both agree.
|
|
func OrderChildren(children []store.Child) []store.Child {
|
|
out := append([]store.Child(nil), children...)
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
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) }
|