// Package accountdelete deactivates an account as legal retention, not erasure: it keeps // the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a // hard delete is impossible) while journalling and freeing the account's credentials, // anonymising the live surfaces, and dropping the account's own social/ephemeral rows. // The retained_identities journal plus the tombstone (deleted_at, deleted_display_name, // last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session // revocation and active-game forfeit are orchestrated one layer up (they need the session // cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper. package accountdelete import ( "context" "database/sql" "errors" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // AnonymizedName is the label a deleted account shows to opponents. Display names are // stored strings resolved identically for every viewer (no per-viewer localisation in this // codebase), so a single canonical label is used. The brackets are deliberate: the // editable-name rule (account.displayNameRe) forbids them, so a live player can never set a // name that impersonates a deleted account. const AnonymizedName = "[Deleted]" // retainDelete is the retained_identities reason written when a credential is journalled // because its account is being deleted. const retainDelete = "delete" // Deleter performs the SQL-atomic part of account deletion over a Postgres handle. type Deleter struct { db *sql.DB now func() time.Time } // NewDeleter constructs a Deleter over db. func NewDeleter(db *sql.DB) *Deleter { return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }} } // AnonymizeAndTombstone retires accountID atomically: it journals every live identity into // retained_identities (reason=delete) then removes them so the credentials free for reuse, // snapshots the real display name into deleted_display_name and scrubs the live one to // AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops // its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat, // feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign // keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling // nothing, since the identities are already gone). func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error { now := d.now() return withTx(ctx, d.db, func(tx *sql.Tx) error { if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil { return err } if err := tombstone(ctx, tx, accountID, now); err != nil { return err } if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName). SET(postgres.String(AnonymizedName)). WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: anonymise seats: %w", err) } return dropSocialAndEphemerals(ctx, tx, accountID) }) } // dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a // human — a robot seat is one whose account holds a 'robot' identity, so this covers both // honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their // moves/chat/players/complaints fall away through ON DELETE CASCADE. const dropAllRobotGamesSQL = ` DELETE FROM games g WHERE EXISTS ( SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1 ) AND NOT EXISTS ( SELECT 1 FROM game_players o WHERE o.game_id = g.game_id AND o.account_id <> $1 AND NOT EXISTS ( SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot' ) )` // DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or // auto-match-robot games), returning how many were removed. Games with any human seat are // kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the // account's active games are resigned, so no live game is removed under the robot driver. func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) { res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID) if err != nil { return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("accountdelete: dropped games count: %w", err) } return n, nil } // journalAndDropIdentities copies the account's live identities into the retention journal // (reason=delete) and then removes them, freeing each (kind, external_id) for reuse. func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { var ids []model.Identities err := postgres.SELECT(table.Identities.AllColumns). FROM(table.Identities). WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). QueryContext(ctx, tx, &ids) if err != nil && !errors.Is(err, qrm.ErrNoRows) { return fmt.Errorf("accountdelete: load identities: %w", err) } for _, id := range ids { rid, err := uuid.NewV7() if err != nil { return fmt.Errorf("accountdelete: new retained id: %w", err) } ins := table.RetainedIdentities.INSERT( table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason, ).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete) if _, err := ins.ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err) } } if _, err := table.Identities.DELETE(). WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete identities: %w", err) } return nil } // tombstone marks the account deleted, snapshotting the real display name into // deleted_display_name (evaluated from the old row) before scrubbing the live one. func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error { upd := table.Accounts.UPDATE( table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, table.Accounts.DisplayName, table.Accounts.UpdatedAt, ).SET( postgres.TimestampzT(now), table.Accounts.DisplayName, postgres.String(AnonymizedName), postgres.TimestampzT(now), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))) if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: tombstone account: %w", err) } return nil } // dropSocialAndEphemerals removes the account's own friendships, blocks, invitations // (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are // the deleting user's private data with no dossier value; chat and feedback are kept. func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error { id := postgres.UUID(accountID) // Friendships and blocks are two-account edges keyed on either endpoint. if _, err := table.Friendships.DELETE(). WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete friendships: %w", err) } if _, err := table.Blocks.DELETE(). WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete blocks: %w", err) } // Invitations: drop the account's invitee rows, then its own invitations' invitees and // the invitations themselves (children first, to respect the foreign key). if _, err := table.GameInvitationInvitees.DELETE(). WHERE(table.GameInvitationInvitees.AccountID.EQ(id)). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete invitee rows: %w", err) } ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID). FROM(table.GameInvitations). WHERE(table.GameInvitations.InviterID.EQ(id)) if _, err := table.GameInvitationInvitees.DELETE(). WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err) } if _, err := table.GameInvitations.DELETE(). WHERE(table.GameInvitations.InviterID.EQ(id)). ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete invitations: %w", err) } // Ephemerals: friend codes, move drafts, pending confirm-codes. if _, err := table.FriendCodes.DELETE(). WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete friend codes: %w", err) } if _, err := table.GameDrafts.DELETE(). WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete drafts: %w", err) } if _, err := table.EmailConfirmations.DELETE(). WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil { return fmt.Errorf("accountdelete: delete confirmations: %w", err) } return nil } // withTx runs fn inside a transaction, committing on success and rolling back on error. func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("accountdelete: begin tx: %w", err) } if err := fn(tx); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return fmt.Errorf("accountdelete: commit tx: %w", err) } return nil }