package store import ( "context" "fmt" ) // allowedActions is the closed set of action verbs persisted in summary_actions. // It mirrors the CHECK constraint in migration 002; validating in Go returns a // clear error before the round-trip rather than a raw constraint violation. var allowedActions = map[string]bool{ "watched": true, "skipped": true, "saved": true, } // oppositeAction maps each mutually-exclusive action to the one it clears: // watching a video clears a prior skip and vice versa (ui-spec.md §4). "saved" // is independent and has no opposite, so it is absent here. var oppositeAction = map[string]string{ "watched": "skipped", "skipped": "watched", } // validateAction rejects any action outside the allowed set. func validateAction(action string) error { if !allowedActions[action] { return fmt.Errorf("store: invalid action %q (want watched|skipped|saved)", action) } return nil } // SetAction records that the user took action on a video. It is idempotent: // re-setting an already-active action refreshes acted_at, never duplicates. // Mutual exclusion is enforced atomically in one tx — setting "watched" clears // "skipped" and vice versa; "saved" coexists with either. func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) error { if err := validateAction(action); err != nil { return err } tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("store: begin set action: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit if opposite, ok := oppositeAction[action]; ok { if _, err := tx.Exec(ctx, `DELETE FROM summary_actions WHERE user_id = $1 AND video_id = $2 AND action = $3`, userID, videoID, opposite); err != nil { return fmt.Errorf("store: clear opposite action: %w", err) } } if _, err := tx.Exec(ctx, `INSERT INTO summary_actions (user_id, video_id, action) VALUES ($1, $2, $3) ON CONFLICT (user_id, video_id, action) DO UPDATE SET acted_at = NOW()`, userID, videoID, action); err != nil { return fmt.Errorf("store: set action: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("store: commit set action: %w", err) } return nil } // ClearAction removes an action for (user, video). Clearing an action that is // not set is a no-op (no error) — toggling off an inactive button is harmless. func (s *Store) ClearAction(ctx context.Context, userID, videoID, action string) error { if err := validateAction(action); err != nil { return err } if _, err := s.pool.Exec(ctx, `DELETE FROM summary_actions WHERE user_id = $1 AND video_id = $2 AND action = $3`, userID, videoID, action); err != nil { return fmt.Errorf("store: clear action: %w", err) } return nil } // ActionsFor returns the active actions per video for the given user, keyed by // video_id, for the list view. Videos with no actions are simply absent from the // map. Scoped by user_id: one user never sees another's actions. func (s *Store) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) { out := make(map[string][]string) if len(videoIDs) == 0 { return out, nil } rows, err := s.pool.Query(ctx, `SELECT video_id, action FROM summary_actions WHERE user_id = $1 AND video_id = ANY($2) ORDER BY video_id, action`, userID, videoIDs) if err != nil { return nil, fmt.Errorf("store: actions for: %w", err) } defer rows.Close() for rows.Next() { var videoID, action string if err := rows.Scan(&videoID, &action); err != nil { return nil, fmt.Errorf("store: scan action: %w", err) } out[videoID] = append(out[videoID], action) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: iterate actions: %w", err) } return out, nil } // attachActions enriches read rows with their current actions in a single query. // Additive: rows with no actions keep a nil Actions slice. Used by the read // views (reads.go) to surface action state without a cross-type SQL join // (summaries.video_id is UUID, summary_actions.video_id is TEXT). func (s *Store) attachActions(ctx context.Context, userID string, rows []SummaryRow) error { if len(rows) == 0 { return nil } ids := make([]string, len(rows)) for i := range rows { ids[i] = rows[i].VideoID } actions, err := s.ActionsFor(ctx, userID, ids) if err != nil { return err } for i := range rows { rows[i].Actions = actions[rows[i].VideoID] } return nil }