Skip to content
singh-kashishPublic

Latest commit

 

History

34 Commits

Folders and files

Repository files navigation

TaskFlow - Full Stack Task Management Platform

Live Demo

Frontend: https://task-management-app-delta-sable.vercel.app
Backend API: https://task-management-app-rs77.onrender.com/api


Architecture

Table of Contents


Design Goals

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.

System Overview

┌───────────────────────────────────────────────────────────┐
│                     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      │
│  Rate limiting (authLimiter + globalLimiter)               │
└─────────────────────────┬──────────────────────────────────┘
                          │
                          │  Mongoose ODM
                          │
┌─────────────────────────▼─────────────────────────────────┐
│                   MongoDB Atlas                           │
│                                                           │
│  users   { email, passwordHash, refreshTokens[] }         │
│  tasks   { title, status, priority, dueDate, userId }     │
└───────────────────────────────────────────────────────────┘

Frontend Architecture

Directory Structure

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: user, accessToken, isAuthenticated, isBootstrapping
│   │   ├── auth.api.ts     # loginUser, logoutUser, bootstrapAuthFlow via Axios
│   │   ├── auth.ts         # Types & Interfaces: User, AuthState, AuthPayload, AuthResponse
│   │   ├── authForm/       # Reusable form for Login & Register flows
│   │   └── auth.hooks.ts   # Custom hooks for login, register, logout via TanStack Query
│   └── tasks/
│       ├── task.api.ts     # getTaskStats, getTasks, createTask, updateTask, deleteTask
│       ├── task.hooks.ts   # useTasks, useCreateTask, useUpdateTask, useDeleteTask
│       └── task.types.ts   # TASK_STATUS, PRIORITY, CreateTaskPayload, UpdateTaskPayload
│
├── hooks/
│   ├── useAppDispatch.ts   # Typed useAppDispatch
│   ├── useAppSelector.ts   # Typed useAppSelector
│   └── useTaskStats.ts     # 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       # localStorage read/write/clear, refreshAccessToken,
│                           # fetchMe, bootstrapAuth
│
├── types/
│   └── axios.d.ts          # Axios interface extension
│
└── providers/
    ├── QueryProvider.tsx   # TanStack Query Client
    └── ThemeProvider.tsx   # Next Themes

State Architecture

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 before any HTTP response arrives. 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 automatically.

Routing Model

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.

Auth Bootstrap Flow

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())
    │       │
    │       └── 401 → fall through ↓
    │
    └─ POST /auth/refresh (browser sends HttpOnly cookie automatically)
            ├── 200 → save new accessToken to localStorage
            │         GET /auth/me → dispatch(setCredentials)
            │         dispatch(finishBootstrap())
            │
            └── 401 → dispatch(logout())
                      dispatch(finishBootstrap())
                      clear localStorage

Axios Interceptor — Transparent Token Refresh

Request fires
    ↓
Attach Authorization: Bearer <accessToken>
    ↓
Response received
    ├── 2xx → return normally
    └── 401 →
            ├── Is this /auth/refresh? → dispatch(logout()), reject
            └── POST /auth/refresh
                    ├── 200 → update token → retry original request
                    └── 401 → dispatch(logout()) → redirect /login

Any concurrent requests that 401 simultaneously queue behind a single in-flight refresh call rather than firing multiple refresh requests.


Backend Architecture

Layer Diagram

HTTP Request
    ↓
routes/          — define endpoint paths, compose middleware chains
    ↓
validators/      — Zod schema validation for body, query, params
    ↓
middleware/      — authMiddleware, errorMiddleware, validate, rateLimiter
    ↓
controllers/     — HTTP layer only: read req, call one service, write res
    ↓
services/        — all business logic: auth, tasks, stats
    ↓
models/          — Mongoose schema definitions
    ↓
MongoDB

Key Utils (backend)

utils/
├── jwt.ts
│   ├── generateAccessToken(payload) → string      pure, no DB
│   ├── generateRefreshToken(payload) → string     pure, no DB
│   ├── verifyAccessToken(token) → JwtPayload
│   └── verifyRefreshToken(token) → JwtPayload
│
├── tokenUtils.ts
│   ├── hashRefreshToken(token) → string
│   │       SHA-256, one-way, pure — raw token never stored in DB
│   │
│   ├── issueTokens(user) → { accessToken, refreshToken }
│   │       Pure function. Composes jwt.ts. No DB calls.
│   │       Persistence is always the caller's responsibility.
│   │
│   ├── storeRefreshToken(userId, rawToken)
│   │       Single aggregation pipeline (updatePipeline: true):
│   │         stage 1: $filter — remove expired tokens
│   │         stage 2: $slice  — trim to MAX_REFRESH_TOKENS - 1
│   │         stage 3: $concatArrays — append new token hash
│   │       Atomic. No gap between cleanup and insert.
│   │
│   ├── rotateRefreshToken(oldHash, user)
│   │       Single aggregation pipeline, filtered on old hash:
│   │         stage 1: $filter — remove old token + expired tokens
│   │         stage 2: $slice  — enforce session cap
│   │         stage 3: $concatArrays — append new token hash
│   │       modifiedCount === 0 → token not found → throws 401
│   │       ATOMIC: no intermediate state between remove and insert
│   │
│   ├── revokeRefreshToken(userId, rawToken)
│   │       $pull exact token by hash (signout)
│   │
│   └── revokeAllRefreshTokens(userId)
│           $set refreshTokens: [] (signout-all)
│
├── AppError.ts         AppError(message, statusCode) extends Error
├── asyncHandler.ts     wraps async handlers, forwards errors to next()
├── authResponse.ts     buildAuthResponse(user, accessToken, refreshToken)
└── cookies.ts          refreshCookieOptions, clearCookieOptions

Authentication Architecture

Why stateless JWT with refresh tokens?

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 SHA-256 hashes — raw values never touch the database and tokens can be revoked instantly.

Token Lifecycle

/auth/login or /auth/register
    ↓
issueTokens()              — pure, no DB
  → accessToken (JWT, 15m)
  → refreshToken (JWT, 7d)
    ↓
storeRefreshToken()        — single atomic pipeline
  filter expired + slice to cap + append new hash
    ↓
accessToken → JSON response body
refreshToken → HttpOnly SameSite=Strict cookie
hash → users.refreshTokens[]

── 15 min ──→ access token expires
    ↓
POST /auth/refresh
  rotateRefreshToken()     — single atomic pipeline
  old hash removed + new hash inserted in one operation
    ↓
new tokens issued, old token gone from DB

Why a single aggregation pipeline for rotation?

The naive two-step approach has a crash window:

step 1: pull old token    ← server crash here
step 2: push new token    ← never executes = user locked out permanently

The aggregation pipeline approach executes both stages atomically in a single document write:

UserModel.updateOne(
  { _id: userId, "refreshTokens.tokenHash": oldHash },
  [
    {
      $set: {
        refreshTokens: {
          $filter: {
            /* remove old + expired */
          },
        },
      },
    },
    { $set: { refreshTokens: { $slice: ["$refreshTokens", MAX - 1] } } },
    {
      $set: {
        refreshTokens: { $concatArrays: ["$refreshTokens", [newToken]] },
      },
    },
  ],
  { updatePipeline: true },
);

Either the entire rotation succeeds or nothing changes. No intermediate state is possible.

Concurrent Refresh Race Safety

If two requests with the same refresh token hit /auth/refresh simultaneously:

Request A → updateOne → oldHash found → pipeline runs → modified ✓ → new token issued
Request B → updateOne → oldHash GONE  → modifiedCount: 0 → throws 401

MongoDB's document-level locking ensures only one write wins. Validated by load testing: zero race conditions across 12,700+ requests at 50 concurrent users.


Database Architecture

Schema Design

// User document
{
  _id: ObjectId,
  email: string,              // unique index
  passwordHash: string,       // bcryptjs, 12 rounds
  refreshTokens: [{
    tokenHash: string,        // SHA-256 of raw token — raw never stored
    expiresAt: Date,
  }],
  createdAt: Date,
  updatedAt: Date
}

// Task document
{
  _id: ObjectId,
  title: string,
  description: string,
  priority: 'LOW' | 'MEDIUM' | 'HIGH',
  status: 'TODO' | 'IN_PROGRESS' | 'DONE',
  dueDate: Date,
  userId: ObjectId,           // ref: User
  createdAt: Date,
  updatedAt: Date
}

Indexing Strategy

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 (default sort)
  { 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 begins with a userId equality match.

Dashboard Aggregation

GET /api/tasks/stats runs a single $match → $group → $project pipeline — one round trip, no N+1:

$match: { userId: ObjectId }      ← hits compound index
    ↓
$group: _id: null
    total:       { $sum: 1 }
    todo:        { $sum: { $cond: [{ $eq: ["$status", "TODO"] }, 1, 0] } }
    inProgress:  { $sum: { $cond: [{ $eq: ["$status", "IN_PROGRESS"] }, 1, 0] } }
    completed:   { $sum: { $cond: [{ $eq: ["$status", "DONE"] }, 1, 0] } }
    pending:     { $sum: { $cond: [{ $ne: ["$status", "DONE"] }, 1, 0] } }
    overdue:     { $sum: { $cond: [overdue condition, 1, 0] } }
    ↓
$project: reshape + compute completionRate = round(completed/total * 100)

Returns: total, todo, inProgress, completed, pending, overdue, completionRate.


Security Architecture

Layer 1 — Transport
  HTTPS in production (Vercel, Render)
  CORS restricted to CLIENT_URL only

Layer 2 — HTTP
  Helmet: CSP, X-Frame-Options, X-Content-Type-Options, HSTS
  Rate limiting: authLimiter on /auth/* routes, globalLimiter site-wide

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 without DB read
  Refresh token: SHA-256 hash only, raw value never persisted
  Cookie: HttpOnly, SameSite=Strict, Secure (production)

Layer 5 — Application
  Ownership checks in services — userId on every task query
  Generic error messages — no email enumeration
  asyncHandler catches all async errors → centralized error middleware

Layer 6 — Data
  Passwords: bcryptjs, 12 rounds
  No sensitive fields in JWT payload (userId + email only)
  No tokens, passwords, or secrets in logs

Error Response Shape

{
  "success": false,
  "message": "Human-readable message",
  "errors": []
}

Stack traces never sent to client.


Load Test Results

Setup: 100,000 tasks seeded across 500 users. Production build (node dist/server.js). Tested with k6.

Stages: 0→20 VUs (30s) → 50 VUs (2m) → ramp down (30s)
Total duration: 3m 30s
Metric Result
Peak concurrent users 50
Total HTTP requests 12,731
HTTP error rate 0.00%
Auth failure rate 0.00%
Refresh token race conditions 0
Dashboard query — median latency 38ms
Dashboard query — p90 242ms
Dashboard query — p95 477ms
Overall requests — median 65ms
Overall requests — p95 441ms

Zero race conditions across 50 concurrent users firing simultaneous token refresh requests. The atomic MongoDB update pipeline handles concurrent 401 storms without duplicate refresh calls or corrupted token state.

Tested locally on Apple M-series. Production numbers on a dedicated server would differ.


Trade-offs and Decisions

JWT over server-side sessions

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.

Cost: access tokens can't be instantly revoked. Mitigated by keeping them short-lived (15 minutes) and using server-side refresh token rotation for longer-lived sessions.

Single aggregation pipeline for token rotation

Chosen because: two-step pull-then-push has a crash window between writes. The pipeline eliminates it. Also handles cap enforcement and expired token cleanup in the same operation.

Cost: aggregation pipeline updates require MongoDB 4.2+. MongoDB Atlas runs 7.0 — not a concern.

MongoDB over PostgreSQL

Chosen because: the task schema is document-shaped with no cross-document joins needed. Flexible schema made early iteration fast.

Cost: schema enforcement is at the application layer (Mongoose + Zod) rather than the database layer.

TanStack Query + Redux (two libraries)

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 that Redux would require significant boilerplate to replicate.

Cost: two mental models for state. Mitigated by clear ownership: Redux owns auth, TanStack Query owns everything from the API.

Feature-based frontend structure

Chosen because: features/auth/ and features/tasks/ are self-contained — hooks, slice, and types live together. Adding a new feature doesn't require touching multiple top-level directories.


Local Development Setup

Prerequisites

  • Node.js 22+
  • MongoDB (or Docker)

Clone

git clone https://github.com/singh-kashish/TaskFlo
cd task-management-app

Backend

cd server
npm install
cp .env.example .env
npm run dev

Frontend

cd web
npm install
cp .env.example .env
npm run dev

Docker (full stack)

touch server/.env   # fill in values from .env.example
docker compose up --build
# Frontend: http://localhost:5173
# Backend:  http://localhost:8000/api

Environment Variables

Backend (server/.env)

Variable Description
PORT Server port
MONGODB_URI MongoDB connection string
CLIENT_URL Frontend URL for CORS
JWT_ACCESS_SECRET Access token signing secret
JWT_REFRESH_SECRET Refresh token signing secret
JWT_ACCESS_EXPIRES Access token TTL (e.g. 15m)
JWT_REFRESH_EXPIRES Refresh token TTL (e.g. 7d)

Frontend (web/.env)

Variable Description
VITE_API_URL Backend API base URL

API Reference

Method Path Auth Description
POST /api/auth/register — Create account, returns accessToken + sets refresh cookie
POST /api/auth/login — Authenticate, returns accessToken + sets refresh cookie
GET /api/auth/me ✓ Return current user (no DB read — token only)
POST /api/auth/refresh cookie Rotate refresh token, return new accessToken
POST /api/auth/signout ✓ Revoke current session's refresh token
POST /api/auth/signout-all ✓ Revoke all sessions (refreshTokens: [])
GET /api/tasks ✓ List tasks — filter by status/priority, sort by dueDate/createdAt
POST /api/tasks ✓ Create task
GET /api/tasks/stats ✓ Dashboard aggregation
GET /api/tasks/:taskId ✓ Get single task (ownership enforced)
PATCH /api/tasks/:taskId ✓ Update task (ownership enforced)
DELETE /api/tasks/:taskId ✓ Delete task (ownership enforced)
GET /api/health — Health check

Testing

cd server && npm test

Covers: registration, duplicate email rejection, login, task CRUD, auth middleware enforcement, cross-user ownership isolation.


CI/CD

GitHub Actions on push to main and all pull requests:

  • npm ci + npm run build for both server/ and web/

Frontend deploys to Vercel, backend deploys to Render automatically on merge.


Project Structure

task-management-app/
├── web/
│   ├── features/
│   ├── components/
│   ├── hooks/
│   ├── store/
│   ├── api/
│   ├── pages/
│   └── utils/
├── server/
│   ├── routes/
│   ├── controllers/
│   ├── services/
│   ├── models/
│   ├── middleware/
│   ├── validators/
│   ├── tests/
│   ├── types/
│   ├── scripts/
│   └── utils/
└── docker-compose.yml

Bonus Features

  • Dark Mode Support
  • Optimistic Updates
  • Dockerized Setup
  • Automated Testing
  • CI/CD Pipeline
  • Compound Database Indexes
  • k6 Load Testing

Screenshots

Home

image

Dashboard

image

Login

image

Register

image image

Task Management

image image image image image

Future Enhancements

Enhancement Rationale
tokensInvalidatedAt field on User Instant access token invalidation on signout-all without a blocklist
WebSockets for real-time task updates Push changes across devices without polling
RBAC Multi-role support (admin, member) for team-based task management
Activity audit log Append-only record of create/update/delete events per user
Structured logging (Winston + correlation IDs) JSON logs with requestId tracing across full request lifecycle
OpenTelemetry Distributed tracing for request lifecycle visibility

License

MIT

Releases

Packages

Contributors

Languages