fcde7d3db6
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.
224 lines
9.8 KiB
Go
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
|
|
}
|