Background
The shipped email OTP verification by adding emailOtp/emailOtpExpiresAt directly onto the Merchant model. That's the wrong shape — a verification code isn't merchant profile data, a single pair of columns can't represent history, and it makes rate-limiting resend attempts (The implementation already specifies "1 resend per minute") awkward to enforce correctly. This issue moves OTP storage into its own model, shaped like the existing AuthNonce precedent (one row per generated code, with usedAt/expiresAt), rather than an upsert-in-place pair of fields.
Proposed Steps
- Add an
EmailOtp model (see Schema Changes).
- Remove
emailOtp and emailOtpExpiresAt from the Merchant model; run prisma migrate dev. Any outstanding unverified codes are lost across this migration — acceptable, nothing depends on preserving in-flight OTPs.
- Update
otp.services.ts:
- Code generation writes a new
EmailOtp row (hashed code) instead of writing to Merchant
- Verification looks up the most recent unused, unexpired
EmailOtp for the merchant, compares the hash, and sets usedAt on success
- Resend (
POST /auth/resend-otp) creates a new EmailOtp row rather than overwriting fields on Merchant
- Enforce the existing "1 resend per minute" rule by checking for an
EmailOtp row created for this merchant within the last 60 seconds before issuing a new one — this was awkward against a single mutable field and is now a normal query.
- No change to
email.service.ts — only where the code is stored and checked changes, not how it's delivered.
Schema Changes
EmailOtp (new model)
id String (uuid, PK)
merchantId String (FK → Merchant)
codeHash String (hash of the 6-digit code — never store raw)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime
Merchant (remove fields)
emailOtp — removed
emailOtpExpiresAt — removed
Acceptance Criteria
Background
The shipped email OTP verification by adding
emailOtp/emailOtpExpiresAtdirectly onto theMerchantmodel. That's the wrong shape — a verification code isn't merchant profile data, a single pair of columns can't represent history, and it makes rate-limiting resend attempts (The implementation already specifies "1 resend per minute") awkward to enforce correctly. This issue moves OTP storage into its own model, shaped like the existingAuthNonceprecedent (one row per generated code, withusedAt/expiresAt), rather than an upsert-in-place pair of fields.Proposed Steps
EmailOtpmodel (see Schema Changes).emailOtpandemailOtpExpiresAtfrom theMerchantmodel; runprisma migrate dev. Any outstanding unverified codes are lost across this migration — acceptable, nothing depends on preserving in-flight OTPs.otp.services.ts:EmailOtprow (hashed code) instead of writing toMerchantEmailOtpfor the merchant, compares the hash, and setsusedAton successPOST /auth/resend-otp) creates a newEmailOtprow rather than overwriting fields onMerchantEmailOtprow created for this merchant within the last 60 seconds before issuing a new one — this was awkward against a single mutable field and is now a normal query.email.service.ts— only where the code is stored and checked changes, not how it's delivered.Schema Changes
EmailOtp (new model)
Merchant (remove fields)
Acceptance Criteria
EmailOtpmodel added;Merchant.emailOtp/emailOtpExpiresAtremoved;prisma migrate devruns cleanlyPOST /auth/verify-emailchecks againstEmailOtp, notMerchantfieldsPOST /auth/resend-otpcreates a newEmailOtprow and enforces the 1-per-minute limit against itEmailOtprow fails verification with the same400responses the current implementation already defined