Survey Flow and Auth Revamp
Specification for five changes proposed by Jerome Duplaix (Discord, 2026-06-17). This is a planning document. No code has been changed. Each section maps the current implementation (with file references), defines the proposed change, lists the touch points, and records open questions to resolve before implementation.
Related specs: Survey Sessions | Structured Assessments | Roles and Access | WeHappers Migration
Summary of requests
| # | Request | Type | Size |
|---|---|---|---|
| 1 | Org-configurable respondent profile (email always + geo/other per org); remove "About You" from the survey | Backend + Frontend | Medium |
| 2 | Two landing flows: Guest first-timers (account at end) vs Sign in returning (at start) | Backend + Frontend | Medium |
| 3 | Show answer help text on mouse-over (hover tooltip) | Frontend only | Small |
| 4 | Remove the 30-day "cannot retake yet" gate | Backend + Frontend | Small |
| 5 | Replace password with email confirmation (passwordless) | Backend + Frontend | Large |
1. Decouple "About You" from the survey
Current state
The "About You" block is part of the survey content, not a separate step.
- The WeHappers HIT survey carries a 7th question group named "About You" alongside the 6 Maslow groups (
backend/tests/test_migration.py:315). It was added during the migration (scripts/migrate_happiness_index.py, theensure_surveystep). - Geolocation is a survey question of type
COUNTRY(backend/app/models/enums.py:24). Its answer is stored inside the response payload like any other answer:responses[country_qid] = {"country": "Canada", "region": "Quebec"}(backend/tests/test_migration.py:467). - It renders as a normal question in the take flow via
CountryRenderer(frontend/src/components/QuestionRenderer.tsx:350-389), a two-level country/region dropdown. - Email is collected separately already, on the intro screen, and stored as
respondent_emailon the response (frontend/src/pages/SurveyTakePage.tsx:895-909,backend/app/api/surveys/routes.py:568). It is required when the survey hasrequire_email = true(backend/app/api/surveys/routes.py:514-519).
Proposed change
Generalize this beyond geolocation. Sondely must not hardcode "About You." Each organization configures the respondent profile fields it wants from every respondent; geolocation becomes one optional, org-configured field rather than a survey question.
- The respondent profile is generic: email is always present; geolocation, name, and any other supported fields are enabled and marked required/optional per organization (an org-level setting).
- When a respondent provides their email, they are asked for exactly the fields that org defined, in a dedicated step, not as survey questions.
- Remove the "About You" group and the
COUNTRYquestion from survey content. Geo is captured through the profile and stored as first-class respondent data, not inside theresponsesmap. - The profile is tied to the account/respondent, reused across attempts and across surveys within the org, and copied onto each response for historical accuracy. A returning respondent is prefilled and not re-asked.
Touch points
- New org-level configuration for respondent profile fields (which fields, required vs optional). New respondent-profile storage tied to the user/email.
- Backend response model: add structured
geo(country, region) fields to the response instead of aCOUNTRYanswer key (backend/app/models/survey.py:227-253). - Survey submission and session start: accept email + geo as a separate payload / pre-step (
backend/app/api/surveys/routes.py:488-619,backend/app/api/sessions/routes.py:126-171). - Migration/template: stop injecting the "About You" group +
COUNTRYquestion; keep the historical data readable (the COUNTRY type stays supported for already-migrated responses). - Frontend: extract the email + country/region capture out of the question loop into a standalone step (
frontend/src/pages/SurveyTakePage.tsx:826-965, reuseCountryRenderer).
Open questions
- Field model: a fixed set of supported profile fields (email, geo, name) toggled per org, versus a fully dynamic custom-field schema. Recommendation: start with a fixed supported set toggled per org; defer dynamic custom fields.
- When are profile fields collected for a Guest, up front or at the save step at the end? Tied to request 2's beginning-vs-end decision. Recommendation: at the end save step for guests.
- Existing migrated responses store geo as a
COUNTRYanswer. Read both shapes; backfilling into the new geo profile is optional.
Decisions (2026-06-28)
- Geolocation is not Sondely-specific. It is one org-configurable profile field. The org defines which fields every respondent fills in.
- Location is mandatory for WeHappers and collected up front with Option B: auto-detect + one-tap confirm. On landing, detect country and best-guess region from the visitor's IP, pre-fill it, and show a single confirm line ("Looks like you're in Canada, Quebec. Change?"). One tap to proceed or correct the region. This captures location on 100% of takers (including those who never save/sign up) while staying effectively frictionless, no account needed.
- Email is optional and collected at the end (the save step), since email's purpose is tracking an individual over time, which is secondary. Location is what the population index actually needs.
- Geo provider: ipinfo or MaxMind (server-side lookup). Note:
hit.wehappers.orgis DNS-only through Cloudflare, so the free Cloudflare country header is not available there; geo comes from the lookup service (or we proxy the subdomain to get the country header). IP gives country reliably and region approximately, which is why the one-tap confirm/correct matters.
2. Two landing flows: Guest vs Returning
Current state
There is a single intro screen for everyone.
- The intro always shows an email input and an optional resume card (
frontend/src/pages/SurveyTakePage.tsx:826-965). - A first-time anonymous respondent enters an email and is auto-enrolled as an
email_onlyRESPONDENT viaPOST /auth/auto-enroll, which returns a 4-hour session-scoped token (backend/app/api/auth/routes.py:410-486,frontend/.../SurveyTakePage.tsx:791-821). - A logged-in user skips the email step (
isLoggedIn,SurveyTakePage.tsx:214). - There is no "I am a returning user, sign in to see my history" path on the take page.
- Sign-in offers Google OAuth and email + password (
frontend/src/pages/SignInPage.tsx:137-195). There is no Apple OAuth anywhere (backend or frontend).
Proposed change
The landing handles two distinct intents:
- 2.1 First test (Guest, zero friction): a prominent "Take the test as a guest" path. The respondent starts the test immediately with no account, no email, no form up front. After finishing, at peak motivation (they have just seen their score), they are offered "Enter your email to save your results and track your progress", which collects the org-required profile fields (request 1) and confirms the email (request 5). The account is created at the end, not the beginning.
- 2.2 Returning user: "Sign in to see your history or retake" via email, Google, or Apple, shown at the beginning, because these users arrived specifically to access history or retake, so sign-in up front is expected rather than friction. After sign in, the respondent sees prior attempts and can start a new one.
Touch points
- Frontend take landing: split into Guest vs Sign-in choices, and move guest account capture to a post-survey save step (
SurveyTakePage.tsx:826-965, the results screen:652-769). - Auth UI: add an Apple button next to Google on
SignInPage.tsx/SignUpPage.tsx(:137-147,:88-98). - Backend: add Apple OAuth (initiate + callback) mirroring the Google flow (
backend/app/api/auth/routes.py:282-406); link byapple_id/ verified email the same way Google links bygoogle_id. - Optional helper endpoint
GET /auth/check-emailreturning{is_new, has_password, has_google, has_apple}so the UI can route a typed email to the right path (get_user_by_emailalready exists,backend/app/utils/auth.py:122).
Open questions
- Beginning vs end of the guest flow (the key decision). Recommendation above: take first, capture/create the account at the end. To confirm with Jerome, he raised whether sign-in/account creation should be at the start or the end. The recommended split is: sign-in at the beginning for returning users (2.2), account capture at the end for guests (2.1).
- Apple OAuth requires an Apple Developer account, a Services ID, key, and domain verification. Confirm these exist or budget for setup.
- Returning-user history view: reuse the existing session history component, or a new "my results" screen?
Decisions (2026-06-28)
- Guest first-timers take the test with zero friction and are asked to save (email + profile + email confirmation) at the end. Returning users sign in at the beginning. (Pending Jerome's final confirmation of beginning-vs-end.)
3. Answer help text on mouse-over
Current state
Rating help text is shown inline or after selection, never on hover.
RatingRenderer(frontend/src/components/QuestionRenderer.tsx:26-80): when a question has no custom labels, the default label (Poor,Good, etc.) shows under each number always (:56-59). When the question has customrating_labels, the selected label appears in a box below the buttons only after a value is chosen (:65-77).- The descriptive
rating_labels(the 0-5 sentences such as "No reliable water source...") already come from the API per question (backend/app/data/survey_templates.py, returned by the survey GET).
Proposed change
Show each rating option's descriptive label on mouse-over of that option, as a tooltip, instead of (or in addition to) the post-selection box. This declutters the scale while keeping the guidance discoverable.
Touch points
- Frontend only:
RatingRendererinQuestionRenderer.tsx:26-80. Add a hover tooltip per rating button bound toratingLabels[rating]. A small reusableTooltipcomponent may be introduced. - No backend change. The labels are already delivered (and already translated per language).
Open questions
- Mobile has no hover. Define the touch behavior (tap to reveal, long-press, or keep the selected-value box on touch devices). Recommendation: keep the existing selected-value box on touch, add the tooltip on pointer devices.
- Accessibility: the tooltip must be keyboard-focusable and screen-reader friendly (describe via
aria-describedby).
4. Remove the 30-day attempt gate
Current state
A configurable cooldown blocks retaking within min_days_between_attempts (default 30).
- Field:
min_days_between_attempts: int = 30on the survey (backend/app/models/survey.py:123). - Enforced in three places:
- Public submit
POST /surveys/{id}/respondreturns HTTP 409 "You can retake this survey after {date}" (backend/app/api/surveys/routes.py:521-540). - Session start
POST /sessions/startreturns HTTP 400 with the same message (backend/app/api/sessions/routes.py:81-95). GET /surveys/{id}/can-attemptreports eligibility (backend/app/api/sessions/routes.py:329-379).
- Public submit
- Frontend shows a yellow "Not yet eligible" box on the intro and hides "Begin Survey" when
cooldownDateis set (frontend/.../SurveyTakePage.tsx:944-952,:286-303). A separate "maximum attempts reached" message exists inSessionHistory.tsx:185-191. - The check is already soft: it only runs when
min_days > 0.
Proposed change
Remove the time-based gate so respondents can retake at any time.
Touch points
Two viable approaches:
- Data-only (fastest): set
min_days_between_attempts = 0on the HIT survey (and default new surveys to 0). All three enforcement sites already no-op when the value is 0. No code change. - Code removal (cleaner, recommended): remove the interval enforcement blocks (
surveys/routes.py:521-540,sessions/routes.py:81-95), simplifycan-attempt, and remove the frontend cooldown UI (SurveyTakePage.tsx:213, 286-303, 944-952).
Recommendation: do both. Default the field to 0 now (immediate effect for the live HIT), and remove the dead UI/enforcement in the same change so the behavior is not silently re-enabled by a stray config value.
Open questions
- Keep the field on the model for other surveys that may still want a cooldown, or remove it entirely? Recommendation: keep the field (default 0); only the HIT needs it off.
5. Replace password with email confirmation (passwordless)
Current state
Authentication is password-first with Google OAuth.
- Register and login use bcrypt passwords (
backend/app/api/auth/routes.py:60-113,backend/app/core/security.py:9-20). Sessions are JWT + a Redis session token (security.py:23-48,auth/routes.py:47-55). - Google OAuth links by
google_idor email (auth/routes.py:282-406). - Email verification and password reset use single-use tokens emailed to the user (
auth/routes.py:118-215,backend/app/services/email.py:76-151). - No OTP, magic link, passwordless, or 2FA exists today. The email service is in place and already sends transactional HTML email, so an OTP or magic-link email is feasible without new infrastructure.
- Auth UI: password fields on
SignInPage,SignUpPage,ResetPasswordPage(frontend/src/pages/).
Proposed change
Remove the password entirely. Authentication is just email confirmation: the user enters their email and confirms it (a one-time code or magic link), and that is the whole login. No password, no separate password reset, no classic two-step on top of a password.
- Primary: email confirmation. Enter email, receive a code or magic link, confirm, you are signed in. Same mechanism for a brand-new respondent and a returning one.
- Social: Google and Apple OAuth as one-tap alternatives (Apple is new, see request 2).
- Existing password users migrate transparently: their next sign-in uses email confirmation; passwords are retired.
Decision (2026-06-28): passwordless = email confirmation only. There is no password to store, reset, or remember.
Touch points
- Backend:
- New OTP issue + verify endpoints; new
otp_codescollection ({user_id|email, code, expires_at, used_at, attempts}). Reuse the token/expiry patterns from password reset (auth/routes.py:170-215). - Rework register/login to the passwordless path (
auth/routes.py:60-113); keep verifying legacy bcrypt only during a transition if desired. - New OTP email template in
services/email.py(:76-151). - Apple OAuth (see request 2).
get_current_useris unaffected (it already accepts JWT/session tokens,utils/auth.py:72-106); only how tokens are issued changes.
- New OTP issue + verify endpoints; new
- Frontend:
- Replace password fields with the email-then-code flow on
SignInPage/SignUpPage; retire or repurposeResetPasswordPage(frontend/src/pages/). - Add the Apple button.
- Replace password fields with the email-then-code flow on
- This also simplifies the Guest-to-account upgrade (request 2): an
email_onlyguest becomes a full account by verifying an OTP, no password needed (replacesPOST /auth/upgrade,auth/routes.py:489-519).
Open questions
- OTP vs magic link as primary. Recommendation: OTP (6-digit, 10-minute expiry, rate-limited, max attempts).
- Migration for existing password accounts: silent switch to OTP on next login (recommended) vs forced re-verification.
- Rate limiting and abuse protection on OTP issuance (per email, per IP).
- Apple Developer prerequisites (see request 2).
Apple Sign In: credentials provisioned (2026-06-28)
Apple Sign in with Apple is set up at the platform level (one config serves all tenants; new customer domains are added to the same Services ID over time). It lives inside the existing IdeaPlaces Apple Developer account (Team 648L7A4BL2) but is branded "Sondely"; end users see "Sondely" + the tenant domain, never IdeaPlaces.
Credentials are stored in Key Vault kv-ideaplaces:
| Secret | Value / purpose |
|---|---|
apple-signin-team-id-impactpulse |
648L7A4BL2 |
apple-signin-services-id-impactpulse |
ai.catalyzeup.impactpulse.web (the web client_id) |
apple-signin-key-id-impactpulse |
8NHM8534GL |
apple-signin-key-impactpulse |
the .p8 private key (used to mint the client-secret JWT) |
Registered on the Services ID: domains hit.wehappers.org, impactpulse.catalyzeup.ai; return URLs https://hit.wehappers.org/api/v1/auth/apple/callback and https://sondely.com/api/v1/auth/apple/callback. Apple's current web flow did not require the domain-association file. The backend Apple OAuth (mirroring Google) is the remaining work; credentials are ready and waiting.
Google Sign In: ready (2026-06-28)
Google OAuth is fully configured and live. Credentials are in kv-ideaplaces (google-oauth-client-id-impactpulse, google-oauth-client-secret-impactpulse) and wired into the prod container (BACKEND_URL=https://sondely.com). The OAuth client (project wehappers, client 900704325281-...) has the prod and dev redirect URIs registered ({domain}/api/v1/auth/google/callback), and the consent screen is In production (publishing required removing the http://localhost URLs from the clients, so local-dev Google login is off in this project; local dev uses seed/quick-login). Scopes are non-sensitive (email/profile/openid), so no verification review and the user cap does not apply. The redirect URI is fixed to BACKEND_URL, so new customer domains need no Google changes.
Sequencing and dependencies
- Request 4 (remove cooldown) and request 3 (hover help) are independent and small. Ship first.
- Request 1 (decouple About You) and request 2 (Guest vs Returning landing) are tightly coupled in the take flow; design and build them together.
- Request 5 (passwordless 2FA) is the largest and underpins the "returning user" and "guest upgrade" paths in request 2. Apple OAuth is shared between requests 2 and 5.
Suggested order: 4 → 3 → (1 + 2) → 5, with Apple OAuth built once and used by both 2 and 5.
Success criteria and test plan
Each change defines "done" as: the acceptance criteria below all hold, and the full test pyramid passes headless from the CLI (backend pytest, frontend/E2E Playwright), with typecheck and lint clean. "Done" is never just code written; it is green tests proving the behavior.
Request 1: org-configurable respondent profile
Acceptance:
- An org can configure which profile fields (email, country/region, name) are collected and whether each is required.
- The survey contains no "About You" group and no
COUNTRYquestion; geo is captured through the profile step, not as a survey answer. - Geo is stored as structured respondent data; a required field missing is rejected; a returning respondent is prefilled and not re-asked.
- Historical migrated responses (geo stored as a
COUNTRYanswer) still read correctly.
Tests:
- Unit: profile-config model + validation; geo serialization; backward-read of old
COUNTRY-shaped responses. - Integration (API): org with profile config → submit → geo stored as profile data; missing required field → 422; returning respondent prefilled.
- E2E: take flow shows a profile step (not a question), captures geo, and the survey body has no About You question.
- Fully automatable.
Request 2: guest vs returning landing
Acceptance:
- First-timer can start the test with no account and nothing asked up front.
- At the end, the save step creates the account by email confirmation, attaches the just-completed result, and captures the org profile fields.
- A guest who declines to save still has a valid response stored (with geo).
- Returning user gets sign-in at the start (email / Google / Apple), then sees prior attempts and can retake.
Tests:
- Unit: first-time vs returning landing logic; attach-result-to-new-account logic.
- Integration: guest submit with no account → response saved; save step (email confirmation) creates account and links the response; returning login returns history.
- E2E: full guest journey (start → answer → results → save → confirm code → account+result linked) and returning journey (sign in → history → retake).
- Caveat: the email-confirmation path is fully automatable (the test reads the code from the in-memory/test mailbox). Real Google/Apple round-trips are covered by mocked provider responses in unit/integration, plus a manual smoke login; they are not run against the live providers in CI (standard practice).
Request 3: hover help text
Acceptance:
- On pointer devices, hovering a rating option shows its
rating_labelsdescription as a tooltip. - On touch devices the description stays reachable (selected-value box retained).
- Tooltip is keyboard-focusable and announced (
aria-describedby). - The description shows in the currently selected language.
Tests:
- Component (Playwright/RTL): hover renders the label; keyboard focus reveals it; no-custom-labels falls back to default behavior.
- E2E: in the take flow, hovering a rating shows the description.
- Fully automatable.
Request 4: remove the 30-day gate
Acceptance:
- A respondent can submit and retake immediately after a prior attempt; no 409/400 cooldown error from any endpoint.
can-attemptnever returns blocked for the interval reason.- The frontend never shows the "Not yet eligible" cooldown box.
- The live HIT survey has
min_days_between_attempts = 0. - (Field kept, defaulted to 0, so other orgs can still opt into a cooldown.)
Tests:
- Unit: submission/session logic no longer blocks on interval at 0.
- Integration: submit twice back to back → both accepted; session start twice → both ok;
can-attemptreturns allowed. - E2E: take then immediately retake → allowed, no cooldown UI.
- Regression: update existing tests that asserted the 409/400 cooldown.
- Fully automatable.
Request 5: passwordless (email confirmation)
Acceptance:
- No password path remains; register/login is email → code → signed in.
- Existing password users sign in via email confirmation (passwords retired).
- Guest upgrade is email confirmation only.
- OTP is single-use, time-limited, and rate-limited; wrong/expired codes are rejected.
- Google and Apple work as alternatives.
Tests:
- Unit: OTP generate/verify, expiry, single-use, rate limit; token issuance.
- Integration: request OTP → read code in test → verify → tokens issued; expired/wrong rejected; legacy password user logs in via OTP.
- E2E: full email-confirmation login.
- OAuth: mocked Google/Apple token + userinfo responses in unit/integration; real-provider login is a manual smoke test.
- Caveat: same as request 2, email confirmation is fully automatable; live social-login round-trips are mock-tested plus manual smoke, not automated against Google/Apple in CI.
Overall testability
- Requests 1, 3, 4: fully automatable end to end (unit + integration + E2E), all green in CI.
- Requests 2 and 5: fully automatable except the live Google/Apple round-trip, which is mock-tested in CI and confirmed by a one-time manual smoke login. The passwordless email-confirmation core is fully automated.
Out of scope / to confirm
- Whether the Happiness Index should allow fully anonymous (no email) guests.
- Apple Developer account availability for Apple Sign In.
- Backfilling historical migrated geo data into the new geo fields.
Status
Planning only. Awaiting review of the open questions above before any implementation.