package account 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" "go.uber.org/zap" "scrabble/backend/internal/postgres/jet/backend/model" "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 (unlink / email change / account deletion here; an account merge that drops a // same-kind colliding identity writes reason "merge" from the accountmerge package). 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 } // RetainedIdentity is one row of the retention journal, for the admin dossier. type RetainedIdentity struct { Kind string ExternalID string Reason string Confirmed bool LinkedAt time.Time DetachedAt time.Time } // RetainedIdentities returns the account's retention-journal rows (the legal dossier of // detached credentials), newest detach first, for the admin console. func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) { var rows []model.RetainedIdentities err := postgres.SELECT(table.RetainedIdentities.AllColumns). FROM(table.RetainedIdentities). WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))). ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()). QueryContext(ctx, s.db, &rows) if err != nil && !errors.Is(err, qrm.ErrNoRows) { return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err) } out := make([]RetainedIdentity, 0, len(rows)) for _, r := range rows { out = append(out, RetainedIdentity{ Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason, Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt, }) } return out, nil } // DeletionInfo is a tombstoned account's dossier header, for the admin console. type DeletionInfo struct { DeletedAt *time.Time DeletedDisplayName string LastLoginAt *time.Time LastLoginIP string } // DeletionInfo reads the account's deletion tombstone + last-login dossier fields. func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) { var row model.Accounts err := postgres.SELECT( table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName, table.Accounts.LastLoginAt, table.Accounts.LastLoginIP, ).FROM(table.Accounts). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))). QueryContext(ctx, s.db, &row) if err != nil { if errors.Is(err, qrm.ErrNoRows) { return DeletionInfo{}, ErrNotFound } return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err) } info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt} if row.DeletedDisplayName != nil { info.DeletedDisplayName = *row.DeletedDisplayName } if row.LastLoginIP != nil { info.LastLoginIP = *row.LastLoginIP } return info, 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)) } } } }