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:
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
@@ -29,40 +31,35 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
||||
return "", fmt.Errorf("store: upsert video: empty provider video id")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
||||
|
||||
// Ensure the owning user exists (FK target) — same as the Deliver path.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||
v.UserID); err != nil {
|
||||
return "", fmt.Errorf("store: upsert user: %w", err)
|
||||
}
|
||||
|
||||
provider := string(v.Provider)
|
||||
if provider == "" {
|
||||
provider = string(domain.ProviderYouTube)
|
||||
}
|
||||
|
||||
var id string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
url = EXCLUDED.url,
|
||||
published_at = EXCLUDED.published_at
|
||||
RETURNING id`,
|
||||
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
||||
).Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("store: upsert video: %w", err)
|
||||
}
|
||||
if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
|
||||
// Ensure the owning user exists (FK target) — same as the Deliver path.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||
v.UserID); err != nil {
|
||||
return fmt.Errorf("store: upsert user: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("store: commit: %w", err)
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
url = EXCLUDED.url,
|
||||
published_at = EXCLUDED.published_at
|
||||
RETURNING id`,
|
||||
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
||||
).Scan(&id); err != nil {
|
||||
return fmt.Errorf("store: upsert video: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user