package store import ( "context" "database/sql" "errors" "fmt" "time" ) // RoutineStatus — the state a proposal is in. A defined type, not a bare // string, because the legal set used to live in a comment: nothing caught a // typo at compile time, nothing enumerated the set for a test, and a bad value // surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46, // #410). type RoutineStatus string // The three states a proposal can be in. A proposal starts proposed and moves // once, either way, and never moves again. const ( RoutineProposed RoutineStatus = "proposed" RoutineAccepted RoutineStatus = "accepted" RoutineDismissed RoutineStatus = "dismissed" ) // RoutineStatuses — the legal set, and the single source of truth a test can // range over. Adding a state means adding it here. var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed} // Valid reports whether s is one of RoutineStatuses. func (s RoutineStatus) Valid() bool { for _, v := range RoutineStatuses { if s == v { return true } } return false } func (s RoutineStatus) String() string { return string(s) } // ProposedRoutine — a detected pattern the system wants to nudge about on a // repeating interval. Status 'proposed' means awaiting human confirmation; // 'accepted' means the human confirmed and the tick loop now owns the schedule; // 'dismissed' means the human declined and we won't re-propose. // // AcceptedTs is when the human said yes; it is the clock start for the first // nudge. LastFiredTs is when the last nudge went out, nil until the first one. // ReminderID is only set on rows accepted before Vikunja #366, when accepting // created a one-shot reminder instead. type ProposedRoutine struct { ID int64 Action string Object string IntervalDays float64 Status RoutineStatus CreatedTs time.Time ReminderID *int64 AcceptedTs *time.Time LastFiredTs *time.Time } var ( ErrProposedRoutineNotFound = errors.New("store: proposed routine not found") ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object") ErrRoutineStatus = errors.New("store: unknown routine status") ) // CreateProposedRoutine inserts a new proposed routine. Returns // ErrProposedRoutineExists if one already exists for this action+object (any // status) — the pattern detector should only propose once per pair. // // action+object is the "same routine" key. It is UNIQUE in the table, so a // routine the human already dismissed can never come back: the detector will // keep finding the pattern, and every re-propose is refused here. Maven is not // a nag. // // The object stays a local string. It is not resolved against Nexus and it // carries no canonical entity ref (asked on the PR 4 review, decided here, // Vikunja #410). Nexus owns identity for things the ecosystem acts on, and // nothing acts on a routine object: it is the word he used, replayed back to // him in a nudge, and compared only against itself for the UNIQUE key. Two // spellings of the same watering can are two routines, and that is the right // answer when the point is to say the sentence he would say. Canonical refs // arrive here only if a routine ever drives a Hexis call, which is Vikunja // #272, not this. // // Vikunja #43: this is called both from the voice fact-write path (for the // immediate spoken confirmation) and from the digestion tick's proactive // scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's // detectAndPropose), so a pattern gets noticed even with nobody at the mic. func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO proposed_routines (action, object, interval_days, status, created_ts) VALUES (?,?,?,'proposed',?) ON CONFLICT(action, object) DO NOTHING`, action, object, intervalDays, ts.UnixMilli()) if err != nil { return 0, fmt.Errorf("create proposed routine: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("create proposed routine: rows affected: %w", err) } if n == 0 { return 0, ErrProposedRoutineExists } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("create proposed routine: last insert id: %w", err) } return id, nil } // LookupProposedRoutine returns the proposed routine for action+object, or // nil (no error) when no row exists. func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines WHERE action = ? AND object = ?`, action, object) r, err := scanProposedRoutine(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &r, nil } // ListProposedRoutines returns the routines still waiting for an answer, // newest first. This is what the /routines page shows. func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { return s.ListProposedRoutinesByStatus(ctx, RoutineProposed) } // ListProposedRoutinesByStatus returns routines in one status, newest first. // An empty status returns every row; an unknown one is refused rather than // silently answering with nothing, since a typo and a genuinely empty state // read the same otherwise. func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) { if status != "" && !status.Valid() { return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status) } q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines` var args []any if status != "" { q += ` WHERE status = ?` args = append(args, status) } q += ` ORDER BY created_ts DESC, id DESC` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list proposed routines: %w", err) } defer rows.Close() var out []ProposedRoutine for rows.Next() { r, err := scanProposedRoutine(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // Status changes below are an in-place UPDATE, on purpose. Facts are // append-only (a correction writes a new row and sets voids_id) because a fact // is a claim about the world and the old claim is still history worth keeping. // A proposal is not a claim, it is a question with one answer, and the same // shape already exists for tools (tools.status flips in place). The guard // `AND status = 'proposed'` makes the move one-way: an answered proposal can // never be answered again. // // AcceptProposedRoutine flips status to 'accepted' and records when. From that // timestamp the tick loop owns the schedule: it re-reads accepted rows every // tick and nudges when the interval has passed. Returns an error if the row is // not in 'proposed' status. func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error { res, err := s.db.ExecContext(ctx, `UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`, ts.UnixMilli(), id) if err != nil { return fmt.Errorf("accept proposed routine: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("accept proposed routine: rows affected: %w", err) } if n == 0 { return fmt.Errorf("%w: id=%d not in 'proposed' status", ErrProposedRoutineNotFound, id) } return nil } // ListAcceptedRoutines returns every accepted routine, oldest first. The tick // loop reads this each tick and decides which ones are due. func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines WHERE status = 'accepted' ORDER BY id`) if err != nil { return nil, fmt.Errorf("list accepted routines: %w", err) } defer rows.Close() var out []ProposedRoutine for rows.Next() { r, err := scanProposedRoutine(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // MarkRoutineFired records that a routine just nudged. The stored time is the // nudge time, not the time it was theoretically due, so a routine that was // silent for a while starts its next interval from now — missed occurrences are // dropped, never replayed as a backlog. func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error { if _, err := s.db.ExecContext(ctx, `UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`, ts.UnixMilli(), id); err != nil { return fmt.Errorf("mark routine fired: %w", err) } return nil } // DismissProposedRoutine flips status to 'dismissed'. Idempotent. func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `UPDATE proposed_routines SET status = 'dismissed' WHERE id = ? AND status = 'proposed'`, id) if err != nil { return fmt.Errorf("dismiss proposed routine: %w", err) } return nil } // scanProposedRoutine scans a row into ProposedRoutine. func scanProposedRoutine(sc scanner) (ProposedRoutine, error) { var r ProposedRoutine var created int64 var reminderID sql.NullInt64 var accepted, lastFired sql.NullInt64 if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil { return ProposedRoutine{}, err } r.CreatedTs = time.UnixMilli(created).UTC() if reminderID.Valid { r.ReminderID = &reminderID.Int64 } r.AcceptedTs = millisToTime(accepted) r.LastFiredTs = millisToTime(lastFired) return r, nil } // millisToTime turns a nullable unix-millis column into a *time.Time. func millisToTime(v sql.NullInt64) *time.Time { if !v.Valid { return nil } t := time.UnixMilli(v.Int64).UTC() return &t }