Stage 11: account linking & merge (email + Telegram Login Widget)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Link an email (confirm-code) or Telegram (web Login Widget) to the current account; if the identity already has its own account, merge the two into the one in use (the current account is primary, except a guest initiator whose durable counterpart wins). The merge runs in one transaction (internal/accountmerge): stats + hint wallet summed, paid_account ORed, identities/games/chat/complaints transferred, friends/blocks de-duplicated, the secondary kept as a merged_into tombstone so a shared finished game's no-cascade FKs hold; a shared active game blocks the merge. - migration 00009: accounts.paid_account, merged_into, merged_at (+ jetgen) - internal/link orchestrator; session.RevokeAllForAccount on merge - connector ValidateLoginWidget RPC + loginwidget HMAC validator - edge ops link.email.request/confirm/merge, link.telegram.confirm/merge; supersedes the Stage 8 email.bind.* surface (request never reveals 'taken' before the code is verified, so a probe cannot enumerate addresses) - UI Profile link section + irreversible-merge dialog; Telegram web sign-in - focused regression tests (merge core, guest inversion, active-game refusal, finished-shared-game kept), gateway transcode + connector + UI codec/e2e - docs: PLAN, ARCHITECTURE 3/4/9, FUNCTIONAL(+ru), module READMEs
This commit is contained in:
@@ -63,8 +63,16 @@ type Account struct {
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account (Stage 9).
|
||||
NotificationsInAppOnly bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||
// never lost when accounts are consolidated (Stage 11).
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
||||
MergedInto uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Identity is one of an account's platform/email identities, surfaced on the
|
||||
@@ -368,6 +376,10 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
var mergedInto uuid.UUID
|
||||
if row.MergedInto != nil {
|
||||
mergedInto = *row.MergedInto
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
@@ -380,6 +392,8 @@ func modelToAccount(row model.Accounts) Account {
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||
// belongs to another account; the caller turns it into a merge (Stage 11).
|
||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||
|
||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||
// (taken or already-confirmed): possession of the address is the authorization for
|
||||
// a later link or merge, and the merge is only revealed once the code is verified,
|
||||
// so a probe cannot learn whether an address is registered (Stage 11).
|
||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := "Your Scrabble confirmation code"
|
||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
return s.mailer.Send(ctx, addr, subject, body)
|
||||
}
|
||||
|
||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||
// current owner. When the address is free it binds a confirmed email identity to
|
||||
// accountID and returns (accountID, true, nil). When accountID already owns it,
|
||||
// it returns (accountID, true, nil) unchanged. When another account owns it, it
|
||||
// returns (owner, false, nil) without consuming the code, so the explicit merge
|
||||
// step can re-verify the same live code. It returns the usual confirm-code errors
|
||||
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
|
||||
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if ok {
|
||||
if owner == accountID {
|
||||
return accountID, true, nil
|
||||
}
|
||||
return owner, false, nil
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return accountID, true, nil
|
||||
}
|
||||
|
||||
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
||||
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
||||
if err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return emailConfirmation{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return emailConfirmation{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
return emailConfirmation{}, ErrCodeMismatch
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||
// flow (Stage 11).
|
||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return acc.ID, true, nil
|
||||
}
|
||||
|
||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||
// to the current account during linking (Stage 11).
|
||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
ins := table.Identities.INSERT(
|
||||
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||
).VALUES(id, accountID, kind, externalID, confirmed)
|
||||
if _, err := ins.ExecContext(ctx, s.db); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrIdentityTaken
|
||||
}
|
||||
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||
// to a durable account once it gains its first identity (Stage 11). It is a no-op
|
||||
// for an already-durable account.
|
||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
|
||||
)
|
||||
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// Package accountmerge retires a secondary account into a primary one in a single
|
||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
|
||||
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
||||
// one layer up (the link service), since the in-memory session cache lives there.
|
||||
package accountmerge
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
|
||||
// without taking a dependency on the game package.
|
||||
const statusActive = "active"
|
||||
|
||||
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||
const (
|
||||
friendAccepted = "accepted"
|
||||
friendPending = "pending"
|
||||
friendDeclined = "declined"
|
||||
)
|
||||
|
||||
// ErrActiveGameConflict is returned when the primary and secondary accounts share
|
||||
// an active game: merging would seat one player against themselves, so the caller
|
||||
// must wait for the game to finish.
|
||||
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
|
||||
|
||||
// ErrSameAccount is returned when primary and secondary are the same account.
|
||||
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
|
||||
|
||||
// Merger merges accounts over a Postgres handle.
|
||||
type Merger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewMerger constructs a Merger over db.
|
||||
func NewMerger(db *sql.DB) *Merger {
|
||||
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Merge retires secondary into primary atomically. The secondary is kept as a
|
||||
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
|
||||
// finished game stay valid; its seat in such a game is left untouched. The merge
|
||||
// is refused with ErrActiveGameConflict when the two share an active game.
|
||||
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
|
||||
if primary == secondary {
|
||||
return ErrSameAccount
|
||||
}
|
||||
now := m.now()
|
||||
return withTx(ctx, m.db, func(tx *sql.Tx) error {
|
||||
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||
}
|
||||
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: chat: %w", err)
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: complaints: %w", err)
|
||||
}
|
||||
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
return tombstone(ctx, tx, primary, secondary, now)
|
||||
})
|
||||
}
|
||||
|
||||
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
|
||||
// are both seated in the same active game.
|
||||
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
pri, err := activeGameIDs(ctx, tx, primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pri) == 0 {
|
||||
return nil
|
||||
}
|
||||
sec, err := activeGameIDs(ctx, tx, secondary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := make(map[uuid.UUID]struct{}, len(pri))
|
||||
for _, id := range pri {
|
||||
have[id] = struct{}{}
|
||||
}
|
||||
for _, id := range sec {
|
||||
if _, ok := have[id]; ok {
|
||||
return ErrActiveGameConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeGameIDs lists the active games accountID is seated in.
|
||||
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
|
||||
WHERE(
|
||||
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Games.Status.EQ(postgres.String(statusActive))),
|
||||
)
|
||||
var rows []model.GamePlayers
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.GameID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
||||
// summed, max points kept) and deletes the secondary row.
|
||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.AccountStats
|
||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
|
||||
}
|
||||
|
||||
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
|
||||
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
|
||||
if _, err := ensure.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
|
||||
}
|
||||
var pri model.AccountStats
|
||||
if err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
|
||||
FOR(postgres.UPDATE()).
|
||||
QueryContext(ctx, tx, &pri); err != nil {
|
||||
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
|
||||
}
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
).SET(
|
||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary stats: %w", err)
|
||||
}
|
||||
|
||||
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.Accounts
|
||||
if err := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec); err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary account: %w", err)
|
||||
}
|
||||
upd := table.Accounts.UPDATE(
|
||||
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
|
||||
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferGamePlayers moves secondary's seats to primary, except in a game primary
|
||||
// already sits in (a shared finished game — active is barred by the guard), where
|
||||
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
|
||||
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
var prows []model.GamePlayers
|
||||
if err := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary seats: %w", err)
|
||||
}
|
||||
}
|
||||
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
|
||||
if len(prows) > 0 {
|
||||
ids := make([]postgres.Expression, len(prows))
|
||||
for i, r := range prows {
|
||||
ids[i] = postgres.UUID(r.GameID)
|
||||
}
|
||||
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
|
||||
}
|
||||
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: transfer seats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reassignColumn blanket-reassigns a no-collision account column from secondary to
|
||||
// primary (identities, chat sender, complaint complainant).
|
||||
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
|
||||
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
|
||||
WHERE(col.EQ(postgres.UUID(secondary)))
|
||||
_, err := upd.ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||
func friendRank(status string) int {
|
||||
switch status {
|
||||
case friendAccepted:
|
||||
return 3
|
||||
case friendPending:
|
||||
return 2
|
||||
case friendDeclined:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
|
||||
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
|
||||
// counterparty by keeping the higher-precedence status (accepted > pending >
|
||||
// declined). Each account has at most one edge per unordered pair, so the per-other
|
||||
// decision is unambiguous.
|
||||
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
|
||||
}
|
||||
|
||||
priByOther := map[uuid.UUID]string{}
|
||||
var prows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary friendships: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
|
||||
}
|
||||
|
||||
var srows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
|
||||
if priStatus, ok := priByOther[other]; ok {
|
||||
if friendRank(r.Status) <= friendRank(priStatus) {
|
||||
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
|
||||
}
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
|
||||
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
|
||||
// primary-secondary block (a self-block) and de-duplicating a counterparty already
|
||||
// blocked by primary in either direction (a block is undirected for suppression).
|
||||
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-block: %w", err)
|
||||
}
|
||||
|
||||
priOthers := map[uuid.UUID]struct{}{}
|
||||
var prows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary blocks: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
|
||||
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup block: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
|
||||
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint block: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
|
||||
// their invitees) and repoints its invitee rows to primary, dropping a row where
|
||||
// primary is already an invitee of the same invitation.
|
||||
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
delInv := table.GameInvitations.DELETE().
|
||||
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := delInv.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
|
||||
}
|
||||
|
||||
priInv := map[uuid.UUID]struct{}{}
|
||||
var prows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary invitees: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priInv[r.InvitationID] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, dup := priInv[r.InvitationID]; dup {
|
||||
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
|
||||
SET(postgres.UUID(primary)).WHERE(where)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteEphemerals drops the secondary's pending email confirmations and friend
|
||||
// codes (short-lived, single-use; not worth carrying over).
|
||||
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
|
||||
if _, err := table.EmailConfirmations.DELETE().
|
||||
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
|
||||
}
|
||||
if _, err := table.FriendCodes.DELETE().
|
||||
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tombstone marks secondary retired, pointing at primary for audit.
|
||||
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
|
||||
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherOf returns the endpoint of a two-account edge that is not self.
|
||||
func otherOf(a, b, self uuid.UUID) uuid.UUID {
|
||||
if a == self {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// selectEdges loads the rows of a symmetric two-column edge table touching account.
|
||||
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
|
||||
err := postgres.SELECT(cols).
|
||||
FROM(tbl).
|
||||
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
|
||||
QueryContext(ctx, tx, dest)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// deletePair deletes the directed-or-reverse edge between a and b.
|
||||
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
|
||||
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteEdge deletes the single edge identified by its (left, right) primary key.
|
||||
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
|
||||
// the edge's direction.
|
||||
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
|
||||
var col postgres.ColumnString
|
||||
var where postgres.BoolExpression
|
||||
if l == secondary {
|
||||
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
|
||||
} else {
|
||||
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
|
||||
}
|
||||
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil 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("accountmerge: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("accountmerge: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -68,14 +68,18 @@ type UserDetailView struct {
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge (Stage 11), or empty for a live account.
|
||||
MergedInto string
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// --- merge test helpers ---
|
||||
|
||||
func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
|
||||
id, w, l, d, mg, mw); err != nil {
|
||||
t.Fatalf("set stats: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
|
||||
t.Fatalf("set wallet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
||||
uuid.New(), acc, email); err != nil {
|
||||
t.Fatalf("bind email identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
|
||||
t.Fatalf("insert friendship: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
var into *uuid.UUID
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
|
||||
t.Fatalf("read merged_into: %v", err)
|
||||
}
|
||||
if into == nil {
|
||||
return uuid.Nil
|
||||
}
|
||||
return *into
|
||||
}
|
||||
|
||||
func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
|
||||
t.Fatalf("seat count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
|
||||
t.Helper()
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seat game: %v", err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
func newLinkService(mailer account.Mailer) *link.Service {
|
||||
store := account.NewStore(testDB)
|
||||
emails := account.NewEmailService(store, mailer)
|
||||
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
||||
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
||||
}
|
||||
|
||||
// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
|
||||
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
|
||||
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
|
||||
func TestAccountMergeCore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
friend := provisionAccount(t)
|
||||
|
||||
setStats(t, primary, 1, 0, 0, 100, 90)
|
||||
setStats(t, secondary, 3, 1, 2, 400, 80)
|
||||
setWallet(t, primary, 2, false)
|
||||
setWallet(t, secondary, 5, true)
|
||||
|
||||
email := "merge-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, secondary, email)
|
||||
insertFriendship(t, secondary, friend, "accepted")
|
||||
gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
w, l, d, mg, mw, found := readStats(t, primary)
|
||||
if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
|
||||
t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
|
||||
}
|
||||
if _, _, _, _, _, found := readStats(t, secondary); found {
|
||||
t.Error("secondary stats row should be deleted after merge")
|
||||
}
|
||||
|
||||
acc, err := store.GetByID(ctx, primary)
|
||||
if err != nil {
|
||||
t.Fatalf("get primary: %v", err)
|
||||
}
|
||||
if acc.HintBalance != 7 {
|
||||
t.Errorf("hint balance = %d, want 7", acc.HintBalance)
|
||||
}
|
||||
if !acc.PaidAccount {
|
||||
t.Error("paid_account should be true (ORed from secondary)")
|
||||
}
|
||||
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
|
||||
t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
|
||||
}
|
||||
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
|
||||
t.Error("non-shared game seat should transfer to primary")
|
||||
}
|
||||
if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
|
||||
t.Errorf("primary friends = %v, want [%s]", friends, friend)
|
||||
}
|
||||
if mergedInto(t, secondary) != primary {
|
||||
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
|
||||
// game (one player cannot be merged against themselves).
|
||||
func TestAccountMergeActiveGameConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
|
||||
t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
|
||||
}
|
||||
if mergedInto(t, secondary) != uuid.Nil {
|
||||
t.Error("a refused merge must not tombstone the secondary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
|
||||
// finished and leaves the secondary's seat in place (the tombstone keeps the
|
||||
// no-cascade foreign key valid).
|
||||
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
|
||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
|
||||
t.Fatalf("finish game: %v", err)
|
||||
}
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
|
||||
t.Error("a shared finished game must keep both seats (secondary as tombstone)")
|
||||
}
|
||||
if mergedInto(t, secondary) != primary {
|
||||
t.Error("secondary should be tombstoned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
guest := provisionGuest(t)
|
||||
email := "fresh-" + uuid.NewString() + "@example.com"
|
||||
if err := links.RequestEmail(ctx, guest, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !res.Linked || res.MergeRequired {
|
||||
t.Fatalf("confirm = %+v, want linked", res)
|
||||
}
|
||||
acc, _ := store.GetByID(ctx, guest)
|
||||
if acc.IsGuest {
|
||||
t.Error("guest flag should clear once an identity is linked")
|
||||
}
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
|
||||
t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
|
||||
// (durable) account: the caller stays primary and keeps its session.
|
||||
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
caller := provisionAccount(t)
|
||||
other := provisionAccount(t)
|
||||
email := "owned-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, other, email)
|
||||
|
||||
if err := links.RequestEmail(ctx, caller, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
confirm, err := links.ConfirmEmail(ctx, caller, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !confirm.MergeRequired || confirm.SecondaryID != other {
|
||||
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
|
||||
}
|
||||
merge, err := links.MergeEmail(ctx, caller, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
|
||||
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
|
||||
}
|
||||
if mergedInto(t, other) != caller {
|
||||
t.Error("other should be tombstoned into caller")
|
||||
}
|
||||
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
|
||||
t.Errorf("email owner = %s, want caller", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
|
||||
// that owns the email: the durable account wins and a fresh session is minted.
|
||||
func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
durable := provisionAccount(t)
|
||||
email := "durable-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, durable, email)
|
||||
guest := provisionGuest(t)
|
||||
|
||||
if err := links.RequestEmail(ctx, guest, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
merge, err := links.MergeEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
|
||||
}
|
||||
if merge.SwitchedToken == "" {
|
||||
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
|
||||
}
|
||||
if mergedInto(t, guest) != durable {
|
||||
t.Error("the guest should be tombstoned into the durable account")
|
||||
}
|
||||
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
|
||||
t.Errorf("email owner = %s, want durable", owner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4).
|
||||
// It sits above the account, accountmerge and session layers: it verifies the
|
||||
// caller's control of an identity (an email confirm-code or a gateway-validated
|
||||
// platform identity), binds a free identity to the current account, and — when the
|
||||
// identity already has its own account — merges the two. The current account is the
|
||||
// merge primary, except when the initiator is a guest and the other account is
|
||||
// durable, in which case the durable account wins and a fresh session is minted for
|
||||
// it (the client switches to it).
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// Service drives the link/merge flow.
|
||||
type Service struct {
|
||||
emails *account.EmailService
|
||||
accounts *account.Store
|
||||
merger *accountmerge.Merger
|
||||
sessions *session.Service
|
||||
}
|
||||
|
||||
// NewService constructs a Service over its collaborators.
|
||||
func NewService(emails *account.EmailService, accounts *account.Store, merger *accountmerge.Merger, sessions *session.Service) *Service {
|
||||
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
|
||||
}
|
||||
|
||||
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
|
||||
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
|
||||
// required (the caller renders an irreversible-merge confirmation from it).
|
||||
type ConfirmResult struct {
|
||||
Linked bool
|
||||
MergeRequired bool
|
||||
SecondaryID uuid.UUID
|
||||
}
|
||||
|
||||
// MergeResult reports a completed merge. PrimaryID is the surviving account.
|
||||
// SwitchedToken is a fresh session token for the primary when the active account
|
||||
// changed (a guest initiator whose durable counterpart won); empty otherwise, in
|
||||
// which case the caller keeps its current session.
|
||||
type MergeResult struct {
|
||||
PrimaryID uuid.UUID
|
||||
SwitchedToken string
|
||||
}
|
||||
|
||||
// RequestEmail mails a confirm-code for email to the caller (always sent).
|
||||
func (s *Service) RequestEmail(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
return s.emails.RequestLinkCode(ctx, accountID, email)
|
||||
}
|
||||
|
||||
// ConfirmEmail verifies the code and either binds the free address to the caller
|
||||
// (Linked) or reports that the address belongs to another account (MergeRequired).
|
||||
func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email, code string) (ConfirmResult, error) {
|
||||
owner, linked, err := s.emails.ConfirmLink(ctx, accountID, email, code)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if linked {
|
||||
if err := s.accounts.ClearGuest(ctx, accountID); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeEmail re-verifies the code and merges the address's account into the
|
||||
// caller's (subject to the guest-primary rule).
|
||||
func (s *Service) MergeEmail(ctx context.Context, callerID uuid.UUID, email, code string) (MergeResult, error) {
|
||||
owner, linked, err := s.emails.ConfirmLink(ctx, callerID, email, code)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if linked {
|
||||
// Raced to free/self between confirm and merge: it is now simply linked.
|
||||
if err := s.accounts.ClearGuest(ctx, callerID); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
return s.merge(ctx, callerID, owner)
|
||||
}
|
||||
|
||||
// ConfirmTelegram attaches a gateway-validated Telegram identity to the caller
|
||||
// (Linked) or reports that it belongs to another account (MergeRequired).
|
||||
func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeTelegram merges the account owning a gateway-validated Telegram identity
|
||||
// into the caller's (subject to the guest-primary rule).
|
||||
func (s *Service) MergeTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
return s.merge(ctx, callerID, owner)
|
||||
}
|
||||
|
||||
// attachTelegram links the identity to the caller and promotes a guest.
|
||||
func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, externalID string) error {
|
||||
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindTelegram, externalID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accounts.ClearGuest(ctx, callerID)
|
||||
}
|
||||
|
||||
// merge decides the primary (the caller, unless it is a guest and the other is
|
||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||
// session when the active account switches.
|
||||
func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (MergeResult, error) {
|
||||
caller, err := s.accounts.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
primary, secondary := callerID, otherID
|
||||
if caller.IsGuest {
|
||||
primary, secondary = otherID, callerID
|
||||
}
|
||||
if err := s.merger.Merge(ctx, primary, secondary); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if err := s.sessions.RevokeAllForAccount(ctx, secondary); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
res := MergeResult{PrimaryID: primary}
|
||||
if primary != callerID {
|
||||
token, _, err := s.sessions.Create(ctx, primary)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
res.SwitchedToken = token
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -26,4 +26,7 @@ type Accounts struct {
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
MergedInto *uuid.UUID
|
||||
MergedAt *time.Time
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ type accountsTable struct {
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
NotificationsInAppOnly postgres.ColumnBool
|
||||
PaidAccount postgres.ColumnBool
|
||||
MergedInto postgres.ColumnString
|
||||
MergedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -84,9 +87,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
PaidAccountColumn = postgres.BoolColumn("paid_account")
|
||||
MergedIntoColumn = postgres.StringColumn("merged_into")
|
||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
@@ -106,6 +112,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
|
||||
PaidAccount: PaidAccountColumn,
|
||||
MergedInto: MergedIntoColumn,
|
||||
MergedAt: MergedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- +goose Up
|
||||
-- Stage 11 account linking & merge: retire a secondary account into a primary one.
|
||||
-- merged_into/merged_at turn the secondary into an audit tombstone (its identities
|
||||
-- are repointed and its non-shared rows transferred to the primary, but the row is
|
||||
-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared
|
||||
-- finished game stay valid). merged_into self-references accounts and is SET NULL on
|
||||
-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary.
|
||||
-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase
|
||||
-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the
|
||||
-- generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN paid_account boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
||||
ADD COLUMN merged_at timestamptz;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts
|
||||
DROP COLUMN merged_at,
|
||||
DROP COLUMN merged_into,
|
||||
DROP COLUMN paid_account;
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
@@ -42,9 +43,15 @@ func (s *Server) registerRoutes() {
|
||||
u.PUT("/profile", s.handleUpdateProfile)
|
||||
u.GET("/stats", s.handleStats)
|
||||
}
|
||||
if s.emails != nil {
|
||||
u.POST("/email/request", s.handleEmailBindRequest)
|
||||
u.POST("/email/confirm", s.handleEmailBindConfirm)
|
||||
if s.links != nil {
|
||||
// Account linking & merge (Stage 11). The request step always mails a code;
|
||||
// a required merge is revealed only after the code is verified, and the
|
||||
// irreversible merge is an explicit second step.
|
||||
u.POST("/link/email/request", s.handleLinkEmailRequest)
|
||||
u.POST("/link/email/confirm", s.handleLinkEmailConfirm)
|
||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
||||
}
|
||||
if s.games != nil {
|
||||
u.GET("/games", s.handleListGames)
|
||||
@@ -179,8 +186,10 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "hint_unavailable"
|
||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||
return http.StatusUnprocessableEntity, "illegal_play"
|
||||
case errors.Is(err, account.ErrEmailTaken):
|
||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||
return http.StatusConflict, "email_taken"
|
||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
||||
return http.StatusConflict, "merge_active_game_conflict"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
|
||||
@@ -38,17 +38,6 @@ type statsDTO struct {
|
||||
MaxWordPoints int `json:"max_word_points"`
|
||||
}
|
||||
|
||||
// emailBindRequestBody starts binding an email to the caller's account.
|
||||
type emailBindRequestBody struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// emailBindConfirmBody completes binding an email with its confirm code.
|
||||
type emailBindConfirmBody struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// parseAwayTime parses an "HH:MM" away-window bound.
|
||||
func parseAwayTime(s string) (time.Time, bool) {
|
||||
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
||||
@@ -117,43 +106,3 @@ func (s *Server) handleStats(c *gin.Context) {
|
||||
MaxWordPoints: st.MaxWordPoints,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
|
||||
func (s *Server) handleEmailBindRequest(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req emailBindRequestBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleEmailBindConfirm verifies the code and binds the email, returning the
|
||||
// updated profile.
|
||||
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req emailBindConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
}
|
||||
|
||||
@@ -110,8 +110,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view := adminconsole.UserDetailView{
|
||||
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest,
|
||||
ConnectorEnabled: s.connector != nil,
|
||||
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt),
|
||||
HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
|
||||
}
|
||||
if acc.MergedInto != uuid.Nil {
|
||||
view.MergedInto = acc.MergedInto.String()
|
||||
}
|
||||
if view.HasStats {
|
||||
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/link"
|
||||
)
|
||||
|
||||
// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The
|
||||
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
|
||||
// enumerate registered emails); confirm reveals a required merge only after the
|
||||
// code is verified; merge performs the irreversible consolidation behind an
|
||||
// explicit step. A merge into a guest initiator's durable counterpart switches the
|
||||
// active session — the new token rides back in the result for the client to adopt.
|
||||
|
||||
// linkEmailRequestBody starts a link/merge by mailing a code to email.
|
||||
type linkEmailRequestBody struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// linkEmailConfirmBody carries the email and its confirm code.
|
||||
type linkEmailConfirmBody struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// linkTelegramBody carries a gateway-validated Telegram identity.
|
||||
type linkTelegramBody struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
||||
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||
// or "merged" (done; token is non-empty when the active account switched).
|
||||
type linkResultResponse struct {
|
||||
Status string `json:"status"`
|
||||
SecondaryUserID string `json:"secondary_user_id,omitempty"`
|
||||
SecondaryName string `json:"secondary_display_name,omitempty"`
|
||||
SecondaryGames int `json:"secondary_games"`
|
||||
SecondaryFriends int `json:"secondary_friends"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Profile *profileResponse `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// handleLinkEmailRequest mails a confirm-code to email for a later link or merge.
|
||||
func (s *Server) handleLinkEmailRequest(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkEmailRequestBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := s.links.RequestEmail(c.Request.Context(), uid, req.Email); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
||||
// required merge.
|
||||
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkEmailConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
res, err := s.links.ConfirmEmail(c.Request.Context(), uid, req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
||||
}
|
||||
|
||||
// handleLinkEmailMerge re-verifies the code and performs the merge.
|
||||
func (s *Server) handleLinkEmailMerge(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkEmailConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
res, err := s.links.MergeEmail(c.Request.Context(), uid, req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||
}
|
||||
|
||||
// handleLinkTelegram attaches a gateway-validated Telegram identity to the caller
|
||||
// or reports a required merge.
|
||||
func (s *Server) handleLinkTelegram(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkTelegramBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "missing external_id")
|
||||
return
|
||||
}
|
||||
res, err := s.links.ConfirmTelegram(c.Request.Context(), uid, req.ExternalID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
||||
}
|
||||
|
||||
// handleLinkTelegramMerge merges the account owning a gateway-validated Telegram
|
||||
// identity into the caller's.
|
||||
func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkTelegramBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "missing external_id")
|
||||
return
|
||||
}
|
||||
res, err := s.links.MergeTelegram(c.Request.Context(), uid, req.ExternalID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||
}
|
||||
|
||||
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||
// or a completed link (the active account's refreshed profile).
|
||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||
ctx := c.Request.Context()
|
||||
if res.MergeRequired {
|
||||
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
||||
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
||||
out.SecondaryName = acc.DisplayName
|
||||
}
|
||||
out.SecondaryGames, out.SecondaryFriends = s.secondaryCounts(ctx, res.SecondaryID)
|
||||
return out
|
||||
}
|
||||
return linkResultResponse{Status: "linked", Profile: s.profileFor(ctx, activeID)}
|
||||
}
|
||||
|
||||
// mergeResultResponse renders a completed merge: the surviving account's profile
|
||||
// plus a switched-session token when the active account changed.
|
||||
func (s *Server) mergeResultResponse(c *gin.Context, res link.MergeResult) linkResultResponse {
|
||||
return linkResultResponse{
|
||||
Status: "merged",
|
||||
Token: res.SwitchedToken,
|
||||
Profile: s.profileFor(c.Request.Context(), res.PrimaryID),
|
||||
}
|
||||
}
|
||||
|
||||
// profileFor loads an account's profile DTO, or nil when it cannot be read.
|
||||
func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse {
|
||||
acc, err := s.accounts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p := profileResponseFor(acc)
|
||||
return &p
|
||||
}
|
||||
|
||||
// secondaryCounts summarises the to-be-retired account for the merge confirmation.
|
||||
func (s *Server) secondaryCounts(ctx context.Context, id uuid.UUID) (games, friends int) {
|
||||
if s.games != nil {
|
||||
if gs, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||
games = len(gs)
|
||||
}
|
||||
}
|
||||
if s.social != nil {
|
||||
if fs, err := s.social.ListFriends(ctx, id); err == nil {
|
||||
friends = len(fs)
|
||||
}
|
||||
}
|
||||
return games, friends
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
@@ -58,6 +59,9 @@ type Deps struct {
|
||||
Matchmaker *lobby.Matchmaker
|
||||
Invitations *lobby.InvitationService
|
||||
Emails *account.EmailService
|
||||
// Links drives account linking & merge (Stage 11): the /api/v1/user/link
|
||||
// endpoints. A nil Links disables them.
|
||||
Links *link.Service
|
||||
// Registry holds the resident dictionaries; the admin console (Stage 10) reads
|
||||
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
|
||||
// reload reads a version subdirectory from. A nil Registry disables the console.
|
||||
@@ -85,6 +89,7 @@ type Server struct {
|
||||
matchmaker *lobby.Matchmaker
|
||||
invitations *lobby.InvitationService
|
||||
emails *account.EmailService
|
||||
links *link.Service
|
||||
registry *engine.Registry
|
||||
dictDir string
|
||||
connector *connector.Client
|
||||
@@ -124,6 +129,7 @@ func New(addr string, deps Deps) *Server {
|
||||
matchmaker: deps.Matchmaker,
|
||||
invitations: deps.Invitations,
|
||||
emails: deps.Emails,
|
||||
links: deps.Links,
|
||||
registry: deps.Registry,
|
||||
dictDir: deps.DictDir,
|
||||
connector: deps.Connector,
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Cache is the in-memory write-through projection of the active rows in
|
||||
@@ -93,3 +95,19 @@ func (c *Cache) Remove(tokenHash string) {
|
||||
defer c.mu.Unlock()
|
||||
delete(c.byHash, tokenHash)
|
||||
}
|
||||
|
||||
// RemoveByAccount evicts every cached session belonging to accountID. The
|
||||
// account-merge flow uses it to drop a retired secondary account's sessions
|
||||
// (Stage 11); a linear scan is adequate at the cache's size.
|
||||
func (c *Cache) RemoveByAccount(accountID uuid.UUID) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for hash, s := range c.byHash {
|
||||
if s.AccountID == accountID {
|
||||
delete(c.byHash, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +71,14 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
|
||||
svc.cache.Remove(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAllForAccount revokes every active session of accountID and evicts them
|
||||
// from the cache. The account-merge flow calls it to retire a secondary account
|
||||
// (Stage 11). It is idempotent.
|
||||
func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||
if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.cache.RemoveByAccount(accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,6 +110,34 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
|
||||
return modelToSession(row), true, nil
|
||||
}
|
||||
|
||||
// RevokeAllForAccount transitions every active session of accountID to revoked
|
||||
// and returns the post-update rows (so the caller can evict them from the cache).
|
||||
// It backs the account-merge flow, which retires a secondary account's sessions
|
||||
// (Stage 11). No matching rows is not an error.
|
||||
func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) {
|
||||
stmt := table.Sessions.
|
||||
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
|
||||
SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.Sessions.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Sessions.Status.EQ(postgres.String(StatusActive))),
|
||||
).
|
||||
RETURNING(table.Sessions.AllColumns)
|
||||
|
||||
var rows []model.Sessions
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("session: revoke all for account %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToSession(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListActive loads every active session. Cache.Warm calls this at boot.
|
||||
func (s *Store) ListActive(ctx context.Context) ([]Session, error) {
|
||||
stmt := postgres.SELECT(table.Sessions.AllColumns).
|
||||
|
||||
Reference in New Issue
Block a user