Files
scrabble-game/backend/internal/account/retention.go
T
Ilia Denisov 9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
feat(social): hide social controls on a deleted opponent's seat
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00

251 lines
9.5 KiB
Go

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
}
// DeletedIDs returns the subset of ids whose accounts are tombstoned (deleted_at set), as a
// set. It is the batch form of Account.Deleted for a caller holding several ids at once —
// the game views resolve every seat's deleted mark in one round-trip. An empty ids slice
// queries nothing and returns an empty set; an unknown id is simply absent from the result.
func (s *Store) DeletedIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error) {
out := map[uuid.UUID]bool{}
if len(ids) == 0 {
return out, nil
}
exprs := make([]postgres.Expression, len(ids))
for i, id := range ids {
exprs[i] = postgres.UUID(id)
}
stmt := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.IN(exprs...).AND(table.Accounts.DeletedAt.IS_NOT_NULL()))
var rows []model.Accounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: deleted ids: %w", err)
}
for _, r := range rows {
out[r.AccountID] = true
}
return out, 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))
}
}
}
}