Skip to content

feat: anonymous-first profile funnel - #8

Merged
krishhimself merged 2 commits into
mainfrom
feat/anonymous-funnel
Aug 23, 2026
Merged

feat: anonymous-first profile funnel#8
krishhimself merged 2 commits into
mainfrom
feat/anonymous-funnel

Conversation

@krishhimself

Copy link
Copy Markdown
Owner

What

A candidate is a pseudonym until their work earns the introduction. This adds the reveal mechanism, the profile surface that reads it, and the name that reveal has to show.

How it works

GET /api/v1/profile/{user_id} returns "Anonymous Candidate" with a null email until the candidate clears the reveal threshold, then latches revealed: true on the user and returns the real details.

Identity is dropped in services/reputation_service.py, not hidden in the UI — an unrevealed name never reaches the browser at all. features/profile/display.js re-checks the flag as a second lock.

Changes

  • users.revealed (default false) and users.name, documented in models/user.py. user_repository backfills both on read, so accounts written before the fields existed behave as unrevealed rather than as missing-key.
  • services/reputation_service.py owns the funnel. meets_reveal_threshold() is a placeholder for the real reputation score: one graded attempt at REVEAL_MIN_SCORE (70) or better. Only status: "graded" counts, so an undefended score cannot buy a reveal.
  • quiz_repository.has_graded_attempt_scoring_at_least() — projected down to _id, since the caller only needs the yes/no.
  • name is now required at registration, stripped before it is measured so a name of spaces is refused rather than stored as one. Profiles created before this keep the email fallback.
  • Frontend gains a Profile tab and a Name input on the register form.

Decisions worth a second opinion

  1. name is required — breaking for any client posting to /auth/register. Optional would have left the funnel falling back to an email address, which was the problem worth fixing.
  2. The endpoint takes no token. The funnel exists so an employer can browse before either side commits, and the profile is anonymous by construction — anonymity is what protects the candidate here, not authentication.
  3. It's a GET that can write. The reveal depends only on what the candidate scored, never on who is looking, so any viewer triggering it produces the same result and it is idempotent after the first. Moving the flip to grading time is the alternative.

Tests

111 backend, 15 frontend, build clean.

Beyond the two asked for (fresh user is anonymous; 70+ reveals): an anonymous payload contains no identifying substring anywhere; the reveal is persisted, not just reported; an already-revealed user does not re-query the threshold; a failed check does not latch the account; legacy documents read as anonymous; name rejects missing/empty/whitespace-only/over-80.

Also verified end to end against the real stack with an in-memory Mongo: a 61, an ungraded 90, and another user's 95 all correctly leave the profile anonymous; a graded 72 reveals.

krishhimself and others added 2 commits August 23, 2026 09:54
A candidate is a pseudonym until their work earns the introduction. This adds
the reveal mechanism and the profile surface that reads it.

- users gains `revealed` (default False) and `name`, documented in models/user.py.
  user_repository backfills both on read, so accounts written before the field
  existed behave as unrevealed rather than as missing-key.
- services/reputation_service.py owns the funnel. `meets_reveal_threshold` is a
  placeholder for the real reputation score: one graded attempt at 70+. Only
  `status: graded` counts, so an undefended score cannot buy a reveal.
- GET /profile/{user_id} returns "Anonymous Candidate" with a null email until
  the threshold is cleared, then latches the account open and returns the real
  details. Identity is dropped in the service, not hidden in the UI, so an
  unrevealed name never reaches the browser.
- Frontend gains a Profile tab; display.js re-checks `revealed` as a second lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o2ByD8XzuAAuwyjhjzYn4
The reveal had nothing to reveal: `users.name` was documented and rendered but
nothing wrote it, so a revealed profile fell back to showing an email address —
not the introduction the funnel promises.

`name` is required on POST /auth/register, stripped before it is measured so a
name of spaces is refused rather than stored as one. The profile view keeps its
email fallback for accounts created before the field existed.

AuthForm's `showRole` flag is now `signup`: it already meant "this is the
register form" in three places, and the name field would have made it four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o2ByD8XzuAAuwyjhjzYn4
@krishhimself
krishhimself merged commit fb17f05 into main Aug 23, 2026
2 checks passed
@krishhimself
krishhimself deleted the feat/anonymous-funnel branch August 23, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant