feat(store): route all DB access through withUser for structural scoping
Add Store.withUser(ctx, userID, fn) — a single choke point that BEGINs a tx, sets the transaction-local GUC tapir.current_user_id via set_config(..., true), runs fn, and commits. set_config is used over SET LOCAL because it is parameterizable; the local flag means the value auto-resets on commit/rollback so a pooled connection never leaks one request's user into the next. Route all 9 DB-touching methods through it (Deliver, HasSummary, SeenVideoIDs, ListSummaries, GetSummaryByVideo, SetAction, ClearAction, ActionsFor, and UpsertVideo; attachActions flows via ActionsFor). Scoping is now structural — not a per-query opt-in someone can forget — and arms the migration-003 RLS policies. Method signatures and existing WHERE clauses are unchanged (defence in depth; superuser DSNs in existing tests bypass RLS so behaviour is preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// allowedActions is the closed set of action verbs persisted in summary_actions.
|
||||
@@ -39,33 +41,25 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
|
||||
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)
|
||||
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
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
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAction removes an action for (user, video). Clearing an action that is
|
||||
@@ -74,13 +68,15 @@ func (s *Store) ClearAction(ctx context.Context, userID, videoID, action string)
|
||||
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
|
||||
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
if _, err := tx.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
|
||||
@@ -91,25 +87,30 @@ func (s *Store) ActionsFor(ctx context.Context, userID string, videoIDs []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)
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.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 fmt.Errorf("store: actions for: %w", err)
|
||||
}
|
||||
out[videoID] = append(out[videoID], action)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate actions: %w", err)
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var videoID, action string
|
||||
if err := rows.Scan(&videoID, &action); err != nil {
|
||||
return fmt.Errorf("store: scan action: %w", err)
|
||||
}
|
||||
out[videoID] = append(out[videoID], action)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("store: iterate actions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user