Target time: 2-4 hours. AI assistance is allowed and encouraged. Knowing when to stop is part of the evaluation.
Our platform helps field researchers in dozens of countries collect survey data — health assessments, water-quality checks, school enrollment counts, that kind of thing. Central staff at HQ publish the surveys; researchers in the field fill them out.
You're building the part that tracks who's working on what. When a team member starts a survey, the system records their name and a due date. While that draft is open:
- The same team member can't start a second draft on the same survey.
- A different team member can still start their own — two researchers can work on the same survey in parallel.
When they finish, they finalize the draft. They can start a fresh one later if needed.
Every start is recorded in an audit log. If writing that log ever fails, the whole submission should be undone — we can't have submissions that aren't audited.
A small survey-tracking API + thin web UI. The Survey entity is fully working as a reference. Your task is to add a Submission feature that mirrors Survey's layering pattern, with two specific mechanics layered on top:
- Clean repo / service / resolver layering — repositories return
UnsecuredDto<T>; services orchestrate; resolvers are thin. - Transactional hooks — when a submission is started, a hook fires inside the same DB transaction as the mutation, and a handler writes an audit row. If anything in the handler throws, the submission insert rolls back.
These two mechanics mirror patterns we use in our production codebase. The take-home strips away the heavier patterns (RBAC, secured-field wrapping) to keep scope realistic.
- API: NestJS 11 (Fastify) · TypeScript · GraphQL Yoga (code-first via
@nestjs/graphql) · Drizzle ORM · PostgreSQL 16 - Web: Next.js 15 (App Router) · Apollo Client · Tailwind
- Container: Docker Compose (Postgres only — the apps run on your host)
Prereqs: Node 20+, Docker, npm.
docker compose up -d # postgres on :5433
cd api
cp .env.example .env
npm install
npm run db:migrate # creates surveys + audit_events
npm run db:seed # 10 sample surveys
npm run dev # http://localhost:4000/graphql
# in another shell
cd web
cp .env.example .env.local
npm install
npm run dev # http://localhost:3000Smoke test:
# Visit http://localhost:4000/graphql and run:
{ surveys { id name category } }
# Visit http://localhost:3000 — you should see 10 surveys.
# Run the provided e2e test
# - survey.e2e-spec.ts should PASS
# - submission.e2e-spec.ts should FAIL (your code will fix this)
cd api && npm testAdd a submissions table. A submission has:
| Column | Type | Notes |
|---|---|---|
id |
text PK | use generateId() |
survey_id |
text NOT NULL | FK to surveys(id) |
team_member |
text NOT NULL | |
due_date |
date NOT NULL | |
finalized_at |
timestamptz NULL | null = open draft |
created_at |
timestamptz NOT NULL | default now() |
Invariant: a survey can have many submissions over time, but only one open draft per (survey, team_member) at a time (finalized_at IS NULL). Enforce this at the DB level. (Hint: partial unique index.)
Add the table to src/core/drizzle/schema.ts AND write a new migration file in src/core/drizzle/migrations/ (e.g. 0001_submissions.sql).
Following the shape of Survey:
Submissiontype with:id,survey: Survey,teamMember,dueDate,finalizedAt.- Query:
submissions(surveyId: ID): [Submission!]! - Mutation:
startSubmission(input: StartSubmissionInput!): SubmissionwhereStartSubmissionInput = { surveyId, teamMember, dueDate }. - Mutation:
finalizeSubmission(submissionId: ID!): Submission. - Field on
Survey:activeDraft: Submission(the current open draft for the requesting team_member — for this take-home, treat the team_member as if there were only one; resolve "the" open draft, if any). Add it as a@ResolveFieldon a newSurveyFieldsResolver, OR extendSurveyResolver— your call.
Wire SubmissionModule to AppModule (it's already imported as an empty stub).
When a submission is started, fire a SubmissionStartedHook. Register a handler that writes a row to audit_events:
kind:'submission.started'payload: jsonb,{ submissionId, surveyId, teamMember }
This handler must run inside the mutation transaction. The provided Hooks service + DrizzleTransactionalMutationsInterceptor make this work — but the rollback test in step 4 will verify it. If your handler doesn't participate in the tx, that test fails.
api/test/submission.e2e-spec.ts ships with this scaffold. Your code makes it pass. The tests are the executable contract for the GraphQL surface and the invariants — if you're unsure what behavior we expect, read the test.
The suite covers:
- Happy path:
startSubmissionwrites the audit row with the rightkindand payload. - Invariant: a second open draft for the same
(survey, team_member)is blocked. - Invariant: separate team members can each have open drafts on the same survey.
- Invariant: a new draft for the same
(survey, team_member)is allowed once the previous is finalized. - Finalize:
finalizeSubmissionsetsfinalizedAt. - Rollback: if the audit write fails, the submission insert is rolled back.
api/test/survey.e2e-spec.ts is shipped as a working reference for the Survey triad — it's not part of your task.
You aren't expected to add tests. Write extras locally for your own verification if useful; only the shipped tests are evaluated.
On web/src/app/surveys/[id]/page.tsx, replace the TODO placeholder:
- Show the survey details and its current
activeDraft(if any). - If no active draft: form with team member + due date → calls
startSubmission. - If active draft: show team member + due date + "Finalize" button → calls
finalizeSubmission.
Don't over-style. Tailwind is wired; functional is enough.
- Layering discipline — repo stays pure (no business logic, no hooks). Service orchestrates. Resolver is thin.
- Transactional reasoning — did your hook handler genuinely run in the same tx? How did you verify? (We will run your e2e test.)
- DB modeling — how did you enforce "one open draft per (survey, team_member)"? Did you index the FK column? (Postgres doesn't auto-index FKs.)
- Type safety — no
any. Repo returnsUnsecuredDto<Submission>; service returnsSubmission. - AI usage transparency — see below.
At the bottom of the README in your submission (or as AI_NOTES.md), tell us briefly:
- What you used AI for.
- Where you accepted its output as-is.
- Where you pushed back, rewrote, or rejected its suggestions.
- Anything you noticed but deferred — inefficiencies, scale concerns, design choices that nagged at you. 2-3 specific things with the tradeoff that kept you from fixing them.
We're hiring senior devs who can leverage AI critically and reason about the tradeoffs of their own code — both are positive signals, not gotchas.
Target 2–4 hours. If you hit 4 hours and aren't done, stop and write up what you'd do next. Submitting an incomplete-but-coherent solution with a clear "here's what I'd do next" beats a sprawling unfinished one.
api/
├── src/
│ ├── main.ts # Fastify bootstrap
│ ├── app.module.ts # registers GraphQL + interceptor + modules
│ ├── core/
│ │ ├── drizzle/
│ │ │ ├── schema.ts # surveys + audit_events. Add submissions here.
│ │ │ ├── drizzle.service.ts # `tx` getter — use this in repos
│ │ │ ├── transactional-mutations.interceptor.ts
│ │ │ └── migrations/0000_init.sql
│ │ ├── hooks/
│ │ │ ├── hooks.service.ts # @OnHook discovery + run()
│ │ │ └── on-hook.decorator.ts
│ │ └── common/
│ │ ├── id.ts # generateId(): nanoid
│ │ └── unsecured-dto.ts
│ └── components/
│ ├── survey/ # 🟢 reference pattern (read this first)
│ │ ├── dto/{survey,create-survey}.dto.ts
│ │ ├── survey.repository.ts
│ │ ├── survey.service.ts
│ │ ├── survey.resolver.ts
│ │ └── survey.module.ts
│ └── submission/ # 🔴 your work
│ └── submission.module.ts # empty stub
└── test/
├── survey.e2e-spec.ts # 🟢 reference e2e (Survey, passes)
└── submission.e2e-spec.ts # 🔴 your spec — make this pass
web/
└── src/
├── app/
│ ├── page.tsx # 🟢 surveys list (works)
│ ├── surveys/[id]/page.tsx # 🔴 your work below the TODO
│ └── layout.tsx
└── lib/
├── apollo.ts
└── apollo-provider.tsx
Push to a fresh GitHub repo (or whatever your interviewer asked for) and send the link. We expect to see your commit history — small commits are fine, one giant commit is harder to review.
Good luck. Have fun with it.