Files
tapir/internal/adapters/store/actions.go
T
mathiasandClaude Opus 4.8 a032c324f6
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
feat(store): persist summary actions (watch/skip/save) — Stage-0 metric
The summary_actions table records the maintainer's act on a summary, the
column that makes the Stage-0 headline test ("acts on >=1 summary") queryable
(ui-spec.md §5, ADR-011). This is the gate lanes B/C build on.

- Migration 002: summary_actions (id, user_id, video_id TEXT, action, acted_at)
  with a CHECK on action IN ('watched','skipped','saved') and a UNIQUE
  (user_id, video_id, action). Per-user isolation: every row carries user_id.
- New actions.go: SetAction (idempotent, atomic watched<->skipped mutual
  exclusion in one tx; saved independent), ClearAction, ActionsFor for the list
  view, plus Go-side action validation.
- reads.go: additive SummaryRow.Actions, populated by composing ActionsFor
  (Go-side, not a SQL join — summaries.video_id is UUID, actions.video_id TEXT).
- embedded-postgres tests: set/clear, mutual exclusion, saved coexistence,
  idempotency, invalid rejection, user scoping, read-view surfacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:36:55 +02:00

138 lines
4.4 KiB
Go

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
}