Frontend https://task-management-cfp75vsq6-singhkashishs-projects.vercel.app/
Backend API https://task-management-app-rs77.onrender.com/api
- Design Goals
- System Overview
- Frontend Architecture
- Backend Architecture
- Authentication Architecture
- Database Architecture
- Security Architecture
- Trade-offs and Decisions
- Future Enhancements
Every architectural decision in TaskFlow traces back to one of these principles:
| Goal | What it means in practice |
|---|---|
| Separation of concerns | Each layer owns exactly one thing. Controllers don't contain business logic. Services don't know about HTTP. |
| Correctness under concurrency | Token rotation is a single atomic DB operation — not two sequential ones with a gap between them. |
| Security by default | Refresh tokens are never stored in plaintext. HttpOnly cookies. Short-lived access tokens. |
| Production readiness | Docker, CI, compound indexes, centralized error handling, environment-based config. |
| Maintainability | Feature-based frontend, layered backend, explicit type contracts at every boundary. |
┌───────────────────────────────────────────────────────────┐
│ React App (Vercel) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Redux Toolkit │ │ TanStack Query │ │
│ │ (client state) │ │ (server state) │ │
│ │ user, authState│ │ tasks, stats, cache, sync │ │
│ └────────┬────────┘ └─────────────┬────────────────┘ │
│ │ │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Axios Client │ │
│ │ + Interceptors │ │
│ └─────────┬──────────┘ │
└─────────────────────────┼─────────────────────────────────┘
│
│ Authorization: Bearer <accessToken>
│ Cookie: refreshToken (HttpOnly, SameSite=Strict)
│
┌─────────────────────────▼──────────────────────────────────┐
│ Express API (Render) │
│ │
│ routes → validators → middleware → controllers → │
│ services → models │
│ │
│ Helmet · CORS · Zod validation · JWT auth middleware │
└─────────────────────────┬──────────────────────────────────┘
│
│ Mongoose ODM
│
┌─────────────────────────▼─────────────────────────────────┐
│ MongoDB Atlas │
│ │
│ users { email, passwordHash, refreshTokens[] } │
│ tasks { title, status, priority, dueDate, userId } │
└───────────────────────────────────────────────────────────┘
src/
├── api/
│ ├── client.ts # Axios instance, request + response interceptors
│ ├── auth.api.ts
│ └── tasks.api.ts
│
├── components/
│ ├── layout/ # AppShell, ErrorBoundary, Navbar, PageSkeleton, ThemeToggle
│ ├── tasks/ #CreateTaskDialog, DashboardStats, DeleteTaskDialog, EditTaskDialog, EmptyState, TaskCard, TaskFilters, TaskForm, TaskList, TaskStats
│ └── ui/ # shadcn/ui primitives (Button, Dialog, Input, etc.)
│
├── features/
│ ├── auth/
│ │ ├── auth.slice.ts # Redux slice: state:(user, accessToken, isAuthenticated, isBootstrapping),exports - actions & reducers
│ │ ├── auth.api.ts # Modules for loginUser, logoutUser, bootstrapAuthFlow, logoutUser using Axios instance
│ │ ├── auth.ts/ # Types & Interfaces(User, AuthState, AuthPayload, AuthResponse)
│ │ └── authForm/ # Reusable and modular form for Login & Register flows
│ │ └── auth.hooks.ts #Custom hooks for login, register, logout(handles logout-all as well) using Tanstack Query & Mutations
│ └── tasks/
│ ├── task.api.ts # Modules for getTaskStats, getTasks, createTask, updateTask, deleteTask using Axios instance
│ └── task.hooks.ts # useTasks, useCreateTask, useUpdateTask, useDeleteTask using Tanstack Query & Mutations
│ └── task.types.ts # TASK_STATUS, PRIORITY, CreateTaskPayload, UpdateTaskPayload, etc task types
├── hooks/
│ └── useAppDispatch # Typed useAppDispatch
│ └── useAppSelector # Typed useAppSelector
│ └── useTaskStats # Hook using tanstack query
├── pages/
│ ├── LoginPage.tsx
│ ├── RegisterPage.tsx
│ ├── DashboardPage.tsx
│ ├── HomePage.tsx
│ └── NotFoundPage.tsx
│
├── routes/
│ ├── AppRouter.tsx # React Router v6 tree
│ ├── ProtectedRoute.tsx # Redirects to /login if not authenticated
│ └── PublicOnlyRoute.tsx # Redirects to /dashboard if already authenticated
│
├── store/
│ └── index.ts # Redux store configuration
│
├── utils/
│ └── authHelper.ts # authHelper - localStorage read/write/clear for access token, refreshAccessToken, fetchMe, bootstrapAuth modules
├── types/
│ └── axios.d.ts #Axios interface extension
└── providers
├── QueryProvider.tsx # Tanstack Query Client
└── ThemeProvider.tsx #Using Next Themes
Two separate state layers handle different concerns:
Client State (Redux Toolkit) Server State (TanStack Query)
────────────────────────────── ──────────────────────────────
user: { id, email } tasks list
accessToken: string | null task detail
isAuthenticated: boolean task stats / dashboard
isBootstrapping: boolean
Why two layers? Redux is the source of truth for auth — it needs to be synchronously readable by the router for protected/public-only route decisions. TanStack Query handles all data that comes from the API: caching, background refetching, stale-while-revalidate, and request deduplication. Using Redux for server state would require manually managing loading/error states that TanStack Query handles for free.
AppRouter
├── / PublicOnlyRoute → LoginPage (redirect to /dashboard if authed)
├── /login PublicOnlyRoute → LoginPage
├── /register PublicOnlyRoute → RegisterPage
│
├── /dashboard ProtectedRoute → DashboardPage
├── /tasks ProtectedRoute → TasksPage
│
└── * → NotFoundPage
ProtectedRoute reads auth.isAuthenticated from Redux. It also reads auth.isBootstrapping — while bootstrap is in progress, it renders a loading state instead of redirecting, preventing incorrect redirects on page load before auth state is resolved.
On application mount, before any protected content renders:
App mounts → dispatch(bootstrapAuthFlow())
│
├─ accessToken in localStorage?
│ │
│ ▼
│ GET /auth/me (with token in header)
│ ├── 200 → dispatch(setCredentials({ user, accessToken }))
│ │ dispatch(finishBootstrap())
│ │ ── done
│ │
│ └── 401 → fall through ↓
│
└─ POST /auth/refresh (browser sends HttpOnly cookie automatically)
├── 200 → save new accessToken to localStorage
│ GET /auth/me (with new token)
│ dispatch(setCredentials({ user, accessToken }))
│ dispatch(finishBootstrap())
│
└── 401 → dispatch(logout())
dispatch(finishBootstrap())
clear localStorage
API request fires
│
▼
Request Interceptor
└── Attach Authorization: Bearer <accessToken> from localStorage
▼
Response received
│
├── 2xx → return response normally
│
└── 401 →
│
├── Is this request to /auth/refresh?
│ └── Yes → dispatch(logout()), reject
│
└── POST /auth/refresh
├── 200 → update accessToken in localStorage + Redux
│ retry original request with new token
│ return retried response to caller
│
└── 401 → dispatch(logout())
redirect to /login
reject
Any number of concurrent requests that 401 at the same time will all queue behind a single /auth/refresh call — the interceptor tracks whether a refresh is already in flight and queues subsequent retries instead of firing multiple refresh requests.
HTTP Request
│
▼
routes/
Purpose: define endpoint paths, compose middleware chains
Does not: contain logic
│
▼
validators/
Purpose: Zod schema validation for body, query, params
Does not: know about business rules
│
▼
middleware/
Purpose: cross-cutting concerns
- authMiddleware: verify access token JWT, attach req.auth
- errorMiddleware: centralized error handler, consistent response shape
- validate: Zod middleware runner
Does not: call services directly
│
▼
controllers/
Purpose: HTTP layer — read req, call one service, write res
Does not: contain business logic, query the DB directly
│
▼
services/
Purpose: all business logic
- credential validation
- token issuance and persistence
- task ownership checks
- statistics computation
Does not: read req or write res
│
▼
models/
Purpose: Mongoose schema definitions and typed document interfaces
Does not: contain business logic
│
▼
MongoDB
utils/
├── jwt.ts # generateAccessToken, generateRefreshToken, verifyAccessToken, verifyRefreshToken
│ # Pure functions. No DB calls.
│
├── tokenHash.ts # hashRefreshToken — SHA-256, one-way
│ # Pure function. No DB calls.
│
├── issueTokens.ts # issueTokens(userId, email) → { accessToken, refreshToken }
│ # Pure function. Composes jwt.ts. No DB calls.
│ # Persistence is always the caller's responsibility.
│
├── refreshTokenStore.ts # All DB operations for refresh tokens
│ │ # Every function is a standalone atomic operation
│ │
│ ├── persistRefreshToken(userId, rawToken)
│ │ Single aggregation pipeline:
│ │ stage 1: $filter expired tokens out
│ │ stage 2: $slice to MAX_REFRESH_TOKENS - 1 (enforce cap)
│ │ stage 3: $concatArrays append new token
│ │
│ ├── rotateRefreshToken(userId, oldRaw, newRaw)
│ │ findOneAndUpdate with aggregation pipeline:
│ │ filter: { _id, tokenHash: oldHash, expiresAt > now }
│ │ stage 1: $filter removes old token + expired tokens
│ │ stage 2: $concatArrays appends new token
│ │ → returns null if old token not found → throws 401
│ │ ATOMIC: no gap between remove and insert
│ │
│ ├── revokeRefreshToken(userId, rawToken)
│ │ updateOne $pull — removes one token by hash
│ │
│ └── revokeAllRefreshTokens(userId)
│ updateOne $set refreshTokens: []
│
├── AppError.ts # AppError(message, statusCode) extends Error
├── asyncHandler.ts # wraps async route handlers, forwards errors to next()
├── authResponse.ts # buildAuthResponse(user, accessToken, refreshToken) → response shape
└── cookies.ts # refreshCookieOptions, clearCookieOptions
Access tokens are stateless — the server verifies them without a DB call. This keeps the hot path (every authenticated request) at zero database reads for auth. The tradeoff is that access tokens cannot be instantly revoked, so they are kept short-lived (15 minutes). Refresh tokens are long-lived (7 days) and stored server-side as hashes — they can be revoked instantly.
┌──────────────────────────┐
│ /auth/login │
│ or /auth/register │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ issueTokens() │
│ (pure — no DB) │
│ → accessToken (JWT, 15m) │
│ → refreshToken (JWT, 7d) │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ persistRefreshToken() │
│ (single pipeline — atomic│
│ filter + slice + push) │
└────────────┬─────────────┘
│
┌─────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
accessToken in JSON refreshToken in hash in
response body HttpOnly cookie users.refreshTokens[]
│
── 15 min ──▶│ access token expires
│
▼
┌───────────────────────┐
│ POST /auth/refresh │
│ rotateRefreshToken() │
│ (single pipeline — │
│ atomic pull+push) │
└───────────┬───────────┘
│
▼
new tokens issued
old token gone from DB
The naive two-step approach:
step 1: findOneAndUpdate → $pull old token
step 2: updateOne → $push new token
Has a window between the two writes. If the server crashes between them, the old token is gone but the new token was never persisted. The client holds a token that doesn't exist in the DB — permanent logout.
The aggregation pipeline approach:
findOneAndUpdate(filter, [
{ $set: { refreshTokens: { $filter: ... remove old + expired } } },
{ $set: { refreshTokens: { $concatArrays: [...existing, newToken] } } }
])
Both stages execute atomically in a single document write. Either the entire operation succeeds (old gone, new present) or it fails (nothing changes). No intermediate state is possible.
If two requests with the same refresh token hit /auth/refresh simultaneously:
Request A MongoDB
│ │
├── findOneAndUpdate ────────────▶│ oldHash found → pipeline runs
│ │ old removed, new A token inserted
│◀─────────────────────────────── │ returns the document (non-null)
│ success, new token issued │
│
Request B │
│ │
├── findOneAndUpdate ────────────▶│ oldHash NOT found (already gone)
│ │ filter matches nothing → returns null
│◀─────────────────────────────── │
│ result === null → throw 401 │
MongoDB's document-level locking ensures only one write wins. The second request gets null back — the token doesn't exist anymore — and correctly gets a 401.
// User document
{
_id: ObjectId,
email: string, // unique index
passwordHash: string, // bcryptjs, 12 rounds — never the raw password
refreshTokens: [
{
tokenHash: string, // SHA-256 of raw token — raw value never touches DB
expiresAt: Date,
}
],
createdAt: Date,
updatedAt: Date
}
// Task document
{
_id: ObjectId,
title: string,
description: string,
priority: 'low' | 'medium' | 'high',
status: 'todo' | 'in-progress' | 'completed',
dueDate: Date,
userId: ObjectId, // ref: User
createdAt: Date,
updatedAt: Date
}users collection
{ email: 1 } unique: true ← login lookup + duplicate check
tasks collection
{ userId: 1, status: 1 } ← GET /tasks?status=todo
{ userId: 1, priority: 1 } ← GET /tasks?priority=high
{ userId: 1, dueDate: 1 } ← GET /tasks?sortBy=dueDate
{ userId: 1, createdAt: -1 } ← GET /tasks?sortBy=createdAt (default)
{ userId: 1, status: 1, priority: 1 } ← dashboard aggregation + combined filter
All task indexes are compound with userId as the leading key. MongoDB will never scan across users for any query — every access pattern starts with a userId equality match.
GET /tasks/stats runs a single aggregation pipeline:
$match: { userId } ← uses index
│
▼
$facet:
total: [$count]
byStatus: [$group by status]
overdue: [$match dueDate < now, status != completed → $count]
│
▼
$project: reshape into dashboard shape
One query, one round trip, no N+1.
Layer 1 — Transport
HTTPS in production (Vercel, Render)
CORS restricted to CLIENT_URL only
Layer 2 — HTTP
Helmet sets security headers:
Content-Security-Policy
X-Frame-Options
X-Content-Type-Options
Strict-Transport-Security
Layer 3 — Input
Zod validation middleware on every route
Body, query params, and route params all validated
Invalid input → 400 before reaching controller
Layer 4 — Authentication
Access token: short-lived JWT, verified on every protected request
Refresh token: stored as SHA-256 hash only, raw value never in DB
Refresh cookie: HttpOnly, SameSite=Strict, Secure (production)
Layer 5 — Application
Ownership checks in services — users can only access their own tasks
Generic error messages for auth failures (no email enumeration)
asyncHandler catches all thrown errors → centralized error middleware
Layer 6 — Data
Passwords: bcryptjs, 12 rounds
No sensitive data in JWT payload beyond userId and email
No raw tokens, passwords, or secrets logged
All errors flow through centralized error middleware and return a consistent shape:
{
"success": false,
"message": "Human-readable message",
"errors": [] // optional, Zod validation errors only
}Stack traces and internal error details are never sent to the client.
Chosen because: stateless access tokens mean the Express server holds no session state. Any number of server instances can verify tokens independently — important for horizontal scaling on Render.
Cost: access tokens can't be instantly invalidated. Mitigated by keeping them short-lived (15 minutes) and using refresh token rotation for longer-lived sessions.
Chosen because: two-step pull-then-push has a crash window. The pipeline eliminates it. Also handles cap enforcement and expired token cleanup in the same operation.
Cost: aggregation pipeline updates (findOneAndUpdate with an array of stages) require MongoDB 4.2+. MongoDB Atlas runs 7.0 — not a concern.
Chosen because: the task schema is document-shaped with no cross-document joins needed. MongoDB's flexible schema also made early iteration fast.
Cost: schema enforcement is at the application layer (Mongoose + Zod) rather than the database layer. For this domain, that's an acceptable trade.
Chosen because: Redux is synchronous and readable from the router before any HTTP response — necessary for the protected/public-only routing model. TanStack Query handles caching, deduplication, and background sync in ways Redux would require significant boilerplate to replicate.
Cost: two mental models for state. Mitigated by clear ownership rules: Redux owns auth state, TanStack Query owns everything from the API.
Chosen because: features/auth/ and features/tasks/ are fully self-contained — their hooks, slice, and components all live together. Adding a new feature doesn't require touching multiple top-level directories.
Cost: slightly more directories. Pays for itself as the app grows.
- Node.js 22+
- MongoDB
- Docker (Optional)
git clone https://github.com/singh-kashish/TASK-MANAGEMENT-APP
cd task-management-appcd server
npm install
cp .env.example .env
npm run devcd web
npm install
cp .env.example .env
npm run devtouch server/.env
Add environment variables as defined in .env.example file.
docker compose up --buildFrontend:
http://localhost:5173
Backend:
http://localhost:8000/api
| Variable | Description |
|---|---|
| PORT | API Port |
| MONGODB_URI | MongoDB Connection String |
| CLIENT_URL | Frontend URL |
| JWT_ACCESS_SECRET | Access Token Secret |
| JWT_REFRESH_SECRET | Refresh Token Secret |
| JWT_ACCESS_EXPIRES | Access Token Expiry |
| JWT_REFRESH_EXPIRES | Refresh Token Expiry |
| Variable | Description |
|---|---|
| VITE_API_URL | Backend API Base URL |
Populate the database with sample data:
cd server
npm run seedThis creates sample users and tasks for local development and testing.
Creates a new user account.
Authenticates a user and returns:
- Access Token
- User Information
Also sets:
- Refresh Token HttpOnly Cookie
Signs out user from client(access token deleted from local storage & redux state cleared), deletes refresh token hash from their collection.
Signs out user from client(access token deleted from local storage & redux state cleared), deletes all refresh token hashes from their collection.
Uses Auth middleware to find user details on server and returns back the same to client.
Returns all tasks for the authenticated user.
Supported Query Parameters:
- status
- priority
- sortBy
- sortOrder
Creates a task.
Returns:
- Total Tasks
- Todo Tasks
- In Progress Tasks
- Completed Tasks
- Pending Tasks
- Overdue Tasks
- Completion Rate
Run backend tests:
cd server
npm testImplemented using:
- Jest
- Supertest
Tests cover:
- Authentication flows
- API routes
- Validation middleware
- Task service behavior
GitHub Actions automatically:
- Installs dependencies
- Runs builds
- Verifies application integrity
Triggered on:
- Pull Requests
- Pushes to main branch
Frontend
- Vercel
Backend
- Render
Database
- MongoDB Atlas
CI/CD
- GitHub Actions
The deployment architecture separates presentation, API, and persistence layers, allowing independent deployment and scaling.
The project includes Docker and Docker Compose support.
Services:
- Frontend (React/Vite)
- Backend (Express)
- MongoDB
Start the full stack:
touch server/.env
Add environment variables as defined in .env.example file.
docker compose up --build- Dark Mode Support
- Optimistic Updates
- Dockerized Setup
- Automated Testing
- CI/CD Pipeline
- Compound Database Indexes
task-management-app/
├── web/
│ ├── features/
│ ├── components/
│ ├── hooks/
│ ├── store/
│ ├── api/
│ ├── pages/
│ └── utils/
│
│ Detailed frontend structure above.
│
├── server/
│ ├── routes/
│ ├── controllers/
│ ├── services/
│ ├── models/
│ ├── middleware/
│ ├── validators/
│ ├── tests/
│ ├── types/
│ ├── scripts/
│ └── utils/
│
│
└── docker-compose.yml
| Enhancement | Rationale |
|---|---|
Rate limiting on /auth/login and /auth/refresh |
Prevent brute-force and token hammering |
| Swagger / OpenAPI docs | Machine-readable API contract, explorable via browser |
| WebSockets for real-time task updates | Push task changes across devices without polling |
tokensInvalidatedAt field on User |
Instant access token invalidation on signout-all without a blocklist |
| RBAC | Multi-role support (admin, member) for team-based task management |
| Activity audit log | Append-only record of create/update/delete events per user |
| Observability | Structured logging, request tracing, error tracking (e.g. Sentry) |
MIT