Skip to content

zeeshan56656/taskflow

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TaskFlow

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.

Demo

TaskFlow demo — creating a task, cursor pagination, and the task detail view

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

Quick start

You need Node 18+ (built and tested on Node 24). Two terminals, or use the root runner.

Option A — per directory (matches the brief)

# 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 dev

Open 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.

Option B — one command from the root

npm install            # installs concurrently
npm run install:all    # installs both apps
npm run dev            # runs backend + frontend together

Copy the two env files first (backend/.env.example → .env, frontend/.env.example → .env.local). Defaults work out of the box for local dev.


Environment variables

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.


Backend — NestJS

REST API

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>.

How the requirements are met

  • Status transition validation — a single declarative table in task-status.enum.ts defines the legal edges; canTransition() enforces them. Illegal moves throw a typed IllegalStatusTransitionException → descriptive 409. Adding a state/edge is a one-line change.
  • API-key protectionApiKeyGuard reads the expected key through the typed AppConfigService (never hardcoded) and throws a typed InvalidApiKeyException. The header name is a constant.
  • Request auditingAuditMiddleware emits 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 via res.on('finish') it captures the final status of every request — including ones the API-key guard rejects with 401, which an interceptor would miss.
  • Real-time updatesEventsGateway broadcasts 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 custom ConfiguredIoAdapter.
  • Consistent errors — a global AllExceptionsFilter serialises every failure into one envelope { statusCode, code, message, path, timestamp } with a stable, machine-readable ErrorCode. Domain errors are dedicated exception classes — no error strings scattered through the service.
  • Module organisationTasksModule (controller, service, repository) imports EventsModule one-directionally; a global AppConfigModule validates the environment at boot. Storage is an abstract TasksRepository bound to InMemoryTasksRepository via useClass — 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 in tasks.constants.ts and sort/order are enums — no magic numbers or strings. Unknown fields are rejected with 400.

State machine

todo        → in_progress | archived
in_progress → todo | done | archived
done        → archived            (cannot reopen to todo)
archived    → (terminal)

Introducing a database — how small the change is

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 ripples awaits 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 into WHERE / 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.


Shared contract — one source of truth, no type drift

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.)


Frontend — Next.js (App Router, SSR)

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.

How the requirements are met

  • WebSocket syncuseTaskSocket is a custom hook that connects on mount, cleans up on unmount (StrictMode-safe), forwards created/updated/deleted events through a ref (so changing handlers never reconnects), and exposes a connected boolean. 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 updatesTaskDetail applies the new status immediately, reconciles with the server response, and rolls back on failure (functional setState, so concurrent changes can't roll back to a wrong baseline) — plain useState, no state-management library. The detail page also stays live via the socket, applying remote updates for its task (reconciled by updatedAt) but never while a local change is in flight. Board updates are likewise updatedAt-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 stateserror.tsx is a route-level error boundary around the board; loading.tsx skeletons give instant pending UI on every navigation (the pages are dynamic/no-store); a filter-aware empty state covers the zero-task case; ReconnectionBanner shows 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=abc is ignored, not a blank board.
  • Localized timestamps (SSR-safe)LocalTime renders a semantic <time dateTime> in the visitor's own locale + timezone. It sidesteps the classic toLocaleString() 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 FilterBar drives 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 a useTransition so the current board stays visible instead of flashing the loading skeleton. Cards have CSS-only enter and leave animations: a small custom useAnimatedList hook keeps a removed card mounted (flagged leaving, in place) until its exit animation's onAnimationEnd fires — the presence-tracking a library like react-transition-group would do, written by hand and honouring prefers-reduced-motion.
  • Extrasdrag-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.

The SSR ↔ client WebSocket boundary

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.


Submission answers

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.


Notes / assumptions

  • In-memory storage means data resets on backend restart — expected per the brief.
  • GET /tasks/:id isn't in the original endpoint table; I added it to support the SSR detail page.
  • GET /tasks returns { 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).

Deliberately out of scope (single-process / in-memory)

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 Map is atomic in single-threaded Node, so no optimistic locking yet. A real DB needs a version/CAS guard.
  • Multi-instance real-timeserver.emit reaches one process; horizontal scaling needs a Redis Socket.IO adapter (the ConfiguredIoAdapter is 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 missing BACKEND_URL / API_KEY / NEXT_PUBLIC_WS_URL rather than silently using defaults.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages