feat(store): DeleteUser + DisplayName for account management

DeleteUser permanently removes a user and all owned data, scoped via
withUser. The users-row ON DELETE CASCADE reaches videos, transcripts,
summaries → sink_deliveries, video_connections, and the user_identities
map (cascades bypass RLS, so a scoped connection still wipes child rows).
summary_actions carries user_id but has NO FK to users (migration 002),
so it is deleted explicitly in the same scoped transaction. Idempotent.

Tapir-side only (decision 2026-06-03): Dex identity is left untouched;
secrets live in the SecretStore and are removed by the account handler.

DisplayName returns the registered name for the account page.

Test proves deletion removes every row for the target user across all
isolated tables (incl. video_connections AND user_identities) and leaves
another user's rows fully intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:49:50 +02:00
co-authored by Claude Opus 4.8
parent 2aad79b2a8
commit c7624d97fe
2 changed files with 156 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
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, transcripts,
// 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. summary_actions is the exception:
// it carries a user_id but has NO foreign key to users (migration 002), so the
// cascade does not reach it; it is 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 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
}