The capstone project is where every concept from the previous 22 chapters converges into a single, production-quality application. Rather than isolated exercises, the Task Manager app is built as a real product would be: starting from requirements and architecture decisions, then scaffolding the monorepo, configuring all tooling, and establishing the conventions that will carry through the entire build. This lesson covers the project requirements, technology decisions, folder structure, environment setup, and the foundational configuration files that make the remaining capstone chapters possible.
Project Feature Set
| Feature Area | Functionality | Chapters Applied |
|---|---|---|
| Authentication | Register, login, JWT refresh, password reset, email verification | Ch 17 |
| Task Management | CRUD, status workflow, priority, due dates, tags, soft delete | Ch 4–8 |
| Workspaces | Multi-user workspaces, member invitations, role-based access | Ch 17 |
| File Attachments | Upload images and documents to tasks, cloud storage | Ch 18 |
| Real-Time Updates | Live task changes via Socket.io, online presence, typing indicators | Ch 18 |
| Search | Full-text search with Atlas Search, filters, pagination | Ch 13, 18 |
| Notifications | In-app and email notifications for assignments, mentions, due dates | Ch 5 |
| Dashboard | Task stats, completion trends, overdue counts, tag cloud | Ch 13 |
| Performance | Redis caching, rate limiting, optimised queries | Ch 5, 22 |
apps/api (Express), apps/client (Angular), and packages/shared (TypeScript interfaces, DTOs, and constants shared by both). This structure enables type-safe contracts between the frontend and backend — the Angular service and the Express controller both import from @taskmanager/shared, making it impossible for them to diverge on interface shape without a TypeScript error at compile time.@typescript-eslint/recommended, Prettier for formatting, and commitlint with conventional commits (feat:, fix:, chore:) from day one. The pre-commit hook that runs lint and typecheck takes 10 seconds but saves hours of “fix linting” commits later..env files for the capstone contain real secrets — JWT secrets, MongoDB Atlas connection strings, email service credentials. Never commit these to the repository. The .env.example files committed to the repo show every variable name with placeholder values and comments explaining each. Run echo ".env*\n!.env.example" >> .gitignore immediately after creating the repository, before adding any .env files.Project Scaffolding
# ── 1. Create monorepo structure ─────────────────────────────────────────
mkdir taskmanager && cd taskmanager
git init
echo "node_modules/\ndist/\n.env\n.env.*\n!.env.example\ncoverage/\n*.heapsnapshot" > .gitignore
# Root package.json — workspace manager
cat > package.json <<'EOF'
{
"name": "taskmanager-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "concurrently \"npm run dev:api\" \"npm run dev:client\"",
"dev:api": "npm run dev --workspace=apps/api",
"dev:client": "npm run dev --workspace=apps/client",
"test": "npm run test --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present"
}
}
EOF
# Create workspace directories
mkdir -p apps/api apps/client packages/shared
# ── 2. Shared package ─────────────────────────────────────────────────────
cat > packages/shared/package.json <<'EOF'
{
"name": "@taskmanager/shared",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"typecheck": "tsc --noEmit"
}
}
EOF
mkdir -p packages/shared/src
# ── 3. Scaffold Express API ───────────────────────────────────────────────
cd apps/api
npm init -y
npm install express mongoose redis bull nodemailer bcryptjs jsonwebtoken
npm install multer helmet morgan cors express-rate-limit
npm install -D typescript @types/node @types/express jest supertest nodemon
# ── 4. Scaffold Angular client ────────────────────────────────────────────
cd ../client
ng new client --routing --style=scss --strict --standalone \
--no-create-application
ng generate application taskmanager --prefix=tm --style=scss --routing
# ── 5. Docker setup ───────────────────────────────────────────────────────
cd ../..
cat > docker-compose.yml <<'EOF'
version: '3.9'
services:
mongodb:
image: mongo:7
command: ["mongod", "--replSet", "rs0"]
ports: ["27017:27017"]
volumes: [mongodb_data:/data/db]
healthcheck:
test: ["CMD","mongosh","--eval","db.adminCommand('ping')","--quiet"]
interval: 10s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: [redis_data:/data]
api:
build: { context: ./apps/api, target: development }
env_file: ./apps/api/.env
volumes: ["./apps/api:/app", "api_modules:/app/node_modules"]
ports: ["3000:3000"]
depends_on:
mongodb: { condition: service_healthy }
client:
build: { context: ./apps/client, target: development }
volumes: ["./apps/client/src:/app/src"]
ports: ["4200:4200"]
environment: [CHOKIDAR_USEPOLLING=true]
volumes:
mongodb_data:
redis_data:
api_modules:
EOF
// ── packages/shared/src/models/task.model.ts ──────────────────────────────
export type TaskStatus = 'todo' | 'in-progress' | 'in-review' | 'done' | 'cancelled';
export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent';
export interface Task {
_id: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
dueDate?: string; // ISO 8601
startDate?: string;
tags: string[];
attachments: Attachment[];
assignees: string[]; // User IDs
workspace: string; // Workspace ID
createdBy: string; // User ID
createdAt: string;
updatedAt: string;
completedAt?: string;
isOverdue: boolean; // virtual
}
export interface Attachment {
_id: string;
filename: string;
url: string;
size: number;
mimeType: string;
uploadedBy: string;
uploadedAt: string;
}
export interface CreateTaskDto {
title: string;
description?: string;
priority?: TaskPriority;
dueDate?: string;
startDate?: string;
tags?: string[];
assignees?: string[];
workspaceId: string;
}
export type UpdateTaskDto = Partial<Omit<CreateTaskDto, 'workspaceId'>> & {
status?: TaskStatus;
};
// ── packages/shared/src/models/user.model.ts ──────────────────────────────
export type UserRole = 'user' | 'admin';
export interface User {
_id: string;
name: string;
email: string;
avatarUrl?: string;
role: UserRole;
isVerified: boolean;
createdAt: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
// ── packages/shared/src/models/workspace.model.ts ─────────────────────────
export type MemberRole = 'owner' | 'admin' | 'member' | 'viewer';
export interface WorkspaceMember {
userId: string;
role: MemberRole;
joinedAt: string;
}
export interface Workspace {
_id: string;
name: string;
slug: string;
description?:string;
members: WorkspaceMember[];
createdBy: string;
createdAt: string;
updatedAt: string;
}
// ── packages/shared/src/index.ts ─────────────────────────────────────────
export * from './models/task.model';
export * from './models/user.model';
export * from './models/workspace.model';
export * from './constants/api-routes';
export * from './constants/socket-events';
// ── packages/shared/src/constants/api-routes.ts ──────────────────────────
export const API_BASE = '/api/v1';
export const API_ROUTES = {
AUTH: `${API_BASE}/auth`,
USERS: `${API_BASE}/users`,
WORKSPACES: `${API_BASE}/workspaces`,
TASKS: `${API_BASE}/tasks`,
UPLOADS: `${API_BASE}/uploads`,
SEARCH: `${API_BASE}/search`,
HEALTH: `${API_BASE}/health`,
} as const;
// ── packages/shared/src/constants/socket-events.ts ───────────────────────
export const SOCKET_EVENTS = {
// Task events
TASK_CREATED: 'task:created',
TASK_UPDATED: 'task:updated',
TASK_DELETED: 'task:deleted',
TASK_ASSIGNED: 'task:assigned',
// User events
USER_TYPING: 'user:typing',
USER_JOINED: 'user:joined',
USER_LEFT: 'user:left',
// Workspace events
WORKSPACE_JOIN: 'workspace:join',
ONLINE_USERS: 'online_users',
// Notifications
NOTIFICATION: 'notification',
} as const;
How It Works
Step 1 — Monorepo Workspaces Share Code Without Publishing
npm workspaces link packages in the packages/ directory into node_modules/@taskmanager/shared automatically. The Angular and Express apps import { Task } from '@taskmanager/shared' — no publishing to npm required. Changes to the shared package are immediately reflected in both consumers. TypeScript’s project references ensure the shared package is compiled before the apps that depend on it.
Step 2 — Shared Models Are the Contract Between API and Client
When a shared Task interface changes — adding a new field — TypeScript compile errors appear in both the Express controller (which must return the field) and the Angular component (which must display it). This compile-time enforcement replaces runtime “undefined is not a field” bugs discovered in production. The shared package also exports API route constants and Socket.io event names, preventing the frontend and backend from diverging on string values.
Step 3 — Single-Node Replica Set Enables Transactions and Change Streams in Development
The Docker Compose MongoDB container uses command: ["mongod", "--replSet", "rs0"] to run as a replica set. After the container starts, the replica set must be initialised with rs.initiate() — the mongo-init.js script handles this. Without a replica set, transactions and Change Streams throw errors. The health check waits for the replica set to be ready before the API container starts.
Step 4 — Shared Constants Prevent Magic Strings
API route strings like '/api/v1/tasks' and Socket.io event names like 'task:created' appear in both the frontend and backend. Defining them once in @taskmanager/shared and importing them in both eliminates the class of bug where the frontend emits 'taskCreated' (camelCase) but the backend listens for 'task:created' (colon-separated) — a completely silent failure with no error message.
Step 5 — Strict TypeScript Catches Issues at Compile Time
The Angular app is generated with --strict, enabling strictNullChecks, noImplicitAny, and strict template checking. The Express API is configured with strict TypeScript settings. Combined with the shared types, this means a Task field used in a template must be typed — accessing task.nonExistentField is a compile error. This strict baseline prevents a category of runtime errors before the application runs.
Quick Reference — Project Structure
taskmanager/
├── apps/
│ ├── api/ # Express API
│ │ ├── src/
│ │ │ ├── config/ # env, logger, redis, mongoose
│ │ │ ├── middleware/ # auth, rbac, rate-limit, error
│ │ │ ├── modules/ # feature modules (tasks, auth, workspaces)
│ │ │ │ └── tasks/
│ │ │ │ ├── task.controller.ts
│ │ │ │ ├── task.service.ts
│ │ │ │ ├── task.model.ts
│ │ │ │ ├── task.routes.ts
│ │ │ │ └── task.validation.ts
│ │ │ ├── queues/ # Bull job queues
│ │ │ ├── sockets/ # Socket.io handlers
│ │ │ ├── app.ts
│ │ │ └── server.ts
│ │ ├── Dockerfile
│ │ └── .env.example
│ └── client/ # Angular app
│ └── src/app/
│ ├── core/ # singleton services, guards, interceptors
│ ├── shared/ # reusable components, pipes, directives
│ └── features/ # feature modules (tasks, auth, workspaces)
│ └── tasks/
│ ├── components/
│ ├── services/
│ └── stores/
└── packages/
└── shared/ # shared TypeScript models and constants
└── src/
├── models/
└── constants/