From f86a21d00c06f9b7ebabf63de7cd1e795bcc59fc Mon Sep 17 00:00:00 2001
From: Krish
Date: Mon, 24 Aug 2026 17:51:59 +0530
Subject: [PATCH 1/4] feat: gate job postings behind a company quiz
The candidate side interrogates a repo before it counts for anything. This does
the same to a job posting: the employer answers for the role they just wrote,
under the same clock and the same single adaptive follow-up, and the posting is
published only if the answers clear 70/100.
Same engine rather than a copy. The clock, the paste and timing ranking in
pick_suspect_answer, and the follow-up generator are all shared with the repo
quiz; only the prompts differ, plus a `framing` argument whose default keeps the
candidate wording byte-identical.
Two gaming holes closed by construction:
* The draft is held server-side from generation and the published posting is
built from that copy, so a company cannot defend an honest draft and publish
a rosier one.
* `status: "graded"` is terminal and carries the job id it produced, so a
replayed final call returns the stored outcome instead of minting a second
posting. Grading also refuses to run if the follow-up round was skipped.
Removes POST /jobs/, which created postings with no auth at all and would have
left the gate decorative. Nothing called it. GET /jobs/ and POST /jobs/apply are
unchanged.
Company attempts get their own collection rather than a discriminator on
quiz_attempts: has_graded_attempt_scoring_at_least() backs the candidate reveal
threshold and matches any graded attempt by user, so a shared collection would
let an employer quiz count as comprehension a candidate never demonstrated.
Frontend adds the employer-only Post a Job tab, reusing the quiz feature
QuestionCard so both sides of the market run the same clock and the same silent
paste recording.
38 new backend tests; suite is 151 green.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01XDgAeL6TE9UCqkWUy6azim
---
README.md | 45 ++-
backend/app/api/v1/endpoints/jobs.py | 81 +++++-
backend/app/core/dependencies.py | 14 +
backend/app/integrations/gemini_client.py | 147 +++++++++-
backend/app/models/job.py | 28 ++
.../repositories/company_quiz_repository.py | 52 ++++
backend/app/schemas/job.py | 61 +++++
backend/app/services/company_quiz_service.py | 193 +++++++++++++
backend/tests/conftest.py | 48 ++++
backend/tests/test_company_quiz_api.py | 257 ++++++++++++++++++
backend/tests/test_company_quiz_service.py | 246 +++++++++++++++++
docs/ARCHITECTURE.md | 42 ++-
frontend/src/App.jsx | 58 ++--
frontend/src/features/jobs/PostJobPage.jsx | 201 ++++++++++++++
frontend/src/features/jobs/api.js | 30 ++
.../features/jobs/components/JobDraftForm.jsx | 50 ++++
.../jobs/components/PostingResult.jsx | 44 +++
frontend/src/shared/api/token.js | 25 +-
frontend/src/styles/globals.css | 14 +
19 files changed, 1586 insertions(+), 50 deletions(-)
create mode 100644 backend/app/repositories/company_quiz_repository.py
create mode 100644 backend/app/services/company_quiz_service.py
create mode 100644 backend/tests/test_company_quiz_api.py
create mode 100644 backend/tests/test_company_quiz_service.py
create mode 100644 frontend/src/features/jobs/PostJobPage.jsx
create mode 100644 frontend/src/features/jobs/components/JobDraftForm.jsx
create mode 100644 frontend/src/features/jobs/components/PostingResult.jsx
diff --git a/README.md b/README.md
index a3f9530..a8fc327 100644
--- a/README.md
+++ b/README.md
@@ -104,14 +104,51 @@ many candidates before it can be calibrated, which does not exist yet. The
complexity tier is a deliberate stopgap — a second, independent signal — not a
substitute for it.
+## The other side: the posting quiz
+
+A resume is a claim about a person; a job posting is a claim about a role. Both are
+cheap to generate and both are usually written by someone other than the person who
+will live with them. So the company side runs the same interrogation in reverse.
+
+1. An employer writes the posting — company, role, stack, what the job actually is.
+2. The same engine generates questions grounded in that posting, in four categories:
+ - **role** — what this person does day to day, and what "doing well" looks like at 90 days
+ - **stack** — which of the listed technologies the hire actually touches, and why each is there
+ - **team** — who they work with, who decides what gets built
+ - **reality** — the constraints, the legacy, what makes the role genuinely hard
+3. Same 75-second clock, same silent paste recording, same single adaptive follow-up
+ before anything is graded.
+4. A posting is published **only** if the answers clear 70/100. Nothing else in the
+ codebase creates a job: `job_service.post_job()` has exactly one caller, and it is
+ the grading step.
+
+The posting that goes live is the draft the questions were generated from, held
+server-side for the whole round — so a company cannot answer honestly about the real
+role and then publish a rosier version of it.
+
+What this catches is a posting nobody behind it can account for. Asked about a
+posting listing Kafka and Kubernetes next to a description that only mentions
+FastAPI and Mongo, the first question generated back was where Kafka actually sits
+in that flow — the kind of question a template cannot survive and the engineer who
+owns the pipeline answers without thinking. A round answered with specifics and a
+defended follow-up scored 98/100 and published; a round whose answers repeated
+themselves and whose follow-up went undefended scored 52 and published nothing.
+
+**Same seam as the candidate side.** The clock and the paste detector are
+client-side, and the pass mark is a single model judgement. What holds is the
+follow-up: it is generated from the employer's own wording at response time, so it
+cannot be prepared in advance. Grading also cannot be replayed — an attempt is
+graded once, keeps the job id it produced, and a retried call returns that instead of
+publishing again.
+
## Status
-**Built:** repo quiz end to end (generate → answer → grade), minimal job
-posting/application CRUD.
+**Built:** repo quiz end to end (generate → answer → grade), the company-side
+quiz gating job postings, anonymous-first candidate profiles, job
+listing/application CRUD.
**Designed, not yet built** (see `docs/ARCHITECTURE.md` for where these
-slot in): company-side quiz gating job postings, anonymous-first candidate
-profiles, unified reputation score, bug-hunt mode, community threads.
+slot in): unified reputation score, bug-hunt mode, community threads.
## Running locally
diff --git a/backend/app/api/v1/endpoints/jobs.py b/backend/app/api/v1/endpoints/jobs.py
index bb4f585..d99b0c3 100644
--- a/backend/app/api/v1/endpoints/jobs.py
+++ b/backend/app/api/v1/endpoints/jobs.py
@@ -1,16 +1,31 @@
-"""Thin HTTP layer for job postings and applications."""
-from fastapi import APIRouter
+"""
+Thin HTTP layer for job postings, applications, and the company-side quiz.
+No business logic here — logic lives in app/services/.
-from app.schemas.job import ApplicationRequest, JobCreateRequest
-from app.services import job_service
+There is deliberately no plain "create a job" route. A posting exists only as the
+output of a defended company quiz (generate -> submit -> followup), which is what
+"the quiz gates job posting creation" has to mean to be worth anything: an ungated
+create endpoint alongside it would make the gate decorative. Job creation itself
+still lives in job_service.post_job(); company_quiz_service is its only caller.
-router = APIRouter()
+The quiz routes are employer-only and each attempt belongs to the account that
+generated it.
+"""
+from fastapi import APIRouter, Depends, HTTPException
+from app.core.dependencies import get_current_employer
+from app.schemas.job import (
+ ApplicationRequest,
+ CompanyQuizFollowUpRequest,
+ CompanyQuizGenerateRequest,
+ CompanyQuizGenerateResponse,
+ CompanyQuizResultResponse,
+ CompanyQuizSubmitRequest,
+ CompanyQuizSubmitResponse,
+)
+from app.services import company_quiz_service, job_service
-@router.post("/")
-async def create_job(job: JobCreateRequest):
- job_id = await job_service.post_job(job.dict())
- return {"id": job_id}
+router = APIRouter()
@router.get("/")
@@ -22,3 +37,51 @@ async def list_jobs():
async def apply(application: ApplicationRequest):
app_id = await job_service.apply_to_job(application.dict())
return {"id": app_id}
+
+
+# --- company quiz ----------------------------------------------------------
+
+
+@router.post("/company-quiz/generate", response_model=CompanyQuizGenerateResponse)
+async def generate_company_quiz(
+ draft: CompanyQuizGenerateRequest, user: dict = Depends(get_current_employer)
+):
+ """Interrogates the draft posting. The draft is held server-side until grading."""
+ try:
+ return await company_quiz_service.create_quiz(draft.model_dump(), user["user_id"])
+ except ValueError:
+ raise HTTPException(400, "Couldn't build questions from that posting — add more detail about the role.")
+
+
+@router.post("/company-quiz/submit", response_model=CompanyQuizSubmitResponse)
+async def submit_company_quiz(
+ req: CompanyQuizSubmitRequest, user: dict = Depends(get_current_employer)
+):
+ """Records answers and returns the adaptive follow-up. Does not grade or post."""
+ try:
+ return await company_quiz_service.start_followup(
+ req.quiz_id, [a.model_dump() for a in req.answers], user["user_id"]
+ )
+ except LookupError:
+ # Also the answer when the quiz exists but belongs to somebody else, so this
+ # cannot be used to discover which quiz ids are real.
+ raise HTTPException(404, "Quiz not found")
+ except company_quiz_service.QuizClosed:
+ raise HTTPException(409, "This quiz has already been graded.")
+ except ValueError:
+ raise HTTPException(400, "No answers submitted")
+
+
+@router.post("/company-quiz/followup", response_model=CompanyQuizResultResponse)
+async def company_quiz_followup(
+ req: CompanyQuizFollowUpRequest, user: dict = Depends(get_current_employer)
+):
+ """Grades the round and, on a pass, publishes the posting that was defended."""
+ try:
+ return await company_quiz_service.grade_and_post(
+ req.quiz_id, req.answer, user["user_id"], req.seconds_left
+ )
+ except LookupError:
+ raise HTTPException(404, "Quiz not found")
+ except company_quiz_service.QuizClosed:
+ raise HTTPException(409, "Answer the follow-up round before grading.")
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
index 7c41e3e..39791b2 100644
--- a/backend/app/core/dependencies.py
+++ b/backend/app/core/dependencies.py
@@ -29,3 +29,17 @@ async def get_current_user(
if not user_id:
raise HTTPException(401, "Invalid or expired token")
return {"user_id": user_id, "role": claims.get("role")}
+
+
+async def get_current_employer(user: dict = Depends(get_current_user)) -> dict:
+ """
+ Same as get_current_user, but only for employer accounts.
+
+ The role is read off the signed token rather than a request body, so a
+ candidate account cannot post jobs by claiming to be a company. 403 rather
+ than 404: which routes exist is not a secret, and a candidate who wound up
+ here should be told why the door is shut.
+ """
+ if user.get("role") != "employer":
+ raise HTTPException(403, "Only employer accounts can post jobs.")
+ return user
diff --git a/backend/app/integrations/gemini_client.py b/backend/app/integrations/gemini_client.py
index 838077c..2bbb89b 100644
--- a/backend/app/integrations/gemini_client.py
+++ b/backend/app/integrations/gemini_client.py
@@ -31,6 +31,13 @@ def _strip_code_fence(text: str) -> str:
}
+def _parse_questions(data) -> list[dict]:
+ """Questions out of either shape the model returns: a bare array or {"questions": [...]}."""
+ if isinstance(data, list):
+ return data
+ return data.get("questions") or []
+
+
def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]:
"""
Split the model's response into (questions, complexity).
@@ -42,9 +49,9 @@ def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]:
data = json.loads(text)
if isinstance(data, list): # model ignored the object wrapper
- return data, dict(UNKNOWN_COMPLEXITY)
+ return _parse_questions(data), dict(UNKNOWN_COMPLEXITY)
- questions = data.get("questions") or []
+ questions = _parse_questions(data)
raw = data.get("complexity")
if not isinstance(raw, dict):
return questions, dict(UNKNOWN_COMPLEXITY)
@@ -98,15 +105,30 @@ async def generate_quiz_questions(files: list[dict], n_questions: int = 5) -> tu
return _parse_quiz_payload(_strip_code_fence(response.text))
-async def generate_followup_question(question: dict, answer: str) -> str:
+# How the follow-up addresses whoever is answering. The generator itself is identical
+# for both sides of the market - only the framing changes, so a company defending its
+# own posting is pushed on exactly as hard as a candidate defending their code.
+CANDIDATE_FRAMING = {
+ "opening": "A developer was asked this about a project they claim to have built",
+ "topic": "the project",
+}
+COMPANY_FRAMING = {
+ "opening": "The person who wrote a job posting was asked this about the role they are hiring for",
+ "topic": "the role",
+}
+
+
+async def generate_followup_question(
+ question: dict, answer: str, framing: dict = CANDIDATE_FRAMING
+) -> str:
"""
- One sharp follow-up that pushes on the candidate's own wording.
+ One sharp follow-up that pushes on the answerer's own wording.
The point is not to ask something harder - it is to ask something that is only
answerable by whoever actually meant what they wrote. A pasted answer has no
author behind it to defend it.
"""
- prompt = f"""A developer was asked this about a project they claim to have built:
+ prompt = f"""{framing["opening"]}:
QUESTION: {question.get("question", "")}
@@ -120,7 +142,7 @@ async def generate_followup_question(question: dict, answer: str) -> str:
throws?"
It must be unanswerable by someone who did not mean what they wrote. Do not ask a
-general question about the project. Do not ask them to quote code.
+general question about {framing["topic"]}. Do not ask them to quote code.
Return ONLY the question text. No prose, no JSON, no quotes around it.
"""
@@ -183,3 +205,116 @@ async def grade_answers(
"""
response = await _model().generate_content_async(prompt)
return json.loads(_strip_code_fence(response.text))
+
+
+def _posting_block(draft: dict) -> str:
+ stack = ", ".join(draft.get("tech_stack") or []) or "(none listed)"
+ return f"""COMPANY: {draft.get("company_name", "")}
+ROLE: {draft.get("role_title", "")}
+TECH STACK LISTED: {stack}
+DESCRIPTION:
+{draft.get("description", "")}"""
+
+
+async def generate_company_quiz_questions(draft: dict, n_questions: int = 5) -> list[dict]:
+ """
+ Interrogate a job posting the way the repo quiz interrogates a repo.
+
+ Same engine, mirrored subject: instead of asking a developer whether they
+ understand the code they submitted, this asks the poster whether they know the
+ role they are advertising. A posting assembled from a template is exactly as
+ cheap as a resume assembled from one, and fails here for the same reason - there
+ is no lived detail behind it to produce under a clock.
+ """
+ prompt = f"""You are an experienced engineer who just read this job posting and is
+deciding whether to apply. You get to ask the hiring manager {n_questions} questions
+first.
+
+Ask what someone who actually owns this role could answer instantly and someone who
+pasted together a template could not. Push on vague recruiter language rather than
+accepting it - if the posting says "fast-paced" or "rockstar" or lists ten
+technologies, make them say what that concretely means here.
+
+Cover these four categories, at least one question each:
+ role - what this person actually does day to day, and what "doing well" looks like in the first 90 days
+ stack - which of the listed technologies the hire actually touches, and why each one is in the stack
+ team - who they work with, who decides what they build, how big the team is
+ reality - the unglamorous part: the constraints, the legacy, what makes this role genuinely hard, why the seat is open
+
+Ground each question in something specific from the posting - a phrase they used, a
+technology they listed, a claim they made. Do NOT ask about salary, benefits, or
+interview logistics. Do NOT ask questions answerable by re-reading the posting aloud.
+
+Return ONLY a JSON object, no prose:
+{{"questions": [{{"question": "...", "category": "role" | "stack" | "team" | "reality"}}]}}
+
+POSTING:
+{_posting_block(draft)}
+"""
+ response = await _model().generate_content_async(prompt)
+ return _parse_questions(json.loads(_strip_code_fence(response.text)))
+
+
+async def grade_company_answers(
+ draft: dict, questions: list[dict], answers: list[dict], followup: dict | None = None
+) -> dict:
+ """
+ Score whether the posting reflects a role this person actually knows.
+
+ The mirror of grade_answers: there, a candidate's claim is their code; here it is
+ their posting. The failure being caught is the same one - a confident description
+ of something the author has no real contact with.
+ """
+ qa_pairs = "\n\n".join(
+ "Q: {q}\nA: {a}".format(
+ q=q["question"],
+ a=next((a["answer"] for a in answers if a["question_id"] == q["id"]), "(no answer)"),
+ )
+ for q in questions
+ )
+
+ followup_block = ""
+ if followup and followup.get("question"):
+ followup_block = f"""
+
+FOLLOW-UP ROUND - this is the strongest signal you have.
+After answering, they were pushed on their own wording and replied under time pressure
+with no chance to prepare:
+
+ Follow-up asked : {followup.get("question")}
+ They replied : {followup.get("answer") or "(no answer)"}
+
+Weigh this heavily. If they cannot defend or even engage with wording they themselves
+used, treat the original answer it came from as very likely not describing a role they
+know, and score that answer down hard regardless of how polished it looked.
+"""
+
+ prompt = f"""You are scoring a hiring manager on whether the job posting below
+reflects a role they actually know, rather than a template they assembled.
+
+Score WELL: concrete, specific answers - named systems, real trade-offs, honest
+constraints, an admission that part of the job is tedious or unresolved. Someone
+describing a real seat on a real team says things that could not be said about any
+other job.
+
+Score POORLY: answers that would fit any company hiring any engineer, buzzwords with
+nothing under them, restating the posting back, or dodging the question. Also score
+down when an answer contradicts the posting - a listed technology nobody touches, or
+a scope that turns out to be far smaller or larger than advertised - because the
+posting failing to match the role is exactly the thing being tested.
+
+Do NOT reward polish, length, or enthusiasm. Do NOT penalise blunt or unflattering
+honesty about the role; that is evidence they know it.
+
+Return ONLY JSON, no prose:
+{{"overall_score": 0-100, "breakdown": [{{"question": "...", "score": 0-10, "note": "..."}}]}}
+
+THE POSTING THEY WROTE:
+{_posting_block(draft)}
+
+THEIR ANSWERS:
+{qa_pairs}
+{followup_block}
+"""
+ response = await _model().generate_content_async(prompt)
+ return json.loads(_strip_code_fence(response.text))
diff --git a/backend/app/models/job.py b/backend/app/models/job.py
index edc5bae..45493cf 100644
--- a/backend/app/models/job.py
+++ b/backend/app/models/job.py
@@ -6,9 +6,17 @@
"role_title": str,
"description": str,
"tech_stack": list[str],
+ "posted_by": str, # user_id of the employer who defended the quiz
+ "company_quiz_id": str, # the attempt that unlocked this posting
"posted_at": datetime,
}
+Every document here is written by services/company_quiz_service.py on a passing
+grade, from the draft that was stored when the questions were generated. There is
+no route that inserts a job any other way, so a posting in this collection has by
+construction been defended by the account named in `posted_by`. Documents created
+before the gate existed have neither key.
+
applications collection:
{
"_id": str (uuid4),
@@ -17,4 +25,24 @@
"quiz_score_id": str | None,
"status": "applied" | "reviewed" | "rejected" | "accepted",
}
+
+company_quiz_attempts collection (see repositories/company_quiz_repository.py for
+why this is not merged into quiz_attempts):
+{
+ "_id": str (uuid4),
+ "user_id": str, # the employer; ownership is checked on every step
+ "draft": {company_name, role_title, description, tech_stack},
+ "questions": [{"id": str, "question": str, "category": str}],
+ "answers": [{"question_id": str, "answer": str, "seconds_left": float | None,
+ "flagged_paste": bool, "paste_delta": int}],
+ "followup": {"id": str, "question": str, "targets_question_id": str,
+ "answer": str | None, "seconds_left": float | None},
+ "result": {"overall_score": float, "breakdown": [...]},
+ "passed": bool,
+ "job_id": str | None, # the posting this attempt produced, None if it failed
+ "status": "generated" | "awaiting_followup" | "graded",
+}
+
+`status: "graded"` is terminal: the attempt keeps whatever job_id it produced and
+is never re-graded, so it cannot be replayed into a second posting.
"""
diff --git a/backend/app/repositories/company_quiz_repository.py b/backend/app/repositories/company_quiz_repository.py
new file mode 100644
index 0000000..e20251c
--- /dev/null
+++ b/backend/app/repositories/company_quiz_repository.py
@@ -0,0 +1,52 @@
+"""
+Only file allowed to query the company_quiz_attempts collection directly.
+
+Deliberately a separate collection from quiz_attempts rather than a `kind` field
+on the same one. quiz_repository.has_graded_attempt_scoring_at_least() matches any
+graded attempt owned by a user, and it backs the candidate reveal threshold — a
+company quiz sharing that collection would silently count as comprehension a
+candidate never demonstrated. Separate collections make that impossible rather
+than merely unlikely.
+"""
+from typing import Optional
+
+from app.db.mongodb import get_collection
+
+collection = get_collection("company_quiz_attempts")
+
+
+async def save_attempt(doc: dict) -> None:
+ await collection.insert_one(doc)
+
+
+async def get_attempt(quiz_id: str) -> Optional[dict]:
+ return await collection.find_one({"_id": quiz_id})
+
+
+async def update_followup(quiz_id: str, answers: list[dict], followup: dict) -> None:
+ await collection.update_one(
+ {"_id": quiz_id},
+ {"$set": {"status": "awaiting_followup", "answers": answers, "followup": followup}},
+ )
+
+
+async def update_result(
+ quiz_id: str, result: dict, followup: dict, passed: bool, job_id: Optional[str]
+) -> None:
+ """
+ Close the attempt out. `job_id` is the posting this quiz unlocked, or None when
+ it did not clear the bar — recorded either way so a graded attempt can never be
+ replayed to mint a second posting.
+ """
+ await collection.update_one(
+ {"_id": quiz_id},
+ {
+ "$set": {
+ "status": "graded",
+ "result": result,
+ "followup": followup,
+ "passed": passed,
+ "job_id": job_id,
+ }
+ },
+ )
diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py
index 94fd1e1..78953ea 100644
--- a/backend/app/schemas/job.py
+++ b/backend/app/schemas/job.py
@@ -4,6 +4,13 @@
from pydantic import BaseModel
+from app.schemas.quiz import (
+ TIME_LIMIT_SECONDS,
+ FollowUpQuestion,
+ QuizAnswer,
+ QuizQuestion,
+)
+
class JobCreateRequest(BaseModel):
company_name: str
@@ -25,3 +32,57 @@ class ApplicationRequest(BaseModel):
job_id: str
user_id: str
quiz_score_id: Optional[str] = None
+
+
+# --- company quiz ----------------------------------------------------------
+#
+# The DTOs below deliberately reuse QuizAnswer, QuizQuestion and FollowUpQuestion
+# from schemas/quiz.py rather than redeclaring them. The two sides of the market
+# run the same engine, so the anti-gaming fields (seconds_left, flagged_paste,
+# paste_delta) must stay one definition — a company-side copy would drift.
+
+
+class CompanyQuizGenerateRequest(JobCreateRequest):
+ """
+ The draft posting itself. Questions are generated from it, and it is stored on
+ the attempt — the job is later created from that stored copy, never from a
+ second body sent at the end, so a passing quiz cannot be redeemed against a
+ different posting than the one that was defended.
+ """
+
+
+class CompanyQuizGenerateResponse(BaseModel):
+ quiz_id: str
+ role_title: str
+ questions: List[QuizQuestion]
+ time_limit_seconds: int = TIME_LIMIT_SECONDS
+
+
+class CompanyQuizSubmitRequest(BaseModel):
+ quiz_id: str
+ answers: List[QuizAnswer]
+
+
+class CompanyQuizSubmitResponse(BaseModel):
+ """Like the candidate flow, submitting opens the follow-up rather than grading."""
+
+ quiz_id: str
+ followup: FollowUpQuestion
+ time_limit_seconds: int = TIME_LIMIT_SECONDS
+
+
+class CompanyQuizFollowUpRequest(BaseModel):
+ quiz_id: str
+ answer: str
+ seconds_left: Optional[float] = None
+
+
+class CompanyQuizResultResponse(BaseModel):
+ quiz_id: str
+ score: float
+ pass_score: float
+ passed: bool
+ # The posting that was created, or None when the quiz did not clear the bar.
+ # This is the whole gate: no other route creates a job.
+ job_id: Optional[str] = None
+ feedback: List[dict]
diff --git a/backend/app/services/company_quiz_service.py b/backend/app/services/company_quiz_service.py
new file mode 100644
index 0000000..3eccf3e
--- /dev/null
+++ b/backend/app/services/company_quiz_service.py
@@ -0,0 +1,193 @@
+"""
+Business logic for the company-side quiz — the employer mirror of quiz_service.
+
+The thesis cuts both ways. A candidate's claim is their repo, and the repo quiz
+tests whether they understand it. A company's claim is its posting, and this tests
+whether the posting describes a role the poster actually knows. Both are cheap to
+generate and expensive to defend, which is the whole point.
+
+It is the same engine: the same clock, the same paste ranking
+(quiz_service.pick_suspect_answer), the same one adaptive follow-up before anything
+is graded. Only the prompts differ — see integrations/gemini_client.py.
+
+The gate: a job posting is created here, on a passing grade, from the draft stored
+at generation time. No other code path creates a job. Two consequences worth being
+explicit about:
+
+ * The posting that gets created is the one the questions were generated from, so
+ a company cannot defend an honest draft and redeem the pass against a different,
+ rosier one.
+ * A graded attempt is closed forever. It carries the job_id it produced (or None),
+ so replaying the final call returns what already happened instead of minting a
+ second posting.
+"""
+import uuid
+
+from app.integrations import gemini_client
+from app.repositories import company_quiz_repository
+from app.schemas.quiz import TIME_LIMIT_SECONDS
+from app.services import job_service
+from app.services.quiz_service import pick_suspect_answer
+
+
+class QuizClosed(Exception):
+ """The attempt is not in a state where the requested step is meaningful."""
+
+
+# The bar a posting must clear to go live. A module constant for the same reason as
+# reputation_service.REVEAL_MIN_SCORE: the tests assert against this rather than a
+# literal, so moving the bar cannot quietly leave the suite asserting the old one.
+PASS_SCORE = 70.0
+
+# Fields of the stored draft that describe the posting. Anything else on the attempt
+# (ownership, answers, grading) is bookkeeping and must not reach the jobs collection.
+DRAFT_FIELDS = ("company_name", "role_title", "description", "tech_stack")
+
+
+async def create_quiz(draft: dict, user_id: str) -> dict:
+ """Generate the interrogation for a draft posting and hold the draft for later."""
+ raw_questions = await gemini_client.generate_company_quiz_questions(draft)
+ if not raw_questions:
+ raise ValueError("no_questions")
+
+ questions = [{"id": str(uuid.uuid4()), **q} for q in raw_questions]
+ stored_draft = {k: draft.get(k) for k in DRAFT_FIELDS}
+
+ quiz_id = str(uuid.uuid4())
+ await company_quiz_repository.save_attempt(
+ {
+ "_id": quiz_id,
+ "draft": stored_draft,
+ "user_id": user_id,
+ "questions": questions,
+ "status": "generated",
+ }
+ )
+ return {
+ "quiz_id": quiz_id,
+ "role_title": stored_draft.get("role_title") or "",
+ "questions": questions,
+ "time_limit_seconds": TIME_LIMIT_SECONDS,
+ }
+
+
+async def _load_owned_attempt(quiz_id: str, user_id: str) -> dict:
+ """
+ Fetch an attempt, but only for the account it belongs to.
+
+ Mirrors quiz_service._load_owned_attempt: a quiz that exists but belongs to
+ someone else raises the same LookupError as one that does not exist, so the
+ endpoint answers 404 either way and cannot be used to discover which quiz ids
+ are real. Kept here rather than shared because the two read different
+ collections, and the ownership rule is short enough that a wrong-collection
+ abstraction would cost more than the duplication.
+ """
+ attempt = await company_quiz_repository.get_attempt(quiz_id)
+ if not attempt or attempt.get("user_id") != user_id:
+ raise LookupError("quiz_not_found")
+ return attempt
+
+
+async def start_followup(quiz_id: str, answers: list[dict], user_id: str) -> dict:
+ """Record the answers and open the follow-up round. Does not grade, does not post."""
+ attempt = await _load_owned_attempt(quiz_id, user_id)
+ if attempt.get("status") == "graded":
+ # Re-opening a closed attempt would let a company answer again after seeing
+ # how it scored.
+ raise QuizClosed("already_graded")
+
+ suspect = pick_suspect_answer(answers)
+ if suspect is None:
+ raise ValueError("no_answers")
+
+ target = next(
+ (q for q in attempt["questions"] if q["id"] == suspect.get("question_id")),
+ attempt["questions"][0],
+ )
+ question_text = await gemini_client.generate_followup_question(
+ target, suspect.get("answer") or "", framing=gemini_client.COMPANY_FRAMING
+ )
+
+ followup = {
+ "id": str(uuid.uuid4()),
+ "question": question_text,
+ "targets_question_id": target["id"],
+ "answer": None,
+ }
+ await company_quiz_repository.update_followup(quiz_id, answers, followup)
+
+ return {
+ "quiz_id": quiz_id,
+ "followup": followup,
+ "time_limit_seconds": TIME_LIMIT_SECONDS,
+ }
+
+
+def _result_payload(quiz_id: str, score: float, passed: bool, job_id, breakdown) -> dict:
+ return {
+ "quiz_id": quiz_id,
+ "score": score,
+ "pass_score": PASS_SCORE,
+ "passed": passed,
+ "job_id": job_id,
+ "feedback": breakdown or [],
+ }
+
+
+async def grade_and_post(
+ quiz_id: str, followup_answer: str, user_id: str, seconds_left: float | None = None
+) -> dict:
+ """
+ Grade the whole round and, only on a pass, create the posting.
+
+ Replaying this against an already-graded attempt returns the stored outcome
+ rather than grading again, so a retried request cannot produce a second posting.
+ """
+ attempt = await _load_owned_attempt(quiz_id, user_id)
+
+ if attempt.get("status") == "graded":
+ stored = attempt.get("result") or {}
+ return _result_payload(
+ quiz_id,
+ float(stored.get("overall_score") or 0),
+ bool(attempt.get("passed")),
+ attempt.get("job_id"),
+ stored.get("breakdown"),
+ )
+
+ followup = dict(attempt.get("followup") or {})
+ if not followup.get("question"):
+ # The follow-up is the round that actually holds; grading without it would
+ # let a company post by calling this endpoint directly and skipping the
+ # interrogation entirely.
+ raise QuizClosed("followup_not_started")
+
+ followup["answer"] = followup_answer
+ followup["seconds_left"] = seconds_left
+
+ result = await gemini_client.grade_company_answers(
+ attempt.get("draft") or {},
+ attempt["questions"],
+ attempt.get("answers") or [],
+ followup=followup,
+ )
+ score = float(result.get("overall_score") or 0)
+ passed = score >= PASS_SCORE
+
+ # Posted before the attempt is closed out, so a failure here leaves the attempt
+ # re-gradable rather than burning a quiz that never produced a posting. The cost
+ # is a narrow window: if the write below fails after this insert, a retry can
+ # post twice. Recording the outcome first would trade that for the opposite
+ # failure, and losing an honest company's defended quiz is the worse one.
+ job_id = None
+ if passed:
+ job_id = await job_service.post_job(
+ {
+ **(attempt.get("draft") or {}),
+ "posted_by": user_id,
+ "company_quiz_id": quiz_id,
+ }
+ )
+
+ await company_quiz_repository.update_result(quiz_id, result, followup, passed, job_id)
+ return _result_payload(quiz_id, score, passed, job_id, result.get("breakdown"))
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 877c35d..3bf678a 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -50,3 +50,51 @@ def attempt(questions, complexity):
},
"status": "awaiting_followup",
}
+
+
+COMPANY_QUESTIONS = [
+ {"id": "cq1", "question": "What does this hire do in their first week?", "category": "role"},
+ {"id": "cq2", "question": "Which of the six listed technologies do they touch daily?", "category": "stack"},
+ {"id": "cq3", "question": "Who decides what they build?", "category": "team"},
+]
+
+DRAFT = {
+ "company_name": "Acme",
+ "role_title": "Backend Engineer",
+ "description": "Own the ingest pipeline end to end.",
+ "tech_stack": ["Python", "MongoDB"],
+}
+
+
+@pytest.fixture
+def draft():
+ return dict(DRAFT)
+
+
+@pytest.fixture
+def company_questions():
+ return [dict(q) for q in COMPANY_QUESTIONS]
+
+
+@pytest.fixture
+def company_attempt(company_questions, draft):
+ """A stored company_quiz_attempts document mid-flow, awaiting its follow-up."""
+ return {
+ "_id": "cquiz-1",
+ "user_id": "e1",
+ "draft": draft,
+ "questions": company_questions,
+ "answers": [
+ {"question_id": "cq1", "answer": "typed reply", "seconds_left": 6.0,
+ "flagged_paste": False, "paste_delta": 0},
+ {"question_id": "cq2", "answer": "x" * 400, "seconds_left": 70.0,
+ "flagged_paste": True, "paste_delta": 400},
+ ],
+ "followup": {
+ "id": "cf1",
+ "question": "You said they own ingest — who is on call for it today?",
+ "targets_question_id": "cq2",
+ "answer": None,
+ },
+ "status": "awaiting_followup",
+ }
diff --git a/backend/tests/test_company_quiz_api.py b/backend/tests/test_company_quiz_api.py
new file mode 100644
index 0000000..6437980
--- /dev/null
+++ b/backend/tests/test_company_quiz_api.py
@@ -0,0 +1,257 @@
+"""
+HTTP contract for the company-side quiz.
+
+Two things are being pinned here. First the flow, which mirrors the candidate
+quiz: generate -> submit -> followup, with /submit returning a follow-up rather
+than a score. Second the gate itself: these routes are employer-only, and there
+is no other way to create a job posting.
+"""
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.core.security import create_access_token
+from app.main import app
+from app.services import company_quiz_service
+
+EMPLOYER = {"Authorization": f"Bearer {create_access_token('test-employer', 'employer')}"}
+CANDIDATE = {"Authorization": f"Bearer {create_access_token('test-user', 'candidate')}"}
+
+client = TestClient(app, headers=EMPLOYER)
+anon = TestClient(app)
+
+DRAFT_BODY = {
+ "company_name": "Acme",
+ "role_title": "Backend Engineer",
+ "description": "Own the ingest pipeline end to end.",
+ "tech_stack": ["Python", "MongoDB"],
+}
+
+
+def generated(questions):
+ return {"quiz_id": "cquiz-1", "role_title": "Backend Engineer",
+ "questions": questions, "time_limit_seconds": 75}
+
+
+def followup_payload(targets="cq2"):
+ return {"quiz_id": "cquiz-1",
+ "followup": {"id": "cf1", "question": "Who is on call?", "targets_question_id": targets},
+ "time_limit_seconds": 75}
+
+
+# --- the gate --------------------------------------------------------------
+
+def test_there_is_no_ungated_way_to_create_a_job():
+ """
+ The whole feature is worthless if a plain POST /jobs/ still inserts a posting.
+ This asserts the route is gone, not merely unused by the frontend.
+ """
+ resp = client.post("/api/v1/jobs/", json=DRAFT_BODY)
+ assert resp.status_code in (404, 405)
+
+
+def test_listing_jobs_still_works():
+ """Reading postings is public; only creating one is gated."""
+ with pytest.MonkeyPatch.context() as mp:
+ from app.services import job_service
+ mp.setattr(job_service, "get_jobs", AsyncMock(return_value=[]))
+ assert anon.get("/api/v1/jobs/").status_code == 200
+
+
+# --- auth ------------------------------------------------------------------
+
+@pytest.mark.parametrize("path, body", [
+ ("generate", DRAFT_BODY),
+ ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}),
+ ("followup", {"quiz_id": "cquiz-1", "answer": "a"}),
+])
+def test_every_company_quiz_route_rejects_an_anonymous_caller(path, body):
+ assert anon.post(f"/api/v1/jobs/company-quiz/{path}", json=body).status_code == 401
+
+
+@pytest.mark.parametrize("path, body", [
+ ("generate", DRAFT_BODY),
+ ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}),
+ ("followup", {"quiz_id": "cquiz-1", "answer": "a"}),
+])
+def test_candidate_accounts_cannot_post_jobs(path, body):
+ """The role is read off the signed token, so this cannot be forged in a body."""
+ resp = TestClient(app, headers=CANDIDATE).post(f"/api/v1/jobs/company-quiz/{path}", json=body)
+ assert resp.status_code == 403
+
+
+def test_generate_is_refused_before_any_api_call_is_spent(monkeypatch):
+ spy = AsyncMock()
+ monkeypatch.setattr(company_quiz_service, "create_quiz", spy)
+ TestClient(app, headers=CANDIDATE).post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY)
+ spy.assert_not_called()
+
+
+# --- generate --------------------------------------------------------------
+
+def test_generate_returns_questions_and_the_time_limit(monkeypatch, company_questions):
+ monkeypatch.setattr(company_quiz_service, "create_quiz",
+ AsyncMock(return_value=generated(company_questions)))
+
+ resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY)
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["quiz_id"] == "cquiz-1"
+ assert body["time_limit_seconds"] == 75
+ assert [q["category"] for q in body["questions"]] == ["role", "stack", "team"]
+
+
+def test_generate_forwards_the_draft_and_the_token_identity(monkeypatch, company_questions):
+ spy = AsyncMock(return_value=generated(company_questions))
+ monkeypatch.setattr(company_quiz_service, "create_quiz", spy)
+
+ client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY)
+
+ draft, user_id = spy.call_args.args
+ assert draft["role_title"] == "Backend Engineer"
+ assert draft["tech_stack"] == ["Python", "MongoDB"]
+ assert user_id == "test-employer"
+
+
+def test_generate_requires_the_posting_fields():
+ resp = client.post("/api/v1/jobs/company-quiz/generate", json={"company_name": "Acme"})
+ assert resp.status_code == 422
+
+
+def test_generate_maps_an_unusable_posting_to_400(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "create_quiz",
+ AsyncMock(side_effect=ValueError("no_questions")))
+ resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY)
+ assert resp.status_code == 400
+
+
+# --- submit ----------------------------------------------------------------
+
+def test_submit_returns_a_followup_and_no_score(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "start_followup",
+ AsyncMock(return_value=followup_payload()))
+
+ resp = client.post("/api/v1/jobs/company-quiz/submit", json={
+ "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}],
+ })
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["followup"]["targets_question_id"] == "cq2"
+ assert "score" not in body
+ assert "job_id" not in body
+
+
+def test_submit_forwards_paste_flags_and_timing(monkeypatch):
+ """The anti-gaming signals are useless if they do not survive the wire."""
+ spy = AsyncMock(return_value=followup_payload("cq1"))
+ monkeypatch.setattr(company_quiz_service, "start_followup", spy)
+
+ client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": [
+ {"question_id": "cq1", "answer": "a", "seconds_left": 61.5,
+ "flagged_paste": True, "paste_delta": 220},
+ ]})
+
+ sent = spy.call_args.args[1][0]
+ assert sent["flagged_paste"] is True
+ assert sent["paste_delta"] == 220
+ assert sent["seconds_left"] == 61.5
+
+
+def test_submit_defaults_paste_flags_when_the_client_omits_them(monkeypatch):
+ spy = AsyncMock(return_value=followup_payload("cq1"))
+ monkeypatch.setattr(company_quiz_service, "start_followup", spy)
+
+ resp = client.post("/api/v1/jobs/company-quiz/submit", json={
+ "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}],
+ })
+
+ assert resp.status_code == 200
+ sent = spy.call_args.args[1][0]
+ assert sent["flagged_paste"] is False
+ assert sent["paste_delta"] == 0
+ assert sent["seconds_left"] is None
+
+
+def test_submit_unknown_quiz_is_404(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=LookupError()))
+ resp = client.post("/api/v1/jobs/company-quiz/submit", json={
+ "quiz_id": "nope", "answers": [{"question_id": "cq1", "answer": "a"}],
+ })
+ assert resp.status_code == 404
+
+
+def test_submit_on_a_graded_quiz_is_409(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "start_followup",
+ AsyncMock(side_effect=company_quiz_service.QuizClosed()))
+ resp = client.post("/api/v1/jobs/company-quiz/submit", json={
+ "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}],
+ })
+ assert resp.status_code == 409
+
+
+def test_submit_with_no_answers_is_400(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=ValueError()))
+ resp = client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": []})
+ assert resp.status_code == 400
+
+
+# --- followup / grading ----------------------------------------------------
+
+def test_a_pass_returns_the_posting_it_created(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={
+ "quiz_id": "cquiz-1", "score": 84.0, "pass_score": 70.0, "passed": True,
+ "job_id": "job-1", "feedback": [{"question": "q", "score": 9, "note": "specific"}],
+ }))
+
+ resp = client.post("/api/v1/jobs/company-quiz/followup", json={
+ "quiz_id": "cquiz-1", "answer": "our defence", "seconds_left": 12.0,
+ })
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["passed"] is True
+ assert body["job_id"] == "job-1"
+ assert body["score"] == 84.0
+ assert body["feedback"][0]["note"] == "specific"
+
+
+def test_a_fail_returns_no_posting(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={
+ "quiz_id": "cquiz-1", "score": 31.0, "pass_score": 70.0, "passed": False,
+ "job_id": None, "feedback": [],
+ }))
+
+ body = client.post("/api/v1/jobs/company-quiz/followup",
+ json={"quiz_id": "cquiz-1", "answer": "vague"}).json()
+
+ assert body["passed"] is False
+ assert body["job_id"] is None
+
+
+def test_followup_accepts_a_blank_answer(monkeypatch):
+ """A timed-out follow-up submits blank - that is a result, not an error."""
+ monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={
+ "quiz_id": "cquiz-1", "score": 0.0, "pass_score": 70.0, "passed": False,
+ "job_id": None, "feedback": [],
+ }))
+ resp = client.post("/api/v1/jobs/company-quiz/followup",
+ json={"quiz_id": "cquiz-1", "answer": ""})
+ assert resp.status_code == 200
+ assert resp.json()["score"] == 0.0
+
+
+def test_followup_unknown_quiz_is_404(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(side_effect=LookupError()))
+ resp = client.post("/api/v1/jobs/company-quiz/followup", json={"quiz_id": "nope", "answer": "a"})
+ assert resp.status_code == 404
+
+
+def test_grading_without_the_followup_round_is_409(monkeypatch):
+ monkeypatch.setattr(company_quiz_service, "grade_and_post",
+ AsyncMock(side_effect=company_quiz_service.QuizClosed()))
+ resp = client.post("/api/v1/jobs/company-quiz/followup",
+ json={"quiz_id": "cquiz-1", "answer": "a"})
+ assert resp.status_code == 409
diff --git a/backend/tests/test_company_quiz_service.py b/backend/tests/test_company_quiz_service.py
new file mode 100644
index 0000000..b2572c1
--- /dev/null
+++ b/backend/tests/test_company_quiz_service.py
@@ -0,0 +1,246 @@
+"""
+The company-side gate.
+
+Everything here is about one property: a job posting exists only as the output of
+a quiz its owner defended, and it is the posting that was defended. The scoring
+itself is the model's business; what these tests hold down is that no other path
+reaches the jobs collection.
+"""
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.services import company_quiz_service
+from app.services.company_quiz_service import PASS_SCORE, QuizClosed
+
+
+def patch_generation(monkeypatch, questions=None):
+ gen = AsyncMock(return_value=questions if questions is not None else [
+ {"question": "What does this hire do in week one?", "category": "role"},
+ ])
+ monkeypatch.setattr(company_quiz_service.gemini_client, "generate_company_quiz_questions", gen)
+ saved = AsyncMock()
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "save_attempt", saved)
+ return gen, saved
+
+
+def patch_grading(monkeypatch, attempt, score, breakdown=None):
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=attempt))
+ monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers",
+ AsyncMock(return_value={"overall_score": score,
+ "breakdown": breakdown or []}))
+ recorded = AsyncMock()
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", recorded)
+ posted = AsyncMock(return_value="job-1")
+ monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted)
+ return posted, recorded
+
+
+# --- generate --------------------------------------------------------------
+
+async def test_create_quiz_assigns_ids_and_stores_the_draft(monkeypatch, draft):
+ _, saved = patch_generation(monkeypatch)
+
+ out = await company_quiz_service.create_quiz(draft, "e1")
+
+ assert out["questions"][0]["id"], "questions must be given ids"
+ assert out["time_limit_seconds"] == 75
+ doc = saved.call_args.args[0]
+ assert doc["draft"] == draft
+ assert doc["user_id"] == "e1"
+ assert doc["status"] == "generated"
+
+
+async def test_create_quiz_stores_only_posting_fields(monkeypatch, draft):
+ """A caller cannot smuggle extra keys into the jobs document via the draft."""
+ _, saved = patch_generation(monkeypatch)
+
+ await company_quiz_service.create_quiz(
+ {**draft, "posted_by": "someone-else", "verified": True}, "e1"
+ )
+
+ assert set(saved.call_args.args[0]["draft"]) == set(company_quiz_service.DRAFT_FIELDS)
+
+
+async def test_create_quiz_rejects_a_posting_that_yields_no_questions(monkeypatch, draft):
+ patch_generation(monkeypatch, questions=[])
+ with pytest.raises(ValueError):
+ await company_quiz_service.create_quiz(draft, "e1")
+
+
+# --- follow-up round -------------------------------------------------------
+
+async def test_start_followup_targets_the_flagged_answer(monkeypatch, company_attempt):
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ gen = AsyncMock(return_value="Who is on call for ingest today?")
+ monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen)
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock())
+
+ out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1")
+
+ assert out["followup"]["targets_question_id"] == "cq2"
+ assert gen.call_args.args[1] == company_attempt["answers"][1]["answer"]
+
+
+async def test_followup_is_asked_in_the_company_framing(monkeypatch, company_attempt):
+ """Same generator as the candidate side - it must not address them as a developer."""
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ gen = AsyncMock(return_value="q?")
+ monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen)
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock())
+
+ await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1")
+
+ assert gen.call_args.kwargs["framing"] is company_quiz_service.gemini_client.COMPANY_FRAMING
+
+
+async def test_start_followup_does_not_grade_or_post(monkeypatch, company_attempt):
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question",
+ AsyncMock(return_value="q?"))
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock())
+ posted = AsyncMock()
+ monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted)
+ graded = AsyncMock()
+ monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", graded)
+
+ out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1")
+
+ posted.assert_not_called()
+ graded.assert_not_called()
+ assert "score" not in out
+
+
+async def test_start_followup_refuses_a_graded_attempt(monkeypatch, company_attempt):
+ """Re-opening a closed attempt would mean answering again after seeing the score."""
+ company_attempt["status"] = "graded"
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ with pytest.raises(QuizClosed):
+ await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1")
+
+
+# --- the gate --------------------------------------------------------------
+
+async def test_passing_publishes_the_posting_that_was_defended(monkeypatch, company_attempt, draft):
+ posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE + 12)
+
+ out = await company_quiz_service.grade_and_post("cquiz-1", "our defence", "e1", 8.0)
+
+ assert out["passed"] is True
+ assert out["job_id"] == "job-1"
+ assert out["pass_score"] == PASS_SCORE
+ sent = posted.call_args.args[0]
+ for field in draft:
+ assert sent[field] == draft[field], "the posting must be the stored draft"
+ assert sent["posted_by"] == "e1"
+ assert sent["company_quiz_id"] == "cquiz-1"
+ assert recorded.call_args.args[4] == "job-1"
+
+
+async def test_failing_publishes_nothing(monkeypatch, company_attempt):
+ posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE - 1)
+
+ out = await company_quiz_service.grade_and_post("cquiz-1", "vague defence", "e1")
+
+ assert out["passed"] is False
+ assert out["job_id"] is None
+ posted.assert_not_called()
+ assert recorded.call_args.args[3] is False
+
+
+async def test_exactly_the_pass_score_passes(monkeypatch, company_attempt):
+ """The bar is inclusive - asserted against the constant, not a literal."""
+ posted, _ = patch_grading(monkeypatch, company_attempt, PASS_SCORE)
+
+ out = await company_quiz_service.grade_and_post("cquiz-1", "defence", "e1")
+
+ assert out["passed"] is True
+ posted.assert_called_once()
+
+
+async def test_the_defence_and_the_posting_both_reach_the_grader(monkeypatch, company_attempt):
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ grade = AsyncMock(return_value={"overall_score": 81, "breakdown": []})
+ monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", grade)
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", AsyncMock())
+ monkeypatch.setattr(company_quiz_service.job_service, "post_job", AsyncMock(return_value="job-1"))
+
+ await company_quiz_service.grade_and_post("cquiz-1", "on-call is me", "e1", 9.0)
+
+ sent = grade.call_args.kwargs["followup"]
+ assert sent["answer"] == "on-call is me"
+ assert sent["targets_question_id"] == "cq2"
+ # The posting is graded alongside the answers, so a contradiction between the two
+ # is visible to the grader rather than invisible.
+ assert grade.call_args.args[0] == company_attempt["draft"]
+
+
+async def test_grading_requires_the_followup_round(monkeypatch, company_attempt):
+ """Calling the API directly must not let a company skip the interrogation."""
+ company_attempt["status"] = "generated"
+ company_attempt["followup"] = None
+ posted, _ = patch_grading(monkeypatch, company_attempt, 95)
+
+ with pytest.raises(QuizClosed):
+ await company_quiz_service.grade_and_post("cquiz-1", "", "e1")
+ posted.assert_not_called()
+
+
+async def test_a_graded_attempt_cannot_mint_a_second_posting(monkeypatch, company_attempt):
+ """A retried request returns what already happened rather than posting again."""
+ company_attempt.update({
+ "status": "graded",
+ "passed": True,
+ "job_id": "job-1",
+ "result": {"overall_score": 88.0, "breakdown": [{"question": "q", "score": 9, "note": "n"}]},
+ })
+ posted, recorded = patch_grading(monkeypatch, company_attempt, 100)
+
+ out = await company_quiz_service.grade_and_post("cquiz-1", "again", "e1")
+
+ assert out == {"quiz_id": "cquiz-1", "score": 88.0, "pass_score": PASS_SCORE,
+ "passed": True, "job_id": "job-1",
+ "feedback": [{"question": "q", "score": 9, "note": "n"}]}
+ posted.assert_not_called()
+ recorded.assert_not_called()
+
+
+async def test_a_failed_attempt_cannot_be_regraded_into_a_pass(monkeypatch, company_attempt):
+ company_attempt.update({"status": "graded", "passed": False, "job_id": None,
+ "result": {"overall_score": 31.0, "breakdown": []}})
+ posted, _ = patch_grading(monkeypatch, company_attempt, 99)
+
+ out = await company_quiz_service.grade_and_post("cquiz-1", "better defence", "e1")
+
+ assert out["passed"] is False and out["job_id"] is None
+ posted.assert_not_called()
+
+
+# --- ownership -------------------------------------------------------------
+
+async def test_another_account_cannot_grade_or_post(monkeypatch, company_attempt):
+ posted, _ = patch_grading(monkeypatch, company_attempt, 95)
+
+ with pytest.raises(LookupError):
+ await company_quiz_service.grade_and_post("cquiz-1", "defence", "another-employer")
+ posted.assert_not_called()
+
+
+async def test_a_missing_quiz_and_someone_elses_are_indistinguishable(monkeypatch, company_attempt):
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=None))
+ with pytest.raises(LookupError) as missing:
+ await company_quiz_service.grade_and_post("nope", "d", "e1")
+
+ monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt",
+ AsyncMock(return_value=company_attempt))
+ with pytest.raises(LookupError) as foreign:
+ await company_quiz_service.grade_and_post("cquiz-1", "d", "another-employer")
+
+ assert str(missing.value) == str(foreign.value)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 1cbc5e9..148d65d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -69,6 +69,44 @@ response carries `"Anonymous Candidate"` and a null email, so an unrevealed name
never reaches the browser at all. `features/profile/display.js` re-checks the flag
as a second lock.
+## Request flow example - posting a job
+
+A posting is the output of a defended quiz, not an input to one. There is no
+ungated create route: `job_service.post_job()` has exactly one caller.
+
+```
+POST /api/v1/jobs/company-quiz/generate (employer token required)
+ -> api/v1/endpoints/jobs.py::generate_company_quiz()
+ -> services/company_quiz_service.py::create_quiz()
+ -> integrations/gemini_client.py::generate_company_quiz_questions() (Gemini)
+ -> repositories/company_quiz_repository.py::save_attempt() (Mongo, holds the draft)
+
+POST /api/v1/jobs/company-quiz/submit
+ -> services/company_quiz_service.py::start_followup()
+ -> services/quiz_service.py::pick_suspect_answer() (same ranking as the repo quiz)
+ -> integrations/gemini_client.py::generate_followup_question(framing=COMPANY_FRAMING)
+
+POST /api/v1/jobs/company-quiz/followup
+ -> services/company_quiz_service.py::grade_and_post()
+ -> integrations/gemini_client.py::grade_company_answers() (Gemini)
+ -> services/job_service.py::post_job() (Mongo, ONLY on a pass,
+ from the stored draft)
+ -> repositories/company_quiz_repository.py::update_result()
+ <- CompanyQuizResultResponse {score, passed, job_id}
+```
+
+Two properties the layering exists to protect:
+
+* The posting that goes live is the draft the questions were generated from, so a
+ company cannot defend an honest draft and publish a different one.
+* `status: "graded"` is terminal and carries the job_id it produced, so a replayed
+ final call returns the stored outcome instead of minting a second posting.
+
+Company attempts live in their own collection rather than in `quiz_attempts` with a
+discriminator: `quiz_repository.has_graded_attempt_scoring_at_least()` backs the
+candidate reveal threshold and matches any graded attempt by user, so a shared
+collection would let an employer quiz count as candidate comprehension.
+
## Frontend structure
```
@@ -93,5 +131,5 @@ should ever import from a `features/` folder — dependencies point inward.
interview rounds elsewhere) replaces the body of that one function — it reads
from quiz + application outcomes and writes to a `scores` collection via a new
`score_repository.py`. Nothing else in the funnel changes when it lands.
-- `services/company_quiz_service.py` — same quiz engine, different prompt,
- gates job posting creation.
+- Company-side bug-hunt and community threads, per the README status list.
+ Nothing about them is designed yet.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index df66544..2dafe68 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,16 +1,20 @@
import { useState } from "react";
import QuizPage from "./features/quiz/QuizPage";
import ProfilePage from "./features/profile/ProfilePage";
+import PostJobPage from "./features/jobs/PostJobPage";
import LoginPage from "./features/auth/LoginPage";
import RegisterPage from "./features/auth/RegisterPage";
import { isLoggedIn, logout } from "./features/auth/api";
+import { getRole } from "./shared/api/token";
-// Swap for a router (react-router) once the jobs/community pages exist.
+// Swap for a router (react-router) once the community pages exist.
export default function App() {
// Seeded from storage so a reload does not log you out.
const [authed, setAuthed] = useState(isLoggedIn);
const [showRegister, setShowRegister] = useState(false);
- const [tab, setTab] = useState("quiz");
+ // Employers land on the posting flow; candidates on the repo quiz. Both are the
+ // thing that account type came here to do.
+ const [tab, setTab] = useState(() => (getRole() === "employer" ? "post" : "quiz"));
if (!authed) {
const Page = showRegister ? RegisterPage : LoginPage;
@@ -18,28 +22,39 @@ export default function App() {
);
}
const onUnauthorized = () => setAuthed(false);
+ // Hiding the tab is a convenience, not the gate: every company-quiz route is
+ // employer-only on the backend, checked against the signed token.
+ const tabs = [
+ ...(getRole() === "employer" ? [["post", "Post a Job"]] : []),
+ ["quiz", "Quiz"],
+ ["profile", "Profile"],
+ ];
+
return (
<>
-
-
+ {tabs.map(([id, label]) => (
+
+ ))}
- {tab === "quiz" ? (
-
- ) : (
- // Re-fetched on every visit, so a reveal earned in the quiz tab shows up
- // as soon as the candidate looks.
-
- )}
+
+ {tab === "post" && }
+ {tab === "quiz" && }
+ {/* Re-fetched on every visit, so a reveal earned in the quiz tab shows up as
+ soon as the candidate looks. */}
+ {tab === "profile" && }
>
);
}
diff --git a/frontend/src/features/jobs/PostJobPage.jsx b/frontend/src/features/jobs/PostJobPage.jsx
new file mode 100644
index 0000000..b827e87
--- /dev/null
+++ b/frontend/src/features/jobs/PostJobPage.jsx
@@ -0,0 +1,201 @@
+import { useEffect, useRef, useState } from "react";
+import JobDraftForm from "./components/JobDraftForm";
+import PostingResult from "./components/PostingResult";
+// The timed, paste-recording question card is shared with the candidate quiz on
+// purpose: both sides of the market must run under the same clock and the same
+// silent paste detection, and two copies would drift. If a third feature needs it,
+// move it to shared/components rather than copying it again.
+import QuestionCard from "../quiz/components/QuestionCard";
+import {
+ generateCompanyQuiz,
+ submitCompanyFollowUp,
+ submitCompanyQuiz,
+} from "./api";
+
+const EMPTY_DRAFT = { company_name: "", role_title: "", description: "", tech_stack: "" };
+
+export default function PostJobPage({ onUnauthorized }) {
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+ const [quiz, setQuiz] = useState(null);
+ const [answers, setAnswers] = useState({});
+ const [followup, setFollowup] = useState(null);
+ const [followupAnswer, setFollowupAnswer] = useState("");
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [expired, setExpired] = useState(() => new Set());
+
+ // Live countdown per question, kept in a ref so ticking never re-renders the page.
+ const timeLeft = useRef({});
+ // Paste signals per question. Also a ref — recording must stay invisible.
+ const inputSignal = useRef({});
+ // Guards against the auto-submit firing twice (expiry racing a manual click).
+ const sent = useRef({ answers: false, followup: false });
+
+ const limit = quiz?.time_limit_seconds ?? 75;
+
+ function markExpired(id) {
+ setExpired((prev) => (prev.has(id) ? prev : new Set(prev).add(id)));
+ }
+
+ function reset() {
+ setQuiz(null);
+ setAnswers({});
+ setFollowup(null);
+ setFollowupAnswer("");
+ setResult(null);
+ setError("");
+ setExpired(new Set());
+ timeLeft.current = {};
+ inputSignal.current = {};
+ sent.current = { answers: false, followup: false };
+ }
+
+ async function handleGenerate() {
+ setLoading(true);
+ reset();
+ try {
+ // The stack is typed as one line and split here; the backend stores the list
+ // it was given and asks which of those the hire actually touches.
+ const data = await generateCompanyQuiz({
+ company_name: draft.company_name.trim(),
+ role_title: draft.role_title.trim(),
+ description: draft.description.trim(),
+ tech_stack: draft.tech_stack
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ });
+ setQuiz(data);
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSubmit() {
+ if (sent.current.answers) return;
+ sent.current.answers = true;
+ setLoading(true);
+ setError("");
+ try {
+ // Every question is sent, answered or not — a blank answer is itself a result.
+ const payload = quiz.questions.map((q) => ({
+ question_id: q.id,
+ answer: answers[q.id] || "",
+ seconds_left: timeLeft.current[q.id] ?? null,
+ flagged_paste: inputSignal.current[q.id]?.flagged_paste ?? false,
+ paste_delta: inputSignal.current[q.id]?.paste_delta ?? 0,
+ }));
+ setFollowup(await submitCompanyQuiz(quiz.quiz_id, payload));
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ sent.current.answers = false;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleFollowUp() {
+ if (sent.current.followup) return;
+ sent.current.followup = true;
+ setLoading(true);
+ setError("");
+ try {
+ const id = followup.followup.id;
+ setResult(
+ await submitCompanyFollowUp(quiz.quiz_id, followupAnswer, timeLeft.current[id] ?? null)
+ );
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ sent.current.followup = false;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // When every question's clock has run out, submit whatever is there.
+ useEffect(() => {
+ if (quiz && !followup && !result && expired.size >= quiz.questions.length) {
+ handleSubmit();
+ }
+ }, [expired, quiz, followup, result]);
+
+ const followupExpired = followup && expired.has(followup.followup.id);
+ useEffect(() => {
+ if (followupExpired && !result) handleFollowUp();
+ }, [followupExpired, result]);
+
+ const drafting = !quiz && !result;
+
+ return (
+
+
Post a Job
+
+ Write the posting, then answer for it. Postings go live only if they describe a
+ role you can actually account for.
+
+
+ {drafting && (
+
+ )}
+
+ {error &&
{error}
}
+
+ {quiz && !followup && !result && (
+
+
+ {limit}s per question · answers lock when the timer runs out · your draft is
+ held until this is graded
+
+ );
+}
diff --git a/frontend/src/features/jobs/api.js b/frontend/src/features/jobs/api.js
index 4fca711..267beac 100644
--- a/frontend/src/features/jobs/api.js
+++ b/frontend/src/features/jobs/api.js
@@ -10,3 +10,33 @@ export function applyToJob(jobId, userId) {
body: JSON.stringify({ job_id: jobId, user_id: userId }),
});
}
+
+// --- company quiz ---------------------------------------------------------
+//
+// There is no createJob() here on purpose: the backend has no ungated route to
+// call. A posting is created by the backend when submitFollowUp comes back with
+// passed: true, from the draft sent to generateCompanyQuiz — which is why the
+// draft is not sent again at the end.
+
+export function generateCompanyQuiz(draft) {
+ return request("/jobs/company-quiz/generate", {
+ method: "POST",
+ body: JSON.stringify(draft),
+ });
+}
+
+// Records answers and opens the follow-up round — does not grade or post.
+export function submitCompanyQuiz(quizId, answers) {
+ return request("/jobs/company-quiz/submit", {
+ method: "POST",
+ body: JSON.stringify({ quiz_id: quizId, answers }),
+ });
+}
+
+// Final grading. Publishes the posting if it clears the bar.
+export function submitCompanyFollowUp(quizId, answer, secondsLeft) {
+ return request("/jobs/company-quiz/followup", {
+ method: "POST",
+ body: JSON.stringify({ quiz_id: quizId, answer, seconds_left: secondsLeft }),
+ });
+}
diff --git a/frontend/src/features/jobs/components/JobDraftForm.jsx b/frontend/src/features/jobs/components/JobDraftForm.jsx
new file mode 100644
index 0000000..f14258c
--- /dev/null
+++ b/frontend/src/features/jobs/components/JobDraftForm.jsx
@@ -0,0 +1,50 @@
+/**
+ * The draft posting.
+ *
+ * This is the last point at which the posting can be edited. Once the quiz is
+ * generated the backend holds this draft and publishes that copy on a pass, so
+ * the posting that goes live is the one the questions were written about.
+ */
+export default function JobDraftForm({ draft, onChange, onSubmit, loading }) {
+ const set = (field) => (e) => onChange({ ...draft, [field]: e.target.value });
+
+ const ready =
+ draft.company_name.trim() && draft.role_title.trim() && draft.description.trim();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/jobs/components/PostingResult.jsx b/frontend/src/features/jobs/components/PostingResult.jsx
new file mode 100644
index 0000000..8a1cbd8
--- /dev/null
+++ b/frontend/src/features/jobs/components/PostingResult.jsx
@@ -0,0 +1,44 @@
+/**
+ * The outcome of a company quiz.
+ *
+ * Same invariant as ScoreResult: the number is never shown alone. A posting that
+ * failed has to say what it failed on, because the point of the gate is to send
+ * the company back to rewrite the posting — not to score them and leave.
+ */
+export default function PostingResult({ result, onRetry }) {
+ const feedback = Array.isArray(result?.feedback) ? result.feedback : [];
+
+ return (
+
+ {result.passed
+ ? "Your posting is live. Candidates see it alongside the fact that you answered for it."
+ : `The bar is ${result.pass_score}/100. Nothing was published — rewrite the posting so it describes the role you just described, and run it again.`}
+
No per-question breakdown was returned for this attempt.
+ )}
+
+ {!result.passed && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/shared/api/token.js b/frontend/src/shared/api/token.js
index 13b2a1c..3fecdb7 100644
--- a/frontend/src/shared/api/token.js
+++ b/frontend/src/shared/api/token.js
@@ -32,18 +32,29 @@ export function clearToken() {
}
}
-export function getUserId() {
- // The `sub` claim, read straight off the token payload. Reading it here saves a
- // round trip for "whose profile am I looking at by default" — it is not a
- // security decision, and nothing here verifies the signature. Every claim that
- // matters is re-checked by the backend against the signed token.
+function claim(name) {
const token = getToken();
if (!token) return null;
try {
const payload = token.split(".")[1];
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
- return JSON.parse(json).sub ?? null;
+ return JSON.parse(json)[name] ?? null;
} catch {
- return null; // malformed token — treat it as no session rather than crashing
+ return null; // malformed token - treat it as no session rather than crashing
}
}
+
+export function getUserId() {
+ // The `sub` claim, read straight off the token payload. Reading it here saves a
+ // round trip for "whose profile am I looking at by default" — it is not a
+ // security decision, and nothing here verifies the signature. Every claim that
+ // matters is re-checked by the backend against the signed token.
+ return claim("sub");
+}
+
+export function getRole() {
+ // Decides which tabs to render, nothing more. The backend re-checks the role
+ // against the signed token on every employer-only route, so editing this in a
+ // console buys a form that the API refuses.
+ return claim("role");
+}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index 63eea7d..5e94dfc 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -114,3 +114,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
letter-spacing: 0.06em; color: var(--text-muted);
}
.reveal-state.revealed { color: var(--success); }
+
+/* Company-side posting flow */
+.draft { display: flex; flex-direction: column; gap: 14px; margin: 24px 0; }
+.draft label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--text-muted); }
+.draft .hint { font-size: 12px; color: var(--text-subtle); }
+.draft button { align-self: flex-start; }
+
+.result .verdict {
+ margin-left: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
+ border-radius: 999px; padding: 3px 10px; vertical-align: middle; font-weight: 600;
+}
+.result .verdict.pass { color: var(--success); border: 1px solid rgba(91, 214, 140, 0.4); }
+.result .verdict.fail { color: var(--danger); border: 1px solid rgba(255, 163, 163, 0.4); }
+.result button { margin-top: 10px; }
From bdc4bfea5ebc2ff5b8e8beef1c0d05d6187eab51 Mon Sep 17 00:00:00 2001
From: Krish
Date: Mon, 24 Aug 2026 18:24:54 +0530
Subject: [PATCH 2/4] feat: pick the role on radios, and show only that role's
flow
The registration form already sent a role and register_user already stored it;
what was wrong was the presentation and the nav around it.
The role picker was a dropdown. It decides which half of the product you land
in, so both options are now radios, visible without opening anything, each
saying what picking it means.
Navigation follows from it: an employer sees Post a Job and Profile, a candidate
sees Quiz and Profile. The repo quiz is a candidate instrument and its score
means nothing on the employer side, so offering it to an employer was offering
a number that could not be read. Hiding a tab remains a convenience rather than
the gate - every employer-only route re-checks the role against the signed
token, and a candidate token still gets a 403.
Adds the test that was missing between the two: registering as an employer must
put "employer" in the issued token, not only in the stored document. The
employer gate reads the token, so a role dropped between those two points would
mean signing up as an employer and landing in an account that cannot reach the
company quiz.
Verified against the running stack with an account registered through the form:
a defended posting scored 98 and published, a template posting answered in
boilerplate scored 0 and published nothing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01XDgAeL6TE9UCqkWUy6azim
---
backend/tests/test_auth_api.py | 17 +++++++++++-
frontend/src/App.jsx | 10 ++++---
frontend/src/features/auth/AuthForm.jsx | 36 ++++++++++++++++++++-----
frontend/src/styles/globals.css | 15 ++++++++---
4 files changed, 64 insertions(+), 14 deletions(-)
diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py
index b349e24..35f94d9 100644
--- a/backend/tests/test_auth_api.py
+++ b/backend/tests/test_auth_api.py
@@ -9,7 +9,7 @@
import pytest
from fastapi.testclient import TestClient
-from app.core.security import create_access_token, hash_password
+from app.core.security import create_access_token, decode_access_token, hash_password
from app.main import app
from app.services import auth_service, quiz_service
@@ -68,6 +68,21 @@ def test_register_starts_the_account_anonymous(no_such_user):
assert no_such_user.call_args.args[0]["revealed"] is False
+def test_the_issued_token_carries_the_role_that_was_chosen(no_such_user):
+ """
+ The registration form is where an employer account comes from, and the
+ employer-only routes read the role off the token rather than the body. If the
+ role were dropped between the two, signing up as an employer would produce an
+ account that cannot reach the company quiz.
+ """
+ body = client.post("/api/v1/auth/register",
+ json={"name": "Ada", "email": "e@f.com",
+ "password": "hunter2hunter2", "role": "employer"}).json()
+
+ assert decode_access_token(body["access_token"])["role"] == "employer"
+ assert no_such_user.call_args.args[0]["role"] == "employer"
+
+
def test_register_defaults_to_candidate(no_such_user):
client.post("/api/v1/auth/register",
json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"})
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2dafe68..f3660c6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -35,11 +35,15 @@ export default function App() {
const onUnauthorized = () => setAuthed(false);
- // Hiding the tab is a convenience, not the gate: every company-quiz route is
+ // One flow per account type: an employer answers for postings, a candidate
+ // answers for repos. Neither is offered the other's, because neither number
+ // means anything on the wrong side of the market.
+ //
+ // Hiding a tab is a convenience, not the gate: every company-quiz route is
// employer-only on the backend, checked against the signed token.
+ const employer = getRole() === "employer";
const tabs = [
- ...(getRole() === "employer" ? [["post", "Post a Job"]] : []),
- ["quiz", "Quiz"],
+ employer ? ["post", "Post a Job"] : ["quiz", "Quiz"],
["profile", "Profile"],
];
diff --git a/frontend/src/features/auth/AuthForm.jsx b/frontend/src/features/auth/AuthForm.jsx
index 573010f..ae00d02 100644
--- a/frontend/src/features/auth/AuthForm.jsx
+++ b/frontend/src/features/auth/AuthForm.jsx
@@ -9,6 +9,13 @@ import { useState } from "react";
* name field arrived and made it three things at once; naming it for the mode
* rather than for one of the fields keeps the next addition from repeating that.
*/
+// [value sent as `role`, label, what picking it means]. The values must match
+// schemas/auth.py's Role literal — anything else is a 422.
+const ROLES = [
+ ["candidate", "Candidate", "Take repo quizzes and build a profile."],
+ ["employer", "Employer", "Post roles — after answering for them."],
+];
+
export default function AuthForm({ title, submitLabel, onSubmit, signup, footer }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
@@ -74,14 +81,29 @@ export default function AuthForm({ title, submitLabel, onSubmit, signup, footer
/>
+ {/* Radios rather than a dropdown: this choice decides which half of the
+ product you land in, so both options should be visible without opening
+ anything. The value is still only a hint — every employer-only route
+ re-checks the role against the signed token. */}
{signup && (
-
+
)}
{error &&
{error}
}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index 5e94dfc..924d043 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -97,10 +97,19 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.auth { display: flex; flex-direction: column; gap: 14px; max-width: 360px; }
.auth h2 { margin: 0; }
.auth label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--text-muted); }
-.auth select {
- padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
- background: var(--navy-sunken); color: var(--text); font-size: 14px;
+.role-choice {
+ border: none; padding: 0; margin: 0;
+ display: flex; flex-direction: column; gap: 8px;
}
+.role-choice legend { padding: 0 0 6px 0; font-size: 14px; color: var(--text-muted); }
+.role-choice label {
+ flex-direction: row; align-items: flex-start; gap: 10px; cursor: pointer;
+ padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px;
+ background: var(--navy-sunken); color: var(--text);
+}
+.role-choice label.chosen { border-color: var(--accent); }
+.role-choice input { width: auto; margin: 2px 0 0 0; accent-color: var(--accent); }
+.role-choice span { display: flex; flex-direction: column; gap: 2px; }
.auth .switch { margin: 0; font-size: 14px; color: var(--text-muted); }
.auth .hint { font-size: 12px; color: var(--text-subtle); }
From a1efd74d4d915eb1e583841967e5b0109bab0f19 Mon Sep 17 00:00:00 2001
From: Krish
Date: Mon, 24 Aug 2026 19:56:41 +0530
Subject: [PATCH 3/4] feat: add the reputation score as a breakdown
Combines the two things this platform can actually observe about a candidate:
what they defended here, and what happened when they applied elsewhere.
overall = comprehension * 0.75 + min(rounds / 4, 1) * 100 * 0.25
Comprehension carries most of the weight because it is the thing we verify;
round history is corroboration from outside this system, which is worth
something precisely because we did not generate it. Rounds saturate at four, so
volume of applications cannot substitute for understanding - the raw count is
still reported, just not rewarded past the cap.
The components ship with the total rather than behind it, for the same reason
ScoreResult never renders a bare quiz score. One blended figure gets read as a
measure of engineering ability and it is not one: a 92 average across one quiz
and a 92 across six are different claims, and only quiz_count says so.
Ungraded attempts are excluded, matching the reveal threshold - an attempt
awaiting its follow-up has no score anyone has stood behind. `applied` is the
only application status that does not count as a round reached; `rejected` does,
which is the arguable one. Status is a single field that moves, so a rejection
after three rounds and a rejection at first screening are stored identically,
and refusing to count it would erase the former to avoid crediting the latter.
Deliberately NOT wired into meets_reveal_threshold(). The reveal is a one-way
latch on someone's identity, and moving it onto a formula whose weights are
still guesses would latch accounts open on a number nobody has calibrated. Both
docs say so.
The page is its own tab rather than part of the quiz result view: that view is
about one attempt on one repo, this is about a history.
Verified against live Mongo as well as the mocked unit tests, since the two new
queries only actually run there: a fresh account reads zeros, and an account
with two graded quizzes and one accepted application reads 80/2/1 for an overall
of 66 - with an ungraded 100 and a still-applied application correctly ignored.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01XDgAeL6TE9UCqkWUy6azim
---
README.md | 6 +-
backend/app/api/v1/endpoints/reputation.py | 24 +++
backend/app/api/v1/router.py | 3 +-
backend/app/repositories/job_repository.py | 13 ++
backend/app/repositories/quiz_repository.py | 25 ++++
backend/app/schemas/reputation.py | 18 +++
backend/app/services/reputation_service.py | 59 +++++++-
backend/tests/test_reputation_api.py | 52 +++++++
backend/tests/test_reputation_service.py | 141 ++++++++++++++++++
docs/ARCHITECTURE.md | 40 ++++-
frontend/src/App.jsx | 12 +-
frontend/src/features/profile/ProfilePage.jsx | 13 +-
.../features/reputation/ReputationPage.jsx | 88 +++++++++++
frontend/src/features/reputation/api.js | 7 +
frontend/src/styles/globals.css | 23 +++
15 files changed, 512 insertions(+), 12 deletions(-)
create mode 100644 backend/app/api/v1/endpoints/reputation.py
create mode 100644 backend/app/schemas/reputation.py
create mode 100644 backend/tests/test_reputation_api.py
create mode 100644 backend/tests/test_reputation_service.py
create mode 100644 frontend/src/features/reputation/ReputationPage.jsx
create mode 100644 frontend/src/features/reputation/api.js
diff --git a/README.md b/README.md
index a8fc327..26e9a28 100644
--- a/README.md
+++ b/README.md
@@ -144,11 +144,13 @@ publishing again.
## Status
**Built:** repo quiz end to end (generate → answer → grade), the company-side
-quiz gating job postings, anonymous-first candidate profiles, job
+quiz gating job postings, anonymous-first candidate profiles, the reputation
+score (quiz depth + round history, shown as a breakdown), job
listing/application CRUD.
**Designed, not yet built** (see `docs/ARCHITECTURE.md` for where these
-slot in): unified reputation score, bug-hunt mode, community threads.
+slot in): reputation feeding the reveal threshold, difficulty-calibrated
+scoring, bug-hunt mode, community threads.
## Running locally
diff --git a/backend/app/api/v1/endpoints/reputation.py b/backend/app/api/v1/endpoints/reputation.py
new file mode 100644
index 0000000..216074b
--- /dev/null
+++ b/backend/app/api/v1/endpoints/reputation.py
@@ -0,0 +1,24 @@
+"""
+Thin HTTP layer for the reputation score. No business logic here — only
+request/response translation and HTTP error mapping. Logic lives in
+app/services/reputation_service.py.
+
+Open for the same reason profiles are: the funnel exists so an employer can weigh
+a candidate before either side has committed to anything, and the payload carries
+no identity — only what the candidate has demonstrated.
+"""
+from fastapi import APIRouter, HTTPException
+
+from app.schemas.reputation import ReputationResponse
+from app.services import reputation_service
+
+router = APIRouter()
+
+
+@router.get("/{user_id}/reputation", response_model=ReputationResponse)
+async def get_reputation(user_id: str):
+ try:
+ breakdown = await reputation_service.compute_reputation(user_id)
+ except LookupError:
+ raise HTTPException(404, "Profile not found")
+ return {"user_id": user_id, **breakdown}
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 16b9ecf..4f81f8f 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -1,10 +1,11 @@
"""Aggregates every v1 route. main.py only ever imports this one router."""
from fastapi import APIRouter
-from app.api.v1.endpoints import auth, jobs, profile, quiz
+from app.api.v1.endpoints import auth, jobs, profile, quiz, reputation
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"])
+api_router.include_router(reputation.router, prefix="/users", tags=["reputation"])
diff --git a/backend/app/repositories/job_repository.py b/backend/app/repositories/job_repository.py
index 8b92c9a..69b5fef 100644
--- a/backend/app/repositories/job_repository.py
+++ b/backend/app/repositories/job_repository.py
@@ -21,3 +21,16 @@ async def get_job(job_id: str) -> Optional[dict]:
async def create_application(doc: dict) -> None:
await applications_collection.insert_one(doc)
+
+
+async def count_applications_with_status(user_id: str, statuses: tuple[str, ...]) -> int:
+ """
+ How many of this user's applications sit in one of `statuses`.
+
+ Which statuses mean anything is the service's call, not this file's — this
+ only counts. Counted server-side rather than by pulling the documents: the
+ caller wants the number, not the applications.
+ """
+ return await applications_collection.count_documents(
+ {"user_id": user_id, "status": {"$in": list(statuses)}}
+ )
diff --git a/backend/app/repositories/quiz_repository.py b/backend/app/repositories/quiz_repository.py
index 608b3e9..20e6384 100644
--- a/backend/app/repositories/quiz_repository.py
+++ b/backend/app/repositories/quiz_repository.py
@@ -48,3 +48,28 @@ async def has_graded_attempt_scoring_at_least(user_id: str, minimum: float) -> b
{"_id": 1},
)
return match is not None
+
+
+async def graded_scores_for_user(user_id: str, limit: int = 500) -> list[float]:
+ """
+ Every defended score this user holds, for the reputation average.
+
+ Only `status: "graded"` counts, for the same reason the reveal threshold only
+ counts graded attempts: an attempt still awaiting its follow-up has no score
+ the candidate has had to stand behind. Attempts whose stored result is missing
+ or non-numeric are skipped rather than coerced, so a malformed grade lowers the
+ quiz count instead of dragging the average toward zero.
+
+ Projected down to the one field the caller averages.
+ """
+ docs = await collection.find(
+ {"user_id": user_id, "status": "graded"},
+ {"result.overall_score": 1},
+ ).to_list(limit)
+
+ scores = []
+ for doc in docs:
+ raw = (doc.get("result") or {}).get("overall_score")
+ if isinstance(raw, (int, float)) and not isinstance(raw, bool):
+ scores.append(float(raw))
+ return scores
diff --git a/backend/app/schemas/reputation.py b/backend/app/schemas/reputation.py
new file mode 100644
index 0000000..0fb0d4a
--- /dev/null
+++ b/backend/app/schemas/reputation.py
@@ -0,0 +1,18 @@
+"""Request/response DTOs for the reputation endpoint — what crosses the wire."""
+from pydantic import BaseModel
+
+
+class ReputationResponse(BaseModel):
+ """
+ The unified score and the components it came from.
+
+ The components are part of the response, not an optional expansion of it: a
+ single number here would be read as a measure of engineering ability, and it
+ is not one. See services/reputation_service.compute_reputation.
+ """
+
+ user_id: str
+ overall: int
+ comprehension: int # mean of every defended quiz score, 0 with no quizzes
+ quiz_count: int # how many defended scores that mean is over
+ rounds_reached: int # applications that moved past the pile
diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py
index ee089fa..369c75e 100644
--- a/backend/app/services/reputation_service.py
+++ b/backend/app/services/reputation_service.py
@@ -18,7 +18,7 @@
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
+from app.repositories import job_repository, quiz_repository, user_repository
ANONYMOUS_NAME = "Anonymous Candidate"
@@ -78,3 +78,60 @@ async def get_public_profile(user_id: str) -> dict:
"role": user.get("role", "candidate"),
"revealed": True,
}
+
+
+# --- the reputation score --------------------------------------------------
+#
+# Two components, deliberately kept visible next to each other rather than
+# blended away. Comprehension is what the platform actually verifies, so it
+# carries most of the weight; rounds reached is corroboration from outside this
+# system, which is worth something precisely because we did not generate it.
+COMPREHENSION_WEIGHT = 0.75
+ROUNDS_WEIGHT = 0.25
+
+# Rounds saturate: getting through four of them says a great deal more than
+# getting through zero, and the fortieth says almost nothing the fourth did not.
+# Without a ceiling the score would reward volume of applications.
+ROUNDS_FOR_FULL_CREDIT = 4
+
+# An application that moved past the pile. `applied` is the only status that means
+# nothing happened; the rest all imply a human looked. `rejected` is counted on
+# purpose and is the arguable one - status is a single field that moves, so a
+# rejection after three rounds and a rejection at first screening are stored
+# identically, and refusing to count it would erase the former to avoid crediting
+# the latter. Separate per-round tracking is what actually fixes this.
+ROUND_STATUSES = ("reviewed", "rejected", "accepted")
+
+
+async def compute_reputation(user_id: str) -> dict:
+ """
+ The unified score, as a breakdown rather than a number.
+
+ Every caller gets the components alongside the total for the same reason
+ ScoreResult never renders a bare quiz score: one blended figure invites being
+ read as a measure of engineering ability, which it is not. A candidate with a
+ 92 average across one quiz and one with 92 across six are not the same
+ candidate, and the payload has to make that visible.
+
+ A user with no history is not an error - it is a new account, and it scores
+ zero across the board. Raises LookupError for an unknown user; the endpoint
+ turns that into a 404.
+ """
+ if not await user_repository.get_user_by_id(user_id):
+ raise LookupError("user_not_found")
+
+ scores = await quiz_repository.graded_scores_for_user(user_id)
+ comprehension = round(sum(scores) / len(scores)) if scores else 0
+
+ rounds_reached = await job_repository.count_applications_with_status(
+ user_id, ROUND_STATUSES
+ )
+ rounds_component = min(rounds_reached / ROUNDS_FOR_FULL_CREDIT, 1.0) * 100
+
+ return {
+ "overall": round(comprehension * COMPREHENSION_WEIGHT
+ + rounds_component * ROUNDS_WEIGHT),
+ "comprehension": comprehension,
+ "rounds_reached": rounds_reached,
+ "quiz_count": len(scores),
+ }
diff --git a/backend/tests/test_reputation_api.py b/backend/tests/test_reputation_api.py
new file mode 100644
index 0000000..979e82d
--- /dev/null
+++ b/backend/tests/test_reputation_api.py
@@ -0,0 +1,52 @@
+"""
+HTTP contract for GET /users/{user_id}/reputation.
+
+The response model is the thing being pinned: response_model strips undeclared
+fields, so a component that is not declared silently stops crossing the wire and
+the page renders a bare number instead of a breakdown.
+"""
+from unittest.mock import AsyncMock
+
+from fastapi.testclient import TestClient
+
+from app.main import app
+from app.services import reputation_service
+
+client = TestClient(app)
+
+BREAKDOWN = {"overall": 66, "comprehension": 80, "quiz_count": 2, "rounds_reached": 1}
+
+
+def test_the_breakdown_survives_the_response_model(monkeypatch):
+ monkeypatch.setattr(reputation_service, "compute_reputation",
+ AsyncMock(return_value=dict(BREAKDOWN)))
+
+ resp = client.get("/api/v1/users/u1/reputation")
+
+ assert resp.status_code == 200
+ assert resp.json() == {"user_id": "u1", **BREAKDOWN}
+
+
+def test_a_new_account_is_zeros_rather_than_a_404(monkeypatch):
+ monkeypatch.setattr(reputation_service, "compute_reputation", AsyncMock(return_value={
+ "overall": 0, "comprehension": 0, "quiz_count": 0, "rounds_reached": 0,
+ }))
+
+ resp = client.get("/api/v1/users/fresh/reputation")
+
+ assert resp.status_code == 200
+ assert resp.json()["overall"] == 0
+ assert resp.json()["quiz_count"] == 0
+
+
+def test_an_unknown_user_is_404(monkeypatch):
+ monkeypatch.setattr(reputation_service, "compute_reputation",
+ AsyncMock(side_effect=LookupError()))
+ assert client.get("/api/v1/users/nobody/reputation").status_code == 404
+
+
+def test_reading_a_reputation_needs_no_token(monkeypatch):
+ """Open like profiles: the payload carries no identity, only what was earned."""
+ monkeypatch.setattr(reputation_service, "compute_reputation",
+ AsyncMock(return_value=dict(BREAKDOWN)))
+ assert TestClient(app).get("/api/v1/users/u1/reputation").status_code == 200
diff --git a/backend/tests/test_reputation_service.py b/backend/tests/test_reputation_service.py
new file mode 100644
index 0000000..298b65e
--- /dev/null
+++ b/backend/tests/test_reputation_service.py
@@ -0,0 +1,141 @@
+"""
+The unified reputation score.
+
+The property under test is not the arithmetic - it is that the components stay
+visible and that an account with no history is an ordinary answer rather than an
+error. A single blended number would be read as a measure of engineering ability,
+which it is not.
+"""
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.services import reputation_service
+from app.services.reputation_service import (
+ COMPREHENSION_WEIGHT,
+ ROUNDS_FOR_FULL_CREDIT,
+ ROUNDS_WEIGHT,
+ ROUND_STATUSES,
+)
+
+
+def patch_history(monkeypatch, scores, rounds=0, user={"_id": "u1"}):
+ monkeypatch.setattr(reputation_service.user_repository, "get_user_by_id",
+ AsyncMock(return_value=user))
+ monkeypatch.setattr(reputation_service.quiz_repository, "graded_scores_for_user",
+ AsyncMock(return_value=scores))
+ counted = AsyncMock(return_value=rounds)
+ monkeypatch.setattr(reputation_service.job_repository,
+ "count_applications_with_status", counted)
+ return counted
+
+
+# --- the two scenarios that have to hold ------------------------------------
+
+async def test_two_graded_quizzes_and_an_accepted_application(monkeypatch):
+ """The worked example: the breakdown has to explain the total."""
+ patch_history(monkeypatch, scores=[88.0, 72.0], rounds=1)
+
+ out = await reputation_service.compute_reputation("u1")
+
+ assert out["comprehension"] == 80, "mean of 88 and 72"
+ assert out["quiz_count"] == 2
+ assert out["rounds_reached"] == 1
+ # 80 * 0.75 + (1/4 * 100) * 0.25 = 60 + 6.25
+ assert out["overall"] == 66
+ assert set(out) == {"overall", "comprehension", "quiz_count", "rounds_reached"}
+
+
+async def test_a_brand_new_user_scores_zero_without_erroring(monkeypatch):
+ patch_history(monkeypatch, scores=[], rounds=0)
+
+ out = await reputation_service.compute_reputation("u1")
+
+ assert out == {"overall": 0, "comprehension": 0, "quiz_count": 0, "rounds_reached": 0}
+
+
+# --- components -------------------------------------------------------------
+
+async def test_the_average_is_over_defended_scores_only(monkeypatch):
+ """One 100 does not make a reputation; the count is what shows that."""
+ patch_history(monkeypatch, scores=[100.0], rounds=0)
+ one = await reputation_service.compute_reputation("u1")
+
+ patch_history(monkeypatch, scores=[100.0] * 6, rounds=0)
+ six = await reputation_service.compute_reputation("u1")
+
+ assert one["comprehension"] == six["comprehension"] == 100
+ assert one["overall"] == six["overall"], "the average alone cannot separate them"
+ assert (one["quiz_count"], six["quiz_count"]) == (1, 6), "the count must"
+
+
+async def test_rounds_alone_produce_a_score(monkeypatch):
+ """Outside corroboration counts even with no quiz taken yet."""
+ patch_history(monkeypatch, scores=[], rounds=ROUNDS_FOR_FULL_CREDIT)
+
+ out = await reputation_service.compute_reputation("u1")
+
+ assert out["comprehension"] == 0
+ assert out["overall"] == round(100 * ROUNDS_WEIGHT)
+
+
+async def test_rounds_saturate_so_volume_is_not_rewarded(monkeypatch):
+ patch_history(monkeypatch, scores=[80.0], rounds=ROUNDS_FOR_FULL_CREDIT)
+ at_ceiling = await reputation_service.compute_reputation("u1")
+
+ patch_history(monkeypatch, scores=[80.0], rounds=ROUNDS_FOR_FULL_CREDIT * 10)
+ far_past = await reputation_service.compute_reputation("u1")
+
+ assert at_ceiling["overall"] == far_past["overall"]
+ assert far_past["rounds_reached"] == ROUNDS_FOR_FULL_CREDIT * 10, "still reported"
+
+
+async def test_a_perfect_history_is_a_hundred(monkeypatch):
+ patch_history(monkeypatch, scores=[100.0], rounds=ROUNDS_FOR_FULL_CREDIT)
+ assert (await reputation_service.compute_reputation("u1"))["overall"] == 100
+
+
+async def test_comprehension_carries_most_of_the_weight(monkeypatch):
+ """The platform verifies comprehension; rounds are corroboration, not the point."""
+ assert COMPREHENSION_WEIGHT > ROUNDS_WEIGHT
+ assert COMPREHENSION_WEIGHT + ROUNDS_WEIGHT == 1.0
+
+ patch_history(monkeypatch, scores=[100.0], rounds=0)
+ quizzes_only = await reputation_service.compute_reputation("u1")
+ patch_history(monkeypatch, scores=[], rounds=99)
+ rounds_only = await reputation_service.compute_reputation("u1")
+
+ assert quizzes_only["overall"] > rounds_only["overall"]
+
+
+# --- which applications count -----------------------------------------------
+
+async def test_applied_is_the_only_status_that_does_not_count(monkeypatch):
+ """Submitting an application is not an achievement; being looked at is."""
+ counted = patch_history(monkeypatch, scores=[], rounds=0)
+ await reputation_service.compute_reputation("u1")
+
+ user_id, statuses = counted.call_args.args
+ assert user_id == "u1"
+ assert "applied" not in statuses
+ assert set(statuses) == {"reviewed", "rejected", "accepted"} == set(ROUND_STATUSES)
+
+
+# --- unknown user -----------------------------------------------------------
+
+async def test_an_unknown_user_raises_rather_than_scoring_zero(monkeypatch):
+ """Zeros mean a new account. A missing account has to be distinguishable."""
+ patch_history(monkeypatch, scores=[], rounds=0, user=None)
+ with pytest.raises(LookupError):
+ await reputation_service.compute_reputation("nobody")
+
+
+async def test_an_unknown_user_costs_no_further_queries(monkeypatch):
+ monkeypatch.setattr(reputation_service.user_repository, "get_user_by_id",
+ AsyncMock(return_value=None))
+ scores = AsyncMock()
+ monkeypatch.setattr(reputation_service.quiz_repository, "graded_scores_for_user", scores)
+
+ with pytest.raises(LookupError):
+ await reputation_service.compute_reputation("nobody")
+ scores.assert_not_called()
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 148d65d..128bda2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -107,6 +107,29 @@ discriminator: `quiz_repository.has_graded_attempt_scoring_at_least()` backs the
candidate reveal threshold and matches any graded attempt by user, so a shared
collection would let an employer quiz count as candidate comprehension.
+## Request flow example - the reputation score
+
+```
+GET /api/v1/users/{user_id}/reputation
+ -> api/v1/endpoints/reputation.py::get_reputation()
+ -> services/reputation_service.py::compute_reputation()
+ -> repositories/user_repository.py::get_user_by_id() (Mongo, 404s if absent)
+ -> repositories/quiz_repository.py::graded_scores_for_user() (Mongo)
+ -> repositories/job_repository.py (Mongo)
+ ::count_applications_with_status()
+ <- ReputationResponse {overall, comprehension, quiz_count, rounds_reached}
+ <- 200 JSON
+```
+
+The components are part of the response rather than an expansion of it. One
+blended figure gets read as a measure of engineering ability, and it is not one:
+a 92 average across one quiz and a 92 across six are different claims, and only
+`quiz_count` says so. `features/reputation/ReputationPage.jsx` renders them in a
+single return for the same reason `ScoreResult` does.
+
+Which statuses count as a round reached is the service's decision, not the
+repository's — `count_applications_with_status()` only counts what it is handed.
+
## Frontend structure
```
@@ -125,11 +148,16 @@ should ever import from a `features/` folder — dependencies point inward.
## What's next architecturally (not yet built)
-- The real reputation score. `services/reputation_service.py` exists and owns the
- anonymous-first funnel, but `meets_reveal_threshold()` is a placeholder: one
- graded quiz at `REVEAL_MIN_SCORE` or better. The unified score (quiz depth +
- interview rounds elsewhere) replaces the body of that one function — it reads
- from quiz + application outcomes and writes to a `scores` collection via a new
- `score_repository.py`. Nothing else in the funnel changes when it lands.
+- Wiring the reputation score into the reveal. `compute_reputation()` now exists
+ and combines quiz depth with round history, but `meets_reveal_threshold()` is
+ still its own placeholder: one graded quiz at `REVEAL_MIN_SCORE` or better. The
+ two are deliberately not connected yet — the reveal is a one-way latch on a
+ candidate's identity, and moving it onto a score whose weights are still
+ guesses would latch accounts open on a formula nobody has calibrated. When it
+ lands it replaces the body of that one function; nothing else in the funnel
+ changes.
+- Difficulty-calibrated scoring, per the README. `comprehension` is a flat mean
+ of defended scores, so six trivial repos average the same as six hard ones.
+ That needs answer data across many candidates before it can be calibrated.
- Company-side bug-hunt and community threads, per the README status list.
Nothing about them is designed yet.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f3660c6..db741ba 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import QuizPage from "./features/quiz/QuizPage";
import ProfilePage from "./features/profile/ProfilePage";
+import ReputationPage from "./features/reputation/ReputationPage";
import PostJobPage from "./features/jobs/PostJobPage";
import LoginPage from "./features/auth/LoginPage";
import RegisterPage from "./features/auth/RegisterPage";
@@ -45,6 +46,9 @@ export default function App() {
const tabs = [
employer ? ["post", "Post a Job"] : ["quiz", "Quiz"],
["profile", "Profile"],
+ // Reputation is quiz depth plus what happened when you applied, so it is a
+ // candidate instrument; an employer looking at their own would read zeros.
+ ...(employer ? [] : [["reputation", "Reputation"]]),
];
return (
@@ -74,7 +78,13 @@ export default function App() {
{tab === "quiz" && }
{/* Re-fetched on every visit, so a reveal earned in the quiz tab shows up as
soon as the candidate looks. */}
- {tab === "profile" && }
+ {tab === "profile" && (
+ setTab("reputation")}
+ />
+ )}
+ {tab === "reputation" && }
>
);
}
diff --git a/frontend/src/features/profile/ProfilePage.jsx b/frontend/src/features/profile/ProfilePage.jsx
index c6ca76d..9097793 100644
--- a/frontend/src/features/profile/ProfilePage.jsx
+++ b/frontend/src/features/profile/ProfilePage.jsx
@@ -7,7 +7,7 @@ import { getUserId } from "../../shared/api/token";
// clears the reveal threshold, at which point the backend latches them open and
// this page starts showing who they are. The reveal is evaluated server-side on
// every read, so simply revisiting this page after passing is enough.
-export default function ProfilePage({ userId, onUnauthorized }) {
+export default function ProfilePage({ userId, onUnauthorized, onViewReputation }) {
const subject = userId ?? getUserId();
const [profile, setProfile] = useState(null);
const [error, setError] = useState("");
@@ -64,6 +64,17 @@ export default function ProfilePage({ userId, onUnauthorized }) {
>
)}
+
+ {/* The reveal is one thing this profile says about a candidate; the
+ reputation breakdown is the rest of it, and it lives on its own page
+ rather than being summarised into a number here. */}
+ {onViewReputation && (
+
+
+
+ )}
);
diff --git a/frontend/src/features/reputation/ReputationPage.jsx b/frontend/src/features/reputation/ReputationPage.jsx
new file mode 100644
index 0000000..130b273
--- /dev/null
+++ b/frontend/src/features/reputation/ReputationPage.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useState } from "react";
+import { getReputation } from "./api";
+import { getUserId } from "../../shared/api/token";
+
+/**
+ * The reputation breakdown, as its own page.
+ *
+ * Invariant, same as ScoreResult and PostingResult: the components render in this
+ * component's single return, so the overall figure cannot appear anywhere without
+ * the three numbers it was computed from. A blended score on its own gets read as
+ * a measure of engineering ability, and an average of 92 across one quiz is not
+ * the same claim as 92 across six — the quiz count is what says so.
+ *
+ * Deliberately not folded into the quiz result view: that view is about one
+ * attempt on one repo, this is about a history.
+ */
+
+// [key, label, how to read it]. Sentence-case labels, no trailing colons.
+const COMPONENTS = [
+ ["comprehension", "Comprehension", "Mean of every defended quiz score."],
+ ["quiz_count", "Quizzes defended", "Attempts that survived a follow-up round."],
+ ["rounds_reached", "Rounds reached", "Applications that moved past the pile."],
+];
+
+export default function ReputationPage({ userId, onUnauthorized }) {
+ const subject = userId ?? getUserId();
+ const [score, setScore] = useState(null);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let live = true;
+ if (!subject) {
+ setError("No reputation to show.");
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ getReputation(subject)
+ .then((data) => live && setScore(data))
+ .catch((e) => {
+ if (!live) return;
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ })
+ .finally(() => live && setLoading(false));
+ // Ignore a response that arrives after the subject changed or the page left.
+ return () => {
+ live = false;
+ };
+ }, [subject]);
+
+ if (loading) return
+ What you have demonstrated here, and what happened when you applied.
+
+
+
+
Overall
+
{score.overall}
+
out of 100
+
+
+
+ {COMPONENTS.map(([key, label, hint]) => (
+
+
{label}
+
{score[key]}
+
{hint}
+
+ ))}
+
+
+
+ {fresh
+ ? "Nothing here yet — this is what a new account looks like, not a failing one. Defend a repo quiz and the first number moves."
+ : "Comprehension is three quarters of the overall figure and rounds reached is the rest, capped so that volume of applications cannot substitute for understanding."}
+
+
+ );
+}
diff --git a/frontend/src/features/reputation/api.js b/frontend/src/features/reputation/api.js
new file mode 100644
index 0000000..7b471a3
--- /dev/null
+++ b/frontend/src/features/reputation/api.js
@@ -0,0 +1,7 @@
+import request from "../../shared/api/client";
+
+// Open on the backend, like the profile read: the payload carries no identity,
+// only what the candidate has demonstrated.
+export function getReputation(userId) {
+ return request(`/users/${encodeURIComponent(userId)}/reputation`);
+}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index 924d043..789a434 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -137,3 +137,26 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.result .verdict.pass { color: var(--success); border: 1px solid rgba(91, 214, 140, 0.4); }
.result .verdict.fail { color: var(--danger); border: 1px solid rgba(255, 163, 163, 0.4); }
.result button { margin-top: 10px; }
+
+/* Reputation breakdown. One hero figure per view; the components sit beside it
+ rather than under a disclosure, so the total never reads alone. Values wear
+ text tokens — no series colour, since none of these is a category. */
+.hero { margin: 28px 0 8px 0; }
+.hero-label, .stat-label {
+ margin: 0; font-size: 11px; text-transform: uppercase;
+ letter-spacing: 0.06em; color: var(--text-muted);
+}
+/* Proportional figures on purpose: tabular-nums gives every digit the width of a
+ zero, which reads loose at display sizes. Tabular is for columns. */
+.hero-value { margin: 6px 0 0 0; font-size: 56px; font-weight: 600; line-height: 1; }
+.hero-of { margin: 4px 0 0 0; font-size: 13px; color: var(--text-subtle); }
+
+.stat-row {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px; margin: 24px 0;
+}
+.stat { padding: 16px; background: var(--surface); border-radius: 10px; }
+.stat-value { margin: 8px 0 0 0; font-size: 32px; font-weight: 600; line-height: 1; }
+.stat-hint { margin: 8px 0 0 0; font-size: 12px; color: var(--text-subtle); }
+
+.profile-card .linkish { font-size: 14px; }
From 89e62a37038097bb187a27d11c8a7b783ad346e5 Mon Sep 17 00:00:00 2001
From: Krish
Date: Mon, 24 Aug 2026 22:24:57 +0530
Subject: [PATCH 4/4] feat: add connections and a text-only community feed
Two collections, two repositories, one service. Connections are instant and
mutual; posts are created and listed. That is the whole feature.
A connection is stored once per pair rather than twice - a sorted two-element
`users` array, so a lookup finds it from either side and the same two people can
only ever produce one document. Two directed rows would mean every read unions
two queries and every write has to keep them in step. Connecting again is a
no-op that reports created: false; with no approval flow there is no state for a
repeat to advance, so erroring would only punish a double-click.
Post authors come from the access token, never from the body, the same rule the
quiz uses. Text is trimmed and capped, and a job reference is checked to exist
before the post is written.
The one part that is not lightweight is identity. Both surfaces list other
people, which makes them the two places the anonymous funnel could quietly leak.
Every name leaving the service goes through one `_display` helper that reads the
latched `revealed` flag and drops the name when it is False, so an unrevealed
candidate is a pseudonym in a feed and in a connections list and the real name is
never in the payload at all. That needed a batch lookup on user_repository - a
page of posts resolving one author per row is how a list view starts costing
twenty round trips.
This module deliberately does not evaluate the reveal threshold. That belongs to
the profile read, where flipping the latch is documented behaviour. A candidate
who has just earned their reveal therefore shows here as themselves only after
their profile has been read, which is the safe direction to be wrong in.
Out of scope on purpose, each one a schema change rather than a flag so the feed
cannot drift into a social network by default: DMs, threaded replies and
comments, likes and reactions, approval-required connections, media. Recorded in
the README and in models/community.py; a test asserts no route mentions any of
them.
Verified against live Mongo as well as the mocked tests: a mutual connection
reads as one from both sides, a repeat reports created: false, and both posts in
the feed render as Anonymous Candidate.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01XDgAeL6TE9UCqkWUy6azim
---
README.md | 23 +-
backend/app/api/v1/endpoints/connections.py | 37 +++
backend/app/api/v1/endpoints/posts.py | 36 +++
backend/app/api/v1/router.py | 13 +-
backend/app/models/community.py | 32 +++
.../app/repositories/connection_repository.py | 38 +++
backend/app/repositories/post_repository.py | 31 +++
backend/app/repositories/user_repository.py | 18 ++
backend/app/schemas/community.py | 64 +++++
backend/app/services/community_service.py | 160 ++++++++++++
backend/tests/test_community_api.py | 169 +++++++++++++
backend/tests/test_community_service.py | 231 ++++++++++++++++++
frontend/src/App.jsx | 4 +
.../src/features/community/CommunityPage.jsx | 131 ++++++++++
frontend/src/features/community/api.js | 28 +++
frontend/src/features/profile/ProfilePage.jsx | 35 +++
frontend/src/styles/globals.css | 4 +
17 files changed, 1050 insertions(+), 4 deletions(-)
create mode 100644 backend/app/api/v1/endpoints/connections.py
create mode 100644 backend/app/api/v1/endpoints/posts.py
create mode 100644 backend/app/models/community.py
create mode 100644 backend/app/repositories/connection_repository.py
create mode 100644 backend/app/repositories/post_repository.py
create mode 100644 backend/app/schemas/community.py
create mode 100644 backend/app/services/community_service.py
create mode 100644 backend/tests/test_community_api.py
create mode 100644 backend/tests/test_community_service.py
create mode 100644 frontend/src/features/community/CommunityPage.jsx
create mode 100644 frontend/src/features/community/api.js
diff --git a/README.md b/README.md
index 26e9a28..133cde9 100644
--- a/README.md
+++ b/README.md
@@ -145,12 +145,29 @@ publishing again.
**Built:** repo quiz end to end (generate → answer → grade), the company-side
quiz gating job postings, anonymous-first candidate profiles, the reputation
-score (quiz depth + round history, shown as a breakdown), job
-listing/application CRUD.
+score (quiz depth + round history, shown as a breakdown), connections and a
+text-only community feed, job listing/application CRUD.
**Designed, not yet built** (see `docs/ARCHITECTURE.md` for where these
slot in): reputation feeding the reveal threshold, difficulty-calibrated
-scoring, bug-hunt mode, community threads.
+scoring, bug-hunt mode.
+
+### Community: what is deliberately absent
+
+Connections are instant and mutual — one document per pair, no request, no
+approval, no pending state — and a post is text that gets created and listed.
+Left out on purpose, each one a schema change rather than a flag so the feed
+cannot drift into a social network by default:
+
+- direct messages
+- threaded replies and comments
+- likes, reactions, any engagement counter
+- approval-required connections (requests, accept/decline, blocking)
+- media in posts
+
+An unrevealed candidate is a pseudonym in the feed and in a connections list for
+exactly as long as they are one on their profile: names are resolved at read time
+from `users`, and an unrevealed one is never in the payload at all.
## Running locally
diff --git a/backend/app/api/v1/endpoints/connections.py b/backend/app/api/v1/endpoints/connections.py
new file mode 100644
index 0000000..c0420a6
--- /dev/null
+++ b/backend/app/api/v1/endpoints/connections.py
@@ -0,0 +1,37 @@
+"""
+Thin HTTP layer for connections. No business logic here — only request/response
+translation and HTTP error mapping. Logic lives in
+app/services/community_service.py.
+
+Instant and mutual: there is no request to send, accept, or decline, so there is
+no pending state and no endpoint to advance one.
+"""
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.core.dependencies import get_current_user
+from app.schemas.community import ConnectionsResponse, ConnectResponse
+from app.services import community_service
+
+router = APIRouter()
+
+
+@router.post("/{user_id}/connect", response_model=ConnectResponse)
+async def connect(user_id: str, user: dict = Depends(get_current_user)):
+ """
+ Connect the caller to `user_id`. Idempotent — a repeat returns the existing
+ connection with `created: false` rather than failing.
+ """
+ try:
+ return await community_service.connect(user["user_id"], user_id)
+ except LookupError:
+ raise HTTPException(404, "Profile not found")
+ except ValueError:
+ raise HTTPException(400, "You cannot connect to yourself.")
+
+
+@router.get("/{user_id}/connections", response_model=ConnectionsResponse)
+async def list_connections(user_id: str):
+ try:
+ return await community_service.list_connections(user_id)
+ except LookupError:
+ raise HTTPException(404, "Profile not found")
diff --git a/backend/app/api/v1/endpoints/posts.py b/backend/app/api/v1/endpoints/posts.py
new file mode 100644
index 0000000..b7c97f6
--- /dev/null
+++ b/backend/app/api/v1/endpoints/posts.py
@@ -0,0 +1,36 @@
+"""
+Thin HTTP layer for the community feed. No business logic here — only
+request/response translation and HTTP error mapping. Logic lives in
+app/services/community_service.py.
+
+Create and list, text only. No comments, likes, reactions, or media: the feed is
+a place to say something, not a social network.
+"""
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from app.core.dependencies import get_current_user
+from app.schemas.community import PostCreateRequest, PostListResponse, PostResponse
+from app.services import community_service
+
+router = APIRouter()
+
+
+@router.post("/", response_model=PostResponse, status_code=201)
+async def create_post(req: PostCreateRequest, user: dict = Depends(get_current_user)):
+ try:
+ return await community_service.create_post(
+ user["user_id"], req.text, req.job_id, req.company_name
+ )
+ except LookupError:
+ raise HTTPException(404, "That job posting does not exist.")
+ except ValueError:
+ raise HTTPException(400, "A post needs text.")
+
+
+@router.get("/", response_model=PostListResponse)
+async def list_posts(
+ limit: int = Query(community_service.DEFAULT_PAGE, ge=1, le=community_service.MAX_PAGE),
+ skip: int = Query(0, ge=0),
+):
+ """Most recent first. Open to read, like the profile and reputation views."""
+ return await community_service.list_posts(limit=limit, skip=skip)
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 4f81f8f..5695223 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -1,7 +1,15 @@
"""Aggregates every v1 route. main.py only ever imports this one router."""
from fastapi import APIRouter
-from app.api.v1.endpoints import auth, jobs, profile, quiz, reputation
+from app.api.v1.endpoints import (
+ auth,
+ connections,
+ jobs,
+ posts,
+ profile,
+ quiz,
+ reputation,
+)
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -9,3 +17,6 @@
api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
api_router.include_router(profile.router, prefix="/profile", tags=["profile"])
api_router.include_router(reputation.router, prefix="/users", tags=["reputation"])
+# Shares the /users prefix with reputation: both hang off a person.
+api_router.include_router(connections.router, prefix="/users", tags=["community"])
+api_router.include_router(posts.router, prefix="/posts", tags=["community"])
diff --git a/backend/app/models/community.py b/backend/app/models/community.py
new file mode 100644
index 0000000..fae9464
--- /dev/null
+++ b/backend/app/models/community.py
@@ -0,0 +1,32 @@
+"""
+connections collection:
+{
+ "_id": str (uuid4),
+ "users": [str, str], # the pair, sorted — see connection_repository.pair()
+ "created_at": datetime (UTC),
+}
+
+One document per connection, not two. Connections are instant and mutual, so
+there is no direction to record and no status field: a document existing IS the
+connection. There is deliberately no `pending`, `requested_by`, or `accepted_at`
+— an approval flow is roadmap, not scope.
+
+posts collection:
+{
+ "_id": str (uuid4),
+ "author_id": str, # from the access token, never from a request body
+ "text": str, # trimmed, 1..MAX_POST_LENGTH
+ "job_id": str | None, # optional reference to a jobs document
+ "company_name": str | None,
+ "created_at": datetime (UTC),
+}
+
+Append-only and text-only. No comment, like, reaction, or attachment storage
+exists here, and adding any of them is a schema change rather than a flag —
+which is the point: the feed cannot quietly grow into a social network.
+
+Neither collection stores a display name. Names live in `users` and are resolved
+at read time through community_service._display, so an unrevealed candidate is a
+pseudonym in a feed and a connections list for exactly as long as they are one on
+their profile.
+"""
diff --git a/backend/app/repositories/connection_repository.py b/backend/app/repositories/connection_repository.py
new file mode 100644
index 0000000..42082a5
--- /dev/null
+++ b/backend/app/repositories/connection_repository.py
@@ -0,0 +1,38 @@
+"""
+Only file allowed to query the connections collection directly.
+
+A connection is stored once, not twice. The pair is held in a sorted two-element
+array, so `{"users": someone}` finds it from either side and the same two people
+can only ever produce one document. Two directed rows would mean every read had
+to union two queries and every write had to keep them in step.
+"""
+from datetime import datetime, timezone
+from typing import List, Optional
+
+from app.db.mongodb import get_collection
+
+collection = get_collection("connections")
+
+
+def pair(a: str, b: str) -> List[str]:
+ """The canonical form of a connection between two people: sorted, so it is one value."""
+ return sorted([a, b])
+
+
+async def get_connection(a: str, b: str) -> Optional[dict]:
+ return await collection.find_one({"users": pair(a, b)})
+
+
+async def create_connection(connection_id: str, a: str, b: str) -> dict:
+ doc = {
+ "_id": connection_id,
+ "users": pair(a, b),
+ "created_at": datetime.now(timezone.utc),
+ }
+ await collection.insert_one(doc)
+ return doc
+
+
+async def list_for_user(user_id: str, limit: int = 200) -> List[dict]:
+ """Every connection this user is part of, most recent first."""
+ return await collection.find({"users": user_id}).sort("created_at", -1).to_list(limit)
diff --git a/backend/app/repositories/post_repository.py b/backend/app/repositories/post_repository.py
new file mode 100644
index 0000000..afcb9b5
--- /dev/null
+++ b/backend/app/repositories/post_repository.py
@@ -0,0 +1,31 @@
+"""
+Only file allowed to query the posts collection directly.
+
+Append-only: posts are created and listed, never edited or deleted. There is no
+comment, like, or reaction storage here on purpose — see models/community.py.
+"""
+from typing import List
+
+from app.db.mongodb import get_collection
+
+collection = get_collection("posts")
+
+
+async def create_post(doc: dict) -> None:
+ await collection.insert_one(doc)
+
+
+async def list_posts(limit: int = 20, skip: int = 0) -> List[dict]:
+ """
+ Most recent first.
+
+ Offset paging rather than a cursor. Posts are append-only, so the only
+ anomaly is a post arriving mid-scroll and shifting a page boundary by one —
+ cheap to live with at this size, and a `created_at` cursor is the fix when it
+ stops being.
+ """
+ return await collection.find().sort("created_at", -1).skip(skip).to_list(limit)
+
+
+async def count_posts() -> int:
+ return await collection.count_documents({})
diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py
index 4c90221..ecab847 100644
--- a/backend/app/repositories/user_repository.py
+++ b/backend/app/repositories/user_repository.py
@@ -49,3 +49,21 @@ async def mark_revealed(user_id: str) -> None:
pseudonym, so there is no un-reveal counterpart to this.
"""
await collection.update_one({"_id": user_id}, {"$set": {"revealed": True}})
+
+
+async def get_users_by_ids(user_ids: list[str]) -> dict[str, dict]:
+ """
+ Look up several users at once, keyed by id.
+
+ One query instead of one per row: the feed and the connections list both need
+ to resolve a page of authors, and doing that in a loop is how a list view
+ starts costing twenty round trips. Projected down to the display fields —
+ nothing here should be pulling password material into a list view.
+ """
+ ids = list(user_ids)
+ if not ids:
+ return {}
+ docs = await collection.find(
+ {"_id": {"$in": ids}}, {"name": 1, "revealed": 1, "role": 1}
+ ).to_list(len(ids))
+ return {doc["_id"]: _with_defaults(doc) for doc in docs}
diff --git a/backend/app/schemas/community.py b/backend/app/schemas/community.py
new file mode 100644
index 0000000..c7cacd6
--- /dev/null
+++ b/backend/app/schemas/community.py
@@ -0,0 +1,64 @@
+"""Request/response DTOs for the community endpoints — what crosses the wire."""
+from datetime import datetime
+from typing import List, Optional
+
+from pydantic import BaseModel, Field
+
+from app.services.community_service import MAX_POST_LENGTH
+
+
+class PersonSummary(BaseModel):
+ """
+ A person as a list view may show them.
+
+ `name` carries the pseudonym while `revealed` is False — the same rule the
+ profile response follows, so a feed or a connections list never becomes the
+ hole the anonymous funnel is plugged everywhere else.
+ """
+
+ user_id: str
+ name: str
+ revealed: bool
+
+
+class ConnectResponse(BaseModel):
+ connection_id: str
+ user_id: str
+ connected_to: str
+ # False when the connection already existed. Connecting twice is a no-op
+ # rather than an error, so this is how a caller tells the two apart.
+ created: bool
+
+
+class ConnectionSummary(PersonSummary):
+ connected_at: Optional[datetime] = None
+
+
+class ConnectionsResponse(BaseModel):
+ user_id: str
+ count: int
+ connections: List[ConnectionSummary]
+
+
+class PostCreateRequest(BaseModel):
+ # author is deliberately absent: it comes from the access token, so a post
+ # cannot be attributed to somebody else by editing the body.
+ text: str = Field(min_length=1, max_length=MAX_POST_LENGTH)
+ job_id: Optional[str] = None
+ company_name: Optional[str] = None
+
+
+class PostResponse(BaseModel):
+ post_id: str
+ author: PersonSummary
+ text: str
+ job_id: Optional[str] = None
+ company_name: Optional[str] = None
+ created_at: Optional[datetime] = None
+
+
+class PostListResponse(BaseModel):
+ total: int
+ limit: int
+ skip: int
+ posts: List[PostResponse]
diff --git a/backend/app/services/community_service.py b/backend/app/services/community_service.py
new file mode 100644
index 0000000..5a623cd
--- /dev/null
+++ b/backend/app/services/community_service.py
@@ -0,0 +1,160 @@
+"""
+Business logic for the community surface: connections and the post feed.
+
+Deliberately small. A connection is instant and mutual — there is no request, no
+approval, no pending state — and a post is text that gets created and listed.
+Comments, likes, reactions, threaded replies, DMs, notifications and media are
+all out of scope; see models/community.py and the README roadmap.
+
+The one thing that is not lightweight here is identity. Both surfaces list other
+people, and an unrevealed candidate is a pseudonym everywhere or the funnel means
+nothing — so every name that leaves this module goes through `_display`, which
+reads the latched `revealed` flag and drops the name if it is False. This module
+never evaluates the reveal threshold itself: that belongs to the profile read,
+where flipping the latch is the documented behaviour. A candidate who has just
+earned their reveal therefore appears here as themselves only after their profile
+has been read, which is the safe direction to be wrong in.
+"""
+import uuid
+from datetime import datetime, timezone
+
+from app.repositories import (
+ connection_repository,
+ job_repository,
+ post_repository,
+ user_repository,
+)
+from app.services.reputation_service import ANONYMOUS_NAME
+
+MAX_POST_LENGTH = 2000
+DEFAULT_PAGE = 20
+MAX_PAGE = 50
+
+
+def _display(user_id: str, users: dict) -> dict:
+ """One person as a list view may show them."""
+ user = users.get(user_id) or {}
+ revealed = bool(user.get("revealed"))
+ return {
+ "user_id": user_id,
+ # Absent for an unrevealed account rather than blanked by the client, so
+ # the name is not in the payload at all.
+ "name": (user.get("name") or ANONYMOUS_NAME) if revealed else ANONYMOUS_NAME,
+ "revealed": revealed,
+ }
+
+
+# --- connections -----------------------------------------------------------
+
+
+async def connect(user_id: str, target_id: str) -> dict:
+ """
+ Connect two people, instantly and mutually.
+
+ Idempotent: connecting to someone you are already connected to returns the
+ existing connection rather than failing or creating a second one. With no
+ approval flow there is no state for a repeat to advance, so an error would
+ only punish a double-click.
+
+ Raises ValueError for a self-connection and LookupError for an unknown target.
+ """
+ if user_id == target_id:
+ raise ValueError("cannot_connect_to_self")
+ if not await user_repository.get_user_by_id(target_id):
+ raise LookupError("user_not_found")
+
+ existing = await connection_repository.get_connection(user_id, target_id)
+ if existing:
+ return {"connection_id": existing["_id"], "user_id": user_id,
+ "connected_to": target_id, "created": False}
+
+ # A concurrent duplicate is possible between the check above and this write.
+ # A unique index on `users` is the real fix; the consequence today is a second
+ # document that reads identically, not a wrong answer.
+ doc = await connection_repository.create_connection(
+ str(uuid.uuid4()), user_id, target_id
+ )
+ return {"connection_id": doc["_id"], "user_id": user_id,
+ "connected_to": target_id, "created": True}
+
+
+async def list_connections(user_id: str) -> dict:
+ """Who this user is connected to. Raises LookupError for an unknown user."""
+ if not await user_repository.get_user_by_id(user_id):
+ raise LookupError("user_not_found")
+
+ rows = await connection_repository.list_for_user(user_id)
+ # The stored pair holds both sides; the caller wants the other one.
+ others = [next(u for u in row["users"] if u != user_id) for row in rows]
+ users = await user_repository.get_users_by_ids(others)
+
+ return {
+ "user_id": user_id,
+ "count": len(others),
+ "connections": [
+ {**_display(other, users), "connected_at": row.get("created_at")}
+ for other, row in zip(others, rows)
+ ],
+ }
+
+
+# --- posts -----------------------------------------------------------------
+
+
+async def create_post(author_id: str, text: str, job_id=None, company_name=None) -> dict:
+ """
+ Add a post to the feed.
+
+ The author comes from the caller's token, never from the body — the same rule
+ the quiz uses, so a post cannot be attributed to somebody else. Raises
+ ValueError for empty or over-long text, LookupError for a job reference that
+ does not exist.
+ """
+ body = (text or "").strip()
+ if not body or len(body) > MAX_POST_LENGTH:
+ raise ValueError("invalid_text")
+
+ if job_id and not await job_repository.get_job(job_id):
+ raise LookupError("job_not_found")
+
+ doc = {
+ "_id": str(uuid.uuid4()),
+ "author_id": author_id,
+ "text": body,
+ "job_id": job_id,
+ "company_name": (company_name or "").strip() or None,
+ "created_at": datetime.now(timezone.utc),
+ }
+ await post_repository.create_post(doc)
+
+ # Rendered from the document that was stored, so the post the author sees is
+ # the post the feed will show them a moment later.
+ users = await user_repository.get_users_by_ids([author_id])
+ return _post_view(doc, users)
+
+
+def _post_view(row: dict, users: dict) -> dict:
+ return {
+ "post_id": row["_id"],
+ "author": _display(row.get("author_id"), users),
+ "text": row.get("text", ""),
+ "job_id": row.get("job_id"),
+ "company_name": row.get("company_name"),
+ "created_at": row.get("created_at"),
+ }
+
+
+async def list_posts(limit: int = DEFAULT_PAGE, skip: int = 0) -> dict:
+ """Most recent first, with authors resolved in one query rather than per row."""
+ limit = max(1, min(limit, MAX_PAGE))
+ skip = max(0, skip)
+
+ rows = await post_repository.list_posts(limit=limit, skip=skip)
+ users = await user_repository.get_users_by_ids([r.get("author_id") for r in rows])
+
+ return {
+ "total": await post_repository.count_posts(),
+ "limit": limit,
+ "skip": skip,
+ "posts": [_post_view(row, users) for row in rows],
+ }
diff --git a/backend/tests/test_community_api.py b/backend/tests/test_community_api.py
new file mode 100644
index 0000000..0dc08d9
--- /dev/null
+++ b/backend/tests/test_community_api.py
@@ -0,0 +1,169 @@
+"""
+HTTP contract for connections and the post feed.
+
+Writing needs a token and the author comes off it; reading is open, like profiles
+and reputation. The response models are pinned because `response_model` strips
+undeclared fields — an author summary that loses `revealed` would render a name
+the funnel says nobody may see.
+"""
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.core.security import create_access_token
+from app.main import app
+from app.services import community_service
+
+NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
+AUTH = {"Authorization": f"Bearer {create_access_token('u1', 'candidate')}"}
+
+client = TestClient(app, headers=AUTH)
+anon = TestClient(app)
+
+HIDDEN_AUTHOR = {"user_id": "u3", "name": "Anonymous Candidate", "revealed": False}
+
+
+# --- connections ------------------------------------------------------------
+
+def test_connect_uses_the_caller_from_the_token(monkeypatch):
+ spy = AsyncMock(return_value={"connection_id": "c1", "user_id": "u1",
+ "connected_to": "u2", "created": True})
+ monkeypatch.setattr(community_service, "connect", spy)
+
+ resp = client.post("/api/v1/users/u2/connect")
+
+ assert resp.status_code == 200
+ assert resp.json()["created"] is True
+ assert spy.call_args.args == ("u1", "u2"), "caller from the token, target from the path"
+
+
+def test_connecting_twice_reports_created_false(monkeypatch):
+ monkeypatch.setattr(community_service, "connect", AsyncMock(return_value={
+ "connection_id": "c1", "user_id": "u1", "connected_to": "u2", "created": False,
+ }))
+ assert client.post("/api/v1/users/u2/connect").json()["created"] is False
+
+
+def test_connecting_needs_a_token():
+ assert anon.post("/api/v1/users/u2/connect").status_code == 401
+
+
+def test_connecting_to_yourself_is_400(monkeypatch):
+ monkeypatch.setattr(community_service, "connect", AsyncMock(side_effect=ValueError()))
+ assert client.post("/api/v1/users/u1/connect").status_code == 400
+
+
+def test_connecting_to_an_unknown_user_is_404(monkeypatch):
+ monkeypatch.setattr(community_service, "connect", AsyncMock(side_effect=LookupError()))
+ assert client.post("/api/v1/users/ghost/connect").status_code == 404
+
+
+def test_the_connections_list_is_open_and_keeps_the_reveal_flag(monkeypatch):
+ monkeypatch.setattr(community_service, "list_connections", AsyncMock(return_value={
+ "user_id": "u1", "count": 1,
+ "connections": [{**HIDDEN_AUTHOR, "connected_at": NOW}],
+ }))
+
+ body = anon.get("/api/v1/users/u1/connections").json()
+
+ assert body["count"] == 1
+ assert body["connections"][0]["revealed"] is False
+ assert body["connections"][0]["name"] == "Anonymous Candidate"
+
+
+def test_listing_connections_for_an_unknown_user_is_404(monkeypatch):
+ monkeypatch.setattr(community_service, "list_connections",
+ AsyncMock(side_effect=LookupError()))
+ assert anon.get("/api/v1/users/ghost/connections").status_code == 404
+
+
+# --- posts ------------------------------------------------------------------
+
+def post_view(**over):
+ return {"post_id": "p1", "author": dict(HIDDEN_AUTHOR), "text": "hello",
+ "job_id": None, "company_name": None, "created_at": NOW, **over}
+
+
+def test_creating_a_post_attributes_it_to_the_token(monkeypatch):
+ spy = AsyncMock(return_value=post_view())
+ monkeypatch.setattr(community_service, "create_post", spy)
+
+ resp = client.post("/api/v1/posts/", json={"text": "hello"})
+
+ assert resp.status_code == 201
+ assert spy.call_args.args[0] == "u1", "author is never read from the body"
+
+
+def test_an_author_field_in_the_body_is_ignored(monkeypatch):
+ """Otherwise anyone could post as anyone."""
+ spy = AsyncMock(return_value=post_view())
+ monkeypatch.setattr(community_service, "create_post", spy)
+
+ client.post("/api/v1/posts/", json={"text": "hello", "author_id": "somebody-else"})
+
+ assert spy.call_args.args[0] == "u1"
+
+
+def test_a_post_may_carry_a_job_reference(monkeypatch):
+ spy = AsyncMock(return_value=post_view(job_id="job-1", company_name="Acme"))
+ monkeypatch.setattr(community_service, "create_post", spy)
+
+ body = client.post("/api/v1/posts/", json={
+ "text": "we are hiring", "job_id": "job-1", "company_name": "Acme",
+ }).json()
+
+ assert spy.call_args.args[1:] == ("we are hiring", "job-1", "Acme")
+ assert body["job_id"] == "job-1"
+
+
+def test_posting_needs_a_token():
+ assert anon.post("/api/v1/posts/", json={"text": "hello"}).status_code == 401
+
+
+@pytest.mark.parametrize("payload", [
+ {},
+ {"text": ""},
+ {"text": "x" * (community_service.MAX_POST_LENGTH + 1)},
+])
+def test_post_validation(payload):
+ assert client.post("/api/v1/posts/", json=payload).status_code == 422
+
+
+def test_a_post_referencing_a_missing_job_is_404(monkeypatch):
+ monkeypatch.setattr(community_service, "create_post", AsyncMock(side_effect=LookupError()))
+ resp = client.post("/api/v1/posts/", json={"text": "see this", "job_id": "nope"})
+ assert resp.status_code == 404
+
+
+def test_the_feed_is_open_to_read(monkeypatch):
+ monkeypatch.setattr(community_service, "list_posts", AsyncMock(return_value={
+ "total": 1, "limit": 20, "skip": 0, "posts": [post_view()],
+ }))
+
+ body = anon.get("/api/v1/posts/").json()
+
+ assert body["total"] == 1
+ assert body["posts"][0]["author"]["revealed"] is False
+
+
+def test_the_feed_forwards_paging(monkeypatch):
+ spy = AsyncMock(return_value={"total": 0, "limit": 5, "skip": 10, "posts": []})
+ monkeypatch.setattr(community_service, "list_posts", spy)
+
+ anon.get("/api/v1/posts/?limit=5&skip=10")
+
+ assert spy.call_args.kwargs == {"limit": 5, "skip": 10}
+
+
+@pytest.mark.parametrize("query", ["limit=0", "limit=999", "skip=-1"])
+def test_the_feed_rejects_nonsense_paging(query):
+ assert anon.get(f"/api/v1/posts/?{query}").status_code == 422
+
+
+def test_there_are_no_comment_like_or_reaction_routes():
+ """Scope guard: the feed creates and lists, and that is all it does."""
+ paths = app.openapi()["paths"]
+ for word in ("comment", "like", "reaction", "reply", "message"):
+ assert not [p for p in paths if word in p.lower()], f"unexpected {word} route"
diff --git a/backend/tests/test_community_service.py b/backend/tests/test_community_service.py
new file mode 100644
index 0000000..dd7fea2
--- /dev/null
+++ b/backend/tests/test_community_service.py
@@ -0,0 +1,231 @@
+"""
+Connections and the post feed.
+
+Two things carry weight here. Connections are instant and mutual, so the stored
+shape has to be one document that reads the same from either side. And both
+surfaces list other people, which makes them the two places the anonymous funnel
+could quietly spring a leak.
+"""
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.repositories.connection_repository import pair
+from app.services import community_service
+from app.services.reputation_service import ANONYMOUS_NAME
+
+NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
+
+REVEALED = {"_id": "u2", "name": "Ada Lovelace", "revealed": True}
+HIDDEN = {"_id": "u3", "name": "Grace Hopper", "revealed": False}
+
+
+def patch_users(monkeypatch, by_id, exists=True):
+ monkeypatch.setattr(community_service.user_repository, "get_users_by_ids",
+ AsyncMock(return_value=by_id))
+ monkeypatch.setattr(community_service.user_repository, "get_user_by_id",
+ AsyncMock(return_value={"_id": "u2"} if exists else None))
+
+
+# --- connections ------------------------------------------------------------
+
+async def test_connecting_is_instant_and_has_no_pending_state(monkeypatch):
+ patch_users(monkeypatch, {})
+ monkeypatch.setattr(community_service.connection_repository, "get_connection",
+ AsyncMock(return_value=None))
+ created = AsyncMock(return_value={"_id": "c1", "users": pair("u1", "u2")})
+ monkeypatch.setattr(community_service.connection_repository, "create_connection", created)
+
+ out = await community_service.connect("u1", "u2")
+
+ assert out["created"] is True
+ assert out["connected_to"] == "u2"
+ doc_fields = created.call_args.args
+ assert doc_fields[1:] == ("u1", "u2")
+
+
+async def test_the_pair_is_stored_once_in_canonical_order():
+ """One document, findable from either side — not two directed rows."""
+ assert pair("u2", "u1") == pair("u1", "u2") == ["u1", "u2"]
+
+
+async def test_connecting_twice_is_a_no_op(monkeypatch):
+ """No approval flow means a repeat has no state to advance; erroring would
+ only punish a double-click."""
+ patch_users(monkeypatch, {})
+ monkeypatch.setattr(community_service.connection_repository, "get_connection",
+ AsyncMock(return_value={"_id": "c1", "users": pair("u1", "u2")}))
+ created = AsyncMock()
+ monkeypatch.setattr(community_service.connection_repository, "create_connection", created)
+
+ out = await community_service.connect("u1", "u2")
+
+ assert out["created"] is False
+ assert out["connection_id"] == "c1"
+ created.assert_not_called()
+
+
+async def test_connecting_to_yourself_is_refused(monkeypatch):
+ patch_users(monkeypatch, {})
+ with pytest.raises(ValueError):
+ await community_service.connect("u1", "u1")
+
+
+async def test_connecting_to_an_unknown_user_is_refused(monkeypatch):
+ patch_users(monkeypatch, {}, exists=False)
+ created = AsyncMock()
+ monkeypatch.setattr(community_service.connection_repository, "create_connection", created)
+
+ with pytest.raises(LookupError):
+ await community_service.connect("u1", "ghost")
+ created.assert_not_called()
+
+
+async def test_the_connections_list_returns_the_other_side(monkeypatch):
+ patch_users(monkeypatch, {"u2": REVEALED, "u3": HIDDEN})
+ monkeypatch.setattr(community_service.connection_repository, "list_for_user",
+ AsyncMock(return_value=[
+ {"_id": "c1", "users": pair("u1", "u2"), "created_at": NOW},
+ {"_id": "c2", "users": pair("u1", "u3"), "created_at": NOW},
+ ]))
+
+ out = await community_service.list_connections("u1")
+
+ assert out["count"] == 2
+ assert [c["user_id"] for c in out["connections"]] == ["u2", "u3"]
+ assert "u1" not in [c["user_id"] for c in out["connections"]]
+
+
+async def test_listing_connections_for_an_unknown_user_raises(monkeypatch):
+ patch_users(monkeypatch, {}, exists=False)
+ with pytest.raises(LookupError):
+ await community_service.list_connections("ghost")
+
+
+# --- the funnel must not leak ----------------------------------------------
+
+async def test_an_unrevealed_connection_is_a_pseudonym(monkeypatch):
+ """The connections list must not be the place a hidden name escapes."""
+ patch_users(monkeypatch, {"u3": HIDDEN})
+ monkeypatch.setattr(community_service.connection_repository, "list_for_user",
+ AsyncMock(return_value=[
+ {"_id": "c2", "users": pair("u1", "u3"), "created_at": NOW},
+ ]))
+
+ shown = (await community_service.list_connections("u1"))["connections"][0]
+
+ assert shown["name"] == ANONYMOUS_NAME
+ assert shown["revealed"] is False
+ assert "Grace" not in str(shown), "the real name must not be in the payload at all"
+
+
+async def test_a_revealed_connection_shows_their_name(monkeypatch):
+ patch_users(monkeypatch, {"u2": REVEALED})
+ monkeypatch.setattr(community_service.connection_repository, "list_for_user",
+ AsyncMock(return_value=[
+ {"_id": "c1", "users": pair("u1", "u2"), "created_at": NOW},
+ ]))
+
+ shown = (await community_service.list_connections("u1"))["connections"][0]
+
+ assert shown["name"] == "Ada Lovelace" and shown["revealed"] is True
+
+
+async def test_an_unrevealed_author_posts_under_the_pseudonym(monkeypatch):
+ patch_users(monkeypatch, {"u3": HIDDEN})
+ monkeypatch.setattr(community_service.post_repository, "list_posts",
+ AsyncMock(return_value=[
+ {"_id": "p1", "author_id": "u3", "text": "hello", "created_at": NOW},
+ ]))
+ monkeypatch.setattr(community_service.post_repository, "count_posts", AsyncMock(return_value=1))
+
+ post = (await community_service.list_posts())["posts"][0]
+
+ assert post["author"]["name"] == ANONYMOUS_NAME
+ assert "Grace" not in str(post)
+
+
+# --- posts ------------------------------------------------------------------
+
+def patch_post_create(monkeypatch, job=None, author=HIDDEN):
+ saved = AsyncMock()
+ monkeypatch.setattr(community_service.post_repository, "create_post", saved)
+ monkeypatch.setattr(community_service.job_repository, "get_job", AsyncMock(return_value=job))
+ monkeypatch.setattr(community_service.user_repository, "get_users_by_ids",
+ AsyncMock(return_value={author["_id"]: author}))
+ return saved
+
+
+async def test_a_post_is_attributed_to_the_caller(monkeypatch):
+ saved = patch_post_create(monkeypatch)
+
+ out = await community_service.create_post("u3", " shipped the ingest rewrite ")
+
+ doc = saved.call_args.args[0]
+ assert doc["author_id"] == "u3"
+ assert doc["text"] == "shipped the ingest rewrite", "text is trimmed"
+ assert out["text"] == doc["text"]
+ assert out["post_id"] == doc["_id"], "the view is rendered from the stored document"
+
+
+async def test_a_post_may_reference_a_job(monkeypatch):
+ saved = patch_post_create(monkeypatch, job={"_id": "job-1"})
+
+ await community_service.create_post("u3", "we are hiring", job_id="job-1",
+ company_name="Acme")
+
+ doc = saved.call_args.args[0]
+ assert doc["job_id"] == "job-1"
+ assert doc["company_name"] == "Acme"
+
+
+async def test_a_post_referencing_a_missing_job_is_refused(monkeypatch):
+ saved = patch_post_create(monkeypatch, job=None)
+
+ with pytest.raises(LookupError):
+ await community_service.create_post("u3", "see this role", job_id="nope")
+ saved.assert_not_called()
+
+
+@pytest.mark.parametrize("text", ["", " ", "\n\t "])
+async def test_an_empty_post_is_refused(monkeypatch, text):
+ saved = patch_post_create(monkeypatch)
+ with pytest.raises(ValueError):
+ await community_service.create_post("u3", text)
+ saved.assert_not_called()
+
+
+async def test_an_over_long_post_is_refused(monkeypatch):
+ saved = patch_post_create(monkeypatch)
+ with pytest.raises(ValueError):
+ await community_service.create_post("u3", "x" * (community_service.MAX_POST_LENGTH + 1))
+ saved.assert_not_called()
+
+
+async def test_the_feed_is_paginated_and_capped(monkeypatch):
+ listed = AsyncMock(return_value=[])
+ monkeypatch.setattr(community_service.post_repository, "list_posts", listed)
+ monkeypatch.setattr(community_service.post_repository, "count_posts", AsyncMock(return_value=0))
+ monkeypatch.setattr(community_service.user_repository, "get_users_by_ids",
+ AsyncMock(return_value={}))
+
+ out = await community_service.list_posts(limit=9999, skip=-5)
+
+ assert listed.call_args.kwargs == {"limit": community_service.MAX_PAGE, "skip": 0}
+ assert out["limit"] == community_service.MAX_PAGE and out["skip"] == 0
+
+
+async def test_the_feed_resolves_authors_in_one_query(monkeypatch):
+ """A page of posts must not cost one lookup per row."""
+ rows = [{"_id": f"p{i}", "author_id": "u3", "text": "x", "created_at": NOW} for i in range(10)]
+ monkeypatch.setattr(community_service.post_repository, "list_posts",
+ AsyncMock(return_value=rows))
+ monkeypatch.setattr(community_service.post_repository, "count_posts",
+ AsyncMock(return_value=10))
+ lookup = AsyncMock(return_value={"u3": HIDDEN})
+ monkeypatch.setattr(community_service.user_repository, "get_users_by_ids", lookup)
+
+ await community_service.list_posts()
+
+ lookup.assert_called_once()
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index db741ba..f8ada56 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import QuizPage from "./features/quiz/QuizPage";
import ProfilePage from "./features/profile/ProfilePage";
import ReputationPage from "./features/reputation/ReputationPage";
+import CommunityPage from "./features/community/CommunityPage";
import PostJobPage from "./features/jobs/PostJobPage";
import LoginPage from "./features/auth/LoginPage";
import RegisterPage from "./features/auth/RegisterPage";
@@ -49,6 +50,8 @@ export default function App() {
// Reputation is quiz depth plus what happened when you applied, so it is a
// candidate instrument; an employer looking at their own would read zeros.
...(employer ? [] : [["reputation", "Reputation"]]),
+ // Shared ground: the feed is the one surface both sides of the market read.
+ ["community", "Community"],
];
return (
@@ -85,6 +88,7 @@ export default function App() {
/>
)}
{tab === "reputation" && }
+ {tab === "community" && }
>
);
}
diff --git a/frontend/src/features/community/CommunityPage.jsx b/frontend/src/features/community/CommunityPage.jsx
new file mode 100644
index 0000000..e533542
--- /dev/null
+++ b/frontend/src/features/community/CommunityPage.jsx
@@ -0,0 +1,131 @@
+import { useEffect, useState } from "react";
+import { createPost, fetchPosts } from "./api";
+
+/**
+ * The community feed: text posts, newest first, with a box to add one.
+ *
+ * Deliberately the whole feature. No comments, likes, reactions, threaded
+ * replies or media — those are roadmap, and each of them is a schema change
+ * rather than a flag, which is what keeps this from drifting into a social
+ * network by accident.
+ *
+ * Authors render as whatever the backend says: an unrevealed candidate is
+ * "Anonymous Candidate" here for exactly as long as they are one on their
+ * profile, and the real name is never in the payload to begin with.
+ */
+const PAGE = 20;
+
+export default function CommunityPage({ onUnauthorized }) {
+ const [feed, setFeed] = useState(null);
+ const [text, setText] = useState("");
+ const [jobId, setJobId] = useState("");
+ const [companyName, setCompanyName] = useState("");
+ const [posting, setPosting] = useState(false);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ async function load(skip = 0) {
+ setLoading(true);
+ try {
+ setFeed(await fetchPosts(PAGE, skip));
+ setError("");
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ load(0);
+ }, []);
+
+ async function handlePost() {
+ if (!text.trim() || posting) return;
+ setPosting(true);
+ setError("");
+ try {
+ await createPost({ text: text.trim(), jobId, companyName });
+ setText("");
+ setJobId("");
+ setCompanyName("");
+ // Re-read rather than splicing the new post in: the feed is the source of
+ // truth for ordering, and it is one cheap call.
+ await load(0);
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setError(e.message);
+ } finally {
+ setPosting(false);
+ }
+ }
+
+ const posts = feed?.posts ?? [];
+ const skip = feed?.skip ?? 0;
+ const total = feed?.total ?? 0;
+
+ return (
+
+
Community
+
What people are working on, hiring for, and running into.
+ );
+}
diff --git a/frontend/src/features/community/api.js b/frontend/src/features/community/api.js
new file mode 100644
index 0000000..d0b4ba1
--- /dev/null
+++ b/frontend/src/features/community/api.js
@@ -0,0 +1,28 @@
+import request from "../../shared/api/client";
+
+// Open to read, like the profile and reputation views. Writing attaches the
+// token, and the backend takes the author from it rather than from the body.
+export function fetchPosts(limit = 20, skip = 0) {
+ return request(`/posts/?limit=${limit}&skip=${skip}`);
+}
+
+export function createPost({ text, jobId, companyName }) {
+ return request("/posts/", {
+ method: "POST",
+ body: JSON.stringify({
+ text,
+ job_id: jobId || null,
+ company_name: companyName || null,
+ }),
+ });
+}
+
+// Instant and mutual — there is nothing to accept, so there is no second call.
+// Connecting again is a no-op that comes back with created: false.
+export function connectTo(userId) {
+ return request(`/users/${encodeURIComponent(userId)}/connect`, { method: "POST" });
+}
+
+export function fetchConnections(userId) {
+ return request(`/users/${encodeURIComponent(userId)}/connections`);
+}
diff --git a/frontend/src/features/profile/ProfilePage.jsx b/frontend/src/features/profile/ProfilePage.jsx
index 9097793..961abf4 100644
--- a/frontend/src/features/profile/ProfilePage.jsx
+++ b/frontend/src/features/profile/ProfilePage.jsx
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
import { getProfile } from "./api";
import { displayName } from "./display";
import { getUserId } from "../../shared/api/token";
+// Connecting is a community action performed from a profile, so the call lives
+// with the rest of the community API rather than being duplicated here.
+import { connectTo } from "../community/api";
// Anonymous-first: a candidate is a pseudonym here until a defended quiz score
// clears the reveal threshold, at which point the backend latches them open and
@@ -12,6 +15,24 @@ export default function ProfilePage({ userId, onUnauthorized, onViewReputation }
const [profile, setProfile] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
+ // Connections are instant and mutual, so this has two states rather than
+ // three: not connected, and connected. There is no pending.
+ const [connection, setConnection] = useState(null);
+
+ // Only when looking at somebody else — connecting to yourself is a 400.
+ const viewingSomeoneElse = Boolean(subject) && subject !== getUserId();
+
+ async function handleConnect() {
+ setConnection({ busy: true });
+ try {
+ const result = await connectTo(subject);
+ setConnection({ done: true, created: result.created });
+ } catch (e) {
+ if (e.status === 401) return onUnauthorized?.();
+ setConnection(null);
+ setError(e.message);
+ }
+ }
useEffect(() => {
let live = true;
@@ -65,6 +86,20 @@ export default function ProfilePage({ userId, onUnauthorized, onViewReputation }
>
)}
+ {viewingSomeoneElse && (
+
+ )}
+
{/* The reveal is one thing this profile says about a candidate; the
reputation breakdown is the rest of it, and it lives on its own page
rather than being summarised into a number here. */}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index 789a434..2e434c7 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -160,3 +160,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.stat-hint { margin: 8px 0 0 0; font-size: 12px; color: var(--text-subtle); }
.profile-card .linkish { font-size: 14px; }
+
+/* Community feed. Reuses the feedback-item card; the only addition is letting a
+ post keep the line breaks its author typed. */
+.post-text { white-space: pre-wrap; }