A production-grade REST API built from scratch as part of the Claude API Challenge — demonstrating real-world backend engineering with TypeScript, Express 5, MongoDB, and a clean modular architecture. Every line of business logic, every schema, every middleware, and every utility is hand-written — no boilerplate generators, no scaffolding tools.
- Overview
- Architecture
- Tech Stack
- Data Models
- API Reference
- Security Design
- Database Layer
- Getting Started
- Project Structure
- Engineering Decisions
This project implements the backend for a multi-tenant task management platform — think Jira or Linear, built ground-up. The system supports organizations with teams, projects, tasks with dependencies, role-based access control, webhooks, API key management, and a full audit trail.
What makes this production-grade:
- Stateless JWT authentication with rotating refresh tokens stored in
httpOnlycookies - Layered service architecture (Controller → Service → Model) with zero business logic leaking into controllers
- Custom
ServiceResult<T>pattern for consistent, typed error propagation without throwing across layers - A
DatabaseManagerclass built on the raw MongoDB driver with connection pooling, exponential-backoff retry, slow-query detection, and periodic health checks — all sitting alongside Mongoose for the application layer - Soft-delete patterns across all entities (
deletedAtfield) for safe data recovery - Schema-level validation with Mongoose custom validators, not just app-level checks
- Compound database indexes chosen to match the real query patterns of the application
┌─────────────────────────────────────────────────────────┐
│ Express App │
├──────────┬──────────────────────────────────────────────┤
│ Routes │ /api/auth │ /api/org │ /api/... │
├──────────┴──────────────────────────────────────────────┤
│ Middleware Layer │
│ authenticate() │ authorize(...roles) │ checkOrg() │
├─────────────────────────────────────────────────────────┤
│ Module Layer (per domain) │
│ Controller → Service → Model (Mongoose Schema) │
├─────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ DatabaseManager (raw driver + pool + health checks) │
│ JWTUtils │ PasswordUtils │ ServiceResult<T> │
└─────────────────────────────────────────────────────────┘
Each domain (auth, org, user, task, project, team…) lives in its own module folder with its own controller, service, and route file. Nothing leaks between modules except through the shared model layer.
| Layer | Technology | Why |
|---|---|---|
| Runtime | Node.js + TypeScript 5 | Type-safe development, catches bugs at compile time |
| Framework | Express 5 | Latest stable, async error propagation built-in |
| Database | MongoDB + Mongoose 9 | Flexible document model fits multi-tenant hierarchy |
| Low-level DB | MongoDB Driver (raw) | Used in DatabaseManager for pool control & monitoring |
| Auth | JWT (jsonwebtoken) | Stateless, scalable; access + refresh token pair |
| Password | bcrypt | Adaptive hashing cost factor, industry standard |
| Dev Tools | ts-node + nodemon | Hot-reload TypeScript in development |
The schema design reflects a real multi-tenant SaaS architecture:
Organization
├── plan: FREE | BASIC | PREMIUM
├── subdomain (unique, indexed, slug-validated)
└── users[] → User
├── role: ADMIN | MANAGER | MEMBER
├── organizationId (ref)
└── refreshTokens[]
Teams (within an org)
└── TeamMembers (role: LEAD | MEMBER)
Projects (within an org)
├── status: ACTIVE | ARCHIVED | ON_HOLD
└── Tasks
├── status: TODO | IN_PROGRESS | IN_REVIEW | DONE | CANCELLED
├── priority: LOW | MEDIUM | HIGH | URGENT
├── parentId (self-ref for subtasks)
├── TaskAssignments
└── TaskDependencies
ApiKeys (org-scoped, key never exposed after creation)
Webhooks (org-scoped, secret never exposed after creation)
WebhookDeliveries (delivery status: PENDING | SUCCESS | FAILED | RETRYING)
AuditLogs (immutable append-only, tracks all mutations with IP + user agent)
Attachments
Comments
Every schema uses deletedAt: Date | null for soft deletes rather than hard removal — entities are logically deleted and can be recovered.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/register |
Public | Register a new user (becomes ADMIN of their org) |
POST |
/login |
Public | Login, returns accessToken in body + refreshToken in httpOnly cookie |
POST |
/refresh-token |
Cookie | Rotate the refresh token, get a new access token |
POST |
/logout |
Bearer | Invalidates the refresh token server-side |
POST |
/change-password |
Bearer | Validates current password, rotates all refresh tokens on change |
GET |
/profile |
Bearer | Returns authenticated user profile (password excluded) |
Token Strategy:
- Access tokens are short-lived JWTs sent in the
Authorization: Bearerheader - Refresh tokens are long-lived, stored in the database, and delivered only via
httpOnly; SameSite=Strictcookie — never accessible to JavaScript - On
change-password, all refresh tokens for the user are revoked (force logout everywhere)
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/ |
Bearer | Create an organization (links to authenticated user) |
GET |
/:id |
Bearer | Fetch organization by ID |
PATCH |
/:id |
Bearer | Update org details (name, subdomain — duplicate check enforced) |
DELETE |
/:id |
Bearer | Soft-delete the organization, unlinks user |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/me |
Bearer | Get current user details |
Security was treated as a first-class concern, not an afterthought:
Authentication Middleware (auth.middleware.ts)
- Extracts and verifies the Bearer token on every protected route
- Re-fetches the user from the database on every request — if the account is deactivated, access is immediately denied even with a valid token
- Three composable middleware functions:
authenticate,authorize(...roles), andcheckOrganization— applied selectively per route
Password Handling (password.utils.ts)
- Passwords are hashed with
bcryptbefore storage - The
passwordfield on the User schema hasselect: false— it is never returned in queries unless explicitly requested with.select('+password') - A
PasswordUtils.validate()function enforces strength rules before hashing
Secret Fields
ApiKey.key—select: falseWebhook.secret—select: false
Neither the raw API key nor the webhook signing secret is ever returned after the creation response. This is enforced at the schema level.
Refresh Token Rotation
Every refresh-token call deletes the old token and issues a brand-new one — preventing replay attacks from stolen tokens.
CORS
Configured to allow only the React/Vite dev origin (http://localhost:5173) with credentials: true. In production this would be parameterised via environment variable.
Two database clients coexist intentionally:
Mongoose handles the application layer — schemas, validation, virtuals, and query building for all CRUD operations.
DatabaseManager (raw MongoDB driver) sits below Mongoose and provides infrastructure-level capabilities that Mongoose doesn't expose:
- Connection pooling with event monitoring — tracks
connectionPoolCreated,connectionCreated,connectionClosed, andconnectionPoolClosedevents - Exponential backoff retry — up to 5 connection attempts with increasing delays (
attempt × 5000ms) - Slow query detection — commands exceeding 1 second are logged as warnings with the command name and duration
- Periodic health checks — pings the database admin every 30 seconds; emits a
healthCheckFailedevent if the ping fails - Metrics collection — tracks total queries, errors, and slow queries; exposes them via
getMetrics() executeWithRetry<T>()— wraps any async operation with per-operation retry logic (separate from connection retries)- Graceful disconnect —
disconnect()clears the health check interval and force-closes the client
- Node.js 18+
- MongoDB 6+ (local or Atlas)
- npm
Create backened/.env:
PORT=3000
NODE_ENV=development
MONGODB_URI=mongodb://localhost:27017/task_management_system
JWT_ACCESS_SECRET=your-access-token-secret-here
JWT_REFRESH_SECRET=your-refresh-token-secret-here
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d# Install backend dependencies
cd backened
npm install
# Development (hot reload)
npm run dev
# Build for production
npm run build
# Start production build
npm startThe server starts on http://localhost:3000 by default.
The React/Vite frontend scaffolding is in frontened/. Auth pages (Login, Signup), org creation, and state management (Zustand stores for auth and org) are in place. Full UI completion is ongoing.
cd frontened
npm install
npm run dev # http://localhost:5173claude-api-challange-main/
├── backened/
│ └── src/
│ ├── app.ts # Express app setup, CORS, route registration
│ ├── index.ts # Entry point, DB connect, server start
│ ├── config/
│ │ ├── auth.config.ts # JWT secrets and expiry configuration
│ │ └── database.config.ts # Per-environment DB config (dev/prod/test)
│ ├── lib/
│ │ └── database.ts # DatabaseManager — pooling, retry, health checks
│ ├── middleware/
│ │ └── auth.middleware.ts # authenticate, authorize, checkOrganization
│ ├── models/
│ │ ├── enums.ts # All domain enums (Role, TaskStatus, Priority…)
│ │ ├── index.ts # Barrel export for all models
│ │ ├── types.ts # All Mongoose document interfaces
│ │ └── schemas/
│ │ ├── User.ts
│ │ ├── Organizations.ts
│ │ ├── Project.ts
│ │ ├── Task.ts
│ │ ├── Team.ts
│ │ ├── TeamMember.ts
│ │ ├── TaskAssignment.ts
│ │ ├── TaskDependency.ts
│ │ ├── Comment.ts
│ │ ├── Attachment.ts
│ │ ├── ApiKey.ts
│ │ ├── RefreshToken.ts
│ │ ├── Webhook.ts
│ │ ├── WebhookDelivery.ts
│ │ └── AuditLog.ts
│ ├── modules/
│ │ ├── auth/
│ │ │ ├── auth.controller.ts
│ │ │ ├── auth.service.ts
│ │ │ └── auth.route.ts
│ │ ├── org/
│ │ │ ├── org.controller.ts
│ │ │ ├── org.service.ts
│ │ │ └── org.route.ts
│ │ └── user/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ └── user.route.ts
│ ├── types/
│ │ ├── auth.types.ts # RegisterDTO, LoginDTO, AuthResponse, JWTPayload
│ │ ├── service-result.ts # ServiceResult<T> generic type
│ │ └── user.types.ts
│ └── utils/
│ ├── jwt.utils.ts # JWTUtils — sign/verify access and refresh tokens
│ └── password.utils.ts # bcrypt hash, compare, and strength validation
└── frontened/
└── src/
├── pages/ # Home, Login, Signup, Dashboard, CreateOrg
├── components/ # Loading, ProtectedRoute
├── store/ # Zustand: auth.js, org.js
└── lib/ # Axios instance with base URL + credentials
ServiceResult<T> pattern — Services never throw errors into controllers. Instead they return { success, status, data?, error? }. Controllers check result.success and respond accordingly. This makes error handling explicit, testable, and consistent across all endpoints.
Dual database clients — Mongoose is excellent for schema management and querying; the raw MongoDB driver gives access to connection pool events and command monitoring that Mongoose abstracts away. Both are used where they're strongest.
Soft deletes everywhere — Setting deletedAt instead of calling deleteOne() means no data is ever truly lost. Audit queries, support tickets, and compliance requirements all become much easier. Hard deletion can be a scheduled cleanup job.
select: false on sensitive fields — Passwords, API keys, and webhook secrets are excluded from all queries by default at the schema level. This is a defence-in-depth measure; even if a developer forgets to exclude them in a query, they won't leak.
Refresh token rotation — Each refresh operation deletes the consumed token and creates a new one. This means stolen refresh tokens have a limited window of usefulness — the next legitimate use by the real user invalidates the attacker's copy.
TypeScript strict mode — All interfaces for Mongoose documents are explicitly defined in models/types.ts. DTOs for request bodies are typed in types/. The compiler catches mismatches before they reach production.
See backened/api-testing.md for curl examples and request/response samples for each endpoint.
Built hand-written from scratch — no generators, no scaffolding, no copy-paste. Just TypeScript, Express, MongoDB, and engineering.