Skip to main content

Capstone Project: Task Management API

In this final chapter, we’ll build a complete, production-ready Task Management API that combines all the concepts from this course. This project will serve as a reference implementation you can adapt for your own applications.

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

# Create project
mkdir task-api && cd task-api
npm init -y

# Install dependencies
npm install express cors helmet compression morgan dotenv
npm install @prisma/client bcrypt jsonwebtoken zod redis uuid
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 and Prisma
npx tsc --init
npx prisma init

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

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().transform(Number).default('3000'),
  DATABASE_URL: z.string(),
  REDIS_URL: z.string().default('redis://localhost:6379'),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('15m'),
  JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
});

const result = envSchema.safeParse(process.env);

if (!result.success) {
  console.error('❌ Invalid environment variables:', result.error.format());
  process.exit(1);
}

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

# Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npx prisma generate

FROM node:20-alpine AS production

WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001

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

CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
# docker-compose.yml
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}
    depends_on:
      - postgres
      - redis
    restart: unless-stopped

  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’ve 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 you’ve learned apply to any domain—e-commerce, social media, CRM, or any other application type.

Next Steps

  1. Add real-time notifications with Socket.io
  2. Implement file uploads for attachments
  3. Add email notifications
  4. Deploy to a cloud provider
  5. Set up CI/CD pipeline
  6. Add API documentation with Swagger