A compact, real-time collaborative task-management app.
- Backend — NestJS REST API + Socket.IO gateway, in-memory storage, API-key-guarded writes.
- Frontend — Next.js (App Router) server-side-rendered board that hydrates and stays live over WebSockets.
taskflow/
├── backend/ # NestJS application
├── frontend/ # Next.js application
├── shared/
│ └── contracts.d.ts # type-only API contract shared by both apps
├── package.json # optional root runner (both apps at once)
└── README.md
You need Node 18+ (built and tested on Node 24). Two terminals, or use the root runner.
# Terminal 1 — backend (http://localhost:4000)
cd backend
cp .env.example .env
npm install
npm run dev
# Terminal 2 — frontend (http://localhost:3000)
cd frontend
cp .env.example .env.local
npm install
npm run devOpen http://localhost:3000. The board comes pre-seeded with 5 demo tasks on first boot
(1 high / 3 medium / 1 low, spread across columns with one already Done) so it's populated out of the
box. The seeder only runs on an empty store; set SEED_DATA=false to start blank.
npm install # installs concurrently
npm run install:all # installs both apps
npm run dev # runs backend + frontend togetherCopy the two env files first (
backend/.env.example → .env,frontend/.env.example → .env.local). Defaults work out of the box for local dev.
backend/.env
| Var | Default | Purpose |
|---|---|---|
PORT |
4000 |
HTTP + WebSocket port |
API_KEY |
taskflow-dev-secret-key |
Required (x-api-key header) for all write ops |
CORS_ORIGIN |
http://localhost:3000 |
Allowed origin(s), comma-separated |
frontend/.env.local
| Var | Default | Purpose |
|---|---|---|
BACKEND_URL |
http://localhost:4000 |
Server-side fetch target (SSR + write proxy) |
API_KEY |
taskflow-dev-secret-key |
Injected by the proxy route handlers — never sent to the browser |
NEXT_PUBLIC_WS_URL |
http://localhost:4000 |
WebSocket URL the browser connects to after hydration |
The two API_KEY values must match.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/tasks |
— | List. ?status= ?priority= ?tag= ?sort=createdAt|priority ?order=asc|desc ?cursor= ?limit= |
GET |
/tasks/stats |
— | Bonus — counts grouped by status & priority |
GET |
/tasks/:id |
— | Single task (powers the SSR detail page) |
POST |
/tasks |
key | Create (starts in todo) |
PATCH |
/tasks/:id |
key | Partial update — title, description, priority, tags |
PATCH |
/tasks/:id/status |
key | Transition status (state-machine validated) |
DELETE |
/tasks/:id |
key | Soft-delete → archived |
Write requests need the header x-api-key: <API_KEY>.
- Status transition validation — a single declarative table in
task-status.enum.tsdefines the legal edges;canTransition()enforces them. Illegal moves throw a typedIllegalStatusTransitionException→ descriptive409. Adding a state/edge is a one-line change. - API-key protection —
ApiKeyGuardreads the expected key through the typedAppConfigService(never hardcoded) and throws a typedInvalidApiKeyException. The header name is a constant. - Request auditing —
AuditMiddlewareemits one structured line per mutating request (method, path, status, latency, user agent, timestamp). It's middleware, not an interceptor, on purpose: middleware runs before guards, so viares.on('finish')it captures the final status of every request — including ones the API-key guard rejects with401, which an interceptor would miss. - Real-time updates —
EventsGatewaybroadcasts the full task on create / update / status-change / soft-delete. Data flows one way (service → gateway → clients), so there are no circular dependencies. WebSocket CORS is sourced from config via a customConfiguredIoAdapter. - Consistent errors — a global
AllExceptionsFilterserialises every failure into one envelope{ statusCode, code, message, path, timestamp }with a stable, machine-readableErrorCode. Domain errors are dedicated exception classes — no error strings scattered through the service. - Module organisation —
TasksModule(controller, service, repository) importsEventsModuleone-directionally; a globalAppConfigModulevalidates the environment at boot. Storage is an abstractTasksRepositorybound toInMemoryTasksRepositoryviauseClass— swapping in a database is a one-line provider change, the service is untouched. - Input validation — a global
ValidationPipe(whitelist,forbidNonWhitelisted,transform) plus typed DTOs. Limits and messages live intasks.constants.tsand sort/order are enums — no magic numbers or strings. Unknown fields are rejected with400.
todo → in_progress | archived
in_progress → todo | done | archived
done → archived (cannot reopen to todo)
archived → (terminal)
In-memory storage is intentional, but the architecture is built so swapping in a real database is a localised, additive change — not a rewrite. Two decisions make this true:
- The repository port is already async. Every method returns a
Promise, so adding a DB never ripplesawaits through the service or controller. - Querying lives behind the port, not in the service. The service hands the repository a
storage-agnostic
TaskQuery(filter + sort + page) and a database adapter turns that intoWHERE / ORDER BY / LIMIT / GROUP BY. The service never loads rows to filter them in JS, so its logic doesn't change when the store does.
The entire diff to go to Postgres:
// 1. NEW FILE — implement the same 4-method port with an ORM
@Injectable()
export class PrismaTasksRepository extends TasksRepository {
constructor(private readonly prisma: PrismaService) { super(); }
findById(id: string) { return this.prisma.task.findUnique({ where: { id } }); }
save(task: Task) { return this.prisma.task.upsert({ where: { id: task.id }, create: task, update: task }); }
findMany(q: TaskQuery) { /* q → where/orderBy/take/cursor */ }
getStats() { /* prisma.task.groupBy(...) */ }
}
// 2. ONE LINE in tasks.module.ts
- { provide: TasksRepository, useClass: InMemoryTasksRepository }
+ { provide: TasksRepository, useClass: PrismaTasksRepository }TasksController, TasksService, every DTO, the gateway, the guard, the filter — all untouched.
The wire shapes that cross the network (task DTO, status/priority, event names, error codes) live once
in shared/contracts.d.ts. The frontend derives its types from it; the
backend asserts its enums/event names match it at compile time
(contracts.parity.ts). Rename a status or an event on one side and
the other side fails to build — drift is caught before runtime.
Why a type-only .d.ts and not a shared npm package? The brief requires each app to install and run
independently. A runtime workspace package would force a root install + build-ordering and add a
bundled dependency. But this contract is purely a compile-time agreement, and import type erases at
build — so a .d.ts consumed via a tsconfig path alias gives the single-source-of-truth and the
compile-time drift detection with zero runtime dependency, zero build step, and no change to how
either app installs or runs. Right-sized tool for the actual problem. (Verified: the built backend
JS contains no reference to the contract, and injecting a drift makes the build fail.)
| Page | Route | Notes |
|---|---|---|
| Board | / |
Tasks fetched server-side, rendered in status columns, priority indicators. Connects to the WebSocket after hydration and patches state live — no reloads. |
| New Task | /tasks/new |
Client-side form (title, description, priority, comma-separated tags). Redirects to /. |
| Detail | /tasks/[id] |
Task fetched server-side. Inline status dropdown with optimistic update + rollback on error; Edit opens a modal to change title/description/priority/tags. Stays live via the socket. |
- WebSocket sync —
useTaskSocketis a custom hook that connects on mount, cleans up on unmount (StrictMode-safe), forwardscreated/updated/deletedevents through a ref (so changing handlers never reconnects), and exposes aconnectedboolean. Payloads are runtime-validated (isTask) at the boundary, and on reconnect the board re-pulls the server snapshot (router.refresh()) since events emitted while disconnected were missed — so "Live" never silently means "stale". - Optimistic updates —
TaskDetailapplies the new status immediately, reconciles with the server response, and rolls back on failure (functionalsetState, so concurrent changes can't roll back to a wrong baseline) — plainuseState, no state-management library. The detail page also stays live via the socket, applying remote updates for its task (reconciled byupdatedAt) but never while a local change is in flight. Board updates are likewiseupdatedAt-reconciled so out-of-order events can't clobber newer state. - Component boundaries — pages (
page.tsx) are server components that do the data fetching; anything touching the socket or interactive state (Board,TaskDetail,NewTaskForm,FilterBar) is a'use client'component. - Error & loading states —
error.tsxis a route-level error boundary around the board;loading.tsxskeletons give instant pending UI on every navigation (the pages are dynamic/no-store); a filter-aware empty state covers the zero-task case;ReconnectionBannershows a non-intrusive live-region banner when the socket drops and dismisses it on reconnect. Search params are validated server-side, so a tampered?priority=abcis ignored, not a blank board. - Localized timestamps (SSR-safe) —
LocalTimerenders a semantic<time dateTime>in the visitor's own locale + timezone. It sidesteps the classictoLocaleString()hydration mismatch by rendering a fixed UTC value on the server / first paint, then re-formatting to the user's locale after mount — i18n-correct and hydration-safe. - Bonus — a
FilterBardrives URL search params and triggers a fresh server-side refetch on navigation. The tag field is a debounced live search (filters as you stop typing — no Enter; case-insensitive substring match, so "u" finds "urgent"), and filter changes run inside auseTransitionso the current board stays visible instead of flashing the loading skeleton. Cards have CSS-only enter and leave animations: a small customuseAnimatedListhook keeps a removed card mounted (flaggedleaving, in place) until its exit animation'sonAnimationEndfires — the presence-tracking a library like react-transition-group would do, written by hand and honouringprefers-reduced-motion. - Extras — drag-and-drop between columns (native HTML5 DnD, no library) that only highlights
and accepts legal transitions and applies the same optimistic-update-with-rollback; "Load more"
pagination driven by the backend keyset cursor; and a branded favicon (
app/icon.svg).
Code organisation. Concerns are split into single-purpose modules so the app scales without
files turning into grab-bags: pure domain types in src/types, language-neutral
domain constants (column order, transitions, priority order) in src/constants,
typed env access split into server/public in src/config, and request paths in
api-routes.ts. The three write route handlers share one
proxyWrite helper. No raw URL, event-name, or env-key strings are
duplicated across the codebase.
Custom UI primitives. Native <select> (whose option list is OS-rendered and unthemeable) is
replaced by a fully-themed, keyboard-accessible Select
(ARIA listbox, arrow-key nav, click-outside). Editing happens in a reusable
Modal (portal, Escape/backdrop close, scroll-lock) — the
Linear/Jira pattern — built from primitives, no UI library.
i18n-ready copy. No component hardcodes user-facing text. Every label, placeholder, button,
empty state, error and banner lives in a typed message catalog (src/i18n/en.ts),
and status/priority labels are keyed by their enum values. Going bilingual is a sibling dictionary
(the Messages type enforces shape parity) plus one line in i18n/index.ts
and wiring getMessages(locale) to a detected locale — zero component changes. Constants stay
language-neutral; the dictionary owns all display strings.
Server components never touch the socket. They render the board with data fetched at request time;
the socket is opened only inside a client component, after hydration, via useTaskSocket. The
initial paint is fully server-rendered and SEO-friendly; live updates are layered on top once
JavaScript runs.
Write operations go browser → Next.js route handler → NestJS. The route handlers
(/api/tasks/*) attach the API key server-side, so the secret never
ships to the browser, while still exercising the guarded write path end-to-end.
1. App Router or Pages Router, and why?
App Router. It makes the server/client boundary explicit at the file level — server components do
the SSR data fetching and client components ('use client') own the socket and interactive state —
which is exactly the architecture this brief asks to demonstrate. It also gives me route-level error
boundaries (error.tsx) and colocated route handlers for the write proxy for free.
2. How did you handle the server-side / client-side WebSocket boundary?
The socket lives entirely on the client. Pages are server components that fetch the initial data at
request time and pass it as props; a client Board then opens the connection after hydration via the
useTaskSocket hook and patches local state in place. The server render is socket-free, so there's no
attempt to hold a connection during SSR and no hydration mismatch — the live layer is purely additive.
3. What would change for production?
Persistence (Postgres/Prisma behind the existing async TasksRepository port — the seam is already
there, and the keyset cursor already maps to a SQL seek); optimistic locking (a version column +
compare-and-swap save) so concurrent writes can't silently clobber each other — the in-memory store is
single-threaded so it's safe today, but a real DB needs it; a Redis Socket.IO adapter wired through
the existing ConfiguredIoAdapter so broadcasts fan out across multiple instances (today emit only
reaches clients on the same process); a transactional outbox so the save + broadcast pair is
atomic/at-least-once; real auth (per-user JWT/OAuth + ownership authorization instead of a shared API
key); plus rate limiting, structured tracing, and health checks.
4. What did you deliberately leave out given 48 hours? Only things the brief itself excludes or doesn't ask for: automated tests (explicitly not required), a database (in-memory is expected), and user accounts/real auth (a single shared API key guards writes instead). I also kept the realtime model deliberately simple — full-task broadcasts to every client rather than rooms, diffs, or presence — which is the right trade-off at this scale. Every feature in the brief (create, list with filter/sort, partial update, status transitions, soft-delete, SSR board, live updates, optimistic detail, error + reconnection handling) and all four bonuses is implemented.
- In-memory storage means data resets on backend restart — expected per the brief.
GET /tasks/:idisn't in the original endpoint table; I added it to support the SSR detail page.GET /tasksreturns{ items, nextCursor, total }to carry pagination metadata cleanly, and applies a default page size (PAGE_LIMIT_DEFAULT) so responses are always bounded.- Pagination is keyset (seek), not offset: the cursor is an opaque base64url token encoding the
sort value + id of the last row, so it translates directly to a SQL
WHERE (sort_col, id) > (…)and degrades gracefully (a stale cursor returns the rows after it rather than erroring).
These are correct as built for in-memory single-process, and the seams to add them exist — see answer 3. Called out so the boundaries are explicit, not accidental:
- Concurrency control — read-modify-write on the
Mapis atomic in single-threaded Node, so no optimistic locking yet. A real DB needs aversion/CAS guard. - Multi-instance real-time —
server.emitreaches one process; horizontal scaling needs a Redis Socket.IO adapter (theConfiguredIoAdapteris the insertion point). - Save/broadcast atomicity — broadcast is fire-and-forget after
save; a DB would warrant a transactional outbox / post-commit dispatch.
Frontend, similarly scoped:
- List virtualisation — the board paginates via "Load more" (keyset cursor); a workspace with tens of thousands of tasks would additionally want windowed/virtualised rendering. Out of scope here.
- Env fail-fast — dev falls back to
localhost; production should fail fast on missingBACKEND_URL/API_KEY/NEXT_PUBLIC_WS_URLrather than silently using defaults.
