// 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 label decoder the brief // uses (FR-17). It decodes lesson periods and timetable events; 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.Label(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) }