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
| Layer | Technology |
|---|---|
| Runtime | Node.js 20+ |
| Framework | Express.js |
| Language | TypeScript |
| Database | PostgreSQL |
| ORM | Prisma |
| Cache | Redis |
| Auth | JWT + bcrypt |
| Validation | Zod |
| Testing | Jest + Supertest |
| Documentation | Swagger/OpenAPI |
Project Structure
Copy
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
Copy
# 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
# 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"]
Copy
# 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
Copy
// 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
Next Steps
- Add real-time notifications with Socket.io
- Implement file uploads for attachments
- Add email notifications
- Deploy to a cloud provider
- Set up CI/CD pipeline
- Add API documentation with Swagger