ProjectsOdysUser Journeys
Professional Sign-Up & Onboarding
How a Brazilian independent professional goes from landing on odys.com.br to having a live public booking page.
Professional Sign-Up & Onboarding
Two phases end-to-end: account creation with email verification, then a multi-step onboarding wizard that captures enough data to render the booking widget. All auth via Supabase; all state persisted via Drizzle.
Phase 1 — Account creation
- The professional lands on
odys.com.br/registerand fills the form. - Client POSTs JSON to
/api/auth/register(src/app/api/auth/register/route.ts). - Handler is rate-limited — 5 attempts per rolling hour per IP via
getRegisterLimiter()insrc/lib/ratelimit.ts(UpstashslidingWindow). - Registration branches on
NODE_ENV:- Production —
supabase.auth.signUp()with the anon client → Supabase sends a verification email via its own SMTP. - Development —
adminClient.auth.admin.createUser({ email_confirm: true })skips the email round-trip for faster iteration.
- Production —
- Response returns
requiresEmailConfirmationso the client page renders either "check your inbox" or proceeds.
Phase 2 — Email verification → session
- Professional clicks the verification link.
- Browser hits
/auth/callback(src/app/auth/callback/route.ts). supabase.auth.exchangeCodeForSession(code)converts the one-time code into a Supabase session.- For
type=clientsignups only, the smart-linking query runs — backfillsclients.userIdfor pre-existing rows whose email matches the new verified user. Gated explicitly ontype === "client"; professional-flagged signups skip it. The match is email-only, so a single client can be linked across all professionals they've previously booked with. - This only runs after verification. Running it before (the old design) allowed the C1 account-takeover finding.
/auth/redirectreadsuser_metadata.typeand forwards — professionals →/onboarding, clients →/c.
The smart-linking placement after verification is the fix for audit finding C1. See Security → Fixed.
Phase 3 — Onboarding wizard (professionals only)
The /onboarding wizard collects 7 fields, posted as JSON to /api/onboarding:
- Full name — used to derive the public URL slug
- Profession — one of 29 keys from
src/lib/professions.ts - WhatsApp (body key:
phone) — canonicalized for reply-routing - Session duration — minutes (integer)
- Session price — cents (integer)
- Short bio — free-form text, optional (the only nullable field)
- Weekly availability — array of
{ dayOfWeek, startTime, endTime }tuples
Handler — /api/onboarding
- Rate-limit — 3 submissions per rolling hour per IP (
getOnboardingLimiter()). - Zod validation —
onboardingSchemavalidates:name(string min 2),profession(string),phone(string min 10),bio(optional),sessionDuration(positive int ≤ 480),sessionPrice(non-negative int),availability(array withdayOfWeek0-6 andHH:mmstart/end). Failure → 400 with field errors. - Identity —
getUser()returns the authenticated Supabase user. - Idempotency — if a
professionalsrow already exists for thisuser.id, return it without recreating. - Slug generation —
generateUniqueSlug(toSlug(body.name)):toSlug()normalizes NFD → strips diacritics → lowercase → dash-separated → drops non-alphanumericsgenerateUniqueSlug()blocks reserved top-level routes (api,auth,dashboard,login,register,onboarding,explore,c,p,privacidade,termos,admin,monitoring,sitemap,robots,opengraph-image) — if a professional's name slugs to one of these, the base is skipped and iteration starts at-2. Otherwise, tries the base; on collision, appends-2,-3,-4, … with one DB round-trip per iteration
- Insert professional — one row.
- Insert availability — one row per weekday window.
- Set user metadata —
{ type: "professional" }so future logins route to the dashboard.
Outcome
Professional is live at odys.com.br/p/[slug].
Why this shape
- Two phases (account → onboarding) mirrors the pattern where initial signup is lightweight but the first-class data model needs structured input. Collecting everything at once kills conversion.
- Idempotent onboarding handler protects against double-click / slow-network duplicate submissions.
- Slug generation as a pure function keeps URL ownership testable and predictable. Professionals don't pick slugs manually — bounded edge case count.
- Smart-linking after email verification, not before is the direct lesson from audit finding C1. Always re-read the threat model when combining signup with auto-link.
- Slug-collision loop is intentionally simple — one DB query per retry. At MVP scale the cost is negligible. If thousands of pros ever collide on the same base, swap for a batch query or a dedicated counter column.