A production-style REST API built with Node.js, Express, and TypeScript. It powers a support-task management system where users can create and manage their own tasks, admins can view and update task statuses across all users, and admins can manage promotional banners with Cloudinary image storage.
The API uses cookie-based JWT authentication, Redis caching, Redis-backed rate limiting, and BullMQ background jobs for async Cloudinary cleanup.
- Features
- Tech Stack
- Architecture
- Project Structure
- Prerequisites
- Getting Started
- Environment Variables
- Database Migrations
- Authentication & CSRF
- API Reference
- Caching Strategy
- Rate Limiting
- Background Jobs
- Scripts
- Error Handling
- User authentication — email/password registration and login
- Google OAuth — sign in with Google; links to existing accounts by email
- Role-based access —
USERandADMINroles with middleware guards - Support tasks — users CRUD their own tasks; admins list, filter, and update task status
- Admin banners — upload, list, and delete banner images via Cloudinary
- Redis caching — cache-aside pattern for task lists, individual tasks, and banners
- Rate limiting — IP-based limits on auth routes; user-based limits on task routes
- CSRF protection — double-submit cookie pattern on mutating task/banner routes
- Background workers — BullMQ worker deletes Cloudinary assets when banners are removed
| Layer | Technology |
|---|---|
| Runtime | Node.js |
| Language | TypeScript |
| Framework | Express 5 |
| Database | PostgreSQL 16 |
| Cache / Queues | Redis 7 (ioredis + BullMQ) |
| Auth | JWT (httpOnly cookies) + Google OAuth |
| File storage | Cloudinary |
| Logging | Pino |
The codebase follows a layered structure:
HTTP Request
↓
Routes → Define endpoints, parse input, send responses
↓
Middleware → Auth, admin guard, CSRF, rate limits, file upload
↓
Services → Business logic, validation, cache orchestration
↓
Repositories → Raw SQL queries via pg pool
↓
PostgreSQL / Redis / Cloudinary
task-server/
├── migrations/ # SQL migration files (run in order)
├── src/
│ ├── app.ts # Express app setup (CORS, middleware, routes)
│ ├── server.ts # Entry point, graceful shutdown
│ ├── config/
│ │ └── env.ts # Environment variable validation
│ ├── constants/
│ │ └── auth.constant.ts # Password length, bcrypt salt rounds
│ ├── db/
│ │ └── migrate.ts # Migration runner
│ ├── lib/ # Shared utilities
│ │ ├── appError.ts # Custom HTTP error class
│ │ ├── bcrypt.ts # Password hashing
│ │ ├── cache-helper.ts # Redis cache key helpers & invalidation
│ │ ├── cloudinary.ts # Image upload/delete
│ │ ├── cookie.ts # Auth cookie management
│ │ ├── db.ts # PostgreSQL connection pool
│ │ ├── google.ts # Google OAuth helpers
│ │ ├── jwt.ts # Token generation & verification
│ │ └── logger.ts # Pino logger
│ ├── middleware/
│ │ ├── admin.middleware.ts # requireAdmin guard
│ │ ├── auth.middleware.ts # authenticate + requireCsrf
│ │ ├── banner.middleware.ts # Multer image upload
│ │ ├── error-handler.ts # Global error handler
│ │ ├── not-found.ts # 404 handler
│ │ └── rateLimit.middleware.ts
│ ├── queues/ # BullMQ queue definitions
│ ├── redis/
│ │ └── redis.ts # Redis client & setDataToRedis helper
│ ├── repositories/ # Database access layer
│ ├── routes/ # Express routers
│ ├── services/ # Business logic
│ ├── workers/ # BullMQ workers
│ └── types.ts # Shared TypeScript types
├── docker-compose.yml # PostgreSQL + Redis containers
├── .env.example # Environment variable template
└── package.json
- Node.js 18+ (ES2022 target)
- npm
- Docker (recommended for PostgreSQL and Redis)
- Cloudinary account (for banner uploads)
- Google Cloud OAuth credentials (for Google login)
git clone <repository-url>
cd task-server
npm installnpm run docker:upThis starts:
| Service | Container | Host port |
|---|---|---|
| PostgreSQL 16 | task-server |
5435 |
| Redis 7 | task-server-cache |
6379 |
cp .env.example .envEdit .env with your secrets. See Environment Variables for the full list.
Note:
.env.exampleusesAUTH_RATE_LIMIT_MAX_REQUESTbut the app expectsAUTH_RATE_LIMIT_MAX_REQUESTS. Also addTASKS_RATE_LIMIT_MAX_REQUESTS— both are required bysrc/config/env.ts.
npm run migratenpm run devThe API will be available at http://localhost:4000/api.
npm run build
npm start| Variable | Required | Description |
|---|---|---|
PORT |
No | Server port (default: 4000) |
NODE_ENV |
No | development or production |
LOG_LEVEL |
No | Pino log level (e.g. info) |
ALLOWED_ORIGINS |
No | Comma-separated CORS origins |
DATABASE_URL |
Yes | PostgreSQL connection string |
ACCESS_TOKEN_SECRET_KEY |
Yes | JWT access token secret |
ACCESS_TOKEN_EXPIRY_PERIOD |
Yes | Access token TTL (e.g. 15m) |
REFRESH_TOKEN_SECRET_KEY |
Yes | JWT refresh token secret |
REFRESH_TOKEN_EXPIRY_PERIOD |
Yes | Refresh token TTL (e.g. 7d) |
COOKIE_SECURE |
No | Set cookies Secure flag (true/false) |
COOKIE_SAME_SITE |
Yes | Cookie SameSite policy (lax, strict, none) |
REDIS_URL |
Yes | Redis connection URL |
GET_ALL_CACHED_TASK_EXPIRY |
Yes | Cache TTL in seconds |
AUTH_RATE_LIMIT_MAX_REQUESTS |
Yes | Max auth requests per window (per IP) |
TASKS_RATE_LIMIT_MAX_REQUESTS |
Yes | Max task requests per window (per user) |
RATE_LIMIT_WINDOW_SECONDS |
Yes | Rate limit window for auth/tasks |
REFRESH_COOKIE_RATE_LIMIT_MAX_REQUESTS |
Yes | Max refresh requests per window |
REFRESH_COOKIE_RATE_LIMIT_WINDOW_SECONDS |
Yes | Refresh rate limit window |
CLOUDINARY_CLOUD_NAME |
Yes | Cloudinary cloud name |
CLOUDINARY_CLOUD_API_KEY |
Yes | Cloudinary API key |
CLOUDINARY_CLOUD_API_SECRET |
Yes | Cloudinary API secret |
GOOGLE_CLIENT_ID |
Yes | Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Yes | Google OAuth client secret |
GOOGLE_CALLBACK_URL |
Yes | Google OAuth redirect URI |
Migrations live in migrations/ and run in alphabetical order. A migrations tracking table prevents re-running completed files.
| File | Description |
|---|---|
001_enable_pgcrypto.sql |
Enables pgcrypto for UUID generation |
002_create_users_table.sql |
Users table (USER / ADMIN roles, Google ID support) |
003_create_support_tasks_table.sql |
Support tasks table with status enum |
004_banners_table.sql |
Banners table for Cloudinary image references |
users
id(UUID),email,password_hash,google_id,role, timestamps
support_tasks
id,title,status(PENDING|IN_PROGRESS|RESOLVED),user_id(FK), timestamps
banners
id,image_url,cloudinary_public_id, timestamps
On login (email/password or Google), the server sets three cookies:
| Cookie | Purpose | httpOnly |
|---|---|---|
access_cookie |
Short-lived JWT for API auth | Yes |
refresh_cookie |
Long-lived JWT for token refresh | Yes |
csrf_token |
CSRF double-submit token | No |
Protected routes read the access token from access_cookie via the authenticate middleware.
POST /api/auth/refresh
Uses the refresh_cookie to issue new access, refresh, and CSRF cookies. Rate-limited per refresh token.
Mutating requests to /api/tasks, /api/admin/tasks, and /api/admin/banner require:
csrf_tokencookie (set at login)- Matching
x-csrf-tokenrequest header
Safe methods (GET, HEAD, OPTIONS) bypass CSRF checks.
GET /api/auth/google— redirects to Google consent screen- Google redirects to
GET /api/auth/callback/google?code=... - Server exchanges the code, creates or links the user, and sets auth cookies
- Send requests with
credentials: 'include'(CORS is configured withcredentials: true) - Include
x-csrf-tokenheader onPOST,PATCH,PUT,DELETErequests
Base URL: /api
All responses follow a consistent shape:
{
"success": true,
"message": "Optional message",
"data": { }
}Errors return:
{
"success": false,
"message": "Error description"
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
No | Health check |
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
POST |
/register |
No | IP | Register with email and password |
POST |
/login |
No | IP | Login; sets auth cookies |
POST |
/refresh |
No | Refresh token | Refresh auth cookies |
GET |
/me |
Yes | — | Get current user from JWT |
GET |
/google |
No | — | Start Google OAuth flow |
GET |
/callback/google |
No | — | Google OAuth callback |
POST |
/logout |
No | — | Clear auth cookies |
Register / Login body:
{
"email": "user@example.com",
"password": "secret123"
}Password must be at least 6 characters.
All routes require authentication, CSRF (on mutations), and are rate-limited per user.
| Method | Path | Description |
|---|---|---|
GET |
/ |
List current user's tasks |
GET |
/:id |
Get a single task (ownership enforced) |
POST |
/ |
Create a task |
PATCH |
/:id |
Update task title |
DELETE |
/:id |
Delete a task |
Create / Update body:
{
"title": "My support request"
}Title is trimmed and limited to 100 characters. Users can only access tasks where user_id matches their own ID.
Requires ADMIN role.
| Method | Path | Description |
|---|---|---|
GET |
/ |
List all tasks (supports query filters) |
GET |
/:taskId |
Get any task by ID |
PATCH |
/:taskId/status |
Update task status |
List query parameters:
| Param | Description |
|---|---|
search |
Filter by title (case-insensitive partial match) |
status |
Filter by status (PENDING, IN_PROGRESS, RESOLVED) |
Update status body:
{
"status": "IN_PROGRESS"
}Valid statuses: PENDING, IN_PROGRESS, RESOLVED.
Requires ADMIN role. CSRF required on mutations.
| Method | Path | Description |
|---|---|---|
GET |
/ |
List all banners |
GET |
/:bannerId |
Get banner by ID |
POST |
/ |
Upload a banner image |
DELETE |
/:bannerId |
Delete banner (DB + async Cloudinary cleanup) |
Upload: multipart/form-data with field name image. Max file size: 5 MB. Images only.
The API uses a cache-aside pattern with Redis. TTL is controlled by GET_ALL_CACHED_TASK_EXPIRY.
| Key pattern | Used for |
|---|---|
admin:tasks:all |
Admin unfiltered task list |
admin:task:{taskId} |
Admin single task |
user:{userId}:tasks:lists |
User's task list |
task:user:{userId}:task:{taskId} |
User single task (ownership-scoped key) |
admin:banner:all |
Admin banner list |
admin:banner:{bannerId} |
Admin single banner |
| Action | Invalidated keys |
|---|---|
| User creates task | admin:tasks:all, user:{userId}:tasks:lists |
| User updates/deletes task | Admin list, user list, user task key, admin task key |
| Admin updates task status | Same as above (uses task.user_id) |
| Admin creates/deletes banner | admin:banner:all |
Filtered admin queries (?search= or ?status=) bypass the cache and always hit the database.
Rate limits are enforced via Redis counters. Response headers are included on limited routes:
X-RateLimit-LimitX-RateLimit-Remaining
| Limiter | Scope | Identifier |
|---|---|---|
Auth (/auth/register, /auth/login) |
Per IP | Client IP |
| Tasks (user + admin task routes) | Per user | Authenticated user ID |
Refresh (/auth/refresh) |
Per session | Refresh token subject |
When the limit is exceeded, the API returns 429 Too Many Requests. On Redis errors, rate limiters fail open (request proceeds).
Banner deletion uses BullMQ backed by Redis:
- Admin deletes a banner via
DELETE /api/admin/banner/:bannerId - The banner row is removed from PostgreSQL
- A job is enqueued to delete the image from Cloudinary
- The worker in
src/workers/deleteCloudinaryImage.worker.tsprocesses the job with 3 retry attempts (exponential backoff)
The worker starts automatically when the server boots (server.ts imports it).
| Command | Description |
|---|---|
npm run dev |
Start dev server with hot reload (tsx watch) |
npm run build |
Compile TypeScript to dist/ |
npm start |
Run compiled production server |
npm run migrate |
Run pending database migrations |
npm run docker:up |
Start PostgreSQL and Redis containers |
npm run docker:down |
Stop containers |
Custom errors use the AppError class with an HTTP status code and message. The global errorHandler middleware catches all errors and returns:
{
"success": false,
"message": "Human-readable error message"
}Common status codes:
| Code | Meaning |
|---|---|
400 |
Validation error |
401 |
Missing or invalid authentication |
403 |
Forbidden (CSRF failure, non-admin access) |
404 |
Resource not found |
409 |
Conflict (e.g. duplicate email) |
429 |
Rate limit exceeded |
500 |
Internal server error |
Built with 💚💚 by Omoshola
ISC