feat: game lobby service
This commit is contained in:
@@ -61,19 +61,14 @@ type Store struct {
|
||||
type accountRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
RaceName string `json:"race_name"`
|
||||
UserName string `json:"user_name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
DeclaredCountry *string `json:"declared_country,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type raceNameReservationRecord struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
ReservedAt string `json:"reserved_at"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type blockedEmailRecord struct {
|
||||
@@ -190,8 +185,8 @@ func (store *Store) Ping(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create stores one new account record together with the exact and canonical
|
||||
// race-name lookup state.
|
||||
// Create stores one new account record together with the exact user-name
|
||||
// lookup state.
|
||||
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
@@ -201,15 +196,10 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
}
|
||||
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.Account.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(input.Account.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
|
||||
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "create account in redis")
|
||||
if err != nil {
|
||||
@@ -224,18 +214,17 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
if err := ensureKeyAbsent(operationCtx, tx, emailLookupKey); err != nil {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, ports.ErrUserNameConflict)
|
||||
}
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
||||
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
|
||||
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
||||
store.addCreatedAtIndex(pipe, operationCtx, input.Account)
|
||||
store.syncDeclaredCountryIndex(pipe, operationCtx, account.UserAccount{}, input.Account)
|
||||
return nil
|
||||
@@ -245,7 +234,7 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, emailLookupKey, raceNameLookupKey, reservationKey)
|
||||
}, accountKey, emailLookupKey, userNameLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -317,26 +306,26 @@ func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByRaceName returns the stored account identified by the exact stored race
|
||||
// name.
|
||||
func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name from redis: %w", err)
|
||||
// GetByUserName returns the stored account identified by the exact stored
|
||||
// user name.
|
||||
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
if err := userName.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by race name from redis")
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from redis")
|
||||
if err != nil {
|
||||
return account.UserAccount{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.RaceNameLookup(raceName))
|
||||
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.UserNameLookup(userName))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, ports.ErrNotFound)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, ports.ErrNotFound)
|
||||
default:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,16 +333,18 @@ func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: lookup references missing user %q", raceName, userID)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: lookup references missing user %q", userName, userID)
|
||||
default:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID identifies a stored account.
|
||||
// ExistsByUserID reports whether userID currently identifies a stored account
|
||||
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
|
||||
// for external callers per Stage 22.
|
||||
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("exists by user id from redis: %w", err)
|
||||
@@ -365,114 +356,25 @@ func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (b
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
exists, err := store.client.Exists(operationCtx, store.keyspace.Account(userID)).Result()
|
||||
if err != nil {
|
||||
record, err := store.loadAccount(operationCtx, store.client, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("exists by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
if record.IsDeleted() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID and swaps the exact
|
||||
// and canonical race-name lookup state atomically.
|
||||
func (store *Store) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("rename account race name in redis: %w", err)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.UserID)
|
||||
newRaceNameLookupKey := store.keyspace.RaceNameLookup(input.NewRaceName)
|
||||
newReservationKey := store.keyspace.RaceNameReservation(input.NewReservation.CanonicalKey)
|
||||
newReservationPayload, err := marshalRaceNameReservationRecord(input.NewReservation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "rename account race name in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
record, err := store.loadAccount(operationCtx, tx, input.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if record.RaceName == input.NewRaceName {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentRaceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
|
||||
currentLookupUserID, err := store.loadLookupUserID(operationCtx, tx, currentRaceNameLookupKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentLookupUserID != input.UserID {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
currentReservation, err := store.loadRaceNameReservation(operationCtx, tx, input.CurrentCanonicalKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentReservation.UserID != input.UserID || currentReservation.RaceName != record.RaceName {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
if err := ensureLookupAvailableOrOwned(operationCtx, tx, newRaceNameLookupKey, input.UserID); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
|
||||
if err := store.ensureReservationAvailableOrOwned(operationCtx, tx, input.NewReservation.CanonicalKey, input.UserID); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
record.RaceName = input.NewRaceName
|
||||
record.UpdatedAt = input.UpdatedAt.UTC()
|
||||
|
||||
payload, err := marshalAccountRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, payload, 0)
|
||||
pipe.Set(operationCtx, newRaceNameLookupKey, input.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, newReservationKey, newReservationPayload, 0)
|
||||
pipe.Del(operationCtx, currentRaceNameLookupKey)
|
||||
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
|
||||
pipe.Del(operationCtx, store.keyspace.RaceNameReservation(input.CurrentCanonicalKey))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, newRaceNameLookupKey, newReservationKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
// Update replaces the stored account state for record.UserID. `email` and
|
||||
// `user_name` are immutable; any attempt to mutate them returns
|
||||
// ports.ErrConflict.
|
||||
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("update account in redis: %w", err)
|
||||
@@ -485,7 +387,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
|
||||
accountKey := store.keyspace.Account(record.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(record.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(record.UserName)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "update account in redis")
|
||||
if err != nil {
|
||||
@@ -498,7 +400,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
||||
}
|
||||
if current.Email != record.Email || current.RaceName != record.RaceName {
|
||||
if current.Email != record.Email || current.UserName != record.UserName {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
@@ -510,11 +412,11 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
raceLookupUserID, err := store.loadLookupUserID(operationCtx, tx, raceNameLookupKey)
|
||||
userNameLookupUserID, err := store.loadLookupUserID(operationCtx, tx, userNameLookupKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
||||
}
|
||||
if raceLookupUserID != record.UserID {
|
||||
if userNameLookupUserID != record.UserID {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
@@ -528,7 +430,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, emailLookupKey, raceNameLookupKey)
|
||||
}, accountKey, emailLookupKey, userNameLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -965,17 +867,31 @@ func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (por
|
||||
accountRecord, err := store.GetByEmailAccount(operationCtx, email)
|
||||
switch {
|
||||
case err == nil:
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindExisting,
|
||||
UserID: accountRecord.UserID,
|
||||
}, nil
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
|
||||
default:
|
||||
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in redis: %w", email, err)
|
||||
}
|
||||
|
||||
if accountRecord.IsDeleted() {
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindBlocked,
|
||||
BlockReasonCode: deletedAccountBlockReasonCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindExisting,
|
||||
UserID: accountRecord.UserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deletedAccountBlockReasonCode is the reason_code returned when an auth-facing
|
||||
// lookup resolves to a soft-deleted account. It is not a real sanction; the
|
||||
// auth/session service treats it as a blocked outcome and refuses to issue a
|
||||
// session for the subject.
|
||||
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
|
||||
|
||||
// EnsureByEmail atomically returns an existing user, creates a new one, or
|
||||
// reports a blocked outcome for one e-mail subject.
|
||||
func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
@@ -995,11 +911,6 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
||||
}
|
||||
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "ensure by email in redis")
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, err
|
||||
@@ -1011,8 +922,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
|
||||
accountKey := store.keyspace.Account(input.Account.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(input.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
|
||||
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
||||
blockedEmailKey := store.keyspace.BlockedEmailSubject(input.Email)
|
||||
entitlementKey := store.keyspace.EntitlementSnapshot(input.Account.UserID)
|
||||
entitlementRecordKey := store.keyspace.EntitlementRecord(input.EntitlementRecord.RecordID)
|
||||
@@ -1039,6 +949,14 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
result = ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
||||
BlockReasonCode: deletedAccountBlockReasonCode,
|
||||
}
|
||||
handled = true
|
||||
return nil
|
||||
}
|
||||
result = ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeExisting,
|
||||
UserID: record.UserID,
|
||||
@@ -1052,15 +970,9 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err := ensureKeyAbsent(operationCtx, tx, accountKey); err != nil {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrUserNameConflict)
|
||||
}
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
@@ -1074,8 +986,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
||||
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
|
||||
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, entitlementKey, entitlementPayload, 0)
|
||||
pipe.Set(operationCtx, entitlementRecordKey, entitlementRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, entitlementHistoryKey, redis.Z{
|
||||
@@ -1100,7 +1011,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
}
|
||||
handled = true
|
||||
return nil
|
||||
}, blockedEmailKey, emailLookupKey, accountKey, raceNameLookupKey, reservationKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
|
||||
}, blockedEmailKey, emailLookupKey, accountKey, userNameLookupKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -1136,6 +1047,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
|
||||
}
|
||||
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentAccount.IsDeleted() {
|
||||
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.UserID)
|
||||
blockedEmailKey := store.keyspace.BlockedEmailSubject(currentAccount.Email)
|
||||
@@ -1145,6 +1059,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
|
||||
if err != nil {
|
||||
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if accountRecord.IsDeleted() {
|
||||
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
blocked, err := store.loadBlockedEmail(operationCtx, tx, accountRecord.Email)
|
||||
switch {
|
||||
@@ -1327,22 +1244,6 @@ func (store *Store) loadLookupUserID(ctx context.Context, getter bytesGetter, ke
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadRaceNameReservation(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key account.RaceNameCanonicalKey,
|
||||
) (account.RaceNameReservation, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.RaceNameReservation(key)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return account.RaceNameReservation{}, ports.ErrNotFound
|
||||
case err != nil:
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
return decodeRaceNameReservationRecord(payload)
|
||||
}
|
||||
|
||||
func (store *Store) loadBlockedEmail(ctx context.Context, getter bytesGetter, email common.Email) (authblock.BlockedEmailSubject, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.BlockedEmailSubject(email)).Bytes()
|
||||
switch {
|
||||
@@ -1436,32 +1337,12 @@ func ensureLookupAvailableOrOwned(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) ensureReservationAvailableOrOwned(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key account.RaceNameCanonicalKey,
|
||||
userID common.UserID,
|
||||
) error {
|
||||
record, err := store.loadRaceNameReservation(ctx, getter, key)
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
if record.UserID != userID {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
|
||||
encoded := accountRecord{
|
||||
UserID: record.UserID.String(),
|
||||
Email: record.Email.String(),
|
||||
RaceName: record.RaceName.String(),
|
||||
UserName: record.UserName.String(),
|
||||
DisplayName: record.DisplayName.String(),
|
||||
PreferredLanguage: record.PreferredLanguage.String(),
|
||||
TimeZone: record.TimeZone.String(),
|
||||
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
@@ -1471,6 +1352,10 @@ func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
|
||||
value := record.DeclaredCountry.String()
|
||||
encoded.DeclaredCountry = &value
|
||||
}
|
||||
if record.DeletedAt != nil {
|
||||
value := record.DeletedAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.DeletedAt = &value
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
@@ -1493,7 +1378,8 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
record := account.UserAccount{
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
Email: common.Email(encoded.Email),
|
||||
RaceName: common.RaceName(encoded.RaceName),
|
||||
UserName: common.UserName(encoded.UserName),
|
||||
DisplayName: common.DisplayName(encoded.DisplayName),
|
||||
PreferredLanguage: common.LanguageTag(encoded.PreferredLanguage),
|
||||
TimeZone: common.TimeZoneName(encoded.TimeZone),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
@@ -1502,6 +1388,14 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
if encoded.DeclaredCountry != nil {
|
||||
record.DeclaredCountry = common.CountryCode(*encoded.DeclaredCountry)
|
||||
}
|
||||
if encoded.DeletedAt != nil {
|
||||
deletedAt, err := time.Parse(time.RFC3339Nano, *encoded.DeletedAt)
|
||||
if err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("decode account record deleted_at: %w", err)
|
||||
}
|
||||
deletedAt = deletedAt.UTC()
|
||||
record.DeletedAt = &deletedAt
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("decode account record: %w", err)
|
||||
}
|
||||
@@ -1509,41 +1403,6 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func marshalRaceNameReservationRecord(record account.RaceNameReservation) ([]byte, error) {
|
||||
encoded := raceNameReservationRecord{
|
||||
CanonicalKey: record.CanonicalKey.String(),
|
||||
UserID: record.UserID.String(),
|
||||
RaceName: record.RaceName.String(),
|
||||
ReservedAt: record.ReservedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
|
||||
func decodeRaceNameReservationRecord(payload []byte) (account.RaceNameReservation, error) {
|
||||
var encoded raceNameReservationRecord
|
||||
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
reservedAt, err := time.Parse(time.RFC3339Nano, encoded.ReservedAt)
|
||||
if err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation reserved_at: %w", err)
|
||||
}
|
||||
|
||||
record := account.RaceNameReservation{
|
||||
CanonicalKey: account.RaceNameCanonicalKey(encoded.CanonicalKey),
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
RaceName: common.RaceName(encoded.RaceName),
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func marshalBlockedEmailRecord(record authblock.BlockedEmailSubject) ([]byte, error) {
|
||||
encoded := blockedEmailRecord{
|
||||
Email: record.Email.String(),
|
||||
@@ -1902,9 +1761,9 @@ func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email)
|
||||
return adapter.store.GetByEmail(ctx, email)
|
||||
}
|
||||
|
||||
// GetByRaceName returns the stored account identified by raceName.
|
||||
func (adapter *AccountStore) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
return adapter.store.GetByRaceName(ctx, raceName)
|
||||
// GetByUserName returns the stored account identified by userName.
|
||||
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
return adapter.store.GetByUserName(ctx, userName)
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
@@ -1913,11 +1772,6 @@ func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.U
|
||||
return adapter.store.ExistsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID atomically.
|
||||
func (adapter *AccountStore) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
|
||||
return adapter.store.RenameRaceName(ctx, input)
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
|
||||
return adapter.store.Update(ctx, record)
|
||||
|
||||
Reference in New Issue
Block a user