package verificationimp import ( "context" "errors" "time" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/verification" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "go.uber.org/zap" ) func (db *verificationDB) Consume( ct context.Context, rawToken string, ) (*model.VerificationToken, error) { hash := tokenHash(rawToken) now := time.Now().UTC() // 1) Find token by hash (do NOT filter by usedAt/expiresAt here), // otherwise you can't distinguish "used/expired" from "not found". filter := repository.Query().And( repository.Filter("verifyTokenHash", hash), ) t, e := db.tf.CreateTransaction().Execute( ct, func(ctx context.Context) (any, error) { var existing model.VerificationToken if err := db.DBImp.FindOne(ctx, filter, &existing); err != nil { if errors.Is(err, merrors.ErrNoData) { db.Logger.Debug("Token hash not found", zap.Error(err), zap.String("hash", hash)) return nil, verification.ErorrTokenNotFound() } db.Logger.Warn("Failed to check token", zap.Error(err), zap.String("hash", hash)) return nil, err } // 2) Semantic checks if existing.UsedAt != nil { db.Logger.Debug( "Token has already been used", zap.String("hash", hash), zap.Time("used_at", *existing.UsedAt), ) return nil, verification.ErorrTokenAlreadyUsed() } if !existing.ExpiresAt.After(now) { // includes equal time edge-case db.Logger.Debug( "Token has already expired", zap.String("hash", hash), zap.Time("expired_at", existing.ExpiresAt), ) return nil, verification.ErorrTokenExpired() } // 3) Mark as used existing.UsedAt = &now if err := db.DBImp.Update(ctx, &existing); err != nil { db.Logger.Warn("Failed to consume token", zap.Error(err), zap.String("hash", hash)) return nil, err } return &existing, nil }, ) if e != nil { return nil, e } res, ok := t.(*model.VerificationToken) if !ok { return nil, merrors.Internal("unexpected token type") } return res, nil }