package store import ( "context" "database/sql" "errors" "fmt" "time" ) // Nudge — one proactive send, with deferred outcome. outcomes are: pending | // acted | snoozed | ignored. outcome_ts set when resolved. the outcome column // IS the restraint-memory signal — no separate table for the feedback loop. type Nudge struct { ID int64 Ts time.Time // sent ts Rule string Channel string Message string Outcome string OutcomeTs sql.NullInt64 } const ( NudgePending = "pending" NudgeActed = "acted" NudgeSnoozed = "snoozed" NudgeIgnored = "ignored" ) var ( ErrNudgeNotFound = errors.New("store: nudge not found") ErrNudgeOutcome = errors.New("store: nudge already resolved") ) // RecordNudge inserts a pending nudge (sent). returns the id. the loop writes // one row per proactive send per tick (one nudge per tick, max severity). func (s *Store) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO nudges (ts, rule, channel, message, outcome) VALUES (?,?,?,?, 'pending')`, ts.UnixMilli(), rule, channel, message) if err != nil { return 0, fmt.Errorf("record nudge: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("record nudge: last insert id: %w", err) } return id, nil } // ResolveNudge sets the outcome of a still-pending nudge. acted | snoozed | // ignored — caller decides by user response (or lack of it). idempotency is // rejected here: a nudge can only be resolved once, by design — re-resolving // would silently corrupt the feedback signal (the table IS the learning input). func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { switch outcome { case NudgeActed, NudgeSnoozed, NudgeIgnored: default: return fmt.Errorf("store: unknown outcome %q", outcome) } res, err := s.db.ExecContext(ctx, `UPDATE nudges SET outcome = ?, outcome_ts = ? WHERE id = ? AND outcome = 'pending'`, outcome, ts.UnixMilli(), id) if err != nil { return fmt.Errorf("resolve nudge: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("resolve nudge: rows affected: %w", err) } if n == 0 { // either no such row, or it was already resolved — distinguish so callers // can tell a bug from a race. var cur string err := s.db.QueryRowContext(ctx, "SELECT outcome FROM nudges WHERE id = ?", id).Scan(&cur) if errors.Is(err, sql.ErrNoRows) { return ErrNudgeNotFound } if err != nil { return err } return fmt.Errorf("%w: currently %s", ErrNudgeOutcome, cur) } return nil } // RecentOutcomes returns the last N outcomes (in reverse chronological order) // for a given rule — the feedback loop's only input. used to compute // ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N. func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT outcome FROM nudges WHERE rule = ? AND outcome != 'pending' ORDER BY ts DESC, id DESC LIMIT ?`, rule, n) if err != nil { return nil, fmt.Errorf("recent outcomes: %w", err) } defer rows.Close() var out []string for rows.Next() { var o string if err := rows.Scan(&o); err != nil { return nil, err } out = append(out, o) } return out, rows.Err() } // UnackedTelegramRules returns rule names that have at least one still-pending // (un-acked) nudge sent over the telegram channel. the dispatcher's // RepeatUnacked re-sends these each tick until MarkAcked. // // telegram is the sev4-away channel by routing-table construction // (ChannelsFor sends sev4 away → telegram, nothing else to telegram), so // `channel='telegram' AND outcome='pending'` already implies sev4 ops-hard — // no severity column on the nudges table, and none needed: the routing table // is the authority. grouped by rule so the repeat stream is one-per-rule // (the ack key IS the rule name). func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT rule FROM nudges WHERE channel = 'telegram' AND outcome = 'pending' GROUP BY rule ORDER BY rule`) if err != nil { return nil, fmt.Errorf("unacked telegram rules: %w", err) } defer rows.Close() var out []string for rows.Next() { var r string if err := rows.Scan(&r); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // RecentNudges — the newest n nudges across all rules, with outcomes, for the // monitoring dash. Newest first. func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges ORDER BY ts DESC, id DESC LIMIT ?`, n) if err != nil { return nil, fmt.Errorf("recent nudges: %w", err) } defer rows.Close() var out []Nudge for rows.Next() { var ng Nudge var tsMilli int64 if err := rows.Scan(&ng.ID, &tsMilli, &ng.Rule, &ng.Channel, &ng.Message, &ng.Outcome, &ng.OutcomeTs); err != nil { return nil, err } ng.Ts = time.UnixMilli(tsMilli).UTC() out = append(out, ng) } return out, rows.Err() } // LastNudge — newest nudge for a rule regardless of outcome. used by the gate // for cooldown enforcement. returns ErrNudgeNotFound if the rule has never fired. func (s *Store) LastNudge(ctx context.Context, rule string) (Nudge, error) { row := s.db.QueryRowContext(ctx, `SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges WHERE rule = ? ORDER BY ts DESC, id DESC LIMIT 1`, rule) var n Nudge var tsMilli int64 var outcomeTs sql.NullInt64 if err := row.Scan(&n.ID, &tsMilli, &n.Rule, &n.Channel, &n.Message, &n.Outcome, &outcomeTs); err != nil { if errors.Is(err, sql.ErrNoRows) { return Nudge{}, ErrNudgeNotFound } return Nudge{}, err } n.Ts = time.UnixMilli(tsMilli).UTC() if outcomeTs.Valid { n.OutcomeTs = outcomeTs } return n, nil }