package account import ( "context" "database/sql" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/postgres/jet/backend/table" ) // RetentionTTL bounds how long the account-deletion legal dossier is kept before the // reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03). const RetentionTTL = 2 * 365 * 24 * time.Hour // Reasons recorded on a retained_identities row: what detached the credential from its // account. The row is written just before the live identities row is removed, preserving // the legal dossier (which email/vk/tg was linked, and when) even as the identity frees // for reuse. See docs/ARCHITECTURE.md ยง9.1. const ( retainUnlink = "unlink" retainChange = "change" retainDelete = "delete" ) // retainIdentityTx appends a retention-journal row for one identity being detached, inside // tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It // must run in the same transaction as the identity removal, so the dossier and the live // state can never diverge. func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error { id, err := uuid.NewV7() if err != nil { return fmt.Errorf("account: 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.Reason, ).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason) if _, err := ins.ExecContext(ctx, tx); err != nil { return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err) } return nil } // StampLastLogin records the account's last cold-load time and client IP, but only when // the stored value is missing or older than an hour โ€” so it costs at most one write per // account per hour (its caller, the profile fetch, runs once per cold app-load). It is a // best-effort audit signal that feeds the account-deletion dossier. func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error { now := time.Now().UTC() upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP). SET(postgres.TimestampzT(now), postgres.String(ip)). WHERE( table.Accounts.AccountID.EQ(postgres.UUID(accountID)). AND( table.Accounts.LastLoginAt.IS_NULL(). OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))), ), ) if _, err := upd.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: stamp last login %s: %w", accountID, err) } return nil } // ReapExpiredRetention purges retention data whose event is older than cutoff: every // retained_identities row by its detached_at (covering unlink/change on live accounts as // well as deleted ones), plus โ€” for accounts tombstoned before cutoff โ€” the retained // feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept // (a shared game artifact), and the tombstone account row itself stays (its no-cascade // foreign keys). It returns how many journal rows and feedback messages were removed. func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) { cut := postgres.TimestampzT(cutoff) delJournal := table.RetainedIdentities.DELETE(). WHERE(table.RetainedIdentities.DetachedAt.LT(cut)) res, err := delJournal.ExecContext(ctx, s.db) if err != nil { return 0, 0, fmt.Errorf("account: reap retained identities: %w", err) } identities, _ = res.RowsAffected() expired := postgres.SELECT(table.Accounts.AccountID). FROM(table.Accounts). WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut))) delFeedback := table.FeedbackMessages.DELETE(). WHERE(table.FeedbackMessages.AccountID.IN(expired)) fbRes, err := delFeedback.ExecContext(ctx, s.db) if err != nil { return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err) } feedback, _ = fbRes.RowsAffected() clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP). SET(postgres.NULL, postgres.NULL). WHERE( table.Accounts.DeletedAt.IS_NOT_NULL(). AND(table.Accounts.DeletedAt.LT(cut)). AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL(). OR(table.Accounts.LastLoginIP.IS_NOT_NULL())), ) if _, err := clearPII.ExecContext(ctx, s.db); err != nil { return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err) } return identities, feedback, nil } // RetentionReaper periodically purges expired account-deletion retention data via // Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once // from main. type RetentionReaper struct { store *Store ttl time.Duration clock func() time.Time log *zap.Logger } // NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be // nil. func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper { if log == nil { log = zap.NewNop() } return &RetentionReaper{ store: store, ttl: ttl, clock: func() time.Time { return time.Now().UTC() }, log: log, } } // Run purges expired retention data on each tick until ctx is cancelled. func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl)) if err != nil { r.log.Warn("retention reap failed", zap.Error(err)) } else if idn > 0 || fb > 0 { r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb)) } } } }