Skip to content

About

Book a mock interview, take it on a video call that writes its own transcript, and get written feedback after. A repeated payment message never pays the interviewer twice. Next.js + FastAPI.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Intervue — mock-interview marketplace

Book a 1:1 mock interview with credits, join a Stream video call that records and transcribes itself, and get structured AI feedback once the call ends. The hard part isn't the video or the model — it's the post-call pipeline: a webhook fires when the transcript is ready, and that path has to survive being delivered more than once without paying an interviewer twice.

Next.js 16 (frontend) · FastAPI + Python 3.12 (backend) · Clerk · Stream Video · Gemini · Prisma/Postgres · GCP Cloud Run

Contents

  1. What it does
  2. Architecture
  3. Where things live
  4. The post-call pipeline (the interesting part)
  5. Auth: stateless JWT verification
  6. Booking and credits — and where "atomic" is a stretch
  7. Invariants the system holds
  8. Design decisions (and the alternatives I turned down)
  9. API reference
  10. Tests & limitations
  11. Run it locally
  12. Tech stack

What it does

Two roles share one app:

Interviewee — signs up with Clerk, picks the INTERVIEWEE role in onboarding, browses interviewer profiles, and books an open slot. Booking spends credits (the interviewer's creditRate) and provisions a Stream call. At session time they join /call/[callId]; Stream records and auto-transcribes. When the transcript lands, Gemini writes feedback — summary, technical, communication, problem-solving, a recommendation, strengths, improvements, and a rating.

Interviewer — onboards as INTERVIEWER, sets bio/title/categories/creditRate, publishes availability slots, runs sessions, and earns credits into a creditBalance. They request a withdrawal; an admin approves it with a password and pays out by hand. There is no automated money movement — Resend just emails the admin a review link.

Optionally, either side can hit POST /api/ai/questions before a call to get six role-specific practice questions from Gemini. That route is stateless — no DB write.

Architecture

A monorepo with two independently deployed services and no shared runtime. They talk over HTTP with a Clerk JWT as the only trust token, and everything stateful lives in one Postgres database (Supabase-hosted) behind Prisma.

flowchart TD
    browser["Browser (Next.js RSC + Server Actions)"] -->|same-origin fetch| bff["app/api/proxy/[...path]<br/>BFF — attaches Clerk token"]
    bff -->|"Bearer JWT"| api["FastAPI backend"]
    api --> routers["routers/ — HTTP contract"]
    routers --> services["services/ — business logic + every 3rd-party call"]
    services --> prisma[("Postgres via Prisma")]
    services --> stream["Stream Video<br/>(calls, recording, transcription, webhook)"]
    services --> gemini["Gemini 2.5 Flash-Lite"]
    services --> resend["Resend (payout email)"]
    stream -.->|"POST /api/webhooks/stream"| api
Loading

The backend keeps no session state of its own — it verifies the Clerk JWT on every protected request (below), which is what lets Cloud Run scale to zero and back without coordination. The layering is strict: routers own the HTTP contract, services own business logic and every external call (Stream, Gemini, Resend), and nothing outside a service touches Prisma.

Core Prisma models: User (one row, role-switched between interviewee and interviewer fields), Availability, Booking (holds streamCallId @unique and recordingUrl), Feedback (bookingId @unique), CreditTransaction (typed ledger: CREDIT_PURCHASE | BOOKING_DEDUCTION | BOOKING_EARNING | ADMIN_ADJUSTMENT), and Payout.

Where things live

frontend/
  app/api/proxy/[...path]/route.ts   BFF: reads the Clerk token, forwards to BACKEND_URL
                                     so the browser never learns the backend origin
  app/                               App Router — RSC pages + Server Actions for first paint

backend/app/
  main.py            FastAPI app; CORS + lifespan; mounts the 10 routers; defines /health
  config.py          pydantic-settings — env vars + the CORS origins list
  database.py        Prisma Client Python singleton + connect/disconnect
  dependencies.py    ← get_clerk_user (JWKS verify) + the token-bucket RateLimiter
  routers/           HTTP contract only: parse the request, call a service, map errors → codes
    webhooks.py  booking.py  dashboard.py  payout.py  call.py
    user.py  onboarding.py  explore.py  appointments.py  ai_questions.py
  services/          business logic + every third-party call; the only layer that touches Prisma
    webhook_handler.py ← the post-call pipeline: transcript → Gemini → feedback → payout
    booking.py         book_slot: role check, credit check, conflict check, Stream call, writes
    dashboard.py       availability, stats, the withdrawal request (+ Resend email)
    payout.py          admin approval (password compare), payout lookup
    call.py  user.py  ai_questions.py
backend/prisma/schema.prisma  the data model — source of truth for every unique/enum constraint

If you only read two files, read backend/app/services/webhook_handler.py (the whole post-call pipeline and its idempotency) and backend/app/dependencies.py (how auth and rate limiting actually work).

The post-call pipeline (the interesting part)

Stream posts webhook events to POST /api/webhooks/stream. The one that matters is call.transcription_ready. By the time it arrives the call is long over, and Stream will happily deliver the same event more than once. The handler (backend/app/services/webhook_handler.py) has to be safe under that.

The flow: split call_cid to recover the streamCallId → look up the Booking by that unique id (with interviewer/interviewee/feedback included) → download the JSONL transcript over httpx → keep only type == "speech" lines → map each speaker_id to the real person's name via clerkUserId → build a Name: text transcript → send a JSON-shaped prompt to Gemini → strip any ```json fences → json.loads → persist.

Idempotency is enforced in three independent layers, so a retried webhook is a no-op and never re-pays the interviewer:

flowchart TD
    W["call.transcription_ready<br/>(may be delivered more than once)"] --> B{Booking found<br/>by streamCallId?}
    B -- no --> OK1["return ok — nothing to do"]
    B -- yes --> L1{"Layer 1<br/>booking.feedback<br/>already exists?"}
    L1 -- yes --> OK2["return early —<br/>no Gemini call, no DB write"]
    L1 -- no --> G["download transcript →<br/>speaker-map → Gemini →<br/>parse JSON"]
    G --> L2["Layer 2<br/>db.feedback.upsert<br/>keyed on unique bookingId<br/>(update:{} on conflict)"]
    L2 --> C["mark Booking COMPLETED"]
    C --> L3{"Layer 3<br/>BOOKING_EARNING row<br/>exists for this booking?"}
    L3 -- yes --> OK3["skip payout — already credited"]
    L3 -- no --> PAY["create BOOKING_EARNING +<br/>increment interviewer creditBalance"]
Loading
  • Layer 1 — the early return on an existing Feedback record short-circuits before Gemini is even called, so the common retry case costs one indexed read.
  • Layer 2 — db.feedback.upsert keyed on the bookingId @unique constraint. If two deliveries race past Layer 1, the second one's write collapses into a no-op update: {} instead of inserting a duplicate.
  • Layer 3 — the payout is a separate guard: find_first for an existing BOOKING_EARNING CreditTransaction on this booking before crediting. This is the one that actually protects money — Layers 1 and 2 protect the feedback row, but the earning is a distinct write and needs its own check.

The Gemini output is stored close to verbatim. The real shape (from the prompt in webhook_handler.py, not an idealized one):

{
  "summary": "string",
  "technical": "string",
  "communication": "string",
  "problemSolving": "string",
  "recommendation": "HIRE | CONSIDER | NO_HIRE, with a one-sentence reason",
  "strengths": ["string", "string", "string"],
  "improvements": ["string", "string", "string"],
  "overallRating": "POOR | AVERAGE | GOOD | EXCELLENT"
}

overallRating maps to the FeedbackRating Prisma enum, so a malformed value from the model fails the write rather than landing bad data. sessionRating/sessionComment exist on Feedback too, but those come from the interviewee rating the session afterward — Gemini doesn't set them.

Honest gap: the webhook is not authenticated. There is no Stream signature verification on /api/webhooks/stream — the handler parses whatever JSON it's given. Anyone who knows a valid streamCallId can forge a call.transcription_ready and make the backend download a URL and call Gemini. The idempotency layers stop a duplicate from double-paying, but they do nothing against a forged event. Verifying Stream's X-Signature against the webhook secret is the first thing I'd add before this went anywhere real.

Auth: stateless JWT verification

backend/app/dependencies.py verifies the Clerk session token itself rather than delegating to a Clerk SDK session. get_clerk_user pulls the kid from the token's unverified header, matches it against Clerk's JWKS (fetched once and cached in a module global), and verifies the RS256 signature with python-jose. verify_aud is off; the sub claim is the Clerk user id the services key on. Missing or invalid tokens raise 401. Because there's no server session, any Cloud Run instance can serve any request.

Two routes carry a per-user token-bucket rate limiter (also in dependencies.py) — in-memory, keyed on sub, thread-locked:

booking_rate_limit    = RateLimiter(capacity=5, refill_rate=5 / 3600)   # POST /api/bookings
withdrawal_rate_limit = RateLimiter(capacity=3, refill_rate=3 / 3600)   # POST /api/dashboard/withdrawal

It's per-process, so it resets on redeploy and doesn't coordinate across instances — fine for one Cloud Run revision, but a real limiter would be Redis-backed. (@arcjet/next is in the frontend package.json but imported nowhere — an unused dependency, not a live defense. The rate limiting that exists is this hand-rolled backend bucket.)

Booking and credits — and where "atomic" is a stretch

book_slot (backend/app/services/booking.py) checks the caller is an INTERVIEWEE, checks credits cover the interviewer's creditRate, does a find_first overlap check for a conflicting SCHEDULED booking, creates the Stream call, then writes:

  1. db.booking.create(...)
  2. db.credittransaction.create({ type: "BOOKING_DEDUCTION", amount: -credits })
  3. db.user.update({ credits: { decrement: credits } })

These are three separate awaits, not one transaction. If the process dies between them the ledger and the balance can disagree, and the pre-write conflict check is a read-then-write race — two bookings for the same slot can both pass it. The honest fix is a DB-level unique constraint on (interviewerId, startTime) plus wrapping the writes in an interactive transaction. streamCallId at least carries a @unique, so the call id can't be double-assigned.

Withdrawals (dashboard.py) apply a flat 20% platform fee (netAmount = credits * 0.8 * 5), create a Payout in PROCESSING, decrement creditBalance, and email the admin via Resend. Approval (payout.py) is a plaintext password compare against ADMIN_PAYOUT_PASSWORD and flips the row to PROCESSED — the actual payment is manual.

Invariants the system holds

These hold no matter what input arrives — some in app code, some as a database constraint (so they survive a bug in the handler):

Invariant Enforced by
A booking pays out at most once three idempotency layers in webhook_handler.py — the feedback short-circuit, the keyed upsert, and the BOOKING_EARNING find_first guard that gates the credit itself
Feedback is written at most once per booking Feedback.bookingId @unique + db.feedback.upsert with update: {} on conflict
A Stream call can't be double-assigned to two bookings Booking.streamCallId @unique
Auth is stateless — any instance serves any request Clerk JWT verified against JWKS in dependencies.py; no server-side session anywhere
overallRating is always a known value or the write fails FeedbackRating Prisma enum on the column

Design decisions (and the alternatives I turned down)

  • Two independently deployed services, not a Next.js monolith. Putting the pipeline inside Next.js API routes would have been one deploy and one language. But the backend does long-running, retryable work (transcript download, a Gemini call) with a very different scaling and cold-start profile from the UI, so each service deploys and scales on its own. The cost is a network hop and a shared JWT contract, which the BFF proxy already manages.
  • Verify the Clerk JWT against JWKS inside FastAPI, not a Clerk SDK session. The SDK session route is less code, but it implies server-held session state and a Clerk round-trip per request. Verifying the RS256 signature against cached JWKS keeps the backend stateless, so Cloud Run can scale to zero and any instance can serve any request without coordination.
  • Three idempotency layers, not one. A single upsert on Feedback would dedupe the feedback row, but paying the interviewer is a separate write to CreditTransaction — an upsert on feedback does nothing to stop a second credit. So the payout gets its own BOOKING_EARNING guard. Layers 1 and 2 protect the feedback; Layer 3 is the one that protects money, and it has to exist independently.
  • Keyless deploys via Workload Identity Federation, not a JSON key in CI. A long-lived service-account key in a GitHub secret is the usual shortcut and the usual leak. GitHub Actions mints a short-lived token GCP trusts directly, so there's no static credential to rotate or exfiltrate.

API reference

All /api/* routes require Authorization: Bearer <clerk_jwt>. Errors use FastAPI's { "detail": "..." }.

Method Path Purpose
GET /health Liveness / CI smoke check
GET /api/user/me Authenticated user profile
POST /api/user/sync Upsert the internal user from the Clerk payload
POST /api/onboarding Set role, complete onboarding
GET /api/interviewers Browse interviewer listings
GET /api/interviewers/{id} Single interviewer profile
POST /api/bookings Book a slot (rate-limited; spends credits)
GET /api/appointments Caller's upcoming appointments
GET /api/calls/{callId} Authorize call access, mint a Stream token
POST /api/ai/questions Six role-specific practice questions (stateless)
GET /api/dashboard/stats · /availability · /appointments Interviewer dashboard
POST /api/dashboard/availability Publish a slot
POST /api/dashboard/withdrawal Request a payout (rate-limited)
GET /api/payouts/{id} Payout detail (admin)
POST /api/payouts/{id}/approve Approve a payout (admin password)
POST /api/webhooks/stream Stream recording/transcription webhook (unauthenticated)

Tests & limitations

There are no automated tests — zero. What the old docs called a "test suite" is really a compile/build check plus a liveness curl:

cd frontend && npm run build            # catches TS + import + build errors
cd backend  && python3 -m compileall app # import/syntax check only
curl https://<backend>/health           # liveness

The CI workflows don't even run those two build commands — each deploy workflow does docker build → docker run → poll for running → curl /health (frontend curls /), and only on a green health check does the deploy job (which needs: test) push a SHA-tagged image to Artifact Registry and gcloud run deploy. GCP auth is keyless via Workload Identity Federation — no JSON key in GitHub. So the safety net is "the container boots and answers /health," nothing more.

If I invested in tests first, the order would be: the transcript parser and speaker-mapping (pure, edge-case-heavy — empty transcript, unknown speaker_id, non-speech lines), then the three idempotency layers (assert a replayed webhook writes nothing and pays nothing), then the booking write path once it's a real transaction.

Known trade-offs, stated plainly:

  • No webhook signature verification — a forged Stream event can trigger a Gemini call and a DB write. Idempotency ≠ authenticity.
  • Booking isn't transactional — three sequential writes plus a racy conflict check (details above).
  • Rate limiting is in-process — resets on redeploy, doesn't span instances.
  • Payout approval is a plaintext password compare, and payment itself is manual (email + admin action).
  • @arcjet/next is an unused dependency — worth removing.
  • Webhook observability is print() — no structured logs, trace ids, or alerting on a failed transcript/feedback run.

Run it locally

Backend:

cd backend
cp .env.example .env            # fill values below
python3.12 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
prisma generate && prisma db push
uvicorn app.main:app --reload --port 8000   # docs at /docs, liveness at /health

Frontend:

cd frontend
cp .env.example .env.local       # Clerk keys, Stream key, BACKEND_URL, NEXT_PUBLIC_APP_URL
npm install
npm run dev                      # http://localhost:3000

Webhooks need a public URL — tunnel with ngrok http 8000 and set the Stream dashboard webhook to https://<ngrok>/api/webhooks/stream.

Backend env: DATABASE_URL, CLERK_SECRET_KEY, NEXT_PUBLIC_STREAM_API_KEY, STREAM_SECRET_KEY, GEMINI_API_KEY, RESEND_API_KEY, ADMIN_EMAIL, ADMIN_PAYOUT_PASSWORD, NEXT_PUBLIC_APP_URL, CORS_ORIGINS.

Tech stack

Frontend — Next.js 16 (App Router, Server Actions), React 19, TypeScript, Tailwind v4, shadcn/Radix, Stream Video React SDK, Prisma (pg adapter).

Backend — FastAPI, Python 3.12, Uvicorn, Prisma Client Python (asyncio), python-jose (JWT), httpx, google-generativeai (gemini-2.5-flash-lite), stream-chat, resend.

Data — PostgreSQL (Supabase-hosted).

Infra — Docker multi-stage builds, GitHub Actions (path-filtered per service), GCP Cloud Run + Artifact Registry, Workload Identity Federation for keyless deploys.

About

Book a mock interview, take it on a video call that writes its own transcript, and get written feedback after. A repeated payment message never pays the interviewer twice. Next.js + FastAPI.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages