End-to-end specification for email + password registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
Actors
Prospective User– submits the sign-up form.
Frontend–frontend/src/auth/view/jwt/jwt-sign-up-view.tsx, calling signUp() and later verifyEmailWithCode() from frontend/src/auth/context/jwt/action.ts.
Backend–AuthController.register and AuthController.verifyEmailWithCode in backend/src/services/auth/authController.ts.
MongoDB–TempVerification collection (temporary), then User collection (final).
Socket.IO– emits referral-signup to the referrer if a referral code is supplied.
Preconditions
The email is not already a verified User. If a TempVerification already exists, its code and metadata are regenerated and resent rather than throwing a conflict.
Outbound SMTP credentials are configured (EMAIL_* env vars consumed by emailService.ts).
If a referralCode is supplied, it does not need to exist for sign-up to succeed — invalid codes are silently ignored at verification time.
State machine: TempVerification → User
stateDiagram-v2
[*] --> NotStarted
NotStarted --> TempCreated: POST /api/auth/register\nemail + role [+ ref]
TempCreated --> TempCreated: POST /api/auth/resend-verification\n(new code, 15-min TTL)
TempCreated --> TempExpired: 15 minutes elapse\nor verification fails
TempExpired --> TempCreated: User clicks "Resend"
TempCreated --> UserActive: POST /api/auth/verify-email-code\n(code + password)
UserActive --> [*]
note right of TempCreated
TempVerification document holds:
email, firstName, lastName, role,
referralCode, code, codeExpires
end note
note right of UserActive
User created with isEmailVerified=true,
status="active"; tokens issued immediately.
end note
Step-by-step narrative
Phase 1 — Submit registration
User visits /auth/jwt/sign-up (optionally with ?ref=ABCD1234 from the short-URL referral redirect implemented at backend/src/app.ts:274-278).
User selects role (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
[!tip] Password is not sent to /register
The password is only included in the second step (/verify-email-code). The intent: never hash and store a password for an unverified account. The TempVerification document carries password: '' until verification.
HTTP request: POST /api/auth/register with { email, password?, firstName?, lastName?, role, referralCode? }. (The frontend currently passes the password through, but the controller stores '' regardless — see authController.ts:123.)
Validation middlewareregisterValidation (authValidation.ts) checks email format, password complexity, and role enum.
Idempotent temp record: TempVerification.findOne({ email }) — if present, the existing temp is updated in place (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
Verification code: authService.generateVerificationCode() (authService.ts:226-228) returns a uniformly random 6-digit string.
Persistence: A new TempVerification is saved with { email, password: '', firstName: defaults to "کاربر", lastName: defaults to "جدید", role, referralCode, emailVerificationCode, emailVerificationCodeExpires }.
Email dispatch: emailService.sendVerificationCodeEmail(email, firstName, code) is called. The email contains the 6-digit code, branding, and a 15-minute expiry notice. Failure to send is logged but the response still succeeds with 201 (the user can resend).
Response: { email, message: "Verification code sent to email" } with HTTP 201 for first-time, 200 for resend.
Frontend transitions to the OTP screen /auth/jwt/verify?email=... (frontend/src/auth/view/jwt/jwt-verify-view.tsx).
Phase 2 — Verify code and finalise
User enters the 6-digit code and confirms the password. The password may be re-entered here for safety.
HTTP request: POST /api/auth/verify-email-code with { email, code, password }.
Format guard: authService.isValidVerificationCode(code) enforces /^\d{6}$/ (authService.ts:236-238).
Lookup: TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } }) — if any field mismatches or the code is older than 15 minutes, returns 400.
Hash password: bcrypt.hash(password, 12) via authService.hashPassword().
Apply referral (authController.ts:411-433): if tempVerification.referralCode exists, find the referrer by User.findOne({ referralCode }). If found:
user.referredBy = referrer._id
referrer.referralStats.totalReferrals += 1
Emit referral-signup on user-${referrer._id} Socket.IO room — see Referral Flow for the points-awarding side effect that happens later on the first purchase.
Persist user, then delete the TempVerification document (findByIdAndDelete).
Token issuance: identical to Authentication Flow — generate access + refresh, push the refresh into user.refreshTokens[].
Response: { user, tokens: { accessToken, refreshToken } }. Frontend writes both into localStorage (action.ts:228-235) and routes the user into the appropriate dashboard (/dashboard/buyer or /dashboard/seller).
Sequence diagram
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend
participant BE as Backend
participant DB as MongoDB
participant MAIL as Email Service
participant IO as Socket.IO
U->>FE: Fill sign-up form (email, role, ref?, password)
FE->>BE: POST /api/auth/register
BE->>DB: User.findOne({ email })
DB-->>BE: null
BE->>DB: TempVerification.findOne({ email })
DB-->>BE: null
BE->>BE: code = generateVerificationCode()
BE->>DB: TempVerification.create({...code, expires=+15m})
BE->>MAIL: sendVerificationCodeEmail(email, firstName, code)
MAIL-->>U: Email with 6-digit code
BE-->>FE: 201 { email, message }
FE-->>U: Redirect /auth/jwt/verify
U->>FE: Enter code + (re)password
FE->>BE: POST /api/auth/verify-email-code { email, code, password }
BE->>DB: TempVerification.findOne({ email, code, expires>now })
DB-->>BE: tempVerification doc
BE->>BE: hashPassword(password)
BE->>DB: User.create({...isEmailVerified:true, status:active})
opt referral present
BE->>DB: User.findOne({ referralCode })
DB-->>BE: referrer
BE->>DB: referrer.referralStats.totalReferrals += 1
BE->>IO: emit user-{refId} 'referral-signup'
end
BE->>DB: TempVerification.findByIdAndDelete(...)
BE->>BE: generate tokens; push refresh
BE-->>FE: 200 { user, tokens }
FE->>FE: localStorage.setItem(accessToken, refreshToken)
FE-->>U: Redirect /dashboard/{role}
Email: one transactional message per /register and per /resend-verification. Content is generated by emailService.sendVerificationCodeEmail. Plain-text fallback included.
Sentry: errors during User.create or email dispatch are captured server-side.
Logs: the controller console.logs the generated code in all environments (authController.ts:88, :117, :518). Useful in dev; in prod the same log line ends up in CloudWatch/Sentry breadcrumbs. (Tracked as a hardening item.)
[!warning] Verification code is logged server-side
The generated 6-digit code is console.log-ed by the controller even in production. Anyone with log access can take over an unverified account. Move behind if (NODE_ENV !== 'production').
Email already in temp (unverified) → 200, code regenerated, email re-sent. User-friendly; no error.
Code mismatch / expired (>15 min) → 400 Invalid or expired verification code. The TempVerification is not deleted, so the user can request a new code via "Resend".
Code format wrong (non-digits or wrong length) → 400 from isValidVerificationCode guard before DB lookup.
Email delivery failure → response still 201/200; the user can hit "Resend" or check spam.
Referral code that does not match any user → silently ignored; the user is still created with referredBy: undefined.
Race condition: two parallel registrations for the same email → MongoDB unique index on User.email ensures only one user document; the loser of the race sees E11000 and returns 409 USER_EXISTS.
Race condition: verify request arrives twice with the same code → second request finds no TempVerification and returns 400. The created User is the canonical record.
Role tampering → role is validated by registerValidation enum (buyer | seller). Admin role is created only via the bootstrap seed (initializeAdminUser in app.ts:377), never via this flow.
Defaults & quirks
firstName / lastName are not required by the frontend in many sign-up variants; the controller defaults them to Persian placeholders "کاربر" / "جدید" (authController.ts:52-53). They can be edited later under /dashboard/account/profile.
The TempVerification TTL is enforced by the emailVerificationCodeExpires check, not by a Mongo TTL index — expired docs remain in the collection until overwritten or manually purged.