feat: anonymous-first profile funnel - #8
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 latchesrevealed: trueon 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.jsre-checks the flag as a second lock.Changes
users.revealed(defaultfalse) andusers.name, documented inmodels/user.py.user_repositorybackfills both on read, so accounts written before the fields existed behave as unrevealed rather than as missing-key.services/reputation_service.pyowns the funnel.meets_reveal_threshold()is a placeholder for the real reputation score: one graded attempt atREVEAL_MIN_SCORE(70) or better. Onlystatus: "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.nameis 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.Decisions worth a second opinion
nameis 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.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;
namerejects 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.