Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@router.post("/register", response_model=TokenResponse, status_code=201)
async def register(req: RegisterRequest):
try:
return await auth_service.register_user(req.email, req.password, req.role)
return await auth_service.register_user(req.email, req.password, req.role, req.name)
except ValueError:
# 409 rather than 400: the request was well-formed, the address is taken.
raise HTTPException(409, "That email is already registered.")
Expand Down
32 changes: 32 additions & 0 deletions backend/app/api/v1/endpoints/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Thin HTTP layer for candidate profiles. No business logic here — only
request/response translation and HTTP error mapping. Logic lives in
app/services/reputation_service.py.

Deliberately open: the funnel exists so an employer can browse candidates before
either side has committed to anything, and requiring a token to read a profile
that is anonymous by construction would gate the wrong thing. Anonymity, not
authentication, is what protects the candidate here.
"""
from fastapi import APIRouter, HTTPException

from app.schemas.profile import ProfileResponse
from app.services import reputation_service

router = APIRouter()


@router.get("/{user_id}", response_model=ProfileResponse)
async def get_profile(user_id: str):
"""
Returns the profile, revealing the candidate if they have cleared the threshold.

A GET that can flip `revealed` is a write on a read path, which is not usually
right. It is here because the reveal belongs to the candidate's own record —
it depends on what they scored, never on who is looking — so any viewer
triggering it produces the same result, and it is idempotent after the first.
"""
try:
return await reputation_service.get_public_profile(user_id)
except LookupError:
raise HTTPException(404, "Profile not found")
3 changes: 2 additions & 1 deletion backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Aggregates every v1 route. main.py only ever imports this one router."""
from fastapi import APIRouter

from app.api.v1.endpoints import auth, jobs, quiz
from app.api.v1.endpoints import auth, jobs, profile, quiz

api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(quiz.router, prefix="/quiz", tags=["quiz"])
api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
api_router.include_router(profile.router, prefix="/profile", tags=["profile"])
17 changes: 13 additions & 4 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@
"email": str, # stored lowercased; unique, see note below
"hashed_password": str, # bcrypt, never the plaintext
"role": "candidate" | "employer",
"name": str | None, # real display name, collected at registration and
# stripped of surrounding whitespace. None only on
# accounts created before the field existed
"revealed": bool, # default False — see the anonymity note below
"created_at": datetime (UTC),
}

The password is never stored or logged in any other form, and no query in
user_repository.py returns it except the one login needs.

Uniqueness note: email uniqueness is currently enforced by a read-before-write in
auth_service.register_user, which is racy under concurrent signups. A unique index
on `email` is the real fix:
Anonymity note: `revealed` drives the anonymous-first funnel. While it is False,
GET /profile/{user_id} answers with "Anonymous Candidate" and no email — the
identifying fields are dropped in services/reputation_service.py, not merely
hidden in the UI, so a profile response never carries a name or an address the
viewer is not entitled to.

db.users.create_index("email", unique=True)
It is a one-way latch: reputation_service flips it to True the first time the
candidate clears the reveal threshold, and nothing sets it back. Accounts created
before this field existed have no `revealed` key at all; every read treats a
missing value as False, so the safe state is also the default.
"""
18 changes: 18 additions & 0 deletions backend/app/repositories/quiz_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,21 @@ async def update_result(quiz_id: str, result: dict, followup: dict | None = None
if followup is not None:
changes["followup"] = followup
await collection.update_one({"_id": quiz_id}, {"$set": changes})


async def has_graded_attempt_scoring_at_least(user_id: str, minimum: float) -> bool:
"""
True if this user owns at least one graded attempt at or above `minimum`.

Only `status: "graded"` counts: an attempt still awaiting its follow-up has no
defended score, and reading `result` off one would credit a score the candidate
has not yet had to stand behind.

Projected down to `_id` because the caller only needs the yes/no — there is no
reason to pull whole attempt documents across the wire to answer it.
"""
match = await collection.find_one(
{"user_id": user_id, "status": "graded", "result.overall_score": {"$gte": minimum}},
{"_id": 1},
)
return match is not None
30 changes: 27 additions & 3 deletions backend/app/repositories/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,43 @@

collection = get_collection("users")

# Fields every user document is expected to carry, and what a document written
# before the field existed should be read as. Applied on write (so new documents
# are complete) and on read (so old ones behave as if they were).
DEFAULTS = {
"name": None,
"revealed": False,
}


def _normalise_email(email: str) -> str:
"""Emails are matched case-insensitively, so they are stored folded."""
return email.strip().lower()


def _with_defaults(doc: Optional[dict]) -> Optional[dict]:
"""Backfills absent fields in memory so callers never branch on a missing key."""
if doc is None:
return None
return {**DEFAULTS, **doc}


async def create_user(doc: dict) -> None:
await collection.insert_one(doc)
await collection.insert_one({**DEFAULTS, **doc})


async def get_user_by_email(email: str) -> Optional[dict]:
return await collection.find_one({"email": _normalise_email(email)})
return _with_defaults(await collection.find_one({"email": _normalise_email(email)}))


async def get_user_by_id(user_id: str) -> Optional[dict]:
return await collection.find_one({"_id": user_id})
return _with_defaults(await collection.find_one({"_id": user_id}))


async def mark_revealed(user_id: str) -> None:
"""
Latch the account as revealed. One way on purpose: a candidate who has cleared
the threshold and been seen by employers cannot be put back behind the
pseudonym, so there is no un-reveal counterpart to this.
"""
await collection.update_one({"_id": user_id}, {"$set": {"revealed": True}})
14 changes: 12 additions & 2 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
"""Request/response DTOs for the auth endpoints — what crosses the wire."""
from typing import Literal
from typing import Annotated, Literal

from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, StringConstraints

Role = Literal["candidate", "employer"]


# Stripped before it is measured, so a name of spaces is rejected rather than
# stored as one. The cap is a storage guard, not a claim about how long real
# names are — it is deliberately generous.
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=80)]


class RegisterRequest(BaseModel):
# Required: this is the name an employer sees once the candidate clears the
# reveal threshold, and a funnel that reveals an email address instead is not
# the introduction the product promises.
name: Name
email: EmailStr
# bcrypt only considers the first 72 bytes, so longer input is rejected rather
# than silently truncated at the boundary.
Expand Down
19 changes: 19 additions & 0 deletions backend/app/schemas/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Request/response DTOs for the profile endpoint — what crosses the wire."""
from pydantic import BaseModel

from app.schemas.auth import Role


class ProfileResponse(BaseModel):
"""
A profile as a viewer may see it.

`name` carries the pseudonym while `revealed` is False, and `email` is None —
the fields are absent from the payload rather than blanked by the client, so
an unrevealed identity is never sent to the browser at all.
"""
user_id: str
name: str | None
email: str | None
role: Role
revealed: bool
8 changes: 7 additions & 1 deletion backend/app/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.repositories import user_repository


async def register_user(email: str, password: str, role: str) -> dict:
async def register_user(email: str, password: str, role: str, name: str) -> dict:
"""Creates the account and returns a token, so signup does not need a second round trip."""
normalised = email.strip().lower()

Expand All @@ -23,8 +23,14 @@ async def register_user(email: str, password: str, role: str) -> dict:
{
"_id": user_id,
"email": normalised,
# Shown only once `revealed` flips; until then it never leaves this file's
# collection. See services/reputation_service.py.
"name": name.strip(),
"hashed_password": hash_password(password),
"role": role,
# Every account starts behind the pseudonym; reputation_service is the
# only thing that flips this, once the reveal threshold is cleared.
"revealed": False,
"created_at": datetime.now(timezone.utc),
}
)
Expand Down
80 changes: 80 additions & 0 deletions backend/app/services/reputation_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Business logic for the anonymous-first funnel.

A candidate is a pseudonym until their work says otherwise. Employers browse
"Anonymous Candidate" profiles and only learn who someone is once that candidate
has demonstrated something — which is the whole point of the platform: the code
earns the introduction, not the CV.

Two things live here:

* `meets_reveal_threshold` — has this candidate earned it yet? Today that is a
single graded quiz at or above REVEAL_MIN_SCORE. The real reputation score
(quiz depth + interview rounds, per docs/ARCHITECTURE.md) replaces the body of
this one function; nothing else below has to change when it lands.
* `get_public_profile` — assembles what a viewer is allowed to see.

Identity is dropped here, in the service, rather than hidden in the UI. An
unrevealed profile has no name and no email anywhere in the response, so there is
nothing for a curious viewer to read out of the network tab.
"""
from app.repositories import quiz_repository, user_repository

ANONYMOUS_NAME = "Anonymous Candidate"

# One defended quiz at 70+ is the current bar. Deliberately a module constant:
# the tests assert against this rather than a literal, so moving the bar cannot
# quietly leave the suite asserting the old one.
REVEAL_MIN_SCORE = 70.0


async def meets_reveal_threshold(user_id: str) -> bool:
"""
Placeholder for the real reputation score.

For now: does this candidate hold at least one graded attempt scoring
REVEAL_MIN_SCORE or better? Grading only happens after the follow-up defence,
so a passing score here already means the answers survived being questioned.
"""
return await quiz_repository.has_graded_attempt_scoring_at_least(user_id, REVEAL_MIN_SCORE)


async def get_public_profile(user_id: str) -> dict:
"""
The profile as a viewer may see it, revealing the candidate if they have earned it.

Reveal is evaluated on read rather than written at grading time so that a
change to the threshold applies to everyone immediately, without a migration
over past attempts. The flag is still persisted once it flips: it is a latch,
so a candidate who was revealed stays revealed even if the bar later rises.

Raises LookupError for an unknown user; the endpoint turns that into a 404.
"""
user = await user_repository.get_user_by_id(user_id)
if not user:
raise LookupError("user_not_found")

revealed = bool(user.get("revealed"))
if not revealed and await meets_reveal_threshold(user_id):
await user_repository.mark_revealed(user_id)
revealed = True

if not revealed:
return {
"user_id": user["_id"],
"name": ANONYMOUS_NAME,
"email": None,
"role": user.get("role", "candidate"),
"revealed": False,
}

return {
"user_id": user["_id"],
# None only on accounts created before registration collected a name;
# the profile view falls back to the email for those. Both are the
# candidate's to show once revealed.
"name": user.get("name"),
"email": user.get("email"),
"role": user.get("role", "candidate"),
"revealed": True,
}
40 changes: 32 additions & 8 deletions backend/tests/test_auth_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,36 +40,60 @@ def auth_header(user_id="user-1", role="candidate"):

def test_register_creates_a_user_and_returns_a_token(no_such_user):
resp = client.post("/api/v1/auth/register",
json={"email": "New@Example.com", "password": "hunter2hunter2", "role": "employer"})
json={"name": "Ada Lovelace", "email": "New@Example.com",
"password": "hunter2hunter2", "role": "employer"})
assert resp.status_code == 201
body = resp.json()
assert body["token_type"] == "bearer" and body["role"] == "employer"
assert body["access_token"]

doc = no_such_user.call_args.args[0]
assert doc["email"] == "new@example.com", "email must be stored folded"
assert doc["name"] == "Ada Lovelace", "the name is stored as given, not folded"
assert doc["role"] == "employer"
assert "password" not in doc
assert doc["hashed_password"] != "hunter2hunter2"


def test_register_rejects_a_duplicate_email(existing_user):
resp = client.post("/api/v1/auth/register",
json={"email": "a@b.com", "password": "hunter2hunter2"})
json={"name": "Ada", "email": "a@b.com", "password": "hunter2hunter2"})
assert resp.status_code == 409


def test_register_starts_the_account_anonymous(no_such_user):
"""The funnel's safe state: a new account is a pseudonym until it earns otherwise."""
client.post("/api/v1/auth/register",
json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"})
assert no_such_user.call_args.args[0]["revealed"] is False


def test_register_defaults_to_candidate(no_such_user):
client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"})
client.post("/api/v1/auth/register",
json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"})
assert no_such_user.call_args.args[0]["role"] == "candidate"


def test_register_trims_the_name(no_such_user):
"""Otherwise a padded name would sort and render as if it were something else."""
client.post("/api/v1/auth/register",
json={"name": " Ada Lovelace ", "email": "c@d.com",
"password": "hunter2hunter2"})
assert no_such_user.call_args.args[0]["name"] == "Ada Lovelace"


@pytest.mark.parametrize("payload", [
{"email": "not-an-email", "password": "hunter2hunter2"},
{"email": "a@b.com", "password": "short"},
{"email": "a@b.com", "password": "x" * 73},
{"email": "a@b.com", "password": "hunter2hunter2", "role": "admin"},
{"password": "hunter2hunter2"},
{"name": "Ada", "email": "not-an-email", "password": "hunter2hunter2"},
{"name": "Ada", "email": "a@b.com", "password": "short"},
{"name": "Ada", "email": "a@b.com", "password": "x" * 73},
{"name": "Ada", "email": "a@b.com", "password": "hunter2hunter2", "role": "admin"},
{"name": "Ada", "password": "hunter2hunter2"},
# A name is what the reveal has to show, so registering without a usable one
# is refused rather than accepted and papered over at display time.
{"email": "a@b.com", "password": "hunter2hunter2"},
{"name": "", "email": "a@b.com", "password": "hunter2hunter2"},
{"name": " ", "email": "a@b.com", "password": "hunter2hunter2"},
{"name": "x" * 81, "email": "a@b.com", "password": "hunter2hunter2"},
])
def test_register_validation(payload):
assert client.post("/api/v1/auth/register", json=payload).status_code == 422
Expand Down
Loading
Loading