12af378b18
The profile GET (fetched once per cold app-load by the SPA) stamps accounts .last_login_at/.last_login_ip, throttled to at most once per hour per account (best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For. Feeds the account-deletion dossier. Integration test covers the throttle.
66 lines
2.6 KiB
Go
66 lines
2.6 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
|
)
|
|
|
|
// 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
|
|
}
|