Reshape the dead per-user transcripts table (PK videos.id, user_id, RLS-FORCEd — never read or written by app code) into the shared public caption store ADR-021 specifies: keyed by (provider, provider_video_id), no user_id, NOT RLS-scoped. Migration 015 (reversible). Add ports.TranscriptStore + Store.GetTranscript/SaveTranscript via the raw pool (no withUser): public content, shared across users by construction. SaveTranscript persists only terminal outcomes (captions/none) and refuses SourceRateLimited so a transient 429 can never be stored as a false permanent absence (ADR-014). Flip the isolation proof: transcripts leaves the RLS-scoped set; TestTranscriptsTableIsSharedNotRLS asserts it is the SINGLE non-RLS surface (writable/readable with no user scope, no user_id column, RLS off on it alone, still on every user-owned table) — the proof the public-content classification was applied exactly here and leaked nowhere. appPool made idempotent so two tests can build it. Adjust the 010/011/014 up-down migration tests for the new HEAD. account.go: user deletion no longer strips shared transcripts. Reconcile data-model.md + CLAUDE.md. Wiring the engine to read-stored-first is the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
59 lines
2.4 KiB
Go
59 lines
2.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// DeleteUser permanently removes a user and all of their data. It runs through
|
|
// withUser so RLS confines every statement to the calling user's own rows.
|
|
//
|
|
// Deleting the users row cascades (ON DELETE CASCADE) to videos, summaries
|
|
// (→ sink_deliveries), video_connections, and the user_identities map —
|
|
// referential-integrity cascades bypass RLS, so a user's child rows are removed
|
|
// even though the deleting connection is scoped. Transcripts are NOT removed:
|
|
// since ADR-021 they are shared public content keyed by (provider,
|
|
// provider_video_id) with no user_id, so another user may still reference the
|
|
// same row — a user deletion must not strip shared caption content. summary_actions and login_events
|
|
// are the exceptions: each carries a user_id but has NO foreign key to users
|
|
// (migrations 002 and 010), so the cascade does not reach them; they are deleted
|
|
// explicitly in the same scoped transaction. Deleting an absent user is a no-op
|
|
// (idempotent).
|
|
//
|
|
// This is tapir-side only (decision 2026-06-03): it removes all tapir data; the
|
|
// Dex login identity is left untouched — a later login simply re-enters
|
|
// registration. The user's secrets (OAuth tokens) live in the SecretStore, not
|
|
// the DB, and are removed by the caller (the account handler).
|
|
func (s *Store) DeleteUser(ctx context.Context, userID string) error {
|
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil {
|
|
return fmt.Errorf("store: delete summary_actions: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM login_events WHERE user_id = $1`, userID); err != nil {
|
|
return fmt.Errorf("store: delete login_events: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM users WHERE id = $1`, userID); err != nil {
|
|
return fmt.Errorf("store: delete user: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// DisplayName returns the user's registered display name (empty if unset). Scoped
|
|
// by user_id via withUser, like every read in this package.
|
|
func (s *Store) DisplayName(ctx context.Context, userID string) (string, error) {
|
|
var name string
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
return tx.QueryRow(ctx,
|
|
`SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&name)
|
|
}); err != nil {
|
|
return "", fmt.Errorf("store: display name: %w", err)
|
|
}
|
|
return name, nil
|
|
}
|