package store import ( "context" "database/sql" "errors" "fmt" "time" ) // SetSetupToken inserts a row, replacing any existing token for // this user (single-outstanding invariant). Caller passes a hash — // raw tokens are never persisted. func (s *Store) SetSetupToken(ctx context.Context, t SetupToken) error { _, err := s.db.ExecContext(ctx, `INSERT OR REPLACE INTO user_setup_tokens (user_id, token_hash, expires_at, created_at, created_by) VALUES (?, ?, ?, ?, ?)`, t.UserID, t.TokenHash, t.ExpiresAt.UTC().Format(time.RFC3339Nano), t.CreatedAt.UTC().Format(time.RFC3339Nano), nullable(t.CreatedBy)) if err != nil { return fmt.Errorf("store: set setup token: %w", err) } return nil } // LookupSetupToken resolves a token hash to its row. Returns // ErrNotFound for missing tokens. Expiry is NOT checked here — // callers must compare ExpiresAt themselves so they can record // 'expired' as a distinct outcome (audit-able) from 'never existed'. func (s *Store) LookupSetupToken(ctx context.Context, tokenHash string) (*SetupToken, error) { row := s.db.QueryRowContext(ctx, `SELECT user_id, token_hash, expires_at, created_at, created_by FROM user_setup_tokens WHERE token_hash = ?`, tokenHash) return scanSetupToken(row.Scan) } // GetSetupTokenByUserID returns the row for one user. Used by the // edit page to know whether a 'Regenerate setup link' button should // show as 'Generate' or 'Regenerate'. Returns ErrNotFound when no // outstanding token exists. func (s *Store) GetSetupTokenByUserID(ctx context.Context, userID string) (*SetupToken, error) { row := s.db.QueryRowContext(ctx, `SELECT user_id, token_hash, expires_at, created_at, created_by FROM user_setup_tokens WHERE user_id = ?`, userID) return scanSetupToken(row.Scan) } // DeleteSetupToken removes the row for a user (single-use cleanup // after /setup completes successfully). func (s *Store) DeleteSetupToken(ctx context.Context, userID string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM user_setup_tokens WHERE user_id = ?`, userID) if err != nil { return fmt.Errorf("store: delete setup token: %w", err) } return nil } // CleanupExpiredSetupTokens removes rows whose expires_at has passed. // Returns the number of rows deleted. Called from the maintenance // ticker every minute. func (s *Store) CleanupExpiredSetupTokens(ctx context.Context, now time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `DELETE FROM user_setup_tokens WHERE expires_at < ?`, now.UTC().Format(time.RFC3339Nano)) if err != nil { return 0, fmt.Errorf("store: cleanup setup tokens: %w", err) } n, _ := res.RowsAffected() return n, nil } func scanSetupToken(scan func(...any) error) (*SetupToken, error) { var t SetupToken var createdBy sql.NullString var expiresAt, createdAt string if err := scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("store: scan setup token: %w", err) } t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt) t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) if createdBy.Valid { v := createdBy.String t.CreatedBy = &v } return &t, nil }