package notifications import ( "context" "fmt" "log" "time" "github.com/jackc/pgx/v5/pgxpool" ) // Service is the entry point for the daily notification scan. It reads // cal_school_event + parent_account + tt_class, decides which (event, // lead_days, audience) pairs are due today, dispatches via the configured // upstream URLs, and writes a notification_log row for idempotency. type Service struct { db *pgxpool.Pool matrixURL string // empty → status=skipped emailURL string // empty → status=skipped httpTimeout time.Duration } // Dispatch is the contract our upstream services (Matrix bridge + Email // gateway, owned by the colleague) must implement. We POST a body with // these fields; they figure out delivery. Stub mode (URL == "") logs // instead, useful for dev + tests. type DispatchPayload struct { Channel string `json:"channel"` // "matrix" | "email" Recipient string `json:"recipient"` // email address; for Matrix the bridge maps it Language string `json:"language"` Subject string `json:"subject"` Body string `json:"body"` EventID string `json:"event_id"` LeadDays int `json:"lead_days"` } func NewService(db *pgxpool.Pool, matrixURL, emailURL string) *Service { return &Service{ db: db, matrixURL: matrixURL, emailURL: emailURL, httpTimeout: 10 * time.Second, } } // LogRow is the read shape for the notification_log table. type LogRow struct { LeadDays int `json:"lead_days"` Audience string `json:"audience"` Channel string `json:"channel"` Status string `json:"status"` Error string `json:"error_message,omitempty"` RunDate string `json:"run_date"` CreatedAt time.Time `json:"created_at"` } // ListLog returns the notification_log rows for one event, scoped to a // teacher so the UI can't query someone else's log. func (s *Service) ListLog(ctx context.Context, eventID, ownerUserID string) ([]LogRow, error) { rows, err := s.db.Query(ctx, ` SELECT nl.lead_days, nl.audience, nl.channel, nl.status, COALESCE(nl.error_message, ''), nl.run_date::text, nl.created_at FROM notification_log nl JOIN cal_school_event ev ON ev.id = nl.event_id WHERE ev.id = $1 AND ev.created_by_user_id = $2 ORDER BY nl.lead_days DESC, nl.audience, nl.channel `, eventID, ownerUserID) if err != nil { return nil, err } defer rows.Close() out := []LogRow{} for rows.Next() { var r LogRow if err := rows.Scan(&r.LeadDays, &r.Audience, &r.Channel, &r.Status, &r.Error, &r.RunDate, &r.CreatedAt); err != nil { return nil, err } out = append(out, r) } return out, nil } // RunForDate scans every active cal_school_event with a lead_day equal to // (event_start - runDate). For each due (audience, channel) pair it // renders + dispatches + logs. Idempotent via the UNIQUE constraint on // notification_log. type RunResult struct { Date string `json:"date"` Sent int `json:"sent"` Failed int `json:"failed"` Skipped int `json:"skipped"` AlreadyLogged int `json:"already_logged"` } func (s *Service) RunForDate(ctx context.Context, runDate time.Time) (*RunResult, error) { res := &RunResult{Date: runDate.Format("2006-01-02")} events, err := s.dueEvents(ctx, runDate) if err != nil { return nil, fmt.Errorf("query due events: %w", err) } for _, e := range events { // For each event we may notify parents and/or students depending on // the row's flags. Channels are derived from the audience: // parents → email + matrix // students → matrix only (students don't always have email) audiences := []string{} if e.NotifyParents { audiences = append(audiences, "parents") } if e.NotifyStudents { audiences = append(audiences, "students") } for _, audience := range audiences { channels := []string{"matrix"} if audience == "parents" { channels = append(channels, "email") } for _, channel := range channels { status, err := s.dispatchOne(ctx, e, audience, channel, runDate) switch status { case "sent": res.Sent++ case "failed": res.Failed++ if err != nil { log.Printf("notification dispatch failed: %v", err) } case "skipped": res.Skipped++ case "already": res.AlreadyLogged++ } } } } return res, nil } // dueEvent holds the small slice of cal_school_event row we need plus the // matched lead_day for this run. type dueEvent struct { ID string Title string EventType string StartDate time.Time ClassName string // optional, may be empty for "alle Klassen" OwnerUserID string NotifyParents bool NotifyStudents bool LeadDays int } func (s *Service) dueEvents(ctx context.Context, runDate time.Time) ([]dueEvent, error) { // A row is due when (start_date - runDate) appears in // notification_lead_days. Lead=0 means "today the event starts". rows, err := s.db.Query(ctx, ` SELECT ev.id::text, ev.title, ev.event_type, ev.start_date, ev.created_by_user_id::text, ev.notify_parents, ev.notify_students, ev.notification_lead_days, COALESCE( (SELECT string_agg(cl.name, ', ' ORDER BY cl.name) FROM tt_class cl WHERE cl.id = ANY(ev.affected_class_ids)), '' ) AS class_names FROM cal_school_event ev WHERE ev.start_date >= $1::date AND (ev.notify_parents OR ev.notify_students) `, runDate.Format("2006-01-02")) if err != nil { return nil, err } defer rows.Close() var out []dueEvent for rows.Next() { var ( id, title, eventType, ownerUserID, classNames string notifyParents, notifyStudents bool startDate time.Time leadDays []int32 ) if err := rows.Scan(&id, &title, &eventType, &startDate, &ownerUserID, ¬ifyParents, ¬ifyStudents, &leadDays, &classNames); err != nil { return nil, err } distanceDays := int(startDate.Sub(runDate).Hours() / 24) for _, lead := range leadDays { if int(lead) == distanceDays { out = append(out, dueEvent{ ID: id, Title: title, EventType: eventType, StartDate: startDate, ClassName: classNames, OwnerUserID: ownerUserID, NotifyParents: notifyParents, NotifyStudents: notifyStudents, LeadDays: int(lead), }) break } } } return out, nil }