Files
scrabble-game/backend/internal/accountdelete/delete.go
T
Ilia Denisov fcde7d3db6 feat(accountdelete): drop all-robot games + unspoofable [Deleted] label
Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or
auto-match-robot; children cascade), keeping games with any human seat (anonymized
instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids
brackets, so no live player can impersonate a deleted account. Integration test covers
drop-vs-keep.
2026-07-03 13:02:14 +02:00

224 lines
9.8 KiB
Go

// 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
}