Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Capstone Project: Task Management API

In this final chapter, we will build a complete, production-ready Task Management API that combines all the concepts from this course. This is not a toy project — it follows the same architecture, patterns, and practices that professional teams use in production. Treat this as a reference implementation you can adapt for your own applications, and as a portfolio piece that demonstrates real-world Node.js proficiency.

Project Overview

We’re building a Task Management API with the following features:
  • User authentication (register, login, logout)
  • CRUD operations for tasks
  • Task categories and tags
  • Task assignment between users
  • Real-time notifications
  • File attachments
  • Search and filtering
  • Rate limiting and security

Tech Stack

LayerTechnology
RuntimeNode.js 20+
FrameworkExpress.js
LanguageTypeScript
DatabasePostgreSQL
ORMPrisma
CacheRedis
AuthJWT + bcrypt
ValidationZod
TestingJest + Supertest
DocumentationSwagger/OpenAPI

Project Structure

task-api/
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── src/
│   ├── config/
│   │   ├── database.ts
│   │   ├── redis.ts
│   │   └── env.ts
│   ├── controllers/
│   │   ├── authController.ts
│   │   ├── taskController.ts
│   │   └── userController.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   ├── errorHandler.ts
│   │   ├── rateLimiter.ts
│   │   └── validate.ts
│   ├── routes/
│   │   ├── index.ts
│   │   ├── authRoutes.ts
│   │   ├── taskRoutes.ts
│   │   └── userRoutes.ts
│   ├── schemas/
│   │   ├── authSchema.ts
│   │   ├── taskSchema.ts
│   │   └── userSchema.ts
│   ├── services/
│   │   ├── authService.ts
│   │   ├── cacheService.ts
│   │   ├── taskService.ts
│   │   └── userService.ts
│   ├── types/
│   │   ├── express.d.ts
│   │   └── index.ts
│   ├── utils/
│   │   ├── errors.ts
│   │   ├── logger.ts
│   │   └── helpers.ts
│   ├── app.ts
│   └── server.ts
├── tests/
│   ├── integration/
│   │   ├── auth.test.ts
│   │   └── tasks.test.ts
│   └── unit/
│       └── services/
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── package.json
├── tsconfig.json
└── README.md

Step 1: Initial Setup

Each dependency below earns its place. No bloated starter templates — only what this project actually uses.
# Create project
mkdir task-api && cd task-api
npm init -y

# Runtime dependencies -- these ship to production
npm install express cors helmet compression morgan dotenv
npm install @prisma/client bcrypt jsonwebtoken zod redis uuid

# Development dependencies -- only needed during development and build
npm install -D typescript ts-node-dev @types/node @types/express
npm install -D @types/bcrypt @types/jsonwebtoken @types/uuid
npm install -D prisma jest @types/jest supertest @types/supertest
npm install -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser

# Initialize TypeScript config (generates tsconfig.json)
# and Prisma (creates prisma/ directory and .env file)
npx tsc --init
npx prisma init
Practical tip: After running npx prisma init, immediately add .env to your .gitignore. Prisma creates a .env with a placeholder database URL, and it is easy to forget that this file should never be committed.

Step 2: Database Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  USER
  ADMIN
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  REVIEW
  DONE
}

enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

model User {
  id            String    @id @default(uuid())
  email         String    @unique
  password      String
  name          String
  role          Role      @default(USER)
  avatar        String?
  
  createdTasks  Task[]    @relation("CreatedTasks")
  assignedTasks Task[]    @relation("AssignedTasks")
  comments      Comment[]
  
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

model Task {
  id          String     @id @default(uuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  dueDate     DateTime?
  
  creator     User       @relation("CreatedTasks", fields: [creatorId], references: [id])
  creatorId   String
  
  assignee    User?      @relation("AssignedTasks", fields: [assigneeId], references: [id])
  assigneeId  String?
  
  category    Category?  @relation(fields: [categoryId], references: [id])
  categoryId  String?
  
  tags        Tag[]
  comments    Comment[]
  attachments Attachment[]
  
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([creatorId])
  @@index([assigneeId])
  @@index([status])
  @@index([priority])
}

model Category {
  id    String @id @default(uuid())
  name  String @unique
  color String @default("#3B82F6")
  tasks Task[]
}

model Tag {
  id    String @id @default(uuid())
  name  String @unique
  tasks Task[]
}

model Comment {
  id        String   @id @default(uuid())
  content   String
  
  task      Task     @relation(fields: [taskId], references: [id], onDelete: Cascade)
  taskId    String
  
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Attachment {
  id        String   @id @default(uuid())
  filename  String
  url       String
  size      Int
  mimeType  String
  
  task      Task     @relation(fields: [taskId], references: [id], onDelete: Cascade)
  taskId    String
  
  createdAt DateTime @default(now())
}

Step 3: Environment Configuration

This is one of the most important files in the entire project. It validates ALL required environment variables at startup using Zod. If any variable is missing or malformed, the app crashes immediately with a clear error message — far better than discovering a missing JWT_SECRET at 2 AM when the first user tries to log in.
// src/config/env.ts
import { z } from 'zod';

// Define the shape of your environment -- every variable your app needs
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().transform(Number).default('3000'),
  DATABASE_URL: z.string(),                    // No default -- must be explicitly set
  REDIS_URL: z.string().default('redis://localhost:6379'),
  JWT_SECRET: z.string().min(32),              // Enforce minimum secret length
  JWT_EXPIRES_IN: z.string().default('15m'),   // Short-lived access tokens
  JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
});

// safeParse does not throw -- it returns a result object so we can
// provide a clean error message instead of a cryptic stack trace
const result = envSchema.safeParse(process.env);

if (!result.success) {
  console.error('Invalid environment variables:', result.error.format());
  process.exit(1); // Fail fast -- do not start the server with bad config
}

export const env = result.data;

Step 4: Authentication Service

// src/services/authService.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { prisma } from '../config/database';
import { redis } from '../config/redis';
import { env } from '../config/env';
import { UnauthorizedError, ConflictError } from '../utils/errors';
import { RegisterInput, LoginInput } from '../schemas/authSchema';

interface TokenPayload {
  userId: string;
  role: string;
}

interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

export class AuthService {
  async register(data: RegisterInput) {
    const existingUser = await prisma.user.findUnique({
      where: { email: data.email }
    });

    if (existingUser) {
      throw new ConflictError('Email already registered');
    }

    const hashedPassword = await bcrypt.hash(data.password, 12);

    const user = await prisma.user.create({
      data: {
        email: data.email,
        password: hashedPassword,
        name: data.name
      },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        createdAt: true
      }
    });

    const tokens = this.generateTokens({ userId: user.id, role: user.role });
    await this.storeRefreshToken(user.id, tokens.refreshToken);

    return { user, tokens };
  }

  async login(data: LoginInput) {
    const user = await prisma.user.findUnique({
      where: { email: data.email }
    });

    if (!user || !await bcrypt.compare(data.password, user.password)) {
      throw new UnauthorizedError('Invalid credentials');
    }

    const tokens = this.generateTokens({ userId: user.id, role: user.role });
    await this.storeRefreshToken(user.id, tokens.refreshToken);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      },
      tokens
    };
  }

  async refresh(refreshToken: string) {
    try {
      const decoded = jwt.verify(
        refreshToken,
        env.JWT_SECRET
      ) as TokenPayload & { type: string };

      if (decoded.type !== 'refresh') {
        throw new UnauthorizedError('Invalid token type');
      }

      const storedToken = await redis.get(`refresh:${decoded.userId}`);
      if (storedToken !== refreshToken) {
        throw new UnauthorizedError('Token revoked');
      }

      const tokens = this.generateTokens({
        userId: decoded.userId,
        role: decoded.role
      });
      await this.storeRefreshToken(decoded.userId, tokens.refreshToken);

      return tokens;
    } catch (error) {
      throw new UnauthorizedError('Invalid refresh token');
    }
  }

  async logout(userId: string) {
    await redis.del(`refresh:${userId}`);
  }

  private generateTokens(payload: TokenPayload): AuthTokens {
    const accessToken = jwt.sign(
      { ...payload, type: 'access' },
      env.JWT_SECRET,
      { expiresIn: env.JWT_EXPIRES_IN }
    );

    const refreshToken = jwt.sign(
      { ...payload, type: 'refresh' },
      env.JWT_SECRET,
      { expiresIn: env.JWT_REFRESH_EXPIRES_IN }
    );

    return { accessToken, refreshToken };
  }

  private async storeRefreshToken(userId: string, token: string) {
    // Parse expiry from env (e.g., "7d" -> 7 * 24 * 60 * 60)
    const expiry = 7 * 24 * 60 * 60; // 7 days in seconds
    await redis.setEx(`refresh:${userId}`, expiry, token);
  }
}

export const authService = new AuthService();

Step 5: Task Service

// src/services/taskService.ts
import { prisma } from '../config/database';
import { cacheService } from './cacheService';
import { NotFoundError, ForbiddenError } from '../utils/errors';
import { CreateTaskInput, UpdateTaskInput, TaskFilters } from '../schemas/taskSchema';
import { Prisma } from '@prisma/client';

export class TaskService {
  async findAll(userId: string, filters: TaskFilters, page = 1, limit = 20) {
    const where: Prisma.TaskWhereInput = {
      OR: [
        { creatorId: userId },
        { assigneeId: userId }
      ]
    };

    if (filters.status) where.status = filters.status;
    if (filters.priority) where.priority = filters.priority;
    if (filters.categoryId) where.categoryId = filters.categoryId;
    if (filters.assigneeId) where.assigneeId = filters.assigneeId;
    if (filters.search) {
      where.OR = [
        { title: { contains: filters.search, mode: 'insensitive' } },
        { description: { contains: filters.search, mode: 'insensitive' } }
      ];
    }

    const [tasks, total] = await Promise.all([
      prisma.task.findMany({
        where,
        include: {
          creator: { select: { id: true, name: true, avatar: true } },
          assignee: { select: { id: true, name: true, avatar: true } },
          category: true,
          tags: true,
          _count: { select: { comments: true, attachments: true } }
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit
      }),
      prisma.task.count({ where })
    ]);

    return {
      tasks,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
      }
    };
  }

  async findById(id: string, userId: string) {
    const cacheKey = `task:${id}`;
    const cached = await cacheService.get(cacheKey);
    
    if (cached) return cached;

    const task = await prisma.task.findUnique({
      where: { id },
      include: {
        creator: { select: { id: true, name: true, email: true, avatar: true } },
        assignee: { select: { id: true, name: true, email: true, avatar: true } },
        category: true,
        tags: true,
        comments: {
          include: {
            author: { select: { id: true, name: true, avatar: true } }
          },
          orderBy: { createdAt: 'desc' }
        },
        attachments: true
      }
    });

    if (!task) {
      throw new NotFoundError('Task');
    }

    // Check access
    if (task.creatorId !== userId && task.assigneeId !== userId) {
      throw new ForbiddenError('Access denied to this task');
    }

    await cacheService.set(cacheKey, task, 300); // 5 minutes
    return task;
  }

  async create(data: CreateTaskInput, creatorId: string) {
    const task = await prisma.task.create({
      data: {
        title: data.title,
        description: data.description,
        status: data.status,
        priority: data.priority,
        dueDate: data.dueDate,
        creatorId,
        assigneeId: data.assigneeId,
        categoryId: data.categoryId,
        tags: data.tagIds ? {
          connect: data.tagIds.map(id => ({ id }))
        } : undefined
      },
      include: {
        creator: { select: { id: true, name: true } },
        assignee: { select: { id: true, name: true } },
        category: true,
        tags: true
      }
    });

    return task;
  }

  async update(id: string, data: UpdateTaskInput, userId: string) {
    const task = await prisma.task.findUnique({ where: { id } });

    if (!task) {
      throw new NotFoundError('Task');
    }

    if (task.creatorId !== userId) {
      throw new ForbiddenError('Only the creator can update this task');
    }

    const updated = await prisma.task.update({
      where: { id },
      data: {
        ...data,
        tags: data.tagIds ? {
          set: data.tagIds.map(id => ({ id }))
        } : undefined
      },
      include: {
        creator: { select: { id: true, name: true } },
        assignee: { select: { id: true, name: true } },
        category: true,
        tags: true
      }
    });

    await cacheService.invalidate(`task:${id}`);
    return updated;
  }

  async delete(id: string, userId: string) {
    const task = await prisma.task.findUnique({ where: { id } });

    if (!task) {
      throw new NotFoundError('Task');
    }

    if (task.creatorId !== userId) {
      throw new ForbiddenError('Only the creator can delete this task');
    }

    await prisma.task.delete({ where: { id } });
    await cacheService.invalidate(`task:${id}`);
  }
}

export const taskService = new TaskService();

Step 6: Controllers

// src/controllers/taskController.ts
import { Request, Response, NextFunction } from 'express';
import { taskService } from '../services/taskService';
import { CreateTaskInput, UpdateTaskInput, TaskFilters } from '../schemas/taskSchema';

export const getTasks = async (
  req: Request<{}, {}, {}, TaskFilters & { page?: string; limit?: string }>,
  res: Response,
  next: NextFunction
) => {
  try {
    const { page, limit, ...filters } = req.query;
    const result = await taskService.findAll(
      req.user!.id,
      filters,
      parseInt(page || '1'),
      parseInt(limit || '20')
    );
    res.json({ success: true, ...result });
  } catch (error) {
    next(error);
  }
};

export const getTask = async (
  req: Request<{ id: string }>,
  res: Response,
  next: NextFunction
) => {
  try {
    const task = await taskService.findById(req.params.id, req.user!.id);
    res.json({ success: true, data: task });
  } catch (error) {
    next(error);
  }
};

export const createTask = async (
  req: Request<{}, {}, CreateTaskInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const task = await taskService.create(req.body, req.user!.id);
    res.status(201).json({ success: true, data: task });
  } catch (error) {
    next(error);
  }
};

export const updateTask = async (
  req: Request<{ id: string }, {}, UpdateTaskInput>,
  res: Response,
  next: NextFunction
) => {
  try {
    const task = await taskService.update(req.params.id, req.body, req.user!.id);
    res.json({ success: true, data: task });
  } catch (error) {
    next(error);
  }
};

export const deleteTask = async (
  req: Request<{ id: string }>,
  res: Response,
  next: NextFunction
) => {
  try {
    await taskService.delete(req.params.id, req.user!.id);
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Step 7: Routes and Middleware

// src/routes/taskRoutes.ts
import { Router } from 'express';
import * as taskController from '../controllers/taskController';
import { auth } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { createTaskSchema, updateTaskSchema } from '../schemas/taskSchema';

const router = Router();

router.use(auth); // All routes require authentication

router.get('/', taskController.getTasks);
router.get('/:id', taskController.getTask);
router.post('/', validate(createTaskSchema), taskController.createTask);
router.put('/:id', validate(updateTaskSchema), taskController.updateTask);
router.delete('/:id', taskController.deleteTask);

export default router;

Step 8: Application Entry

// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';

import { env } from './config/env';
import { errorHandler } from './middleware/errorHandler';
import { rateLimiter } from './middleware/rateLimiter';
import { NotFoundError } from './utils/errors';

import authRoutes from './routes/authRoutes';
import userRoutes from './routes/userRoutes';
import taskRoutes from './routes/taskRoutes';

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({
  origin: env.NODE_ENV === 'production' 
    ? 'https://yourdomain.com' 
    : 'http://localhost:3000',
  credentials: true
}));

// Rate limiting
app.use(rateLimiter);

// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Compression
app.use(compression());

// Logging
if (env.NODE_ENV !== 'test') {
  app.use(morgan('combined'));
}

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/tasks', taskRoutes);

// 404 handler
app.use((req, res, next) => {
  next(new NotFoundError(`Route ${req.originalUrl} not found`));
});

// Error handler
app.use(errorHandler);

export default app;

Step 9: Docker Setup

This Dockerfile uses a multi-stage build — the first stage (builder) installs all dependencies and compiles TypeScript, then the second stage (production) copies only the compiled output and production dependencies. This keeps your production image small and free of build tools, TypeScript source, and devDependencies.
# Dockerfile

# Stage 1: Build -- install everything, compile TS, generate Prisma client
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                     # ci is faster and more deterministic than install
COPY . .
RUN npm run build              # Compile TypeScript to JavaScript
RUN npx prisma generate        # Generate the Prisma Client based on schema

# Stage 2: Production -- only what we need to run
FROM node:20-alpine AS production
WORKDIR /app

# Security: run as non-root user -- never run production containers as root
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001

# Copy only the artifacts needed for production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma

USER nodejs
EXPOSE 3000

# Run migrations then start the server.
# In production, you may want to run migrations as a separate step
# in your CI/CD pipeline instead.
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
# docker-compose.yml
# This file defines your entire local development stack -- one `docker compose up`
# and you have a running API, database, and cache.
version: '3.8'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://postgres:password@postgres:5432/taskdb
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=${JWT_SECRET}    # Read from your host .env file -- never hardcode secrets
    depends_on:
      - postgres                     # Docker starts postgres before api
      - redis
    restart: unless-stopped          # Auto-restart on crash (but not manual stop)

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=taskdb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"

volumes:
  postgres-data:
  redis-data:

Step 10: Testing

// tests/integration/tasks.test.ts
import request from 'supertest';
import app from '../../src/app';
import { prisma } from '../../src/config/database';

describe('Task API', () => {
  let authToken: string;
  let userId: string;

  beforeAll(async () => {
    // Create test user and get token
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User'
      });

    authToken = response.body.tokens.accessToken;
    userId = response.body.user.id;
  });

  afterAll(async () => {
    await prisma.user.deleteMany();
    await prisma.$disconnect();
  });

  describe('POST /api/tasks', () => {
    it('should create a new task', async () => {
      const response = await request(app)
        .post('/api/tasks')
        .set('Authorization', `Bearer ${authToken}`)
        .send({
          title: 'Test Task',
          description: 'Test description',
          priority: 'HIGH'
        })
        .expect(201);

      expect(response.body.success).toBe(true);
      expect(response.body.data.title).toBe('Test Task');
      expect(response.body.data.creatorId).toBe(userId);
    });

    it('should return 401 without auth', async () => {
      await request(app)
        .post('/api/tasks')
        .send({ title: 'Test' })
        .expect(401);
    });

    it('should validate required fields', async () => {
      const response = await request(app)
        .post('/api/tasks')
        .set('Authorization', `Bearer ${authToken}`)
        .send({})
        .expect(400);

      expect(response.body.errors).toBeDefined();
    });
  });
});

Summary

Congratulations — you have built a production-ready Task Management API that demonstrates:
  • TypeScript for type safety
  • Prisma for database management
  • JWT authentication with refresh tokens
  • Redis caching
  • Zod validation
  • Error handling with custom error classes
  • Rate limiting and security headers
  • Docker containerization
  • Testing with Jest and Supertest
This project serves as a template for building any REST API with Node.js. The patterns and practices covered here apply to any domain — e-commerce, social media, CRM, or any other application type. The architecture is deliberately conventional: controllers, services, routes, middleware. This is not because it is the only way, but because it is the most widely understood pattern in the Node.js ecosystem, making it easy for other developers to onboard.

Next Steps

Once the core API is working, here are high-value extensions ordered by learning impact:
  1. Add real-time notifications with Socket.io — integrates Chapter 16 concepts with this codebase
  2. Implement file uploads for attachments — applies the Multer security patterns from Chapter 17
  3. Add email notifications — practice asynchronous processing (send emails in a worker, not in the request handler)
  4. Deploy to a cloud provider — use the Docker setup from Step 9 on Railway, Render, or AWS ECS
  5. Set up CI/CD pipeline — run tests automatically on every push with GitHub Actions
  6. Add API documentation with Swagger — use swagger-jsdoc to generate OpenAPI docs from your route annotations