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>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
// Package config loads the Compass/NHS service configuration from the process
|
|
// environment. Every secret comes from the environment (environment.d); nothing
|
|
// is compiled into the binary and nothing secret is read from a committed file.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
// Embed the IANA time zone database so SCHOOL_TIMEZONE resolves even in a
|
|
// container without tzdata.
|
|
_ "time/tzdata"
|
|
)
|
|
|
|
// Config is the fully resolved runtime configuration.
|
|
type Config struct {
|
|
// Addr is the listen address, for example ":8792".
|
|
Addr string
|
|
// DBPath is the SQLite database file, for example "/data/school.db".
|
|
DBPath string
|
|
// SessionSecret signs the session cookie. Required, >= 16 bytes.
|
|
SessionSecret []byte
|
|
// AdminUsername is the single dashboard login name (Sam).
|
|
AdminUsername string
|
|
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
|
|
AdminPassword string
|
|
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
|
|
AdminPasswordHash string
|
|
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
|
|
CookieSecure bool
|
|
// SessionTTL is how long a session cookie stays valid.
|
|
SessionTTL time.Duration
|
|
// LogLevel is the slog level.
|
|
LogLevel slog.Level
|
|
// AppEnv is a label such as "production" or "development".
|
|
AppEnv string
|
|
// Version is injected at build time.
|
|
Version string
|
|
// Location is the family timezone. Due dates and briefs are wall-clock, so
|
|
// this must be the family's zone, not the container default of UTC.
|
|
Location *time.Location
|
|
// 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
|
|
// from the environment (environment.d); the token never reaches git, the
|
|
// database or the logs (AGENTS.md §10 rule 3, AUD-110).
|
|
//
|
|
// Environment variables:
|
|
//
|
|
// SCHOOL_HA_URL base URL, for example http://192.168.20.30:8123
|
|
// SCHOOL_HA_TOKEN long-lived access token (secret)
|
|
// SCHOOL_HA_CALENDAR_ENTITY calendar entity id, for example calendar.school
|
|
// SCHOOL_HA_INSECURE true to skip TLS verification (internal CA)
|
|
// SCHOOL_HA_TIMEOUT per-request timeout, default 20s
|
|
//
|
|
// Calendar writing stays off until all three of URL, token and entity are set,
|
|
// so the service still starts before the calendar is configured.
|
|
type CalendarConfig struct {
|
|
BaseURL string
|
|
Token string
|
|
EntityID string
|
|
Insecure bool
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Enabled reports whether the calendar target is fully configured.
|
|
func (c CalendarConfig) Enabled() bool {
|
|
return c.BaseURL != "" && c.Token != "" && c.EntityID != ""
|
|
}
|
|
|
|
// Missing lists the environment variables that are still unset, in the order
|
|
// the operator should set them. An empty result means the target is usable.
|
|
func (c CalendarConfig) Missing() []string {
|
|
var out []string
|
|
if c.BaseURL == "" {
|
|
out = append(out, "SCHOOL_HA_URL")
|
|
}
|
|
if c.Token == "" {
|
|
out = append(out, "SCHOOL_HA_TOKEN")
|
|
}
|
|
if c.EntityID == "" {
|
|
out = append(out, "SCHOOL_HA_CALENDAR_ENTITY")
|
|
}
|
|
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"
|
|
|
|
// Load reads and validates the configuration.
|
|
func Load() (*Config, error) {
|
|
cfg := &Config{
|
|
Addr: env("SCHOOL_ADDR", ":8792"),
|
|
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
|
|
AdminUsername: env("SCHOOL_ADMIN_USERNAME", "sam"),
|
|
AdminPassword: os.Getenv("SCHOOL_ADMIN_PASSWORD"),
|
|
AdminPasswordHash: os.Getenv("SCHOOL_ADMIN_PASSWORD_HASH"),
|
|
CookieSecure: envBool("SCHOOL_COOKIE_SECURE", true),
|
|
AppEnv: env("SCHOOL_APP_ENV", "production"),
|
|
}
|
|
|
|
secret := os.Getenv("SCHOOL_SESSION_SECRET")
|
|
if len(secret) < 16 {
|
|
return nil, errors.New("SCHOOL_SESSION_SECRET must be set and at least 16 characters")
|
|
}
|
|
cfg.SessionSecret = []byte(secret)
|
|
|
|
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
|
|
return nil, errors.New("set SCHOOL_ADMIN_PASSWORD or SCHOOL_ADMIN_PASSWORD_HASH")
|
|
}
|
|
if cfg.AdminUsername == "" {
|
|
return nil, errors.New("SCHOOL_ADMIN_USERNAME must not be empty")
|
|
}
|
|
|
|
ttl, err := time.ParseDuration(env("SCHOOL_SESSION_TTL", "12h"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SCHOOL_SESSION_TTL: %w", err)
|
|
}
|
|
cfg.SessionTTL = ttl
|
|
|
|
lvl, err := parseLevel(env("SCHOOL_LOG_LEVEL", "info"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.LogLevel = lvl
|
|
|
|
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
|
|
}
|
|
cfg.Location = loc
|
|
cfg.Calendar = loadCalendar()
|
|
apprise, err := loadApprise()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.Apprise = apprise
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// DBConfig is the minimum a database-only command needs. The parse subcommand
|
|
// opens the same SQLite file as the service without requiring the service's
|
|
// session secret or login.
|
|
type DBConfig struct {
|
|
DBPath string
|
|
Location *time.Location
|
|
Calendar CalendarConfig
|
|
Apprise AppriseConfig
|
|
}
|
|
|
|
// 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.
|
|
func loadCalendar() CalendarConfig {
|
|
timeout := 20 * time.Second
|
|
if v := strings.TrimSpace(os.Getenv("SCHOOL_HA_TIMEOUT")); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
|
timeout = d
|
|
}
|
|
}
|
|
return CalendarConfig{
|
|
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCHOOL_HA_URL")), "/"),
|
|
Token: strings.TrimSpace(os.Getenv("SCHOOL_HA_TOKEN")),
|
|
EntityID: strings.TrimSpace(os.Getenv("SCHOOL_HA_CALENDAR_ENTITY")),
|
|
Insecure: envBool("SCHOOL_HA_INSECURE", false),
|
|
Timeout: timeout,
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return b
|
|
}
|
|
|
|
func parseLevel(s string) (slog.Level, error) {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "debug":
|
|
return slog.LevelDebug, nil
|
|
case "info", "":
|
|
return slog.LevelInfo, nil
|
|
case "warn", "warning":
|
|
return slog.LevelWarn, nil
|
|
case "error":
|
|
return slog.LevelError, nil
|
|
default:
|
|
return 0, fmt.Errorf("SCHOOL_LOG_LEVEL: unknown level %q", s)
|
|
}
|
|
}
|