package verificationimp import ( "context" "errors" "time" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository/builder" "github.com/tech/sendico/pkg/db/verification" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" mutil "github.com/tech/sendico/pkg/mutil/db" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) func (db *verificationDB) Consume( ct context.Context, accountRef bson.ObjectID, purpose model.VerificationPurpose, rawToken string, ) (*model.VerificationToken, error) { now := time.Now().UTC() t, e := db.tf.CreateTransaction().Execute( ct, func(ctx context.Context) (any, error) { // 1) Load active tokens for this context activeFilter := repository.Query().And( repository.Filter("accountRef", accountRef), repository.Filter("purpose", purpose), repository.Filter("usedAt", nil), repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), ) tokens, err := mutil.GetObjects[model.VerificationToken]( ctx, db.Logger, activeFilter, nil, db.DBImp.Repository, ) if err != nil { if errors.Is(err, merrors.ErrNoData) { db.Logger.Debug("No tokens found", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose))) return nil, verification.ErorrTokenNotFound() } db.Logger.Warn("Failed to load active tokens", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose))) return nil, err } if len(tokens) == 0 { db.Logger.Debug("No tokens found", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose))) return nil, verification.ErorrTokenNotFound() } // 2) Find matching token via hasher (OTP or Magic — doesn't matter) var token *model.VerificationToken for i := range tokens { t := &tokens[i] hash := hasherFor(t).Hash(rawToken, t) if hash == t.VerifyTokenHash { token = t break } } if token == nil { // wrong code/token → increment attempts for _, t := range tokens { _, _ = db.DBImp.PatchMany( ctx, repository.IDFilter(t.ID), repository.Patch().Inc(repository.Field("attempts"), 1), ) } return nil, verification.ErorrTokenNotFound() } // 3) Static checks if token.UsedAt != nil { return nil, verification.ErorrTokenAlreadyUsed() } if !token.ExpiresAt.After(now) { return nil, verification.ErorrTokenExpired() } if token.MaxRetries != nil && token.Attempts >= *token.MaxRetries { return nil, verification.ErrorTokenAttemptsExceeded() } // 4) Atomic consume consumeFilter := repository.Query().And( repository.IDFilter(token.ID), repository.Filter("accountRef", accountRef), repository.Filter("purpose", purpose), repository.Filter("usedAt", nil), repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), ) if token.MaxRetries != nil { consumeFilter = consumeFilter.And( repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries), ) } updated, err := db.DBImp.PatchMany( ctx, consumeFilter, repository.Patch().Set(repository.Field("usedAt"), now), ) if err != nil { return nil, err } if updated == 1 { token.UsedAt = &now return token, nil } // 5) Consume failed → increment attempts _, _ = db.DBImp.PatchMany( ctx, repository.IDFilter(token.ID), repository.Patch().Inc(repository.Field("attempts"), 1), ) // 6) Re-check state var fresh model.VerificationToken if err := db.DBImp.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil { return nil, merrors.Internal("failed to re-check token state") } if fresh.UsedAt != nil { return nil, verification.ErorrTokenAlreadyUsed() } if !fresh.ExpiresAt.After(now) { return nil, verification.ErorrTokenExpired() } if fresh.MaxRetries != nil && fresh.Attempts >= *fresh.MaxRetries { return nil, verification.ErrorTokenAttemptsExceeded() } return nil, verification.ErorrTokenNotFound() }, ) if e != nil { return nil, e } res, ok := t.(*model.VerificationToken) if !ok { return nil, merrors.Internal("unexpected token type") } return res, nil }