Files
tapir/internal/adapters/store/videos.go
T
mathiasandClaude Opus 4.8 7b139c2cd7 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>
2026-06-03 15:15:52 +02:00

75 lines
2.4 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain"
)
// UpsertVideo persists a video's metadata and returns its durable store id (the
// videos.id UUID). It is idempotent on (user_id, provider, provider_video_id):
// the same provider video for a user always resolves to the same row and the
// same returned id, so the run loop can use that id as the stable dedup key
// across restarts (it matches summaries.video_id once a summary exists).
//
// This lives in a separate file from store.go on purpose: the Sink port only
// carries a domain.Summary (no title/channel), so video metadata is persisted
// here, out of the delivery path, to keep the reader's rows readable.
//
// subscription_id is intentionally left NULL at Stage 0: the YouTube
// Subscription.ID is a provider resource id, not the UUID that column expects,
// and the subscriptions table is not part of this slice (data-model.md).
func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error) {
if v.UserID == "" {
return "", fmt.Errorf("store: upsert video: empty user id")
}
if v.ProviderVideoID == "" {
return "", fmt.Errorf("store: upsert video: empty provider video id")
}
provider := string(v.Provider)
if provider == "" {
provider = string(domain.ProviderYouTube)
}
var id string
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.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
}
// nullTime maps the zero time to NULL so an unknown published_at is stored as
// SQL NULL rather than year 0001.
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}