From c75e1a85596541050388815ff3a2172a88adf23d Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Mon, 6 Oct 2025 01:49:25 +0500 Subject: [PATCH 01/14] feat: add comprehensive rules for architecture, controllers, services, and development workflows --- .cursor/rules/README.md | 162 ++++++++++++ .cursor/rules/architecture.mdc | 79 ++++++ .cursor/rules/controllers.mdc | 432 +++++++++++++++++++++++++++++++ .cursor/rules/development.mdc | 355 ++++++++++++++++++++++++++ .cursor/rules/email.mdc | 454 +++++++++++++++++++++++++++++++++ .cursor/rules/environment.mdc | 221 ++++++++++++++++ .cursor/rules/models.mdc | 415 ++++++++++++++++++++++++++++++ .cursor/rules/new-module.mdc | 432 +++++++++++++++++++++++++++++++ .cursor/rules/routing.mdc | 319 +++++++++++++++++++++++ .cursor/rules/schemas.mdc | 287 +++++++++++++++++++++ .cursor/rules/services.mdc | 357 ++++++++++++++++++++++++++ 11 files changed, 3513 insertions(+) create mode 100644 .cursor/rules/README.md create mode 100644 .cursor/rules/architecture.mdc create mode 100644 .cursor/rules/controllers.mdc create mode 100644 .cursor/rules/development.mdc create mode 100644 .cursor/rules/email.mdc create mode 100644 .cursor/rules/environment.mdc create mode 100644 .cursor/rules/models.mdc create mode 100644 .cursor/rules/new-module.mdc create mode 100644 .cursor/rules/routing.mdc create mode 100644 .cursor/rules/schemas.mdc create mode 100644 .cursor/rules/services.mdc diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..213ac55 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,162 @@ +# Cursor Rules for TypeScript Backend Toolkit + +This directory contains Cursor Rules (`.mdc` files) that help AI assistants understand and work with this codebase effectively. + +## Rules Overview + +### Core Architecture Rules + +**[architecture.mdc](architecture.mdc)** - _Always Applied_ + +- Core architectural patterns +- Technology stack overview +- MagicRouter system +- Module structure +- Configuration management +- Background jobs and queues + +### File-Type Specific Rules + +**[routing.mdc](routing.mdc)** - _Applies to: `_.router.ts`, `_.routes.ts`_ + +- MagicRouter usage patterns +- Route configuration +- Authentication middleware +- File upload handling +- Common routing mistakes + +**[schemas.mdc](schemas.mdc)** - _Applies to: `_.schema.ts`\* + +- Zod schema patterns +- OpenAPI metadata +- Request/response validation +- Common schema patterns +- Type inference + +**[controllers.mdc](controllers.mdc)** - _Applies to: `_.controller.ts`\* + +- Controller patterns +- Request handling +- JWT payload access +- Error handling +- Response formatting + +**[services.mdc](services.mdc)** - _Applies to: `_.service.ts`, `_.services.ts`_ + +- Service layer patterns +- Database operations +- Business logic +- Background jobs +- Error handling + +**[models.mdc](models.mdc)** - _Applies to: `_.model.ts`\* + +- Mongoose model patterns +- Schema definitions +- Indexes +- Hooks/middleware +- Virtual properties +- Instance and static methods + +### Configuration & Environment + +**[environment.mdc](environment.mdc)** - _Applies to: `.env_`, `config.service.ts`\* + +- Environment variables +- Configuration management +- Required variables +- Adding new config +- Security best practices + +**[email.mdc](email.mdc)** - _Applies to: `src/email/\*\*/_`, `email.queue.ts`\* + +- Email system architecture +- React Email templates +- Mailgun integration +- Queue-based sending +- Common email patterns + +### Development & Workflows + +**[development.mdc](development.mdc)** - _Manual Application_ + +- Setup instructions +- Development commands +- Project structure +- Testing the API +- Debugging tips +- Common issues +- Production deployment + +**[new-module.mdc](new-module.mdc)** - _Manual Application_ + +- Step-by-step guide for creating new modules +- Complete example with all files +- Registration steps +- Testing checklist + +## How Rules Are Applied + +### Automatic Application + +Rules are automatically applied based on: + +- **Always Apply**: Rules with `alwaysApply: true` in frontmatter +- **File Globs**: Rules with `globs` pattern matching current file +- **Description**: AI can fetch rules based on description + +### Manual Application + +Some rules (like `new-module.mdc` and `development.mdc`) are applied when: + +- User explicitly references the task +- AI determines the rule is relevant to the current task + +## Rule File Format + +Each rule file uses Markdown with YAML frontmatter: + +```markdown +--- +alwaysApply: true|false +description: 'Rule description' +globs: '*.ts,*.tsx' +--- + +# Rule Content in Markdown + +Rules can reference files using: +[filename.ext](mdc:path/to/filename.ext) +``` + +## Adding New Rules + +To add a new rule: + +1. Create a new `.mdc` file in this directory +2. Add YAML frontmatter with appropriate metadata +3. Write rule content in Markdown +4. Reference files using `[name](mdc:path)` format +5. Test with AI assistant + +## Best Practices + +- Keep rules focused and specific +- Use file globs to target specific file types +- Reference actual code files with `mdc:` links +- Provide examples and common patterns +- List common mistakes to avoid +- Keep rules up-to-date with codebase changes + +## Rule Maintenance + +When updating the codebase: + +- Update relevant rules if patterns change +- Add new rules for new features/patterns +- Remove obsolete rules +- Keep examples current and working + +## Questions? + +If you need to modify or add rules, refer to the Cursor Rules documentation or ask the AI assistant for help. diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc new file mode 100644 index 0000000..b52f8fa --- /dev/null +++ b/.cursor/rules/architecture.mdc @@ -0,0 +1,79 @@ +--- +alwaysApply: true +description: Core architecture and patterns for the TypeScript backend toolkit +--- + +# Architecture Overview + +This is a TypeScript Express.js backend toolkit with a modular, type-safe architecture. + +## Core Patterns + +### MagicRouter System + +- All routes MUST use MagicRouter from [magic-router.ts](mdc:src/openapi/magic-router.ts) +- MagicRouter automatically generates OpenAPI/Swagger documentation from Zod schemas +- Never use plain Express `app.get()` or `router.get()` - always use MagicRouter + +### Module Structure + +Modules live in [src/modules/](mdc:src/modules/) and follow this structure: + +``` +module-name/ + ├── module.controller.ts # Business logic handlers + ├── module.router.ts # MagicRouter route definitions + ├── module.service.ts # Database and external service interactions + ├── module.schema.ts # Zod schemas for validation + ├── module.model.ts # Mongoose models + └── module.dto.ts # TypeScript types/interfaces +``` + +### Validation & Type Safety + +- ALWAYS use Zod schemas for request/response validation +- Runtime validation via [validate-zod-schema.middleware.ts](mdc:src/middlewares/validate-zod-schema.middleware.ts) +- Extend Zod with OpenAPI metadata using `.openapi()` method from [zod-extend.ts](mdc:src/openapi/zod-extend.ts) +- Use TypeScript strict mode - no `any` types + +### Configuration + +- All config in [config.service.ts](mdc:src/config/config.service.ts) +- Environment variables validated with Zod +- Time values are in milliseconds (converted from strings like "1d" or "7d") + +### Database + +- MongoDB with Mongoose ODM +- Connection managed in [database.ts](mdc:src/lib/database.ts) +- Models defined per module (e.g., [user.model.ts](mdc:src/modules/user/user.model.ts)) + +### Background Jobs & Queues + +- BullMQ with Redis for all background jobs +- Email queue in [email.queue.ts](mdc:src/queues/email.queue.ts) +- Admin dashboard at `/admin/queues` + +### Error Handling + +- Global error handler in [globalErrorHandler.ts](mdc:src/utils/globalErrorHandler.ts) +- Throw errors with proper HTTP status codes +- Errors are automatically caught and formatted + +## Technology Stack + +- **Runtime**: Node.js with TypeScript +- **Framework**: Express.js +- **Validation**: Zod +- **Database**: MongoDB + Mongoose +- **Cache/Queue**: Redis + BullMQ +- **Auth**: JWT (with optional OTP) +- **Storage**: AWS S3 +- **Email**: React Email + Mailgun +- **Real-time**: Socket.io +- **API Docs**: Swagger/OpenAPI (auto-generated) +- **Logger**: Pino + +## Package Manager + +ALWAYS use `pnpm` - never npm or yarn diff --git a/.cursor/rules/controllers.mdc b/.cursor/rules/controllers.mdc new file mode 100644 index 0000000..219de59 --- /dev/null +++ b/.cursor/rules/controllers.mdc @@ -0,0 +1,432 @@ +--- +globs: *.controller.ts +description: Controller patterns for handling business logic +--- + +# Controller Patterns + +## Core Principle + +Controllers are async functions that handle validated requests and return responses. They should be thin - delegate complex logic to services. + +## Controller Template + +```typescript +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { successResponse } from '@/utils/api.utils'; +import type { JwtPayload } from '@/utils/auth.utils'; +import type { CreateItemSchemaType, GetItemsSchemaType } from './module.schema'; +import { + createItem, + deleteItem, + findById, + getItems, + updateItem, +} from './module.service'; + +/** + * Description of what this controller does + */ +export const handleAction = async ( + req: Request, + res: Response, +) => { + // 1. Extract validated data (already validated by Zod middleware) + const { email, name } = req.body; // From body schema + const { id } = req.params; // From params schema + const { page = 1, limit = 10 } = req.query; // From query schema + + // 2. Access JWT payload (if route uses extractJwt middleware) + const userId = req.user?.sub; + + // 3. Call service layer for business logic + const result = await createItem({ email, name, userId }); + + // 4. Return response using successResponse helper + return successResponse( + res, + 'Item created successfully', + result, + StatusCodes.CREATED, + ); +}; + +/** + * Example: Get single item + */ +export const handleGetById = async ( + req: Request<{ id: string }, unknown, unknown>, + res: Response, +) => { + const { id } = req.params; + + const item = await findById(id); + + if (!item) { + return successResponse( + res, + 'Item not found', + undefined, + StatusCodes.NOT_FOUND, + ); + } + + return successResponse(res, undefined, item); +}; + +/** + * Example: List with pagination + */ +export const handleGetItems = async ( + req: Request, + res: Response, +) => { + const { results, paginatorInfo } = await getItems(req.query); + + return successResponse(res, undefined, { results, paginatorInfo }); +}; + +/** + * Example: Create new item + */ +export const handleCreate = async ( + req: Request, + res: Response, +) => { + const data = req.body; + const userId = req.user?.sub; + + const item = await createItem({ ...data, createdBy: userId }); + + return successResponse(res, 'Item created', item, StatusCodes.CREATED); +}; + +/** + * Example: Update item + */ +export const handleUpdate = async ( + req: Request<{ id: string }, unknown, UpdateItemSchemaType>, + res: Response, +) => { + const { id } = req.params; + const data = req.body; + const userId = req.user?.sub; + + const item = await updateItem(id, data, userId); + + if (!item) { + return successResponse( + res, + 'Item not found', + undefined, + StatusCodes.NOT_FOUND, + ); + } + + return successResponse(res, 'Item updated', item); +}; + +/** + * Example: Delete item + */ +export const handleDelete = async ( + req: Request<{ id: string }, unknown, unknown>, + res: Response, +) => { + const { id } = req.params; + + await deleteItem(id); + + return successResponse(res, 'Item deleted successfully'); +}; + +/** + * Example: Controller with no request params (unused) + */ +export const handlePublicAction = async (_: Request, res: Response) => { + const result = await performAction(); + + return successResponse(res, 'Action completed', result); +}; +``` + +## Key Points + +### TypeScript Request Typing + +Always use TypeScript generics for type-safe requests: + +```typescript +Request; + +// Examples: +Request<{ id: string }, unknown, unknown>; // params only +Request; // body only +Request; // query only +Request<{ id: string }, unknown, UpdateUserSchemaType>; // params + body +``` + +### Request Data Access + +- `req.body` - Request body (validated by Zod) +- `req.params` - URL parameters (validated by Zod) +- `req.query` - Query parameters (validated by Zod) +- `req.user` - JWT token payload (if using extractJwt middleware) +- `req.file` / `req.files` - Uploaded files (if using multer middleware) + +### JWT Payload Access + +When route uses `extractJwt` middleware from [extract-jwt-schema.middleware.ts](mdc:src/middlewares/extract-jwt-schema.middleware.ts): + +```typescript +import type { JwtPayload } from '@/utils/auth.utils'; + +// Access JWT payload via req.user +const userId = req.user?.sub; // User ID +const email = req.user?.email; // User email +const username = req.user?.username; // Username +const role = req.user?.role; // User role + +// Type assertion if needed +const payload = req.user as JwtPayload; +``` + +**JwtPayload Type:** + +```typescript +type JwtPayload = { + sub: string; // User ID + email?: string | null; + phoneNo?: string | null; + username: string; + role: RoleType; +}; +``` + +### File Upload Access + +When route uses multer middleware from [multer-s3.middleware.ts](mdc:src/middlewares/multer-s3.middleware.ts): + +```typescript +const file = req.file; // For single file +const files = req.files; // For multiple files +const url = (req.file as any).location; // S3 URL +``` + +### Response Pattern + +**ALWAYS use `successResponse()` helper** from [api.utils.ts](mdc:src/utils/api.utils.ts): + +```typescript +import { successResponse } from '@/utils/api.utils'; +import { StatusCodes } from 'http-status-codes'; + +// Basic success (200 OK) +return successResponse(res, 'Success message'); + +// Success with data +return successResponse(res, 'User created', user); + +// Success with custom status code +return successResponse(res, 'Created', item, StatusCodes.CREATED); + +// Success with data but no message +return successResponse(res, undefined, { results, paginatorInfo }); +``` + +**Function Signature:** + +```typescript +successResponse( + res: Response, + message?: string, + payload?: Record, + statusCode: StatusCodes = StatusCodes.OK, +): void +``` + +**Response Format:** + +```json +{ + "success": true, + "message": "Optional message", + "data": { + /* Optional payload */ + } +} +``` + +### Cookie Management + +For authentication tokens: + +```typescript +import { AUTH_COOKIE_KEY, COOKIE_CONFIG } from './auth.constants'; + +// Set auth cookie +res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + +// Clear cookie on logout +res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); +``` + +### Error Handling + +- Controllers don't need try-catch blocks +- Global error handler in [globalErrorHandler.ts](mdc:src/utils/globalErrorHandler.ts) catches all errors +- Just throw errors - they'll be handled automatically: + +```typescript +// Simple error (500) +throw new Error('Something went wrong'); + +// Not found - use successResponse with 404 +if (!item) { + return successResponse( + res, + 'Item not found', + undefined, + StatusCodes.NOT_FOUND, + ); +} + +// Or throw with custom status +const error = new Error('Not found') as any; +error.statusCode = 404; +throw error; +``` + +### Status Codes + +Use `http-status-codes` package for type-safe status codes: + +```typescript +import { StatusCodes } from 'http-status-codes'; + +StatusCodes.OK; // 200 +StatusCodes.CREATED; // 201 +StatusCodes.BAD_REQUEST; // 400 +StatusCodes.UNAUTHORIZED; // 401 +StatusCodes.FORBIDDEN; // 403 +StatusCodes.NOT_FOUND; // 404 +StatusCodes.INTERNAL_SERVER_ERROR; // 500 +``` + +### Logging + +Use Pino logger from [logger.service.ts](mdc:src/lib/logger.service.ts): + +```typescript +import logger from '@/lib/logger.service'; + +logger.info('Action performed', { userId, action: 'create' }); +logger.error('Error occurred', { error: error.message, userId }); +logger.warn('Warning message', { data }); +``` + +## Service Layer Pattern + +Controllers should delegate to services in `module.service.ts`: + +- **Controllers**: Handle HTTP concerns (req/res, cookies, response formatting) +- **Services**: Handle business logic, database operations, external APIs + +Import individual service functions: + +```typescript +// ✅ DO: Import specific functions +import { createUser, deleteUser, getUsers } from './user.service'; + +// ❌ DON'T: Use namespace imports +import * as userService from './user.service'; +``` + +## Naming Conventions + +- Controller functions: `handle` + `PascalCase` action + - `handleGetUsers` + - `handleCreateUser` + - `handleDeleteUser` + - `handleLoginByEmail` + - `handleGetCurrentUser` + +## Real-World Examples + +### Authentication Controller + +```typescript +export const handleLoginByEmail = async ( + req: Request, + res: Response, +) => { + const token = await loginUserByEmail(req.body); + + if (config.SET_SESSION) { + res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + } + + return successResponse(res, 'Login successful', { token }); +}; + +export const handleLogout = async (_: Request, res: Response) => { + res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); + + return successResponse(res, 'Logout successful'); +}; +``` + +### Protected Route with JWT + +```typescript +export const handleChangePassword = async ( + req: Request, + res: Response, +) => { + const userId = (req.user as JwtPayload).sub; + + await changePassword(userId, req.body); + + return successResponse(res, 'Password successfully changed'); +}; +``` + +### Paginated List + +```typescript +export const handleGetUsers = async ( + req: Request, + res: Response, +) => { + const { results, paginatorInfo } = await getUsers( + { id: req.user.sub }, + req.query, + ); + + return successResponse(res, undefined, { results, paginatorInfo }); +}; +``` + +## Common Mistakes to Avoid + +❌ DON'T use direct `res.status().json()` +✅ DO use `successResponse()` helper + +❌ DON'T use `req.jwtPayload` +✅ DO use `req.user` for JWT payload + +❌ DON'T put business logic in controllers +✅ DO move complex logic to services + +❌ DON'T validate data in controllers (Zod does this) +✅ DO trust validated data from req.body/params/query + +❌ DON'T use try-catch everywhere +✅ DO let global error handler catch errors + +❌ DON'T send multiple responses +✅ DO return single response per request + +❌ DON'T use namespace imports for services +✅ DO import individual service functions diff --git a/.cursor/rules/development.mdc b/.cursor/rules/development.mdc new file mode 100644 index 0000000..d74c85c --- /dev/null +++ b/.cursor/rules/development.mdc @@ -0,0 +1,355 @@ +--- +description: Development workflow and commands +--- + +# Development Workflow + +## Setup + +### Initial Setup + +```bash +# 1. Install dependencies +pnpm install + +# 2. Start Docker services (MongoDB + Redis) +docker compose up -d + +# 3. Copy environment template +cp .env.sample .env + +# 4. Edit .env with your values +nano .env + +# 5. (Optional) Seed database +pnpm run seeder + +# 6. Start development server +pnpm run dev +``` + +### Prerequisites + +- Node.js (v18+) +- pnpm (package manager) +- Docker and Docker Compose +- MongoDB (via Docker or local) +- Redis (via Docker or local) + +## Development Commands + +### Running the Server + +```bash +# Development with hot reload +pnpm run dev + +# Backend only (without email template server) +pnpm run start:dev + +# Production build + start +pnpm run build && pnpm run start:prod + +# Local production (uses .env.local) +pnpm run start:local +``` + +### Building + +```bash +# Build TypeScript to dist/ +pnpm run build + +# Build uses tsup (configured in build.ts) +``` + +### Linting + +```bash +# Check for linting errors +pnpm run lint + +# Auto-fix linting errors +pnpm run lint:fix +``` + +### Database + +```bash +# Run database seeder +pnpm run seeder +``` + +### Email Development + +```bash +# Start email template development server +pnpm run email:dev + +# Access at: http://localhost:3001 +``` + +## Project Structure + +``` +src/ +├── main.ts # Application entry point +├── config/ # Configuration management +├── lib/ # Core libraries (DB, Redis, AWS, etc.) +├── modules/ # Feature modules (auth, user, etc.) +│ └── module-name/ +│ ├── module.model.ts +│ ├── module.controller.ts +│ ├── module.service.ts +│ ├── module.router.ts +│ ├── module.schema.ts +│ └── module.dto.ts +├── middlewares/ # Express middlewares +├── openapi/ # MagicRouter & OpenAPI generation +├── queues/ # BullMQ background jobs +├── routes/ # Route registration +├── upload/ # File upload handling +├── email/ # Email templates (React Email) +└── utils/ # Utility functions +``` + +## Key Endpoints + +### API Documentation + +- Swagger UI: `http://localhost:3000/api-docs` +- OpenAPI JSON: `http://localhost:3000/api-docs.json` + +### Queue Dashboard + +- BullMQ Admin: `http://localhost:3000/admin/queues` + +### Health Check + +- `GET http://localhost:3000/api/health` + +## Development Workflow + +### Creating a New Feature + +1. Create new module in `src/modules/feature-name/` +2. Create model, controller, service, router, schema files +3. Register router in `src/routes/routes.ts` +4. Test in Swagger UI +5. (Optional) Add seeder + +See [new-module.mdc](mdc:.cursor/rules/new-module.mdc) for detailed steps. + +### Making Changes + +1. Edit files (hot reload enabled in dev mode) +2. Check for linter errors: `pnpm run lint` +3. Fix errors: `pnpm run lint:fix` +4. Test changes in Swagger UI or API client +5. Commit changes + +### Adding Dependencies + +```bash +# Add runtime dependency +pnpm add package-name + +# Add dev dependency +pnpm add -D package-name +``` + +## Testing the API + +### Using Swagger UI + +1. Navigate to `http://localhost:3000/api-docs` +2. Expand endpoint +3. Click "Try it out" +4. Fill in parameters +5. Execute request +6. View response + +### Using curl + +```bash +# Public endpoint +curl http://localhost:3000/api/health + +# Protected endpoint (requires JWT) +curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + http://localhost:3000/api/user/profile + +# POST request +curl -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"password123"}' +``` + +### Using Postman/Insomnia + +1. Import OpenAPI spec from `http://localhost:3000/api-docs.json` +2. All endpoints auto-configured +3. Set Authorization header for protected routes + +## Debugging + +### Logging + +Logs use Pino logger from [src/lib/logger.service.ts](mdc:src/lib/logger.service.ts): + +```typescript +import { logger } from '@/lib/logger.service'; + +logger.info('Info message', { data }); +logger.error('Error message', { error }); +logger.debug('Debug message', { data }); +``` + +### VS Code Debugging + +Add to `.vscode/launch.json`: + +```json +{ + "type": "node", + "request": "launch", + "name": "Debug Dev Server", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "skipFiles": ["/**"] +} +``` + +### MongoDB Debugging + +```bash +# Connect to MongoDB +docker exec -it mongodb mongosh + +# List databases +show dbs + +# Use database +use your-db-name + +# List collections +show collections + +# Query data +db.users.find() +``` + +### Redis Debugging + +```bash +# Connect to Redis +docker exec -it redis redis-cli + +# List all keys +KEYS * + +# Get value +GET key-name + +# Monitor commands +MONITOR +``` + +## Common Issues + +### Port Already in Use + +```bash +# Find process using port 3000 +lsof -i :3000 + +# Kill process +kill -9 PID +``` + +### MongoDB Connection Failed + +- Check Docker is running: `docker ps` +- Check connection string in `.env` +- Restart MongoDB: `docker compose restart mongodb` + +### Redis Connection Failed + +- Check Docker is running: `docker ps` +- Check Redis config in `.env` +- Restart Redis: `docker compose restart redis` + +### TypeScript Errors + +```bash +# Check TypeScript errors +npx tsc --noEmit + +# Clean build and rebuild +rm -rf dist && pnpm run build +``` + +### Module Not Found + +```bash +# Clear node_modules and reinstall +rm -rf node_modules pnpm-lock.yaml +pnpm install +``` + +## Production Deployment + +### Build + +```bash +pnpm run build +``` + +### Start Production Server + +```bash +# Using .env.production +pnpm run start:prod + +# Using PM2 (recommended) +pm2 start ecosystem.config.js +``` + +### Environment Variables + +- Set all required variables in production environment +- Use strong secrets (min 32 characters) +- Enable production mode: `NODE_ENV=production` + +## Best Practices + +### Code Style + +- Use TypeScript strict mode +- No `any` types +- Use Zod for validation +- Follow ESLint rules +- Use async/await (not callbacks) + +### Git Workflow + +- Create feature branches +- Write descriptive commit messages +- Keep commits focused +- Review changes before committing +- Never commit `.env` files + +### Performance + +- Use `.lean()` for Mongoose queries when not modifying +- Add database indexes for queried fields +- Use background jobs for heavy operations +- Cache frequently accessed data in Redis + +### Security + +- Never log sensitive data (passwords, tokens) +- Validate all inputs with Zod +- Use JWT for authentication +- Rate limit API endpoints (if configured) +- Keep dependencies updated diff --git a/.cursor/rules/email.mdc b/.cursor/rules/email.mdc new file mode 100644 index 0000000..76226bd --- /dev/null +++ b/.cursor/rules/email.mdc @@ -0,0 +1,454 @@ +--- +globs: src/email/**/*,email.queue.ts +description: Email system using React Email and Mailgun with queue-based sending +--- + +# Email System + +## Architecture + +- **Templates**: React Email components in [src/email/templates/](mdc:src/email/templates/) +- **Service**: Email service in [src/email/email.service.ts](mdc:src/email/email.service.ts) +- **Provider**: Mailgun integration in [src/lib/mailgun.server.ts](mdc:src/lib/mailgun.server.ts) +- **Queue**: Background sending via [src/queues/email.queue.ts](mdc:src/queues/email.queue.ts) +- **Development**: Preview server for templates + +## Email Configuration + +### Environment Variables + +```bash +# Mailgun +MAILGUN_API_KEY=your-mailgun-api-key +MAILGUN_DOMAIN=your-domain.com +MAILGUN_FROM=noreply@your-domain.com + +# Optional: Override recipient in dev +MAILGUN_TO_OVERRIDE=dev@example.com +``` + +## Creating Email Templates + +### Step 1: Create React Component + +Create new file in `src/email/templates/TemplateName.tsx`: + +```typescript +import { + Html, + Head, + Body, + Container, + Section, + Text, + Button, + Hr, + Img, +} from "@react-email/components"; + +interface TemplateNameProps { + name: string; + actionUrl: string; +} + +export default function TemplateName({ name, actionUrl }: TemplateNameProps) { + return ( + + + + +
+ Logo + + Hello, {name}! + + + Your email content goes here. + + + + +
+ + + © 2025 Your Company. All rights reserved. + +
+
+ + + ); +} + +const styles = { + body: { + backgroundColor: "#f6f9fc", + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + }, + container: { + margin: "0 auto", + padding: "20px 0", + }, + section: { + backgroundColor: "#ffffff", + borderRadius: "8px", + padding: "40px", + }, + logo: { + margin: "0 auto 20px", + display: "block", + }, + heading: { + fontSize: "24px", + fontWeight: "bold", + margin: "20px 0", + color: "#1a1a1a", + }, + text: { + fontSize: "16px", + lineHeight: "24px", + color: "#525252", + margin: "16px 0", + }, + button: { + backgroundColor: "#007bff", + color: "#ffffff", + padding: "12px 32px", + borderRadius: "6px", + textDecoration: "none", + display: "inline-block", + margin: "20px 0", + }, + hr: { + borderColor: "#e6e6e6", + margin: "30px 0", + }, + footer: { + fontSize: "14px", + color: "#8c8c8c", + textAlign: "center" as const, + }, +}; + +// Preview props for development +TemplateName.PreviewProps = { + name: "John Doe", + actionUrl: "https://example.com/action", +} as TemplateNameProps; +``` + +### Step 2: Test Template + +```bash +# Start email development server +pnpm run email:dev + +# Open browser to preview +# http://localhost:3001 +``` + +## Sending Emails + +### Method 1: Direct Send (Simple) + +```typescript +import { sendEmail } from '@/email/email.service'; + +await sendEmail({ + to: 'user@example.com', + subject: 'Welcome!', + template: 'TemplateName', + data: { + name: 'John Doe', + actionUrl: 'https://example.com/verify', + }, +}); +``` + +### Method 2: Queue-based (Recommended) + +```typescript +import { emailQueue } from '@/queues/email.queue'; + +await emailQueue.add('sendEmail', { + to: 'user@example.com', + subject: 'Welcome!', + template: 'TemplateName', + data: { + name: 'John Doe', + actionUrl: 'https://example.com/verify', + }, +}); +``` + +## Email Service Usage + +The email service in [email.service.ts](mdc:src/email/email.service.ts) handles: + +- Template rendering +- HTML/text generation +- Queue job creation + +### Function Signature + +```typescript +interface SendEmailOptions { + to: string | string[]; // Recipient(s) + subject: string; + template: string; // Template name (without .tsx) + data: Record; // Props for template + from?: string; // Optional: override default sender + replyTo?: string; // Optional: reply-to address + attachments?: Array<{ + filename: string; + content: Buffer | string; + contentType?: string; + }>; +} + +export const sendEmail = async (options: SendEmailOptions): Promise; +``` + +## Queue System + +Email queue in [email.queue.ts](mdc:src/queues/email.queue.ts) provides: + +- Async sending (doesn't block API response) +- Automatic retries on failure +- Queue monitoring via dashboard + +### Queue Configuration + +```typescript +// Default options +{ + attempts: 3, // Retry up to 3 times + backoff: { + type: "exponential", + delay: 1000, // Start with 1 second delay + }, +} +``` + +### Custom Queue Options + +```typescript +await emailQueue.add( + 'sendEmail', + { to, subject, template, data }, + { + delay: 60000, // Send after 1 minute + attempts: 5, // Retry up to 5 times + priority: 1, // Higher priority (default: 0) + }, +); +``` + +## Common Email Templates + +### Welcome Email + +```typescript +await sendEmail({ + to: user.email, + subject: 'Welcome to Our Platform!', + template: 'Welcome', + data: { + name: user.name, + verifyUrl: `${config.FRONTEND_URL}/verify?token=${token}`, + }, +}); +``` + +### Password Reset + +```typescript +await sendEmail({ + to: user.email, + subject: 'Reset Your Password', + template: 'ResetPassword', + data: { + name: user.name, + resetUrl: `${config.FRONTEND_URL}/reset-password?token=${token}`, + expiresIn: '1 hour', + }, +}); +``` + +### OTP Verification + +```typescript +await sendEmail({ + to: user.email, + subject: 'Your Verification Code', + template: 'OTP', + data: { + name: user.name, + otp: otpCode, + expiresIn: '10 minutes', + }, +}); +``` + +### Notification + +```typescript +await sendEmail({ + to: user.email, + subject: 'New Activity', + template: 'Notification', + data: { + name: user.name, + message: 'You have a new message', + actionUrl: `${config.FRONTEND_URL}/messages`, + }, +}); +``` + +## React Email Components + +### Available Components + +- `Html` - Root HTML element +- `Head` - Head section +- `Body` - Body section +- `Container` - Main container +- `Section` - Content section +- `Text` - Text paragraph +- `Heading` - Heading element +- `Button` - Button/link +- `Hr` - Horizontal rule +- `Img` - Image +- `Link` - Hyperlink +- `Row` / `Column` - Grid layout + +### Styling + +```typescript +// Inline styles (required for email compatibility) +const styles = { + element: { + backgroundColor: "#ffffff", + padding: "20px", + fontSize: "16px", + }, +}; + +Content +``` + +## Monitoring + +### Queue Dashboard + +Access BullMQ dashboard at: `http://localhost:3000/admin/queues` + +View: + +- Queued emails +- Processing status +- Failed emails +- Retry attempts + +### Logs + +Check email sending logs: + +```typescript +import { logger } from '@/lib/logger.service'; + +// Logs are automatically added by email service +logger.info('Email sent', { to, template }); +logger.error('Email failed', { to, template, error }); +``` + +## Testing Emails + +### Development Mode + +Set `MAILGUN_TO_OVERRIDE` to redirect all emails: + +```bash +MAILGUN_TO_OVERRIDE=dev@example.com +``` + +All emails will be sent to this address instead of actual recipients. + +### Preview in Browser + +```bash +# Start dev server +pnpm run email:dev + +# Visit http://localhost:3001 +# All templates listed with previews +``` + +### Manual Testing + +```bash +# In development console or test file +import { sendEmail } from "@/email/email.service"; + +await sendEmail({ + to: "test@example.com", + subject: "Test Email", + template: "TemplateName", + data: { /* test data */ }, +}); +``` + +## Best Practices + +### Template Design + +- Keep templates simple and clean +- Use inline styles (required for email clients) +- Test in multiple email clients +- Provide plain text fallback +- Include unsubscribe link (if applicable) +- Use responsive design +- Optimize images (small file sizes) + +### Sending + +- Always use queue for production (async) +- Set appropriate retry attempts +- Handle failures gracefully +- Log all email operations +- Rate limit sending if needed +- Verify email addresses before sending + +### Content + +- Personalize with user data +- Clear subject lines +- Brief and actionable content +- Include clear call-to-action +- Mobile-friendly design +- Avoid spam trigger words + +## Common Mistakes to Avoid + +❌ DON'T send emails synchronously in API handlers +✅ DO use queue for background sending + +❌ DON'T use external CSS +✅ DO use inline styles + +❌ DON'T forget to handle email failures +✅ DO set retry logic and monitor queue + +❌ DON'T send sensitive data in emails +✅ DO send links to secure pages instead + +❌ DON'T spam users +✅ DO respect user preferences and rate limits diff --git a/.cursor/rules/environment.mdc b/.cursor/rules/environment.mdc new file mode 100644 index 0000000..fd083a7 --- /dev/null +++ b/.cursor/rules/environment.mdc @@ -0,0 +1,221 @@ +--- +globs: .env*,config.service.ts +description: Environment configuration and secrets management +--- + +# Environment Configuration + +## Configuration Files + +- `.env.sample` - Template with all available variables +- `.env` - Local development (gitignored) +- `.env.local` - Local production build (gitignored) +- `.env.production` - Production environment (gitignored) +- [src/config/config.service.ts](mdc:src/config/config.service.ts) - Type-safe config with Zod validation + +## Configuration Pattern + +All environment variables are validated and typed in [config.service.ts](mdc:src/config/config.service.ts): + +```typescript +import { z } from 'zod'; + +const configSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']), + PORT: z.string().transform(Number), + DATABASE_URL: z.string().url(), + JWT_SECRET: z.string().min(32), + // ... more config +}); + +export type Config = z.infer; + +export const config: Config = configSchema.parse({ + NODE_ENV: process.env.NODE_ENV || 'development', + PORT: process.env.PORT || '3000', + DATABASE_URL: process.env.DATABASE_URL, + JWT_SECRET: process.env.JWT_SECRET, + // ... more config +}); +``` + +## Time Duration Format + +All time-based config values use milliseconds internally: + +```typescript +// In .env +JWT_EXPIRES_IN=7d +OTP_EXPIRES_IN=10m + +// In config.service.ts - convert to milliseconds +import ms from "ms"; + +JWT_EXPIRES_IN: z.string().transform((val) => ms(val)), +// Converts "7d" → 604800000ms +``` + +## Required Environment Variables + +### Core + +```bash +NODE_ENV=development +PORT=3000 +``` + +### Database + +```bash +DATABASE_URL=mongodb://localhost:27017/your-db +``` + +### Authentication + +```bash +JWT_SECRET=your-super-secret-key-at-least-32-characters +JWT_EXPIRES_IN=7d +OTP_EXPIRES_IN=10m +OTP_SECRET=your-otp-secret-key +``` + +### Redis + +```bash +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +``` + +### AWS S3 (File Uploads) + +```bash +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +AWS_S3_BUCKET=your-bucket-name +``` + +### Email (Mailgun) + +```bash +MAILGUN_API_KEY=your-mailgun-api-key +MAILGUN_DOMAIN=your-domain.com +MAILGUN_FROM=noreply@your-domain.com +``` + +### OAuth (Google) + +```bash +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback +``` + +### Session + +```bash +SESSION_SECRET=your-session-secret-key +``` + +### Frontend URL + +```bash +FRONTEND_URL=http://localhost:5173 +``` + +## Adding New Config Variables + +### Step 1: Add to `.env.sample` + +```bash +# New Feature Config +NEW_API_KEY=your-api-key +NEW_API_TIMEOUT=30s +``` + +### Step 2: Add to config schema + +```typescript +// In src/config/config.service.ts +const configSchema = z.object({ + // ... existing config + NEW_API_KEY: z.string().min(1), + NEW_API_TIMEOUT: z.string().transform((val) => ms(val)), +}); +``` + +### Step 3: Parse from environment + +```typescript +export const config: Config = configSchema.parse({ + // ... existing config + NEW_API_KEY: process.env.NEW_API_KEY, + NEW_API_TIMEOUT: process.env.NEW_API_TIMEOUT || '30s', +}); +``` + +### Step 4: Use in code + +```typescript +import { config } from '@/config/config.service'; + +const apiKey = config.NEW_API_KEY; +const timeout = config.NEW_API_TIMEOUT; // In milliseconds +``` + +## Best Practices + +### Security + +- NEVER commit actual `.env` files to git +- Keep secrets in environment variables, not hardcoded +- Use different secrets for development and production +- Rotate secrets regularly + +### Validation + +- Always validate with Zod in config.service.ts +- Fail fast if required config is missing +- Provide sensible defaults where appropriate +- Use type inference for type safety + +### Documentation + +- Document all variables in `.env.sample` +- Add comments explaining what each variable does +- Provide example values +- Indicate which variables are required vs optional + +### Time Values + +- Always use human-readable format in .env (e.g., "7d", "10m", "30s") +- Convert to milliseconds in config.service.ts using `ms` package +- Never use raw milliseconds in .env files + +## Docker Setup + +For local development with Docker: + +```bash +# Start services +docker compose up -d + +# Services included: +# - MongoDB (port 27017) +# - Redis (port 6379) +``` + +## Common Mistakes to Avoid + +❌ DON'T access `process.env` directly in code +✅ DO import from `config.service.ts` + +❌ DON'T use hardcoded values +✅ DO use environment variables + +❌ DON'T forget to validate new config variables +✅ DO add Zod validation in config.service.ts + +❌ DON'T commit `.env` files +✅ DO commit `.env.sample` as template diff --git a/.cursor/rules/models.mdc b/.cursor/rules/models.mdc new file mode 100644 index 0000000..2b88386 --- /dev/null +++ b/.cursor/rules/models.mdc @@ -0,0 +1,415 @@ +--- +globs: *.model.ts +description: Mongoose model patterns for MongoDB schemas +--- + +# Mongoose Model Patterns + +## Core Principle + +Models define MongoDB schemas using Mongoose. Keep them simple and focused on data structure. + +## Model Template + +```typescript +import { Schema, model, type Document } from 'mongoose'; + +// TypeScript interface +export interface IModel extends Document { + name: string; + email: string; + status: 'active' | 'inactive'; + metadata?: Record; + createdBy?: Schema.Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +// Mongoose schema +const schema = new Schema( + { + name: { + type: String, + required: [true, 'Name is required'], + trim: true, + minlength: [2, 'Name must be at least 2 characters'], + maxlength: [100, 'Name must not exceed 100 characters'], + }, + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, + trim: true, + match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'], + }, + status: { + type: String, + enum: { + values: ['active', 'inactive'], + message: 'Status must be either active or inactive', + }, + default: 'active', + }, + metadata: { + type: Schema.Types.Mixed, + default: {}, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + }, + }, + { + timestamps: true, // Adds createdAt and updatedAt automatically + collection: 'models', // Optional: specify collection name + }, +); + +// Indexes for query performance +schema.index({ email: 1 }); // Single field index +schema.index({ status: 1, createdAt: -1 }); // Compound index +schema.index({ name: 'text' }); // Text index for search + +// Virtual properties +schema.virtual('displayName').get(function () { + return `${this.name} (${this.email})`; +}); + +// Instance methods +schema.methods.isActive = function () { + return this.status === 'active'; +}; + +schema.methods.toJSON = function () { + const obj = this.toObject(); + delete obj.__v; // Remove version key + return obj; +}; + +// Static methods +schema.statics.findActive = function () { + return this.find({ status: 'active' }); +}; + +schema.statics.findByEmail = function (email: string) { + return this.findOne({ email: email.toLowerCase() }); +}; + +// Pre-save hook +schema.pre('save', async function (next) { + // Example: Normalize email + if (this.isModified('email')) { + this.email = this.email.toLowerCase().trim(); + } + next(); +}); + +// Post-save hook +schema.post('save', function (doc) { + // Example: Log creation + console.log('Document saved:', doc._id); +}); + +// Pre-remove hook +schema.pre('remove', async function (next) { + // Example: Clean up related data + await RelatedModel.deleteMany({ modelId: this._id }); + next(); +}); + +// Create and export model +export const Model = model('Model', schema); +``` + +## Common Field Types + +### Basic Types + +```typescript +{ + stringField: { type: String }, + numberField: { type: Number }, + booleanField: { type: Boolean }, + dateField: { type: Date }, + bufferField: { type: Buffer }, + mixedField: { type: Schema.Types.Mixed }, +} +``` + +### References + +```typescript +{ + userId: { + type: Schema.Types.ObjectId, + ref: "User", // Reference to User model + required: true, + }, +} +``` + +### Arrays + +```typescript +{ + tags: [String], // Array of strings + items: [{ // Array of subdocuments + name: String, + quantity: Number, + }], + userIds: [{ + type: Schema.Types.ObjectId, + ref: "User", + }], +} +``` + +### Enums + +```typescript +{ + status: { + type: String, + enum: { + values: ["pending", "active", "inactive"], + message: "Invalid status value", + }, + default: "pending", + }, +} +``` + +### Nested Objects + +```typescript +{ + address: { + street: String, + city: String, + country: String, + zipCode: String, + }, +} +``` + +## Field Options + +### Common Options + +```typescript +{ + field: { + type: String, + required: [true, "Error message"], // or just true + unique: true, // Creates unique index + index: true, // Creates index + default: "value", // or function: () => Date.now() + lowercase: true, // Auto-lowercase (String only) + uppercase: true, // Auto-uppercase (String only) + trim: true, // Remove whitespace (String only) + minlength: 5, // Min length (String only) + maxlength: 100, // Max length (String only) + min: 0, // Min value (Number/Date only) + max: 100, // Max value (Number/Date only) + match: /regex/, // Regex validation (String only) + validate: { // Custom validator + validator: (v) => v > 0, + message: "Must be positive", + }, + }, +} +``` + +## Indexes + +Add indexes for frequently queried fields: + +```typescript +// Single field index +schema.index({ email: 1 }); // 1 = ascending, -1 = descending + +// Compound index +schema.index({ status: 1, createdAt: -1 }); + +// Text index for search +schema.index({ name: 'text', description: 'text' }); + +// Unique compound index +schema.index({ userId: 1, itemId: 1 }, { unique: true }); + +// Sparse index (only for documents with the field) +schema.index({ optionalField: 1 }, { sparse: true }); + +// TTL index (auto-delete after time) +schema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }); +``` + +## Hooks (Middleware) + +### Pre hooks + +```typescript +// Before save +schema.pre('save', async function (next) { + // this = document being saved + if (this.isModified('password')) { + // Hash password + } + next(); +}); + +// Before remove +schema.pre('remove', async function (next) { + // Clean up related data + next(); +}); + +// Before findOneAndUpdate +schema.pre('findOneAndUpdate', function (next) { + // this = query object + this.set({ updatedAt: new Date() }); + next(); +}); +``` + +### Post hooks + +```typescript +// After save +schema.post('save', function (doc) { + // Log or trigger events +}); + +// After find +schema.post('find', function (docs) { + // Process results +}); +``` + +## Virtual Properties + +```typescript +// Getter +schema.virtual('fullName').get(function () { + return `${this.firstName} ${this.lastName}`; +}); + +// Setter +schema.virtual('fullName').set(function (value: string) { + const [firstName, lastName] = value.split(' '); + this.firstName = firstName; + this.lastName = lastName; +}); + +// Include virtuals in JSON +schema.set('toJSON', { virtuals: true }); +schema.set('toObject', { virtuals: true }); + +// Virtual populate +schema.virtual('posts', { + ref: 'Post', + localField: '_id', + foreignField: 'userId', +}); +``` + +## Methods + +### Instance Methods + +```typescript +schema.methods.methodName = function () { + // this = document instance + return this.field; +}; + +// Usage: const result = await document.methodName(); +``` + +### Static Methods + +```typescript +schema.statics.methodName = function () { + // this = model + return this.find({ ... }); +}; + +// Usage: const result = await Model.methodName(); +``` + +### Query Helpers + +```typescript +schema.query.byStatus = function (status: string) { + return this.where({ status }); +}; + +// Usage: await Model.find().byStatus("active"); +``` + +## Common Patterns + +### Soft Delete + +```typescript +{ + isDeleted: { + type: Boolean, + default: false, + }, + deletedAt: Date, +} + +schema.pre(/^find/, function (next) { + this.where({ isDeleted: { $ne: true } }); + next(); +}); +``` + +### Timestamps + +```typescript +// Option 1: Automatic (recommended) +{ timestamps: true } // in schema options + +// Option 2: Manual +{ + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, +} + +schema.pre("save", function (next) { + this.updatedAt = new Date(); + next(); +}); +``` + +### User Reference + +```typescript +{ + createdBy: { + type: Schema.Types.ObjectId, + ref: "User", + }, + updatedBy: { + type: Schema.Types.ObjectId, + ref: "User", + }, +} +``` + +## Common Mistakes to Avoid + +❌ DON'T use arrow functions in methods/hooks (breaks `this`) +✅ DO use regular functions + +❌ DON'T forget to create indexes for queried fields +✅ DO add indexes for performance + +❌ DON'T validate in models AND Zod schemas (duplication) +✅ DO use Zod for API validation, Mongoose for data integrity + +❌ DON'T put business logic in models +✅ DO keep models simple, logic in services diff --git a/.cursor/rules/new-module.mdc b/.cursor/rules/new-module.mdc new file mode 100644 index 0000000..f8c4807 --- /dev/null +++ b/.cursor/rules/new-module.mdc @@ -0,0 +1,432 @@ +--- +description: Step-by-step guide for creating a new module +--- + +# Creating a New Module + +Follow these steps to create a new module in the backend toolkit. + +## Step 1: Create Module Directory + +```bash +mkdir -p src/modules/module-name +``` + +## Step 2: Create Model (`module.model.ts`) + +```typescript +import { Schema, model, type Document } from 'mongoose'; + +export interface IModule extends Document { + name: string; + description: string; + status: 'active' | 'inactive'; + createdAt: Date; + updatedAt: Date; +} + +const schema = new Schema( + { + name: { type: String, required: true }, + description: { type: String }, + status: { + type: String, + enum: ['active', 'inactive'], + default: 'active', + }, + }, + { timestamps: true }, +); + +export const ModuleModel = model('Module', schema); +``` + +## Step 3: Create DTOs (`module.dto.ts`) + +```typescript +export interface CreateModuleInput { + name: string; + description?: string; +} + +export interface UpdateModuleInput { + name?: string; + description?: string; + status?: 'active' | 'inactive'; +} + +export interface ModuleResponse { + id: string; + name: string; + description?: string; + status: string; + createdAt: string; + updatedAt: string; +} +``` + +## Step 4: Create Schemas (`module.schema.ts`) + +```typescript +import { z } from 'zod'; +import '@/openapi/zod-extend'; + +const ModuleResponseSchema = z.object({ + id: z.string().openapi({ example: '507f1f77bcf86cd799439011' }), + name: z.string().openapi({ example: 'Module Name' }), + description: z.string().optional(), + status: z.enum(['active', 'inactive']), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +const CreateModuleSchema = z.object({ + name: z.string().min(2), + description: z.string().optional(), +}); + +const UpdateModuleSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().optional(), + status: z.enum(['active', 'inactive']).optional(), +}); + +const ParamsSchema = z.object({ + id: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid ID'), +}); + +const QuerySchema = z.object({ + page: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + limit: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + search: z.string().optional(), +}); + +export const listSchema = { + request: { query: QuerySchema }, + response: { + 200: z.object({ + data: z.array(ModuleResponseSchema), + pagination: z.object({ + page: z.number(), + limit: z.number(), + total: z.number(), + }), + }), + }, +}; + +export const getSchema = { + request: { params: ParamsSchema }, + response: { + 200: ModuleResponseSchema, + 404: z.object({ message: z.string() }), + }, +}; + +export const createSchema = { + request: { body: CreateModuleSchema }, + response: { + 201: ModuleResponseSchema, + 400: z.object({ message: z.string() }), + }, +}; + +export const updateSchema = { + request: { + params: ParamsSchema, + body: UpdateModuleSchema, + }, + response: { + 200: ModuleResponseSchema, + 404: z.object({ message: z.string() }), + }, +}; + +export const deleteSchema = { + request: { params: ParamsSchema }, + response: { + 200: z.object({ message: z.string() }), + 404: z.object({ message: z.string() }), + }, +}; +``` + +## Step 5: Create Service (`module.service.ts`) + +```typescript +import { ModuleModel } from './module.model'; +import type { CreateModuleInput, UpdateModuleInput } from './module.dto'; + +export const findAll = async (options: { + page: number; + limit: number; + search?: string; +}) => { + const { page, limit, search } = options; + const skip = (page - 1) * limit; + + const query = search ? { name: { $regex: search, $options: 'i' } } : {}; + + const [data, total] = await Promise.all([ + ModuleModel.find(query).skip(skip).limit(limit).lean(), + ModuleModel.countDocuments(query), + ]); + + return { + data: data.map((item) => ({ + id: item._id.toString(), + name: item.name, + description: item.description, + status: item.status, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })), + pagination: { + page, + limit, + total, + }, + }; +}; + +export const findById = async (id: string) => { + const item = await ModuleModel.findById(id).lean(); + + if (!item) { + return null; + } + + return { + id: item._id.toString(), + name: item.name, + description: item.description, + status: item.status, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +}; + +export const create = async (data: CreateModuleInput) => { + const item = await ModuleModel.create(data); + + return { + id: item._id.toString(), + name: item.name, + description: item.description, + status: item.status, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +}; + +export const update = async (id: string, data: UpdateModuleInput) => { + const item = await ModuleModel.findByIdAndUpdate( + id, + { $set: data }, + { new: true }, + ).lean(); + + if (!item) { + return null; + } + + return { + id: item._id.toString(), + name: item.name, + description: item.description, + status: item.status, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +}; + +export const remove = async (id: string) => { + const item = await ModuleModel.findByIdAndDelete(id); + return !!item; +}; +``` + +## Step 6: Create Controller (`module.controller.ts`) + +```typescript +import type { Request, Response } from 'express'; +import * as service from './module.service'; + +export const list = async (req: Request, res: Response) => { + const { page = 1, limit = 10, search } = req.query; + + const result = await service.findAll({ + page: Number(page), + limit: Number(limit), + search: search as string, + }); + + return res.status(200).json(result); +}; + +export const getById = async (req: Request, res: Response) => { + const { id } = req.params; + + const item = await service.findById(id); + + if (!item) { + return res.status(404).json({ message: 'Item not found' }); + } + + return res.status(200).json(item); +}; + +export const create = async (req: Request, res: Response) => { + const data = req.body; + + const item = await service.create(data); + + return res.status(201).json(item); +}; + +export const update = async (req: Request, res: Response) => { + const { id } = req.params; + const data = req.body; + + const item = await service.update(id, data); + + if (!item) { + return res.status(404).json({ message: 'Item not found' }); + } + + return res.status(200).json(item); +}; + +export const remove = async (req: Request, res: Response) => { + const { id } = req.params; + + const deleted = await service.remove(id); + + if (!deleted) { + return res.status(404).json({ message: 'Item not found' }); + } + + return res.status(200).json({ message: 'Item deleted successfully' }); +}; +``` + +## Step 7: Create Router (`module.router.ts`) + +```typescript +import { MagicRouter } from '@/openapi/magic-router'; +import { extractJwtSchema } from '@/middlewares/extract-jwt-schema.middleware'; +import * as controller from './module.controller'; +import * as schemas from './module.schema'; + +const router = MagicRouter(); + +router.get({ + path: '/modules', + schemas: schemas.listSchema, + controller: controller.list, + tags: ['Module'], + summary: 'List all modules', + middlewares: [extractJwtSchema], +}); + +router.get({ + path: '/modules/:id', + schemas: schemas.getSchema, + controller: controller.getById, + tags: ['Module'], + summary: 'Get module by ID', + middlewares: [extractJwtSchema], +}); + +router.post({ + path: '/modules', + schemas: schemas.createSchema, + controller: controller.create, + tags: ['Module'], + summary: 'Create new module', + middlewares: [extractJwtSchema], +}); + +router.put({ + path: '/modules/:id', + schemas: schemas.updateSchema, + controller: controller.update, + tags: ['Module'], + summary: 'Update module', + middlewares: [extractJwtSchema], +}); + +router.delete({ + path: '/modules/:id', + schemas: schemas.deleteSchema, + controller: controller.remove, + tags: ['Module'], + summary: 'Delete module', + middlewares: [extractJwtSchema], +}); + +export default router; +``` + +## Step 8: Register Router + +Add to [src/routes/routes.ts](mdc:src/routes/routes.ts): + +```typescript +import moduleRouter from '@/modules/module-name/module.router'; + +// In the registerRoutes function +app.use('/api', moduleRouter); +``` + +## Step 9: Test + +1. Start the server: `pnpm run dev` +2. Visit API docs: `http://localhost:3000/api-docs` +3. Test endpoints using Swagger UI + +## Optional: Add to Seeder + +If you want seed data, create `module.seeder.ts`: + +```typescript +import { ModuleModel } from './module.model'; + +export const seedModules = async () => { + const count = await ModuleModel.countDocuments(); + + if (count > 0) { + console.log('Modules already seeded'); + return; + } + + await ModuleModel.create([ + { name: 'Module 1', description: 'First module' }, + { name: 'Module 2', description: 'Second module' }, + ]); + + console.log('Modules seeded'); +}; +``` + +Register in main seeder script. + +## Checklist + +- [ ] Created model with proper schema +- [ ] Created DTOs for type safety +- [ ] Created Zod schemas with OpenAPI metadata +- [ ] Created service with business logic +- [ ] Created controller with HTTP handling +- [ ] Created router with MagicRouter +- [ ] Registered router in routes.ts +- [ ] Tested in Swagger UI +- [ ] (Optional) Created seeder diff --git a/.cursor/rules/routing.mdc b/.cursor/rules/routing.mdc new file mode 100644 index 0000000..cca326b --- /dev/null +++ b/.cursor/rules/routing.mdc @@ -0,0 +1,319 @@ +--- +globs: *.router.ts,*.routes.ts +description: Routing patterns using MagicRouter for automatic OpenAPI generation +--- + +# Routing with MagicRouter + +## Core Principle + +NEVER use plain Express routing. ALWAYS use MagicRouter from [magic-router.ts](mdc:src/openapi/magic-router.ts). + +## Pattern Template + +```typescript +import MagicRouter from '@/openapi/magic-router'; +import { canAccess } from '@/middlewares/can-access.middleware'; +import { handleAction, handleGetById, handleCreate } from './module.controller'; +import { actionSchema, createSchema } from './module.schema'; + +export const MODULE_ROUTER_ROOT = '/module'; + +const moduleRouter = new MagicRouter(MODULE_ROUTER_ROOT); + +// Public route with schema validation +moduleRouter.post( + '/action', + { requestType: { body: actionSchema } }, + handleAction, +); + +// Protected route with authentication +moduleRouter.get('/me', {}, canAccess(), handleGetById); + +// Protected route with schema and auth +moduleRouter.post( + '/create', + { requestType: { body: createSchema } }, + canAccess(), + handleCreate, +); + +// Route with params +moduleRouter.get( + '/:id', + { requestType: { params: idParamsSchema } }, + handleGetById, +); + +// Route with query params +moduleRouter.get( + '/search', + { requestType: { query: searchQuerySchema } }, + handleSearch, +); + +export default moduleRouter.getRouter(); +``` + +## MagicRouter API + +### Router Instantiation + +```typescript +const router = new MagicRouter(ROUTER_ROOT); +``` + +- Create router instance with root path (e.g., `/auth`, `/user`) +- Root path used for route grouping and OpenAPI tag generation + +### Route Definition Signature + +```typescript +router.method(path, requestType, ...handlers); +``` + +**Parameters:** + +1. `path`: Route path string (e.g., `/login`, `/:id`) +2. `requestType`: Schema configuration object +3. `...handlers`: Middleware functions and controller (spread arguments) + +### Request Type Object + +```typescript +{ + requestType?: { + body?: ZodSchema, // Request body validation + params?: ZodSchema, // URL params validation + query?: ZodSchema, // Query string validation + }, + contentType?: string, // 'application/json' | 'multipart/form-data' | etc. +} +``` + +- Use empty object `{}` when no validation needed +- Can combine `body`, `params`, and `query` in same route + +### Handler Order + +The last handler in the spread is treated as the **controller**. All preceding handlers are **middleware**. + +```typescript +// Public route +router.post('/action', { requestType: { body: schema } }, controller); + +// With one middleware +router.get('/me', {}, canAccess(), controller); + +// With multiple middleware +router.post('/upload', {}, middleware1(), middleware2(), controller); +``` + +## Authentication + +### Public Routes + +No authentication required - just pass the controller: + +```typescript +router.post('/login', { requestType: { body: loginSchema } }, handleLogin); +``` + +### Protected Routes + +Add `canAccess()` middleware before the controller: + +```typescript +import { canAccess } from '@/middlewares/can-access.middleware'; + +router.get('/me', {}, canAccess(), handleGetCurrentUser); +``` + +- Security is auto-detected in OpenAPI by presence of `canAccess()` middleware +- JWT payload available as `req.jwtPayload` in controller (via `canAccess()`) + +## Common Route Patterns + +### Body Validation + +```typescript +router.post('/create', { requestType: { body: createSchema } }, handleCreate); +``` + +### Params Validation + +```typescript +router.get('/:id', { requestType: { params: idParamsSchema } }, handleGetById); +``` + +### Query Validation + +```typescript +router.get( + '/search', + { requestType: { query: searchQuerySchema } }, + handleSearch, +); +``` + +### Combined Validation + +```typescript +router.put( + '/:id', + { + requestType: { + params: idParamsSchema, + body: updateSchema, + }, + }, + canAccess(), + handleUpdate, +); +``` + +### No Validation + +```typescript +router.post('/logout', {}, handleLogout); +``` + +## File Uploads + +Add multer middleware before controller: + +```typescript +import { multerS3 } from '@/middlewares/multer-s3.middleware'; + +router.post( + '/upload', + { contentType: 'multipart/form-data' }, + canAccess(), + multerS3.single('file'), + handleUpload, +); +``` + +## Available HTTP Methods + +- `router.get()` +- `router.post()` +- `router.put()` +- `router.patch()` +- `router.delete()` + +## Route Organization + +### File Structure + +``` +module/ + ├── module.controller.ts # Export named controller functions + ├── module.router.ts # Define routes + ├── module.schema.ts # Zod schemas + ├── module.service.ts # Business logic + └── module.model.ts # Database models +``` + +### Router Export Pattern + +```typescript +export const MODULE_ROUTER_ROOT = '/module'; +const moduleRouter = new MagicRouter(MODULE_ROUTER_ROOT); + +// ... define routes ... + +export default moduleRouter.getRouter(); +``` + +### Register in Routes + +Add router to [routes.ts](mdc:src/routes/routes.ts): + +```typescript +import moduleRouter from './modules/module/module.router'; + +app.use(moduleRouter); +``` + +## OpenAPI Generation + +MagicRouter automatically generates OpenAPI documentation: + +- **Tags**: Auto-generated from router root path +- **Summary**: Auto-generated from controller function name +- **Security**: Auto-detected from `canAccess()` middleware +- **Schemas**: Generated from Zod schemas in `requestType` +- **Responses**: 200, 400, 404, 500 automatically configured + +## Common Mistakes to Avoid + +❌ **DON'T** use plain Express routing + +```typescript +router.get('/path', handler); // Wrong +``` + +✅ **DO** use MagicRouter signature + +```typescript +router.get('/path', {}, handler); // Correct +``` + +❌ **DON'T** forget the request type object + +```typescript +router.post('/create', handleCreate); // Wrong +``` + +✅ **DO** always include it (use `{}` if no validation) + +```typescript +router.post('/create', {}, handleCreate); // Correct +``` + +❌ **DON'T** use array syntax for handlers + +```typescript +router.get('/me', {}, [canAccess(), handler]); // Wrong +``` + +✅ **DO** use spread arguments + +```typescript +router.get('/me', {}, canAccess(), handler); // Correct +``` + +❌ **DON'T** forget to call `.getRouter()` + +```typescript +export default moduleRouter; // Wrong +``` + +✅ **DO** call `.getRouter()` on export + +```typescript +export default moduleRouter.getRouter(); // Correct +``` + +❌ **DON'T** use wrong schema object structure + +```typescript +{ + schema: bodySchema; +} // Wrong +{ + body: bodySchema; +} // Wrong +``` + +✅ **DO** use correct nesting + +```typescript +{ + requestType: { + body: bodySchema; + } +} // Correct +``` diff --git a/.cursor/rules/schemas.mdc b/.cursor/rules/schemas.mdc new file mode 100644 index 0000000..984b5dc --- /dev/null +++ b/.cursor/rules/schemas.mdc @@ -0,0 +1,287 @@ +--- +globs: *.schema.ts +description: Zod schema patterns for validation and OpenAPI documentation +--- + +# Zod Schema Patterns + +## Core Principle + +Every module should have a schema file that defines request/response validation using Zod schemas. + +## Import Pattern + +```typescript +import validator from 'validator'; +import z from 'zod'; +// OR +import * as z from 'zod'; +``` + +## Schema Structure + +Schemas are exported directly, NOT wrapped in request/response objects: + +```typescript +import validator from 'validator'; +import z from 'zod'; + +export const createItemSchema = z.object({ + name: z.string({ required_error: 'Name is required' }).min(1).max(100), + description: z + .string({ required_error: 'Description is required' }) + .min(10) + .max(500), + status: z.enum(['active', 'inactive']).default('active'), + categoryId: z + .string({ required_error: 'Category ID is required' }) + .refine((value) => validator.isMongoId(value), 'Category ID must be valid'), +}); + +export const updateItemSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().min(10).max(500).optional(), + status: z.enum(['active', 'inactive']).optional(), +}); +``` + +## Common Patterns + +### String Validation with Required Error + +```typescript +z.string({ required_error: 'Field name is required' }).min(1).max(64); +``` + +### Email Validation + +```typescript +z.string({ required_error: 'Email is required' }).email({ + message: 'Email is not valid', +}); +``` + +### MongoDB ObjectId Validation + +Use validator package, NOT regex: + +```typescript +z.string({ required_error: 'ID is required' }) + .min(1) + .refine((value) => validator.isMongoId(value), 'ID must be valid'); +``` + +### Alphanumeric Validation + +```typescript +z.string({ required_error: 'Code is required' }) + .min(4) + .max(4) + .refine((value) => validator.isAlphanumeric(value), 'Code must be valid'); +``` + +### Query Parameters with Transform + +```typescript +export const listItemsQuerySchema = z.object({ + searchString: z.string().optional(), + limitParam: z + .string() + .default('10') + .refine( + (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, + 'Input must be positive integer', + ) + .transform(Number), + pageParam: z + .string() + .default('1') + .refine( + (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, + 'Input must be positive integer', + ) + .transform(Number), + filterByStatus: z.enum(['active', 'inactive', 'archived']).optional(), +}); +``` + +### Enum Validation + +```typescript +// From enum object keys +z.enum(Object.keys(STATUS_ENUM) as [StatusType]).optional(); + +// Direct enum values +z.enum(['pending', 'approved', 'rejected']).optional(); +``` + +## Schema Composition + +### Merging Schemas + +```typescript +// Base schema +export const baseItemSchema = z.object({ + name: z.string({ required_error: 'Name is required' }).min(1), + description: z.string().optional(), +}); + +// Extended schema +export const createItemSchema = z + .object({ + categoryId: z.string().refine((value) => validator.isMongoId(value)), + tags: z.array(z.string()).optional(), + }) + .merge(baseItemSchema) + .strict(); +``` + +### Cross-Field Validation with .refine() + +```typescript +export const createItemWithConfirmationSchema = z + .object({ + price: z.number().positive(), + confirmPrice: z.number().positive(), + discountPrice: z.number().positive().optional(), + }) + .refine( + ({ price, confirmPrice }) => price === confirmPrice, + 'Price and confirm price must match', + ) + .refine( + ({ price, discountPrice }) => !discountPrice || discountPrice < price, + 'Discount price must be less than original price', + ); +``` + +### Strict Mode + +Use `.strict()` to disallow extra properties: + +```typescript +z.object({ + name: z.string(), + email: z.string().email(), +}).strict(); +``` + +## Reusable Schema Patterns + +### Password Validation Function + +Define in `common.schema.ts`: + +```typescript +export const passwordValidationSchema = (fieldName: string) => + z + .string({ required_error: `${fieldName} is required` }) + .min(8) + .max(64) + .refine( + (value) => + validator.isStrongPassword(value, { + minLength: 8, + minLowercase: 1, + minNumbers: 1, + minUppercase: 1, + minSymbols: 1, + }), + 'Password is too weak', + ); +``` + +### MongoDB ID Schema + +```typescript +export const mongoIdSchema = z.object({ + id: z.string().refine((value) => validator.isMongoId(value)), +}); +``` + +### Response Schemas + +```typescript +export const successResponseSchema = z.object({ + success: z.boolean().default(true), + message: z.string().optional(), + data: z.record(z.string(), z.any()).optional(), +}); + +export const errorResponseSchema = z.object({ + message: z.string(), + success: z.boolean().default(false), + data: z.record(z.string(), z.any()), + stack: z.string().optional(), +}); +``` + +### Paginator Schema + +```typescript +export const paginatorSchema = z.object({ + skip: z.number().min(0), + limit: z.number().min(1), + currentPage: z.number().min(1), + pages: z.number().min(0), + hasNextPage: z.boolean(), + totalRecords: z.number().min(0), + pageSize: z.number().min(1), +}); + +export const paginatedResponseSchema = z.object({ + success: z.boolean().default(true), + message: z.string().optional(), + data: z + .object({ + items: z.array(z.unknown()), + paginator: paginatorSchema, + }) + .optional(), +}); +``` + +## Custom Validators + +Use `.refine()` with custom validation functions: + +```typescript +export const createItemSchema = z.object({ + slug: z + .string({ required_error: 'Slug is required' }) + .min(1) + .refine((value) => isValidSlug(value), 'Slug must be valid'), + email: z + .string({ required_error: 'Email is required' }) + .refine((value) => validator.isEmail(value), 'Email must be valid'), +}); +``` + +## Type Inference + +Export TypeScript types from schemas: + +```typescript +export type CreateItemSchemaType = z.infer; +export type UpdateItemSchemaType = z.infer; +export type ListItemsQuerySchemaType = z.infer; +export type ItemParamsSchemaType = z.infer; +``` + +## Key Patterns to Follow + +✅ DO import validator from "validator" +✅ DO use `{ required_error: "message" }` for required fields +✅ DO use `.min(1)` for required strings +✅ DO use `.refine()` with validator functions +✅ DO export schemas directly (not wrapped in objects) +✅ DO export types using `z.infer` +✅ DO use `.merge()` to compose schemas +✅ DO use `.strict()` to disallow extra properties +✅ DO use `.refine()` for cross-field validation +✅ DO create reusable schema functions in common.schema.ts + +❌ DON'T use `.openapi()` method in schema files +❌ DON'T wrap schemas in request/response objects (that's for routers) +❌ DON'T use regex for MongoDB IDs (use validator.isMongoId) +❌ DON'T forget to handle query parameter transforms with .transform(Number) diff --git a/.cursor/rules/services.mdc b/.cursor/rules/services.mdc new file mode 100644 index 0000000..6d7c974 --- /dev/null +++ b/.cursor/rules/services.mdc @@ -0,0 +1,357 @@ +--- +globs: *.service.ts,*.services.ts +description: Service layer patterns for business logic and data access +--- + +# Service Layer Patterns + +## Core Principle + +Services contain business logic, database operations, external API calls, and complex computations. They should be framework-agnostic (no Express req/res). + +## Service Template + +```typescript +import { Model } from './module.model'; +import { logger } from '@/lib/logger.service'; +import type { CreateInput, UpdateInput } from './module.dto'; + +/** + * Find item by ID + */ +export const findById = async (id: string) => { + const item = await Model.findById(id); + return item; +}; + +/** + * Find all items with pagination + */ +export const findAll = async (options: { + page: number; + limit: number; + search?: string; +}) => { + const { page, limit, search } = options; + const skip = (page - 1) * limit; + + const query = search ? { name: { $regex: search, $options: 'i' } } : {}; + + const [items, total] = await Promise.all([ + Model.find(query).skip(skip).limit(limit).lean(), + Model.countDocuments(query), + ]); + + return { + data: items, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; +}; + +/** + * Create new item + */ +export const create = async (data: CreateInput) => { + const item = await Model.create(data); + + logger.info('Item created', { itemId: item._id }); + + return item.toObject(); +}; + +/** + * Update item + */ +export const update = async ( + id: string, + data: UpdateInput, + userId?: string, +) => { + const item = await Model.findById(id); + + if (!item) { + return null; + } + + // Business logic: Check permissions + if (item.createdBy?.toString() !== userId) { + const error = new Error('Forbidden') as any; + error.statusCode = 403; + throw error; + } + + Object.assign(item, data); + await item.save(); + + logger.info('Item updated', { itemId: id, userId }); + + return item.toObject(); +}; + +/** + * Delete item + */ +export const remove = async (id: string, userId?: string) => { + const item = await Model.findById(id); + + if (!item) { + return false; + } + + // Business logic: Check permissions + if (item.createdBy?.toString() !== userId) { + const error = new Error('Forbidden') as any; + error.statusCode = 403; + throw error; + } + + await item.deleteOne(); + + logger.info('Item deleted', { itemId: id, userId }); + + return true; +}; + +/** + * Complex business logic example + */ +export const performComplexOperation = async (input: { + userId: string; + data: any; +}) => { + // 1. Validate business rules + const user = await UserModel.findById(input.userId); + if (!user) { + throw new Error('User not found'); + } + + // 2. Perform operations + const result = await Model.create({ + ...input.data, + userId: input.userId, + }); + + // 3. Trigger background jobs if needed + await triggerEmailJob(user.email, result); + + // 4. Return result + return result; +}; + +/** + * Trigger background job + */ +const triggerEmailJob = async (email: string, data: any) => { + const { emailQueue } = await import('@/queues/email.queue'); + await emailQueue.add('sendNotification', { email, data }); +}; +``` + +## Key Patterns + +### Database Operations + +Use Mongoose models from `module.model.ts`: + +```typescript +// Find +const item = await Model.findById(id); +const items = await Model.find({ status: 'active' }); + +// Create +const item = await Model.create({ name: 'Test' }); + +// Update +const item = await Model.findByIdAndUpdate(id, { name: 'New' }, { new: true }); + +// Delete +await Model.findByIdAndDelete(id); + +// Count +const count = await Model.countDocuments({ status: 'active' }); + +// Use .lean() for better performance (returns plain objects) +const items = await Model.find().lean(); +``` + +### Pagination Helper + +Use pagination utility from [getPaginator.ts](mdc:src/utils/getPaginator.ts) if available, or implement manually: + +```typescript +const skip = (page - 1) * limit; +const items = await Model.find().skip(skip).limit(limit); +const total = await Model.countDocuments(); +``` + +### Background Jobs + +Queue background tasks using BullMQ: + +```typescript +import { emailQueue } from '@/queues/email.queue'; + +await emailQueue.add( + 'jobName', + { data }, + { + delay: 5000, // Optional: delay in ms + attempts: 3, // Optional: retry attempts + }, +); +``` + +### Email Sending + +Send emails through queue system: + +```typescript +import { sendEmail } from '@/email/email.service'; + +await sendEmail({ + to: user.email, + subject: 'Welcome', + template: 'Welcome', + data: { name: user.name }, +}); +``` + +### File Storage (S3) + +Use AWS service from [aws.service.ts](mdc:src/lib/aws.service.ts): + +```typescript +import { s3Client, uploadToS3, deleteFromS3 } from '@/lib/aws.service'; + +// Upload is handled by multer middleware +// Just save the URL returned in controller + +// Delete file +await deleteFromS3(fileKey); +``` + +### Authentication & Tokens + +Use auth utilities from [auth.utils.ts](mdc:src/utils/auth.utils.ts): + +```typescript +import { + generateToken, + verifyToken, + hashPassword, + comparePassword, +} from '@/utils/auth.utils'; + +// Generate JWT +const token = generateToken({ userId: user._id }); + +// Verify JWT +const payload = verifyToken(token); + +// Hash password +const hashed = await hashPassword(plainPassword); + +// Compare password +const isValid = await comparePassword(plainPassword, hashedPassword); +``` + +### Error Handling + +Throw errors with status codes: + +```typescript +// Not found +const error = new Error('Item not found') as any; +error.statusCode = 404; +throw error; + +// Forbidden +const error = new Error('Insufficient permissions') as any; +error.statusCode = 403; +throw error; + +// Bad request +const error = new Error('Invalid input') as any; +error.statusCode = 400; +throw error; + +// Internal server error (default) +throw new Error('Something went wrong'); // 500 +``` + +### Logging + +Use Pino logger: + +```typescript +import { logger } from '@/lib/logger.service'; + +logger.info('Operation performed', { userId, itemId }); +logger.error('Error occurred', { error: err.message, stack: err.stack }); +logger.warn('Warning', { data }); +logger.debug('Debug info', { data }); +``` + +## Service Organization + +- One service file per module: `module.service.ts` +- Export individual functions (not a class) +- Keep functions focused and single-purpose +- Use TypeScript types from `module.dto.ts` + +## Common Patterns + +### Transaction Support (if needed) + +```typescript +import { startSession } from 'mongoose'; + +const session = await startSession(); +session.startTransaction(); + +try { + await Model1.create([data1], { session }); + await Model2.create([data2], { session }); + + await session.commitTransaction(); +} catch (error) { + await session.abortTransaction(); + throw error; +} finally { + session.endSession(); +} +``` + +### Caching with Redis + +```typescript +import { redisClient } from '@/lib/redis.server'; + +// Get from cache +const cached = await redisClient.get(`key:${id}`); +if (cached) { + return JSON.parse(cached); +} + +// Set cache +await redisClient.set(`key:${id}`, JSON.stringify(data), 'EX', 3600); // 1 hour +``` + +## Common Mistakes to Avoid + +❌ DON'T import Express types (Request, Response) +✅ DO keep services framework-agnostic + +❌ DON'T handle HTTP status codes in services (except throwing errors) +✅ DO let controllers handle HTTP concerns + +❌ DON'T perform heavy operations synchronously +✅ DO use background jobs for heavy tasks + +❌ DON'T forget to log important operations +✅ DO log creates, updates, deletes, and errors From 6f3438c37f7694432835f231962cd02e6198d545 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Mon, 6 Oct 2025 15:00:06 +0500 Subject: [PATCH 02/14] chore: remove VSCode extensions configuration and clean up settings --- .vscode/extensions.json | 14 -------------- .vscode/settings.json | 13 +------------ 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 627f28b..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "recommendations": [ - "mattpocock.ts-error-translator", - "yoavbls.pretty-ts-errors", - "prisma.prisma", - "esbenp.prettier-vscode", - "christian-kohler.path-intellisense", - "dbaeumer.vscode-eslint", - "ms-azuretools.vscode-docker", - "digitalbrainstem.javascript-ejs-support", - "j69.ejs-beautify", - "dbaeumer.vscode-eslint" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 543e746..2612aca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,3 @@ { - "eslint.useFlatConfig": true, - "typescript.preferences.importModuleSpecifier": "relative", - "git.ignoreLimitWarning": true, - "prettier.useEditorConfig": false, - "totalTypeScript.hideAllTips": true, - "typescript.tsdk": "node_modules/typescript/lib", - "emmet.includeLanguages": { - "ejs": "html" - }, - "[html]": { - "editor.defaultFormatter": "j69.ejs-beautify" - } + "typescript.preferences.importModuleSpecifier": "relative" } From a35cb5489145bd22079b6b76289021dcd6525efc Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 07:17:16 +0500 Subject: [PATCH 03/14] chore: update configuration files and enhance middleware structure --- .cursor/rules/architecture.mdc | 2 +- .cursor/rules/controllers.mdc | 4 +- .cursor/rules/new-module.mdc | 2 +- .cursor/rules/routing.mdc | 6 +- .cursorignore | 1 + .gitignore | 4 + CLAUDE.md | 68 --- README.md | 10 +- bin/tbk | 445 ++++++++++++++++++ eslint.config.mjs | 2 + package.json | 13 +- pnpm-lock.yaml | 81 +++- scripts/gen-openapi.ts | 23 + src/app/app.ts | 50 ++ src/app/createApp.ts | 48 ++ src/config/config.service.ts | 51 -- src/config/env.ts | 71 +++ src/core/router.ts | 30 ++ src/core/validate.ts | 2 + src/email/email.service.ts | 144 +++--- src/index.ts | 35 ++ src/lib/database.ts | 26 +- src/lib/email.server.ts | 18 +- src/lib/mailgun.server.ts | 10 +- src/lib/queue.server.ts | 54 +-- src/lib/realtime.server.ts | 22 +- src/lib/redis.server.ts | 14 +- src/lib/session.store.ts | 8 - src/main.ts | 223 ++++----- ...can-access.middleware.ts => can-access.ts} | 0 .../extract-jwt-schema.middleware.ts | 25 - src/middlewares/extract-jwt.ts | 25 + src/middlewares/metrics.ts | 28 ++ .../{multer-s3.middleware.ts => multer-s3.ts} | 0 src/middlewares/requestId.ts | 19 + ...a.middleware.ts => validate-zod-schema.ts} | 0 src/modules/auth/auth.constants.ts | 16 +- src/modules/auth/auth.controller.ts | 134 +++--- src/modules/auth/auth.router.ts | 78 +-- src/modules/auth/auth.service.ts | 275 ++++++----- src/modules/user/user.controller.ts | 105 ++--- src/modules/user/user.router.ts | 38 +- src/observability/logger.ts | 97 ++++ src/observability/metrics.ts | 62 +++ src/openapi/magic-router.ts | 4 +- src/openapi/registry.ts | 1 + src/openapi/swagger-doc-generator.ts | 2 +- src/plugins/auth.ts | 30 ++ src/plugins/cache.ts | 28 ++ src/plugins/observability.ts | 38 ++ src/plugins/openapi.ts | 32 ++ src/plugins/security.ts | 18 + src/plugins/types.ts | 27 ++ src/plugins/uploads.ts | 37 ++ src/routes/ops.ts | 79 ++++ src/routes/routes.ts | 12 +- src/server/lifecycle.ts | 101 ++++ src/server/security.ts | 72 +++ src/upload/upload.router.ts | 4 +- src/utils/api.utils.ts | 108 ++--- src/utils/auth.utils.ts | 202 ++++---- src/utils/common.utils.ts | 111 +++-- src/utils/globalErrorHandler.ts | 44 +- 63 files changed, 2301 insertions(+), 1018 deletions(-) create mode 100644 .cursorignore delete mode 100644 CLAUDE.md create mode 100755 bin/tbk create mode 100644 scripts/gen-openapi.ts create mode 100644 src/app/app.ts create mode 100644 src/app/createApp.ts delete mode 100644 src/config/config.service.ts create mode 100644 src/config/env.ts create mode 100644 src/core/router.ts create mode 100644 src/core/validate.ts create mode 100644 src/index.ts delete mode 100644 src/lib/session.store.ts rename src/middlewares/{can-access.middleware.ts => can-access.ts} (100%) delete mode 100644 src/middlewares/extract-jwt-schema.middleware.ts create mode 100644 src/middlewares/extract-jwt.ts create mode 100644 src/middlewares/metrics.ts rename src/middlewares/{multer-s3.middleware.ts => multer-s3.ts} (100%) create mode 100644 src/middlewares/requestId.ts rename src/middlewares/{validate-zod-schema.middleware.ts => validate-zod-schema.ts} (100%) create mode 100644 src/observability/logger.ts create mode 100644 src/observability/metrics.ts create mode 100644 src/openapi/registry.ts create mode 100644 src/plugins/auth.ts create mode 100644 src/plugins/cache.ts create mode 100644 src/plugins/observability.ts create mode 100644 src/plugins/openapi.ts create mode 100644 src/plugins/security.ts create mode 100644 src/plugins/types.ts create mode 100644 src/plugins/uploads.ts create mode 100644 src/routes/ops.ts create mode 100644 src/server/lifecycle.ts create mode 100644 src/server/security.ts diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index b52f8fa..bdd1c90 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -32,7 +32,7 @@ module-name/ ### Validation & Type Safety - ALWAYS use Zod schemas for request/response validation -- Runtime validation via [validate-zod-schema.middleware.ts](mdc:src/middlewares/validate-zod-schema.middleware.ts) +- Runtime validation via [validate-zod-schema.ts](mdc:src/middlewares/validate-zod-schema.ts) - Extend Zod with OpenAPI metadata using `.openapi()` method from [zod-extend.ts](mdc:src/openapi/zod-extend.ts) - Use TypeScript strict mode - no `any` types diff --git a/.cursor/rules/controllers.mdc b/.cursor/rules/controllers.mdc index 219de59..9a5451a 100644 --- a/.cursor/rules/controllers.mdc +++ b/.cursor/rules/controllers.mdc @@ -177,7 +177,7 @@ Request<{ id: string }, unknown, UpdateUserSchemaType>; // params + body ### JWT Payload Access -When route uses `extractJwt` middleware from [extract-jwt-schema.middleware.ts](mdc:src/middlewares/extract-jwt-schema.middleware.ts): +When route uses `extractJwt` middleware from [extract-jwt-schema.ts](mdc:src/middlewares/extract-jwt-schema.ts): ```typescript import type { JwtPayload } from '@/utils/auth.utils'; @@ -206,7 +206,7 @@ type JwtPayload = { ### File Upload Access -When route uses multer middleware from [multer-s3.middleware.ts](mdc:src/middlewares/multer-s3.middleware.ts): +When route uses multer middleware from [multer-s3.ts](mdc:src/middlewares/multer-s3.ts): ```typescript const file = req.file; // For single file diff --git a/.cursor/rules/new-module.mdc b/.cursor/rules/new-module.mdc index f8c4807..bfc2938 100644 --- a/.cursor/rules/new-module.mdc +++ b/.cursor/rules/new-module.mdc @@ -322,7 +322,7 @@ export const remove = async (req: Request, res: Response) => { ```typescript import { MagicRouter } from '@/openapi/magic-router'; -import { extractJwtSchema } from '@/middlewares/extract-jwt-schema.middleware'; +import { extractJwtSchema } from '@/middlewares/extract-jwt-schema'; import * as controller from './module.controller'; import * as schemas from './module.schema'; diff --git a/.cursor/rules/routing.mdc b/.cursor/rules/routing.mdc index cca326b..66b9b87 100644 --- a/.cursor/rules/routing.mdc +++ b/.cursor/rules/routing.mdc @@ -13,7 +13,7 @@ NEVER use plain Express routing. ALWAYS use MagicRouter from [magic-router.ts](m ```typescript import MagicRouter from '@/openapi/magic-router'; -import { canAccess } from '@/middlewares/can-access.middleware'; +import { canAccess } from '@/middlewares/can-access'; import { handleAction, handleGetById, handleCreate } from './module.controller'; import { actionSchema, createSchema } from './module.schema'; @@ -125,7 +125,7 @@ router.post('/login', { requestType: { body: loginSchema } }, handleLogin); Add `canAccess()` middleware before the controller: ```typescript -import { canAccess } from '@/middlewares/can-access.middleware'; +import { canAccess } from '@/middlewares/can-access'; router.get('/me', {}, canAccess(), handleGetCurrentUser); ``` @@ -184,7 +184,7 @@ router.post('/logout', {}, handleLogout); Add multer middleware before controller: ```typescript -import { multerS3 } from '@/middlewares/multer-s3.middleware'; +import { multerS3 } from '@/middlewares/multer-s3'; router.post( '/upload', diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6a7b41b --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +.dump \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1cc806e..9753d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ dist # database .database .aider* + +.claude + +.dump diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f7fa39c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,68 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development Commands - -- **Development server**: `pnpm run dev` - Starts both backend and email template development server -- **Backend only**: `pnpm run start:dev` - Starts just the backend with hot reload -- **Build**: `pnpm run build` - Builds the project using tsup -- **Production start**: `pnpm run start:prod` - Starts production build with .env.production -- **Local start**: `pnpm run start:local` - Starts production build with .env.local -- **Database seeder**: `pnpm run seeder` - Runs database seeding scripts -- **Linting**: `pnpm run lint` - Runs ESLint, `pnpm run lint:fix` - Auto-fixes linting issues -- **Email templates**: `pnpm run email:dev` - Starts email template development server - -## Architecture Overview - -This is a TypeScript Express.js backend toolkit with the following key architectural components: - -### Core Architecture -- **MagicRouter System**: Custom routing ([src/openapi/magic-router.ts](src/openapi/magic-router.ts)) that automatically generates OpenAPI/Swagger documentation from Zod schemas -- **Module-based structure**: Features organized in modules under [src/modules/](src/modules/) (auth, user) -- **Configuration management**: Type-safe config using Zod validation in [src/config/config.service.ts](src/config/config.service.ts) -- **Database**: MongoDB with Mongoose ODM, connection managed in [src/lib/database.ts](src/lib/database.ts) - -### Key Features -- **Authentication**: JWT-based with optional OTP verification, Google OAuth support -- **File Uploads**: Multer with S3 integration via [src/lib/aws.service.ts](src/lib/aws.service.ts) -- **Email System**: React Email templates with Mailgun provider, queue-based sending -- **Real-time**: Socket.io integration with Redis adapter -- **Background Jobs**: BullMQ with Redis for email queues and other background tasks -- **API Documentation**: Auto-generated Swagger docs at `/api-docs` from MagicRouter -- **Queue Dashboard**: BullMQ admin dashboard at `/admin/queues` - -### Middleware Stack -- Request validation with Zod schemas ([src/middlewares/validate-zod-schema.middleware.ts](src/middlewares/validate-zod-schema.middleware.ts)) -- JWT extraction and authorization ([src/middlewares/extract-jwt-schema.middleware.ts](src/middlewares/extract-jwt-schema.middleware.ts)) -- File upload handling with S3 ([src/middlewares/multer-s3.middleware.ts](src/middlewares/multer-s3.middleware.ts)) -- Access control middleware ([src/middlewares/can-access.middleware.ts](src/middlewares/can-access.middleware.ts)) - -### Environment Setup -1. Start Docker services: `docker compose up -d` (MongoDB + Redis) -2. Install dependencies: `pnpm i` -3. Configure environment variables using `.env.sample` as template - -### Key Patterns -- **MagicRouter**: All API routes use MagicRouter for automatic OpenAPI generation -- **Zod Schemas**: Every route uses Zod for request/response validation -- **Service Layer**: Business logic separated into service files -- **Queue-based**: Email sending and background jobs use BullMQ queues -- **Type Safety**: Full TypeScript coverage with Zod for runtime validation - -### File Upload & Storage -- Multer middleware handles file uploads -- AWS S3 integration for file storage -- File upload routes in [src/upload/](src/upload/) - -### Email System -- React Email for template development -- Mailgun for email delivery -- Queue-based sending system in [src/queues/email.queue.ts](src/queues/email.queue.ts) - -## Important Notes -- All expiration times in config are in milliseconds (converted from strings) -- The project uses pnpm as package manager -- Database seeding is available via the seeder script -- Global error handling in [src/utils/globalErrorHandler.ts](src/utils/globalErrorHandler.ts) -- Logging uses Pino logger with pretty printing in development \ No newline at end of file diff --git a/README.md b/README.md index 770e556..dfdca0d 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,10 @@ Before you get started, make sure you have the following installed on your machi │   │   └── session.store.ts │   ├── main.ts │   ├── middlewares -│   │   ├── can-access.middleware.ts -│   │   ├── extract-jwt-schema.middleware.ts -│   │   ├── multer-s3.middleware.ts -│   │   └── validate-zod-schema.middleware.ts +│   │   ├── can-access.ts +│   │   ├── extract-jwt-schema.ts +│   │   ├── multer-s3.ts +│   │   └── validate-zod-schema.ts │   ├── modules │   │   ├── auth │   │   │   ├── auth.constants.ts @@ -168,7 +168,7 @@ MagicRouter now supports multipart/form-data file uploads with automatic OpenAPI import { z } from 'zod'; import MagicRouter from './openapi/magic-router'; import { zFile } from './openapi/zod-extend'; -import { uploadMiddleware } from './middlewares/multer-s3.middleware'; +import { uploadMiddleware } from './middlewares/multer-s3'; const router = new MagicRouter('/api'); diff --git a/bin/tbk b/bin/tbk new file mode 100755 index 0000000..74c90de --- /dev/null +++ b/bin/tbk @@ -0,0 +1,445 @@ +#!/usr/bin/env tsx + +import { Command } from 'commander'; +import fs from 'fs/promises'; +import path from 'path'; + +const program = new Command(); + +program + .name('tbk') + .description('TypeScript Backend Toolkit CLI') + .version('1.0.0'); + +program + .command('generate:plugin ') + .alias('g:plugin') + .description('Generate a new plugin') + .action(async (name) => { + const pluginName = name.toLowerCase(); + const className = name.charAt(0).toUpperCase() + name.slice(1); + + const pluginContent = `import type { ToolkitPlugin, PluginFactory } from './types'; + +export interface ${className}Options { + enabled?: boolean; +} + +export const ${pluginName}Plugin: PluginFactory<${className}Options> = ( + options = {}, +): ToolkitPlugin<${className}Options> => { + const { enabled = true } = options; + + return { + name: '${pluginName}', + priority: 50, + options, + + register({ app }) { + if (!enabled) { + return; + } + + // Plugin implementation here + console.log('${className} plugin registered'); + }, + + onShutdown: async () => { + // Cleanup logic here + console.log('${className} plugin shutdown'); + }, + }; +}; + +export default ${pluginName}Plugin; +`; + + const outputPath = path.join( + process.cwd(), + 'src', + 'plugins', + `${pluginName}.ts`, + ); + + try { + await fs.writeFile(outputPath, pluginContent, 'utf-8'); + console.log(`✓ Plugin created: ${outputPath}`); + } catch (error) { + console.error('Failed to create plugin:', error); + process.exit(1); + } + }); + +program + .command('generate:middleware ') + .alias('g:middleware') + .description('Generate a new middleware') + .action(async (name) => { + const middlewareName = name.toLowerCase(); + + const middlewareContent = `import type { Request, Response, NextFunction } from 'express'; + +export function ${middlewareName}Middleware( + req: Request, + res: Response, + next?: NextFunction, +): void { + // Middleware implementation here + next?.(); +} + +export default ${middlewareName}Middleware; +`; + + const outputPath = path.join( + process.cwd(), + 'src', + 'middlewares', + `${middlewareName}.ts`, + ); + + try { + await fs.writeFile(outputPath, middlewareContent, 'utf-8'); + console.log(`✓ Middleware created: ${outputPath}`); + } catch (error) { + console.error('Failed to create middleware:', error); + process.exit(1); + } + }); + +program + .command('generate:module ') + .alias('g:module') + .description( + 'Generate a complete module with all files (dto, model, schema, services, controller, router)', + ) + .option('-p, --path ', 'API path prefix', '/api') + .action(async (name, options) => { + const moduleName = name.toLowerCase(); + const className = name.charAt(0).toUpperCase() + name.slice(1); + const moduleDir = path.join(process.cwd(), 'src', 'modules', moduleName); + + try { + // Create module directory + await fs.mkdir(moduleDir, { recursive: true }); + + // 1. DTO file + const dtoContent = `import { z } from "zod"; +import { definePaginatedResponse } from "../../common/common.utils"; + +export const ${moduleName}OutSchema = z.object({ + name: z.string(), + description: z.string().optional(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), +}); + +export const ${moduleName}Schema = ${moduleName}OutSchema.extend({ + // Add internal fields here +}); + +export const ${moduleName}sPaginatedSchema = definePaginatedResponse(${moduleName}OutSchema); + +export type ${className}ModelType = z.infer; +export type ${className}Type = z.infer & { id: string; _id: string }; +export type ${className}PaginatedType = z.infer; +`; + + // 2. Model file + const modelContent = `import mongoose, { type Document, Schema } from "mongoose"; +import type { ${className}ModelType, ${className}Type } from "./${moduleName}.dto"; + +const ${className}Schema: Schema<${className}Type> = new Schema( + { + name: { type: String, required: true }, + description: { type: String }, + }, + { timestamps: true }, +); + +export interface I${className}Document extends Document, ${className}ModelType {} +const ${className} = mongoose.model<${className}Type>("${className}", ${className}Schema); +export default ${className}; +`; + + // 3. Schema file (validation) + const schemaContent = `import { z } from "zod"; + +export const create${className}Schema = z.object({ + name: z.string({ required_error: "Name is required" }).min(1), + description: z.string().optional(), +}); + +export const update${className}Schema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional(), +}); + +export const get${className}sSchema = z.object({ + searchString: z.string().optional(), + limitParam: z + .string() + .default("10") + .refine( + (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, + "Input must be positive integer", + ) + .transform(Number), + pageParam: z + .string() + .default("1") + .refine( + (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, + "Input must be positive integer", + ) + .transform(Number), +}); + +export type Create${className}SchemaType = z.infer; +export type Update${className}SchemaType = z.infer; +export type Get${className}sSchemaType = z.infer; +`; + + // 4. Services file + const servicesContent = `import type { FilterQuery } from "mongoose"; +import type { MongoIdSchemaType } from "../../common/common.schema"; +import { getPaginator } from "../../utils/getPaginator"; +import type { ${className}Type } from "./${moduleName}.dto"; +import ${className}, { type I${className}Document } from "./${moduleName}.model"; +import type { Create${className}SchemaType, Get${className}sSchemaType, Update${className}SchemaType } from "./${moduleName}.schema"; + +export const create${className} = async ( + payload: Create${className}SchemaType, +): Promise<${className}Type> => { + const created${className} = await ${className}.create(payload); + return created${className}.toObject(); +}; + +export const get${className}ById = async (${moduleName}Id: string): Promise<${className}Type> => { + const ${moduleName} = await ${className}.findById(${moduleName}Id); + + if (!${moduleName}) { + throw new Error("${className} not found"); + } + + return ${moduleName}.toObject(); +}; + +export const update${className} = async ( + ${moduleName}Id: string, + payload: Update${className}SchemaType, +): Promise<${className}Type> => { + const ${moduleName} = await ${className}.findByIdAndUpdate( + ${moduleName}Id, + { $set: payload }, + { new: true }, + ); + + if (!${moduleName}) { + throw new Error("${className} not found"); + } + + return ${moduleName}.toObject(); +}; + +export const delete${className} = async (${moduleName}Id: MongoIdSchemaType): Promise => { + const ${moduleName} = await ${className}.findByIdAndDelete(${moduleName}Id.id); + + if (!${moduleName}) { + throw new Error("${className} not found"); + } +}; + +export const get${className}s = async ( + payload: Get${className}sSchemaType, +) => { + const conditions: FilterQuery = {}; + + if (payload.searchString) { + conditions.$or = [ + { name: { $regex: payload.searchString, $options: "i" } }, + { description: { $regex: payload.searchString, $options: "i" } }, + ]; + } + + const totalRecords = await ${className}.countDocuments(conditions); + const paginatorInfo = getPaginator( + payload.limitParam, + payload.pageParam, + totalRecords, + ); + + const results = await ${className}.find(conditions) + .limit(paginatorInfo.limit) + .skip(paginatorInfo.skip) + .exec(); + + return { + results, + paginatorInfo, + }; +}; +`; + + // 5. Controller file + const controllerContent = `import type { Request, Response } from "express"; +import { StatusCodes } from "http-status-codes"; +import type { MongoIdSchemaType } from "../../common/common.schema"; +import { successResponse } from "../../utils/api.utils"; +import type { Create${className}SchemaType, Get${className}sSchemaType, Update${className}SchemaType } from "./${moduleName}.schema"; +import { create${className}, delete${className}, get${className}ById, get${className}s, update${className} } from "./${moduleName}.services"; + +export const handleCreate${className} = async ( + req: Request, + res: Response, +) => { + const ${moduleName} = await create${className}(req.body); + return successResponse( + res, + "${className} created successfully", + ${moduleName}, + StatusCodes.CREATED, + ); +}; + +export const handleGet${className}s = async ( + req: Request, + res: Response, +) => { + const { results, paginatorInfo } = await get${className}s(req.query); + return successResponse(res, undefined, { results, paginatorInfo }); +}; + +export const handleGet${className}ById = async ( + req: Request, + res: Response, +) => { + const ${moduleName} = await get${className}ById(req.params.id); + return successResponse(res, undefined, ${moduleName}); +}; + +export const handleUpdate${className} = async ( + req: Request, + res: Response, +) => { + const ${moduleName} = await update${className}(req.params.id, req.body); + return successResponse(res, "${className} updated successfully", ${moduleName}); +}; + +export const handleDelete${className} = async ( + req: Request, + res: Response, +) => { + await delete${className}({ id: req.params.id }); + return successResponse(res, "${className} deleted successfully"); +}; +`; + + // 6. Router file + const routerContent = `import { canAccess } from "../../middlewares/can-access"; +import MagicRouter from "../../openapi/magic-router"; +import { + handleCreate${className}, + handleDelete${className}, + handleGet${className}ById, + handleGet${className}s, + handleUpdate${className}, +} from "./${moduleName}.controller"; +import { create${className}Schema, get${className}sSchema, update${className}Schema } from "./${moduleName}.schema"; + +export const ${moduleName.toUpperCase()}_ROUTER_ROOT = "${options.path}/${moduleName}s"; + +const ${moduleName}Router = new MagicRouter(${moduleName.toUpperCase()}_ROUTER_ROOT); + +${moduleName}Router.get( + "/", + { + requestType: { query: get${className}sSchema }, + }, + canAccess(), + handleGet${className}s, +); + +${moduleName}Router.post( + "/", + { requestType: { body: create${className}Schema } }, + canAccess(), + handleCreate${className}, +); + +${moduleName}Router.get( + "/:id", + {}, + canAccess(), + handleGet${className}ById, +); + +${moduleName}Router.patch( + "/:id", + { requestType: { body: update${className}Schema } }, + canAccess(), + handleUpdate${className}, +); + +${moduleName}Router.delete( + "/:id", + {}, + canAccess(), + handleDelete${className}, +); + +export default ${moduleName}Router.getRouter(); +`; + + // Write all files + await fs.writeFile( + path.join(moduleDir, `${moduleName}.dto.ts`), + dtoContent, + 'utf-8', + ); + await fs.writeFile( + path.join(moduleDir, `${moduleName}.model.ts`), + modelContent, + 'utf-8', + ); + await fs.writeFile( + path.join(moduleDir, `${moduleName}.schema.ts`), + schemaContent, + 'utf-8', + ); + await fs.writeFile( + path.join(moduleDir, `${moduleName}.services.ts`), + servicesContent, + 'utf-8', + ); + await fs.writeFile( + path.join(moduleDir, `${moduleName}.controller.ts`), + controllerContent, + 'utf-8', + ); + await fs.writeFile( + path.join(moduleDir, `${moduleName}.router.ts`), + routerContent, + 'utf-8', + ); + + console.log(`✓ Module created: ${moduleDir}`); + console.log(` ├── ${moduleName}.dto.ts`); + console.log(` ├── ${moduleName}.model.ts`); + console.log(` ├── ${moduleName}.schema.ts`); + console.log(` ├── ${moduleName}.services.ts`); + console.log(` ├── ${moduleName}.controller.ts`); + console.log(` └── ${moduleName}.router.ts`); + console.log(); + console.log(`Next steps:`); + console.log(` 1. Register the router in your main app file`); + console.log(` 2. Customize the model fields in ${moduleName}.model.ts`); + console.log(` 3. Update validation schemas in ${moduleName}.schema.ts`); + console.log(` 4. Add business logic to ${moduleName}.services.ts`); + } catch (error) { + console.error('Failed to create module:', error); + process.exit(1); + } + }); + +program.parse(); diff --git a/eslint.config.mjs b/eslint.config.mjs index c3c6d61..c6aa982 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,8 @@ export default [ 'dist', '.database', '.database/*', + 'public/*', + 'public/**/*', ], }, pluginJs.configs.recommended, diff --git a/package.json b/package.json index 35b0732..14f2d3c 100644 --- a/package.json +++ b/package.json @@ -4,21 +4,26 @@ "description": "", "main": "dist/main.js", "scripts": { + "dev": "concurrently \"pnpm start:dev\" \"pnpm email:dev\"", "start:dev": "dotenv -e .env.development -- tsx --watch ./src/main.ts", - "seeder": "tsx ./src/seeder.ts", "build": "tsup --config build.ts", + "start": "node ./dist/main.js", "start:prod": "dotenv -e .env.production -- node ./dist/main.js", "start:local": "dotenv -e .env.local -- node ./dist/main.js", + "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix", + "openapi": "tsx scripts/gen-openapi.ts", + "seeder": "tsx ./src/seeder.ts", "email:dev": "email dev --dir ./src/email/templates", - "dev": "concurrently \"pnpm start:dev\" \"pnpm email:dev\"" + "tbk": "tsx bin/tbk" }, "devDependencies": { "@eslint/js": "^9.4.0", "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.13", "@types/express": "^4.17.15", + "@types/express-rate-limit": "^6.0.2", "@types/express-session": "^1.17.5", "@types/helmet": "^4.0.0", "@types/http-status-codes": "^1.2.0", @@ -34,6 +39,7 @@ "@types/validator": "^13.7.17", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^7.11.0", + "commander": "^14.0.1", "concurrently": "^9.1.0", "esbuild": "^0.19.8", "eslint": "~9.4.0", @@ -68,7 +74,9 @@ "dotenv-cli": "^7.4.2", "express": "^4.19.2", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.1.0", "express-session": "^1.18.0", + "form-data": "^4.0.4", "helmet": "^6.0.1", "http-status-codes": "^2.3.0", "ioredis": "^5.3.2", @@ -86,6 +94,7 @@ "pino": "^9.1.0", "pino-http": "^10.1.0", "pino-pretty": "^11.1.0", + "prom-client": "^15.1.3", "react": "^18.3.1", "react-email": "^3.0.2", "redis": "^4.6.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383fe70..1b08c55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,15 @@ importers: express-async-handler: specifier: ^1.2.0 version: 1.2.0 + express-rate-limit: + specifier: ^8.1.0 + version: 8.1.0(express@4.21.2) express-session: specifier: ^1.18.0 version: 1.18.1 + form-data: + specifier: ^4.0.4 + version: 4.0.4 helmet: specifier: ^6.0.1 version: 6.2.0 @@ -122,6 +128,9 @@ importers: pino-pretty: specifier: ^11.1.0 version: 11.3.0 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -159,6 +168,9 @@ importers: '@types/express': specifier: ^4.17.15 version: 4.17.21 + '@types/express-rate-limit': + specifier: ^6.0.2 + version: 6.0.2(express@4.21.2) '@types/express-session': specifier: ^1.17.5 version: 1.18.1 @@ -204,6 +216,9 @@ importers: '@typescript-eslint/parser': specifier: ^7.11.0 version: 7.18.0(eslint@9.4.0)(typescript@5.7.3) + commander: + specifier: ^14.0.1 + version: 14.0.1 concurrently: specifier: ^9.1.0 version: 9.1.2 @@ -1866,6 +1881,10 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/express-rate-limit@6.0.2': + resolution: {integrity: sha512-e1xZLOOlxCDvplAGq7rDcXtbdBu2CWRsMjaIu1LVqGxWtKvwr884YE5mPs3IvHeG/OMDhf24oTaqG5T1bV3rBQ==} + deprecated: This is a stub types definition. express-rate-limit provides its own type definitions, so you do not need this installed. + '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} @@ -2199,6 +2218,9 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -2351,6 +2373,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2798,6 +2824,12 @@ packages: express-async-handler@1.2.0: resolution: {integrity: sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==} + express-rate-limit@8.1.0: + resolution: {integrity: sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express-session@1.18.1: resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} engines: {node: '>= 0.8.0'} @@ -2897,8 +2929,8 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -3106,6 +3138,10 @@ packages: resolution: {integrity: sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==} engines: {node: '>=12.22.0'} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3862,6 +3898,10 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -4260,6 +4300,9 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5662,8 +5705,7 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@opentelemetry/api@1.9.0': - optional: true + '@opentelemetry/api@1.9.0': {} '@phc/format@1.0.0': {} @@ -6246,6 +6288,12 @@ snapshots: '@types/estree@1.0.6': {} + '@types/express-rate-limit@6.0.2(express@4.21.2)': + dependencies: + express-rate-limit: 8.1.0(express@4.21.2) + transitivePeerDependencies: + - express + '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 18.19.76 @@ -6637,7 +6685,7 @@ snapshots: axios@1.7.9: dependencies: follow-redirects: 1.15.9 - form-data: 4.0.2 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -6654,6 +6702,8 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bintrees@1.0.2: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -6825,6 +6875,8 @@ snapshots: commander@11.1.0: {} + commander@14.0.1: {} + commander@4.1.1: {} compressible@2.0.18: @@ -7420,6 +7472,11 @@ snapshots: express-async-handler@1.2.0: {} + express-rate-limit@8.1.0(express@4.21.2): + dependencies: + express: 4.21.2 + ip-address: 10.0.1 + express-session@1.18.1: dependencies: cookie: 0.7.2 @@ -7554,11 +7611,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.2: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -7797,6 +7855,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -8532,6 +8592,11 @@ snapshots: process@0.11.10: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.0 + tdigest: 0.1.2 + proto-list@1.2.4: {} proxy-addr@2.0.7: @@ -9069,6 +9134,10 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + text-table@0.2.0: {} thenify-all@1.6.0: diff --git a/scripts/gen-openapi.ts b/scripts/gen-openapi.ts new file mode 100644 index 0000000..39412c5 --- /dev/null +++ b/scripts/gen-openapi.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env tsx + +import fs from 'fs/promises'; +import path from 'path'; +import { convertDocumentationToYaml } from '../src/openapi/swagger-doc-generator'; + +async function generateOpenApiSpec() { + try { + console.log('Generating OpenAPI specification...'); + + const yamlContent = convertDocumentationToYaml(); + + const outputPath = path.join(process.cwd(), 'openapi.yml'); + await fs.writeFile(outputPath, yamlContent, 'utf-8'); + + console.log(`✓ OpenAPI spec generated successfully at: ${outputPath}`); + } catch (error) { + console.error('Failed to generate OpenAPI spec:', error); + process.exit(1); + } +} + +generateOpenApiSpec(); diff --git a/src/app/app.ts b/src/app/app.ts new file mode 100644 index 0000000..7516b85 --- /dev/null +++ b/src/app/app.ts @@ -0,0 +1,50 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import compression from 'compression'; +import path from 'path'; +import { createApp } from './createApp'; +import config from '../config/env'; +import { extractJwt } from '../middlewares/extract-jwt'; +import { securityPlugin } from '../plugins/security'; +import { observabilityPlugin } from '../plugins/observability'; +import { openApiPlugin } from '../plugins/openapi'; + +export async function initializeApp() { + const { app, server, plugins } = await createApp({ + plugins: [ + securityPlugin({ + corsEnabled: config.CORS_ENABLED, + corsOrigins: [config.CLIENT_SIDE_URL], + corsCredentials: true, + helmetEnabled: config.NODE_ENV === 'production', + rateLimitEnabled: config.RATE_LIMIT_ENABLED, + rateLimitWindowMs: config.RATE_LIMIT_WINDOW_MS, + rateLimitMax: config.RATE_LIMIT_MAX_REQUESTS, + trustProxy: config.TRUST_PROXY, + }), + observabilityPlugin({ + requestId: true, + logging: true, + metrics: config.METRICS_ENABLED, + }), + openApiPlugin({ + path: '/api-docs', + enabled: config.NODE_ENV !== 'production', + }), + ], + config: config, + }); + + app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + + app.use(express.static(path.join(__dirname, '..', '..', 'public'))); + + app.use(cookieParser()); + app.use(compression()); + app.use(extractJwt); + + return { app, server, plugins }; +} + +export default initializeApp; diff --git a/src/app/createApp.ts b/src/app/createApp.ts new file mode 100644 index 0000000..384d320 --- /dev/null +++ b/src/app/createApp.ts @@ -0,0 +1,48 @@ +import express, { type Application } from 'express'; +import { createServer, type Server } from 'http'; +import type { AppContext, ToolkitPlugin } from '../plugins/types'; +import logger from '../observability/logger'; + +export interface CreateAppOptions { + plugins?: ToolkitPlugin[]; + config?: Record; +} + +export async function createApp(options: CreateAppOptions = {}): Promise<{ + app: Application; + server: Server; + plugins: ToolkitPlugin[]; +}> { + const { plugins = [], config = {} } = options; + + const app = express(); + const server = createServer(app); + + const context: AppContext = { + app, + server, + config, + }; + + const sortedPlugins = [...plugins].sort( + (a, b) => (b.priority || 0) - (a.priority || 0), + ); + + for (const plugin of sortedPlugins) { + try { + logger.info(`Registering plugin: ${plugin.name}`); + await plugin.register(context); + logger.debug(`Plugin registered: ${plugin.name}`); + } catch (error) { + logger.error( + { err: error, plugin: plugin.name }, + `Failed to register plugin: ${plugin.name}`, + ); + throw error; + } + } + + return { app, server, plugins: sortedPlugins }; +} + +export default createApp; diff --git a/src/config/config.service.ts b/src/config/config.service.ts deleted file mode 100644 index 8c52967..0000000 --- a/src/config/config.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import dotenv from "dotenv"; -import { z } from "zod"; - -dotenv.config(); - -// Remove .optional() from requried schema properties - -const configSchema = z.object({ - REDIS_URL: z.string().url(), - PORT: z.string().regex(/^\d+$/).transform(Number), - MONGO_DATABASE_URL: z.string().url(), - SMTP_HOST: z.string().min(1).optional(), - SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), - SMTP_USERNAME: z.string().email().optional(), - EMAIL_FROM: z.string().email().optional(), - SMTP_FROM: z.string().min(1).optional(), - SMTP_PASSWORD: z.string().min(1).optional(), - CLIENT_SIDE_URL: z.string().url(), - JWT_SECRET: z.string().min(1), - JWT_EXPIRES_IN: z.string().default("86400").transform(Number), - SESSION_EXPIRES_IN: z.string().default("86400").transform(Number), - PASSWORD_RESET_TOKEN_EXPIRES_IN: z.string().default("86400").transform(Number), - SET_PASSWORD_TOKEN_EXPIRES_IN: z.string().default("86400").transform(Number), - STATIC_OTP: z.enum(["1", "0"]).transform(Number).optional(), - NODE_ENV: z - .union([z.literal("production"), z.literal("development")]) - .default("development") - .optional(), - SET_SESSION: z - .string() - .transform((value) => !!Number(value)) - .optional(), - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), - GOOGLE_REDIRECT_URI: z.string().optional(), - APP_NAME: z.string().default("API V1"), - APP_VERSION: z.string().default("1.0.0"), - // Mailgun configuration - MAILGUN_API_KEY: z.string().min(1), - MAILGUN_DOMAIN: z.string().min(1), - MAILGUN_FROM_EMAIL: z.string().email(), - ADMIN_EMAIL: z.string().email(), - ADMIN_PASSWORD: z.string().min(1), - OTP_VERIFICATION_ENABLED: z.string().transform((value) => !!Number(value)), -}); - -export type Config = z.infer; - -const config = configSchema.parse(process.env); - -export default config; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..9869c35 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,71 @@ +import dotenv from 'dotenv'; +import { z } from 'zod'; + +dotenv.config(); + +const booleanString = z + .string() + .transform((value) => value === 'true' || value === '1') + .pipe(z.boolean()); + +const configSchema = z.object({ + NODE_ENV: z + .enum(['production', 'development', 'test']) + .default('development'), + + PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'), + + REDIS_URL: z.string().url(), + MONGO_DATABASE_URL: z.string().url(), + + CLIENT_SIDE_URL: z.string().url(), + + JWT_SECRET: z.string().min(1), + JWT_EXPIRES_IN: z.string().default('86400').transform(Number), + SESSION_EXPIRES_IN: z.string().default('86400').transform(Number), + PASSWORD_RESET_TOKEN_EXPIRES_IN: z.string().default('86400').transform(Number), + SET_PASSWORD_TOKEN_EXPIRES_IN: z.string().default('86400').transform(Number), + SET_SESSION: booleanString.optional(), + + SMTP_HOST: z.string().min(1).optional(), + SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), + SMTP_USERNAME: z.string().email().optional(), + SMTP_PASSWORD: z.string().min(1).optional(), + SMTP_FROM: z.string().min(1).optional(), + EMAIL_FROM: z.string().email().optional(), + + MAILGUN_API_KEY: z.string().min(1), + MAILGUN_DOMAIN: z.string().min(1), + MAILGUN_FROM_EMAIL: z.string().email(), + + ADMIN_EMAIL: z.string().email(), + ADMIN_PASSWORD: z.string().min(1), + + OTP_VERIFICATION_ENABLED: booleanString, + STATIC_OTP: z.enum(['1', '0']).transform(Number).optional(), + + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GOOGLE_REDIRECT_URI: z.string().optional(), + + APP_NAME: z.string().default('API V1'), + APP_VERSION: z.string().default('1.0.0'), + + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).optional(), + METRICS_ENABLED: booleanString.default('true'), + HEALTH_ENABLED: booleanString.default('true'), + + CORS_ENABLED: booleanString.default('true'), + RATE_LIMIT_ENABLED: booleanString.default('true'), + RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'), + RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'), + + TRUST_PROXY: booleanString.default('false'), + HTTPS_ENABLED: booleanString.default('false'), +}); + +export type Config = z.infer; + +const config = configSchema.parse(process.env); + +export default config; diff --git a/src/core/router.ts b/src/core/router.ts new file mode 100644 index 0000000..d62aa61 --- /dev/null +++ b/src/core/router.ts @@ -0,0 +1,30 @@ +import { MagicRouter } from '../openapi/magic-router'; +import type { + MagicPathType, + RequestAndResponseType, + MagicMiddleware, +} from '../openapi/magic-router'; + +export function defineRoute( + path: string, + config: RequestAndResponseType, +): { path: MagicPathType; config: RequestAndResponseType } { + return { + path: path as MagicPathType, + config, + }; +} + +export function createRouter(rootRoute: string): MagicRouter { + return new MagicRouter(rootRoute); +} + +export { MagicRouter }; + +export type { + MagicPathType, + RequestAndResponseType, + MagicMiddleware, +}; + +export default MagicRouter; diff --git a/src/core/validate.ts b/src/core/validate.ts new file mode 100644 index 0000000..872ef55 --- /dev/null +++ b/src/core/validate.ts @@ -0,0 +1,2 @@ +export { validateZodSchema } from '../middlewares/validate-zod-schema'; +export type { RequestZodSchemaType } from '../types'; diff --git a/src/email/email.service.ts b/src/email/email.service.ts index eb0a97a..225ddc6 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -1,95 +1,95 @@ -import { render } from "@react-email/render"; -import config from "../config/config.service"; -import logger from "../lib/logger.service"; -import mailgunClient from "../lib/mailgun.server"; -import ResetPasswordEmail from "./templates/ResetPassword"; +import { render } from '@react-email/render'; +import config from '../config/env'; +import logger from '../lib/logger.service'; +import mailgunClient from '../lib/mailgun.server'; +import ResetPasswordEmail from './templates/ResetPassword'; export type SendResetPasswordTypePayload = { - email: string; - resetLink: string; - userName: string; + email: string; + resetLink: string; + userName: string; }; class EmailError extends Error { - constructor( - message: string, - public readonly cause?: unknown, - ) { - super(message); - this.name = "EmailError"; - } + constructor( + message: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = 'EmailError'; + } } // Utility functions for sending emails export const sendEmail = async ({ - to, - subject, - html, + to, + subject, + html, }: { - to: string; - subject: string; - html: string; + to: string; + subject: string; + html: string; }) => { - try { - const messageData = { - from: config.MAILGUN_FROM_EMAIL, - to, - subject, - html, - }; + try { + const messageData = { + from: config.MAILGUN_FROM_EMAIL, + to, + subject, + html, + }; - const result = await mailgunClient.messages.create( - config.MAILGUN_DOMAIN, - messageData, - ); + const result = await mailgunClient.messages.create( + config.MAILGUN_DOMAIN, + messageData, + ); - logger.info({ - msg: "Email sent successfully", - id: result.id, - to, - subject, - }); + logger.info({ + msg: 'Email sent successfully', + id: result.id, + to, + subject, + }); - return result; - } catch (error) { - logger.error({ - msg: "Failed to send email", - error, - to, - subject, - }); + return result; + } catch (error) { + logger.error({ + msg: 'Failed to send email', + error, + to, + subject, + }); - throw new EmailError("Failed to send email", error); - } + throw new EmailError('Failed to send email', error); + } }; export const sendResetPasswordEmail = async ( - payload: SendResetPasswordTypePayload, + payload: SendResetPasswordTypePayload, ) => { - const { email, resetLink, userName } = payload; + const { email, resetLink, userName } = payload; - try { - // Render the React email template to HTML - const emailHtml = await render( - ResetPasswordEmail({ - resetLink, - userName, - }), - ); + try { + // Render the React email template to HTML + const emailHtml = await render( + ResetPasswordEmail({ + resetLink, + userName, + }), + ); - // Send the email with the rendered HTML - await sendEmail({ - to: email, - subject: "Reset Your Password", - html: emailHtml, - }); - } catch (error) { - logger.error({ - msg: "Failed to send reset password email", - error, - email, - }); + // Send the email with the rendered HTML + await sendEmail({ + to: email, + subject: 'Reset Your Password', + html: emailHtml, + }); + } catch (error) { + logger.error({ + msg: 'Failed to send reset password email', + error, + email, + }); - throw new EmailError("Failed to send reset password email", error); - } + throw new EmailError('Failed to send reset password email', error); + } }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..587716c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +export { createApp } from './app/createApp'; +export { initializeApp } from './app/app'; + +export { MagicRouter, createRouter, defineRoute } from './core/router'; +export { validateZodSchema } from './core/validate'; +export { registry } from './openapi/registry'; + +export { logger, httpLogger, createChildLogger } from './observability/logger'; +export { metricsCollector, MetricsCollector } from './observability/metrics'; + +export { requestIdMiddleware } from './middlewares/requestId'; +export { metricsMiddleware } from './middlewares/metrics'; + +export { applySecurity } from './server/security'; +export { LifecycleManager } from './server/lifecycle'; + +export { createOpsRoutes } from './routes/ops'; + +export { securityPlugin } from './plugins/security'; +export { observabilityPlugin } from './plugins/observability'; +export { openApiPlugin } from './plugins/openapi'; +export { authPlugin } from './plugins/auth'; +export { cachePlugin } from './plugins/cache'; +export { uploadsPlugin } from './plugins/uploads'; + +export type { ToolkitPlugin, PluginFactory, AppContext } from './plugins/types'; +export type { SecurityOptions } from './server/security'; +export type { ObservabilityOptions } from './plugins/observability'; +export type { OpenApiOptions } from './plugins/openapi'; +export type { HealthCheck, OpsRoutesOptions } from './routes/ops'; +export type { + MagicPathType, + RequestAndResponseType, + MagicMiddleware, +} from './openapi/magic-router'; diff --git a/src/lib/database.ts b/src/lib/database.ts index 8fe0d2c..a692d9b 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,19 +1,19 @@ -import mongoose from "mongoose"; -import config from "../config/config.service"; -import logger from "./logger.service"; +import mongoose from 'mongoose'; +import config from '../config/env'; +import logger from './logger.service'; export const connectDatabase = async () => { - try { - logger.info("Connecting database..."); - await mongoose.connect(config.MONGO_DATABASE_URL); - logger.info("Database connected"); - } catch (err) { - logger.error((err as Error).message); - process.exit(1); - } + try { + logger.info('Connecting database...'); + await mongoose.connect(config.MONGO_DATABASE_URL); + logger.info('Database connected'); + } catch (err) { + logger.error((err as Error).message); + process.exit(1); + } }; export const disconnectDatabase = async () => { - await mongoose.disconnect(); - logger.info("Database disconnected"); + await mongoose.disconnect(); + logger.info('Database disconnected'); }; diff --git a/src/lib/email.server.ts b/src/lib/email.server.ts index e028b5b..d3682a1 100644 --- a/src/lib/email.server.ts +++ b/src/lib/email.server.ts @@ -1,14 +1,14 @@ -import nodemailer from "nodemailer"; -import type SMTPTransport from "nodemailer/lib/smtp-transport"; -import config from "../config/config.service"; +import nodemailer from 'nodemailer'; +import type SMTPTransport from 'nodemailer/lib/smtp-transport'; +import config from '../config/env'; const mailer = nodemailer.createTransport({ - host: config.SMTP_HOST, - port: config.SMTP_PORT, - auth: { - user: config.SMTP_USERNAME, - pass: config.SMTP_PASSWORD, - }, + host: config.SMTP_HOST, + port: config.SMTP_PORT, + auth: { + user: config.SMTP_USERNAME, + pass: config.SMTP_PASSWORD, + }, } as SMTPTransport.Options); export default mailer; diff --git a/src/lib/mailgun.server.ts b/src/lib/mailgun.server.ts index 3d8dde8..55bc846 100644 --- a/src/lib/mailgun.server.ts +++ b/src/lib/mailgun.server.ts @@ -1,12 +1,12 @@ -import formData from "form-data"; -import Mailgun from "mailgun.js"; -import config from "../config/config.service"; +import formData from 'form-data'; +import Mailgun from 'mailgun.js'; +import config from '../config/env'; const mailgun = new Mailgun(formData); const mailgunClient = mailgun.client({ - username: "api", - key: config.MAILGUN_API_KEY, + username: 'api', + key: config.MAILGUN_API_KEY, }); export default mailgunClient; diff --git a/src/lib/queue.server.ts b/src/lib/queue.server.ts index aecd4e4..5ed2d42 100644 --- a/src/lib/queue.server.ts +++ b/src/lib/queue.server.ts @@ -1,41 +1,41 @@ -import type { Processor } from "bullmq"; -import { Queue as BullQueue, Worker } from "bullmq"; +import type { Processor, QueueOptions } from 'bullmq'; +import { Queue as BullQueue, Worker } from 'bullmq'; -import logger from "./logger.service"; -import redisClient from "./redis.server"; +import logger from './logger.service'; +import redisClient from './redis.server'; type RegisteredQueue = { - queue: BullQueue; - worker: Worker; + queue: BullQueue; + worker: Worker; }; -declare global { - // eslint-disable-next-line no-var - var __registeredQueues: Record | undefined; -} - -if (!global.__registeredQueues) { - global.__registeredQueues = {}; -} -const registeredQueues = global.__registeredQueues; +const registeredQueues: Record = {}; export function Queue( - name: string, - handler: Processor, + name: string, + handler: Processor, + queueOptions?: QueueOptions, + workerOptions?: WorkerOptions, ): BullQueue { - if (registeredQueues[name]) { - return registeredQueues[name].queue as BullQueue; - } + if (registeredQueues[name]) { + return registeredQueues[name].queue as BullQueue; + } - const queue = new BullQueue(name, { connection: redisClient }); + const queue = new BullQueue(name, { + connection: redisClient, + ...queueOptions, + }); - const worker = new Worker(name, handler, { - connection: redisClient, - }); + const worker = new Worker(name, handler, { + connection: redisClient, + ...workerOptions, + }); - registeredQueues[name] = { queue, worker }; + registeredQueues[name] = { queue, worker }; - logger.info({ name: "Queue" }, `${name}: Initialize`); + logger.info({ name: 'Queue' }, `${name}: Initialize`); - return queue; + return queue; } + +export const getRegisteredQueues = () => registeredQueues; diff --git a/src/lib/realtime.server.ts b/src/lib/realtime.server.ts index 9efcd72..94dc7c0 100644 --- a/src/lib/realtime.server.ts +++ b/src/lib/realtime.server.ts @@ -1,14 +1,14 @@ -import type { Server as IServer } from "node:http"; -import { Server as RealtimeServer } from "socket.io"; +import type { Server as IServer } from 'node:http'; +import { Server as RealtimeServer } from 'socket.io'; -export const useSocketIo = (server: IServer): RealtimeServer => { - const io = new RealtimeServer(server, { - transports: ["polling", "websocket"], - cors: { - origin: "*", - methods: ["GET", "POST"], - }, - }); +export const setupSocketIo = (server: IServer): RealtimeServer => { + const io = new RealtimeServer(server, { + transports: ['polling', 'websocket'], + cors: { + origin: '*', + methods: ['GET', 'POST'], + }, + }); - return io; + return io; }; diff --git a/src/lib/redis.server.ts b/src/lib/redis.server.ts index 89524dc..729d3b2 100644 --- a/src/lib/redis.server.ts +++ b/src/lib/redis.server.ts @@ -1,13 +1,13 @@ -import type { RedisOptions } from "ioredis"; -import Redis from "ioredis"; -import config from "../config/config.service"; +import type { RedisOptions } from 'ioredis'; +import Redis from 'ioredis'; +import config from '../config/env'; const redisOptions: RedisOptions = { - maxRetriesPerRequest: null, - enableReadyCheck: false, - host: "redis", + maxRetriesPerRequest: null, + enableReadyCheck: false, + host: 'redis', }; -const redisClient = new Redis(config.REDIS_URL || "", redisOptions); +const redisClient = new Redis(config.REDIS_URL || '', redisOptions); export default redisClient; diff --git a/src/lib/session.store.ts b/src/lib/session.store.ts deleted file mode 100644 index 4e5e214..0000000 --- a/src/lib/session.store.ts +++ /dev/null @@ -1,8 +0,0 @@ -import RedisStore from "connect-redis"; -import redisClient from "./redis.server"; - -const redisStore = new RedisStore({ - client: redisClient, -}); - -export default redisStore; diff --git a/src/main.ts b/src/main.ts index b3a2c3d..3dac586 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,136 +1,93 @@ -import "./openapi/zod-extend"; - -import { createServer } from "node:http"; -import path from "node:path"; -import process from "node:process"; -import { createBullBoard } from "@bull-board/api"; -import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; -import { ExpressAdapter } from "@bull-board/express"; -import compression from "compression"; -import cookieParser from "cookie-parser"; -import cors from "cors"; -import express from "express"; -import session from "express-session"; -import helmet from "helmet"; -import morgan from "morgan"; -import config from "./config/config.service"; -import { connectDatabase, disconnectDatabase } from "./lib/database"; -import logger, { httpLogger } from "./lib/logger.service"; -import { useSocketIo } from "./lib/realtime.server"; -import redisStore from "./lib/session.store"; -import { extractJwt } from "./middlewares/extract-jwt-schema.middleware"; -import apiRoutes from "./routes/routes"; - -import swaggerUi from "swagger-ui-express"; - -import YAML from "yaml"; -import { convertDocumentationToYaml } from "./openapi/swagger-doc-generator"; -import globalErrorHandler from "./utils/globalErrorHandler"; - -const app = express(); - -app.set("trust proxy", true); - -const server = createServer(app); - -const io = useSocketIo(server); - -const boostrapServer = async () => { - await connectDatabase(); - - app.use((req, _, next) => { - req.io = io; - next(); - }); - - app.use( - cors({ - origin: [config.CLIENT_SIDE_URL], - optionsSuccessStatus: 200, - credentials: true, - }), - ); - - if (config.NODE_ENV === "development") { - app.use(morgan("dev")); - } else { - app.use(httpLogger); - } - - app.use(express.json()); - app.use(express.urlencoded({ extended: false })); - - app.use( - session({ - secret: config.JWT_SECRET, - resave: false, - saveUninitialized: true, - cookie: { secure: true }, - store: redisStore, - }), - ); - - // Middleware to serve static files - app.use(express.static(path.join(__dirname, "..", "public"))); - - app.use(cookieParser()); - - app.use(compression()); - - app.use(extractJwt); - - if (config.NODE_ENV === "production") { - app.use(helmet()); - } - - app.use("/api", apiRoutes); - - const swaggerDocument = YAML.parse(convertDocumentationToYaml()); - app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); - - const serverAdapter = new ExpressAdapter(); - serverAdapter.setBasePath("/admin/queues"); - - createBullBoard({ - queues: Object.entries(global.__registeredQueues || {}).map( - ([, values]) => new BullMQAdapter(values.queue), - ), - serverAdapter, - }); - - // Dashbaord for BullMQ - app.use("/admin/queues", serverAdapter.getRouter()); - - // Global Error Handler - app.use(globalErrorHandler); - - server.listen(config.PORT, () => { - logger.info(`Server is running on http://localhost:${config.PORT}`); - logger.info(`RESTful API: http://localhost:${config.PORT}/api`); - logger.info(`Swagger API Docs: http://localhost:${config.PORT}/api-docs`); - logger.info(`BullBoard: http://localhost:${config.PORT}/admin/queues`); - logger.info(`Client-side url set to: ${config.CLIENT_SIDE_URL}`); - }); +import './openapi/zod-extend'; + +import { createBullBoard } from '@bull-board/api'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { ExpressAdapter } from '@bull-board/express'; +import { initializeApp } from './app/app'; +import config from './config/env'; +import { connectDatabase, disconnectDatabase } from './lib/database'; +import logger from './observability/logger'; +import { setupSocketIo } from './lib/realtime.server'; +import { LifecycleManager } from './server/lifecycle'; +import { createOpsRoutes } from './routes/ops'; +import apiRoutes from './routes/routes'; +import globalErrorHandler from './utils/globalErrorHandler'; +import { getRegisteredQueues } from './lib/queue.server'; + +const bootstrapServer = async () => { + await connectDatabase(); + + const { app, server } = await initializeApp(); + + const io = setupSocketIo(server); + + app.use((req, _, next) => { + req.io = io; + next(); + }); + + // Mock routes for ops health checks - don't forget to implement the actual checks + const opsRoutes = createOpsRoutes({ + healthChecks: [ + { + name: 'database', + check: async () => { + return true; + }, + }, + { + name: 'redis', + check: async () => { + return true; + }, + }, + ], + metricsEnabled: config.METRICS_ENABLED, + }); + + app.use('/ops', opsRoutes); + + app.use('/api', apiRoutes); + + const serverAdapter = new ExpressAdapter(); + serverAdapter.setBasePath('/admin/queues'); + + console.log(getRegisteredQueues()); + + createBullBoard({ + queues: Object.entries(getRegisteredQueues() || {}).map( + ([, values]) => new BullMQAdapter(values.queue), + ), + serverAdapter, + }); + + app.use('/admin/queues', serverAdapter.getRouter()); + + app.use(globalErrorHandler); + + const lifecycle = new LifecycleManager({ gracefulShutdownTimeout: 30000 }); + lifecycle.registerServer(server); + + lifecycle.registerCleanup(async () => { + await disconnectDatabase(); + io.disconnectSockets(true); + }); + + lifecycle.setupSignalHandlers(); + + server.listen(config.PORT, () => { + logger.info(`Server is running on http://localhost:${config.PORT}`); + logger.info(`RESTful API: http://localhost:${config.PORT}/api`); + logger.info(`Swagger API Docs: http://localhost:${config.PORT}/api-docs`); + logger.info(`Health: http://localhost:${config.PORT}/ops/health`); + logger.info(`Readiness: http://localhost:${config.PORT}/ops/readiness`); + logger.info(`Metrics: http://localhost:${config.PORT}/ops/metrics`); + logger.info(`BullBoard: http://localhost:${config.PORT}/admin/queues`); + logger.info(`Client-side url set to: ${config.CLIENT_SIDE_URL}`); + }); }; -boostrapServer().catch((err) => { - logger.error(err.message); - process.exit(1); -}); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, async () => { - await disconnectDatabase(); - logger.info("Server is shutting down..."); - io.disconnectSockets(true); - logger.info("Server disconnected from sockets"); - server.close(); - logger.info("Server closed"); - process.exit(0); - }); -} - -process.on("uncaughtException", (err) => { - logger.error(err.message); - process.exit(1); +bootstrapServer().catch((err) => { + logger.error({ err }, 'Failed to bootstrap server'); + process.exit(1); }); diff --git a/src/middlewares/can-access.middleware.ts b/src/middlewares/can-access.ts similarity index 100% rename from src/middlewares/can-access.middleware.ts rename to src/middlewares/can-access.ts diff --git a/src/middlewares/extract-jwt-schema.middleware.ts b/src/middlewares/extract-jwt-schema.middleware.ts deleted file mode 100644 index c32334d..0000000 --- a/src/middlewares/extract-jwt-schema.middleware.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { NextFunction } from "express"; -import { type JwtPayload, verifyToken } from "../utils/auth.utils"; -import type { RequestAny, ResponseAny } from "../openapi/magic-router"; - -export const extractJwt = async ( - req: RequestAny, - _: ResponseAny, - next: NextFunction, -) => { - try { - const token = - req.cookies?.accessToken ?? req.headers.authorization?.split(" ")[1]; - - if (!token) { - return next(); - } - - const decode = await verifyToken(token); - - req.user = decode; - return next(); - } catch { - return next(); - } -}; diff --git a/src/middlewares/extract-jwt.ts b/src/middlewares/extract-jwt.ts new file mode 100644 index 0000000..df2b15e --- /dev/null +++ b/src/middlewares/extract-jwt.ts @@ -0,0 +1,25 @@ +import type { NextFunction } from 'express'; +import { type JwtPayload, verifyToken } from '../utils/auth.utils'; +import type { RequestAny, ResponseAny } from '../openapi/magic-router'; + +export const extractJwt = async ( + req: RequestAny, + _: ResponseAny, + next: NextFunction, +) => { + try { + const token = + req.cookies?.accessToken ?? req.headers.authorization?.split(' ')[1]; + + if (!token) { + return next(); + } + + const decode = await verifyToken(token); + + req.user = decode; + return next(); + } catch { + return next(); + } +}; diff --git a/src/middlewares/metrics.ts b/src/middlewares/metrics.ts new file mode 100644 index 0000000..e12917b --- /dev/null +++ b/src/middlewares/metrics.ts @@ -0,0 +1,28 @@ +import type { Request, Response, NextFunction } from 'express'; +import { metricsCollector } from '../observability/metrics'; + +export function metricsMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const route = req.route?.path || req.path || 'unknown'; + const method = req.method; + + const start = Date.now(); + metricsCollector.startRequest(method, route); + + res.on('finish', () => { + const duration = Date.now() - start; + metricsCollector.recordRequest(method, route, res.statusCode, duration); + metricsCollector.endRequest(method, route); + }); + + res.on('close', () => { + if (!res.writableEnded) { + metricsCollector.endRequest(method, route); + } + }); + + next(); +} diff --git a/src/middlewares/multer-s3.middleware.ts b/src/middlewares/multer-s3.ts similarity index 100% rename from src/middlewares/multer-s3.middleware.ts rename to src/middlewares/multer-s3.ts diff --git a/src/middlewares/requestId.ts b/src/middlewares/requestId.ts new file mode 100644 index 0000000..8f5949d --- /dev/null +++ b/src/middlewares/requestId.ts @@ -0,0 +1,19 @@ +import type { Request, Response, NextFunction } from 'express'; +import { nanoid } from 'nanoid'; + +export function requestIdMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const requestId = + (req.headers['x-request-id'] as string) || + (req.headers['x-correlation-id'] as string) || + nanoid(); + + (req as Request & { id?: string }).id = requestId; + + res.setHeader('X-Request-ID', requestId); + + next(); +} diff --git a/src/middlewares/validate-zod-schema.middleware.ts b/src/middlewares/validate-zod-schema.ts similarity index 100% rename from src/middlewares/validate-zod-schema.middleware.ts rename to src/middlewares/validate-zod-schema.ts diff --git a/src/modules/auth/auth.constants.ts b/src/modules/auth/auth.constants.ts index fcf889c..feab68a 100644 --- a/src/modules/auth/auth.constants.ts +++ b/src/modules/auth/auth.constants.ts @@ -1,14 +1,14 @@ -import type { CookieOptions } from "express"; -import config from "../../config/config.service"; +import type { CookieOptions } from 'express'; +import config from '../../config/env'; const clientSideUrl = new URL(config.CLIENT_SIDE_URL); -export const AUTH_COOKIE_KEY = "accessToken"; +export const AUTH_COOKIE_KEY = 'accessToken'; export const COOKIE_CONFIG: CookieOptions = { - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - maxAge: config.SESSION_EXPIRES_IN * 1000, - domain: clientSideUrl.hostname, + httpOnly: true, + sameSite: 'lax', + secure: config.NODE_ENV === 'production', + maxAge: config.SESSION_EXPIRES_IN * 1000, + domain: clientSideUrl.hostname, }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d0e1f31..c316313 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,109 +1,109 @@ -import type { Request, Response } from "express"; -import config from "../../config/config.service"; -import type { GoogleCallbackQuery } from "../../types"; -import { successResponse } from "../../utils/api.utils"; -import type { JwtPayload } from "../../utils/auth.utils"; -import { AUTH_COOKIE_KEY, COOKIE_CONFIG } from "./auth.constants"; +import type { Request, Response } from 'express'; +import config from '../../config/env'; +import type { GoogleCallbackQuery } from '../../types'; +import { successResponse } from '../../utils/api.utils'; +import type { JwtPayload } from '../../utils/auth.utils'; +import { AUTH_COOKIE_KEY, COOKIE_CONFIG } from './auth.constants'; import type { - ChangePasswordSchemaType, - ForgetPasswordSchemaType, - LoginUserByEmailSchemaType, - RegisterUserByEmailSchemaType, - ResetPasswordSchemaType, -} from "./auth.schema"; + ChangePasswordSchemaType, + ForgetPasswordSchemaType, + LoginUserByEmailSchemaType, + RegisterUserByEmailSchemaType, + ResetPasswordSchemaType, +} from './auth.schema'; import { - changePassword, - forgetPassword, - googleLogin, - loginUserByEmail, - registerUserByEmail, - resetPassword, -} from "./auth.service"; + changePassword, + forgetPassword, + googleLogin, + loginUserByEmail, + registerUserByEmail, + resetPassword, +} from './auth.service'; export const handleResetPassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - await resetPassword(req.body); + await resetPassword(req.body); - return successResponse(res, "Password successfully reset"); + return successResponse(res, 'Password successfully reset'); }; export const handleForgetPassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const user = await forgetPassword(req.body); + const user = await forgetPassword(req.body); - return successResponse(res, "Code has been sent", { userId: user._id }); + return successResponse(res, 'Code has been sent', { userId: user._id }); }; export const handleChangePassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - await changePassword((req.user as JwtPayload).sub, req.body); + await changePassword((req.user as JwtPayload).sub, req.body); - return successResponse(res, "Password successfully changed"); + return successResponse(res, 'Password successfully changed'); }; export const handleRegisterUser = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const user = await registerUserByEmail(req.body); + const user = await registerUserByEmail(req.body); - if (config.OTP_VERIFICATION_ENABLED) { - return successResponse(res, "Please check your email for OTP", user); - } + if (config.OTP_VERIFICATION_ENABLED) { + return successResponse(res, 'Please check your email for OTP', user); + } - return successResponse(res, "User has been reigstered", user); + return successResponse(res, 'User has been reigstered', user); }; export const handleLogout = async (_: Request, res: Response) => { - res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); + res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); - return successResponse(res, "Logout successful"); + return successResponse(res, 'Logout successful'); }; export const handleLoginByEmail = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const token = await loginUserByEmail(req.body); - if (config.SET_SESSION) { - res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); - } - return successResponse(res, "Login successful", { token: token }); + const token = await loginUserByEmail(req.body); + if (config.SET_SESSION) { + res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + } + return successResponse(res, 'Login successful', { token: token }); }; export const handleGetCurrentUser = async (req: Request, res: Response) => { - const user = req.user; + const user = req.user; - return successResponse(res, undefined, user); + return successResponse(res, undefined, user); }; export const handleGoogleLogin = async (_: Request, res: Response) => { - if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_REDIRECT_URI) { - throw new Error("Google credentials are not set"); - } + if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_REDIRECT_URI) { + throw new Error('Google credentials are not set'); + } - const googleAuthURL = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${config.GOOGLE_CLIENT_ID}&redirect_uri=${config.GOOGLE_REDIRECT_URI}&scope=email profile`; + const googleAuthURL = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${config.GOOGLE_CLIENT_ID}&redirect_uri=${config.GOOGLE_REDIRECT_URI}&scope=email profile`; - res.redirect(googleAuthURL); + res.redirect(googleAuthURL); }; export const handleGoogleCallback = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const user = await googleLogin(req.query); - if (!user) throw new Error("Failed to login"); - res.cookie( - AUTH_COOKIE_KEY, - user.socialAccount?.[0]?.accessToken, - COOKIE_CONFIG, - ); - - return successResponse(res, "Logged in successfully", { - token: user.socialAccount?.[0]?.accessToken, - }); + const user = await googleLogin(req.query); + if (!user) throw new Error('Failed to login'); + res.cookie( + AUTH_COOKIE_KEY, + user.socialAccount?.[0]?.accessToken, + COOKIE_CONFIG, + ); + + return successResponse(res, 'Logged in successfully', { + token: user.socialAccount?.[0]?.accessToken, + }); }; diff --git a/src/modules/auth/auth.router.ts b/src/modules/auth/auth.router.ts index 5cf02d5..897a014 100644 --- a/src/modules/auth/auth.router.ts +++ b/src/modules/auth/auth.router.ts @@ -1,64 +1,64 @@ -import { canAccess } from "../../middlewares/can-access.middleware"; -import MagicRouter from "../../openapi/magic-router"; +import { canAccess } from '../../middlewares/can-access'; +import MagicRouter from '../../openapi/magic-router'; import { - handleChangePassword, - handleForgetPassword, - handleGetCurrentUser, - handleGoogleCallback, - handleGoogleLogin, - handleLoginByEmail, - handleLogout, - handleRegisterUser, - handleResetPassword, -} from "./auth.controller"; + handleChangePassword, + handleForgetPassword, + handleGetCurrentUser, + handleGoogleCallback, + handleGoogleLogin, + handleLoginByEmail, + handleLogout, + handleRegisterUser, + handleResetPassword, +} from './auth.controller'; import { - changePasswordSchema, - forgetPasswordSchema, - loginUserByEmailSchema, - registerUserByEmailSchema, - resetPasswordSchema, -} from "./auth.schema"; + changePasswordSchema, + forgetPasswordSchema, + loginUserByEmailSchema, + registerUserByEmailSchema, + resetPasswordSchema, +} from './auth.schema'; -export const AUTH_ROUTER_ROOT = "/auth"; +export const AUTH_ROUTER_ROOT = '/auth'; const authRouter = new MagicRouter(AUTH_ROUTER_ROOT); authRouter.post( - "/login/email", - { requestType: { body: loginUserByEmailSchema } }, - handleLoginByEmail, + '/login/email', + { requestType: { body: loginUserByEmailSchema } }, + handleLoginByEmail, ); authRouter.post( - "/register/email", - { requestType: { body: registerUserByEmailSchema } }, - handleRegisterUser, + '/register/email', + { requestType: { body: registerUserByEmailSchema } }, + handleRegisterUser, ); -authRouter.post("/logout", {}, handleLogout); +authRouter.post('/logout', {}, handleLogout); -authRouter.get("/me", {}, canAccess(), handleGetCurrentUser); +authRouter.get('/me', {}, canAccess(), handleGetCurrentUser); authRouter.post( - "/forget-password", - { requestType: { body: forgetPasswordSchema } }, - handleForgetPassword, + '/forget-password', + { requestType: { body: forgetPasswordSchema } }, + handleForgetPassword, ); authRouter.post( - "/change-password", - { requestType: { body: changePasswordSchema } }, - canAccess(), - handleChangePassword, + '/change-password', + { requestType: { body: changePasswordSchema } }, + canAccess(), + handleChangePassword, ); authRouter.post( - "/reset-password", - { requestType: { body: resetPasswordSchema } }, - handleResetPassword, + '/reset-password', + { requestType: { body: resetPasswordSchema } }, + handleResetPassword, ); -authRouter.get("/google", {}, handleGoogleLogin); -authRouter.get("/google/callback", {}, handleGoogleCallback); +authRouter.get('/google', {}, handleGoogleLogin); +authRouter.get('/google/callback', {}, handleGoogleCallback); export default authRouter.getRouter(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 99ca864..b950926 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,187 +1,184 @@ -import config from "../../config/config.service"; -import { ROLE_ENUM, type RoleType, SOCIAL_ACCOUNT_ENUM } from "../../enums"; -import type { GoogleCallbackQuery } from "../../types"; +import config from '../../config/env'; +import { ROLE_ENUM, type RoleType, SOCIAL_ACCOUNT_ENUM } from '../../enums'; +import type { GoogleCallbackQuery } from '../../types'; import { - type JwtPayload, - compareHash, - fetchGoogleTokens, - generateOTP, - getUserInfo, - hashPassword, - signToken, -} from "../../utils/auth.utils"; -import { generateRandomNumbers } from "../../utils/common.utils"; -import type { UserType } from "../user/user.dto"; + type JwtPayload, + compareHash, + fetchGoogleTokens, + generateOTP, + getUserInfo, + hashPassword, + signToken, +} from '../../utils/auth.utils'; +import { generateRandomNumbers } from '../../utils/common.utils'; +import type { UserType } from '../user/user.dto'; import { - createUser, - getUserByEmail, - getUserById, - updateUser, -} from "../user/user.services"; + createUser, + getUserByEmail, + getUserById, + updateUser, +} from '../user/user.services'; import type { - ChangePasswordSchemaType, - ForgetPasswordSchemaType, - LoginUserByEmailSchemaType, - RegisterUserByEmailSchemaType, - ResetPasswordSchemaType, -} from "./auth.schema"; + ChangePasswordSchemaType, + ForgetPasswordSchemaType, + LoginUserByEmailSchemaType, + RegisterUserByEmailSchemaType, + ResetPasswordSchemaType, +} from './auth.schema'; export const resetPassword = async (payload: ResetPasswordSchemaType) => { - const user = await getUserById(payload.userId); + const user = await getUserById(payload.userId); - if (!user || user.passwordResetCode !== payload.code) { - throw new Error("token is not valid or expired, please try again"); - } + if (!user || user.passwordResetCode !== payload.code) { + throw new Error('token is not valid or expired, please try again'); + } - if (payload.confirmPassword !== payload.password) { - throw new Error("Password and confirm password must be same"); - } + if (payload.confirmPassword !== payload.password) { + throw new Error('Password and confirm password must be same'); + } - const hashedPassword = await hashPassword(payload.password); + const hashedPassword = await hashPassword(payload.password); - await updateUser(payload.userId, { - password: hashedPassword, - passwordResetCode: null, - }); + await updateUser(payload.userId, { + password: hashedPassword, + passwordResetCode: null, + }); }; export const forgetPassword = async ( - payload: ForgetPasswordSchemaType, + payload: ForgetPasswordSchemaType, ): Promise => { - const user = await getUserByEmail(payload.email); + const user = await getUserByEmail(payload.email); - if (!user) { - throw new Error("user doesn't exists"); - } + if (!user) { + throw new Error("user doesn't exists"); + } - const code = generateRandomNumbers(4); + const code = generateRandomNumbers(4); - await updateUser(user._id, { passwordResetCode: code }); + await updateUser(user._id, { passwordResetCode: code }); - return user; + return user; }; export const changePassword = async ( - userId: string, - payload: ChangePasswordSchemaType, + userId: string, + payload: ChangePasswordSchemaType, ): Promise => { - const user = await getUserById(userId, "+password"); + const user = await getUserById(userId, '+password'); - if (!user || !user.password) { - throw new Error("User is not found"); - } + if (!user || !user.password) { + throw new Error('User is not found'); + } - const isCurrentPassowordCorrect = await compareHash( - user.password, - payload.currentPassword, - ); + const isCurrentPassowordCorrect = await compareHash( + user.password, + payload.currentPassword, + ); - if (!isCurrentPassowordCorrect) { - throw new Error("current password is not valid"); - } + if (!isCurrentPassowordCorrect) { + throw new Error('current password is not valid'); + } - const hashedPassword = await hashPassword(payload.newPassword); + const hashedPassword = await hashPassword(payload.newPassword); - await updateUser(userId, { password: hashedPassword }); + await updateUser(userId, { password: hashedPassword }); }; export const registerUserByEmail = async ( - payload: RegisterUserByEmailSchemaType, + payload: RegisterUserByEmailSchemaType, ): Promise => { - const userExistByEmail = await getUserByEmail(payload.email); + const userExistByEmail = await getUserByEmail(payload.email); - if (userExistByEmail) { - throw new Error("Account already exist with same email address"); - } + if (userExistByEmail) { + throw new Error('Account already exist with same email address'); + } - const { confirmPassword, ...rest } = payload; + const { confirmPassword, ...rest } = payload; - const otp = config.OTP_VERIFICATION_ENABLED ? generateOTP() : null; + const otp = config.OTP_VERIFICATION_ENABLED ? generateOTP() : null; - const user = await createUser( - { ...rest, role: "DEFAULT_USER", otp }, - false, - ); + const user = await createUser({ ...rest, role: 'DEFAULT_USER', otp }, false); - return user; + return user; }; export const loginUserByEmail = async ( - payload: LoginUserByEmailSchemaType, + payload: LoginUserByEmailSchemaType, ): Promise => { - const user = await getUserByEmail(payload.email, "+password"); + const user = await getUserByEmail(payload.email, '+password'); - if (!user || !(await compareHash(String(user.password), payload.password))) { - throw new Error("Invalid email or password"); - } + if (!user || !(await compareHash(String(user.password), payload.password))) { + throw new Error('Invalid email or password'); + } - const jwtPayload: JwtPayload = { - sub: String(user._id), - email: user?.email, - phoneNo: user?.phoneNo, - role: String(user.role) as RoleType, - username: user.username, - }; + const jwtPayload: JwtPayload = { + sub: String(user._id), + email: user?.email, + phoneNo: user?.phoneNo, + role: String(user.role) as RoleType, + username: user.username, + }; - const token = await signToken(jwtPayload); + const token = await signToken(jwtPayload); - return token; + return token; }; export const googleLogin = async ( - payload: GoogleCallbackQuery, + payload: GoogleCallbackQuery, ): Promise => { - const { code, error } = payload; - - if (error) { - throw new Error(error); - } - - if (!code) { - throw new Error("Code Not Provided"); - } - const tokenResponse = await fetchGoogleTokens({ code }); - - const { access_token, refresh_token, expires_in } = tokenResponse; - - const userInfoResponse = await getUserInfo(access_token); - - const { id, email, name, picture } = userInfoResponse; - - const user = await getUserByEmail(email); - - if (!user) { - const newUser = await createUser({ - email, - username: name, - avatar: picture, - role: ROLE_ENUM.DEFAULT_USER, - password: generateRandomNumbers(4), - socialAccount: [ - { - refreshToken: refresh_token, - tokenExpiry: new Date(Date.now() + expires_in * 1000), - accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, - accessToken: access_token, - accountID: id, - }, - ], - }); - - return newUser; - } - - const updatedUser = await updateUser(user._id, { - socialAccount: [ - { - refreshToken: refresh_token, - tokenExpiry: new Date(Date.now() + expires_in * 1000), - accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, - accessToken: access_token, - accountID: id, - }, - ], - }); - - return updatedUser; + const { code, error } = payload; + + if (error) { + throw new Error(error); + } + + if (!code) { + throw new Error('Code Not Provided'); + } + const tokenResponse = await fetchGoogleTokens({ code }); + + const { access_token, refresh_token, expires_in } = tokenResponse; + + const userInfoResponse = await getUserInfo(access_token); + + const { id, email, name, picture } = userInfoResponse; + + const user = await getUserByEmail(email); + + if (!user) { + const newUser = await createUser({ + email, + username: name, + avatar: picture, + role: ROLE_ENUM.DEFAULT_USER, + password: generateRandomNumbers(4), + socialAccount: [ + { + refreshToken: refresh_token, + tokenExpiry: new Date(Date.now() + expires_in * 1000), + accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, + accessToken: access_token, + accountID: id, + }, + ], + }); + + return newUser; + } + + const updatedUser = await updateUser(user._id, { + socialAccount: [ + { + refreshToken: refresh_token, + tokenExpiry: new Date(Date.now() + expires_in * 1000), + accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, + accessToken: access_token, + accountID: id, + }, + ], + }); + + return updatedUser; }; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 693ecea..5cd9702 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -1,74 +1,73 @@ -import type { Request, Response } from "express"; -import { StatusCodes } from "http-status-codes"; -import type { MongoIdSchemaType } from "../../common/common.schema"; -import config from "../../config/config.service"; -import { successResponse } from "../../utils/api.utils"; -import { generateRandomPassword } from "../../utils/auth.utils"; -import type { CreateUserSchemaType, GetUsersSchemaType } from "./user.schema"; -import { createUser, deleteUser, getUsers } from "./user.services"; +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import type { MongoIdSchemaType } from '../../common/common.schema'; +import config from '../../config/env'; +import { successResponse } from '../../utils/api.utils'; +import { generateRandomPassword } from '../../utils/auth.utils'; +import type { CreateUserSchemaType, GetUsersSchemaType } from './user.schema'; +import { createUser, deleteUser, getUsers } from './user.services'; export const handleDeleteUser = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - await deleteUser({ id: req.params.id }); + await deleteUser({ id: req.params.id }); - return successResponse(res, "User has been deleted"); + return successResponse(res, 'User has been deleted'); }; export const handleCreateUser = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const data = req.body; + const data = req.body; - const user = await createUser({ - ...data, - password: generateRandomPassword(), - role: "DEFAULT_USER", - }); + const user = await createUser({ + ...data, + password: generateRandomPassword(), + role: 'DEFAULT_USER', + }); - return successResponse( - res, - "Email has been sent to the user", - user, - StatusCodes.CREATED, - ); + return successResponse( + res, + 'Email has been sent to the user', + user, + StatusCodes.CREATED, + ); }; export const handleCreateSuperAdmin = async ( - _: Request, - res: Response, + _: Request, + res: Response, ) => { - - const user = await createUser({ - email: config.ADMIN_EMAIL, - name: "Super Admin", - username: "super_admin", - password: config.ADMIN_PASSWORD, - role: "SUPER_ADMIN", - phoneNo: "123456789", - otp: null, - }); + const user = await createUser({ + email: config.ADMIN_EMAIL, + name: 'Super Admin', + username: 'super_admin', + password: config.ADMIN_PASSWORD, + role: 'SUPER_ADMIN', + phoneNo: '123456789', + otp: null, + }); - return successResponse( - res, - "Super Admin has been created", - { email: user.email, password: config.ADMIN_PASSWORD }, - StatusCodes.CREATED, - ); + return successResponse( + res, + 'Super Admin has been created', + { email: user.email, password: config.ADMIN_PASSWORD }, + StatusCodes.CREATED, + ); }; export const handleGetUsers = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const { results, paginatorInfo } = await getUsers( - { - id: req.user.sub, - }, - req.query, - ); + const { results, paginatorInfo } = await getUsers( + { + id: req.user.sub, + }, + req.query, + ); - return successResponse(res, undefined, { results, paginatorInfo }); + return successResponse(res, undefined, { results, paginatorInfo }); }; diff --git a/src/modules/user/user.router.ts b/src/modules/user/user.router.ts index 6863d99..f572a08 100644 --- a/src/modules/user/user.router.ts +++ b/src/modules/user/user.router.ts @@ -1,32 +1,32 @@ -import { canAccess } from "../../middlewares/can-access.middleware"; -import MagicRouter from "../../openapi/magic-router"; +import { canAccess } from '../../middlewares/can-access'; +import MagicRouter from '../../openapi/magic-router'; import { - handleCreateSuperAdmin, - handleCreateUser, - handleGetUsers, -} from "./user.controller"; -import { createUserSchema, getUsersSchema } from "./user.schema"; + handleCreateSuperAdmin, + handleCreateUser, + handleGetUsers, +} from './user.controller'; +import { createUserSchema, getUsersSchema } from './user.schema'; -export const USER_ROUTER_ROOT = "/users"; +export const USER_ROUTER_ROOT = '/users'; const userRouter = new MagicRouter(USER_ROUTER_ROOT); userRouter.get( - "/", - { - requestType: { query: getUsersSchema }, - }, - canAccess(), - handleGetUsers, + '/', + { + requestType: { query: getUsersSchema }, + }, + canAccess(), + handleGetUsers, ); userRouter.post( - "/user", - { requestType: { body: createUserSchema } }, - canAccess("roles", ["SUPER_ADMIN"]), - handleCreateUser, + '/user', + { requestType: { body: createUserSchema } }, + canAccess('roles', ['SUPER_ADMIN']), + handleCreateUser, ); -userRouter.post("/_super-admin", {}, handleCreateSuperAdmin); +userRouter.post('/_super-admin', {}, handleCreateSuperAdmin); export default userRouter.getRouter(); diff --git a/src/observability/logger.ts b/src/observability/logger.ts new file mode 100644 index 0000000..2adf91c --- /dev/null +++ b/src/observability/logger.ts @@ -0,0 +1,97 @@ +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import type { RequestExtended } from '../types'; +import { ServerResponse as ResponseHTTP } from 'node:http'; + +const isDevelopment = process.env.NODE_ENV === 'development'; +const logLevel = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'); + +export const logger = pino({ + level: logLevel, + transport: isDevelopment + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + } + : undefined, + formatters: { + level: (label) => { + return { level: label.toUpperCase() }; + }, + }, +}); + +export const httpLogger = pinoHttp({ + logger, + customLogLevel: (_req, res, err) => { + if (res.statusCode >= 500 || err) { + return 'error'; + } + if (res.statusCode >= 400) { + return 'warn'; + } + return 'info'; + }, + customSuccessMessage: (req, res) => { + return `${req.method} ${req.url} ${res.statusCode}`; + }, + customErrorMessage: (req, res, err) => { + return `${req.method} ${req.url} ${res.statusCode} - ${err.message}`; + }, + customAttributeKeys: { + req: 'request', + res: 'response', + err: 'error', + responseTime: 'duration', + }, + serializers: { + req: (req) => { + const extended = req as RequestExtended; + return { + id: extended.id, + method: req.method, + url: req.url, + path: req.path, + headers: { + host: req.headers.host, + 'user-agent': req.headers['user-agent'], + 'x-request-id': req.headers['x-request-id'], + }, + remoteAddress: req.remoteAddress, + remotePort: req.remotePort, + }; + }, + res: (res: unknown) => ({ + statusCode: + res instanceof Response + ? res.statusText + : res instanceof ResponseHTTP + ? 200 + : 200, + headers: { + 'content-type': + res instanceof Response + ? res.headers.get('content-type') + : res instanceof ResponseHTTP + ? res.getHeader('content-type') + : 'application/json', + 'content-length': + res instanceof Response + ? res.headers.get('content-length') + : res instanceof ResponseHTTP + ? res.getHeader('content-length') + : '100', + }, + }), + }, +}); + +export function createChildLogger(context: Record) { + return logger.child(context); +} + +export default logger; diff --git a/src/observability/metrics.ts b/src/observability/metrics.ts new file mode 100644 index 0000000..d4164be --- /dev/null +++ b/src/observability/metrics.ts @@ -0,0 +1,62 @@ +import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from 'prom-client'; + +export class MetricsCollector { + public readonly register: Registry; + private httpRequestDuration: Histogram; + private httpRequestTotal: Counter; + private httpRequestsInProgress: Gauge; + + constructor() { + this.register = new Registry(); + + collectDefaultMetrics({ + register: this.register, + prefix: 'nodejs_', + }); + + this.httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5], + registers: [this.register], + }); + + this.httpRequestTotal = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], + registers: [this.register], + }); + + this.httpRequestsInProgress = new Gauge({ + name: 'http_requests_in_progress', + help: 'Number of HTTP requests currently in progress', + labelNames: ['method', 'route'], + registers: [this.register], + }); + } + + recordRequest(method: string, route: string, statusCode: number, duration: number): void { + const labels = { method, route, status_code: statusCode.toString() }; + + this.httpRequestDuration.observe(labels, duration / 1000); + this.httpRequestTotal.inc(labels); + } + + startRequest(method: string, route: string): void { + this.httpRequestsInProgress.inc({ method, route }); + } + + endRequest(method: string, route: string): void { + this.httpRequestsInProgress.dec({ method, route }); + } + + async getMetrics(): Promise { + return this.register.metrics(); + } +} + +export const metricsCollector = new MetricsCollector(); + +export default metricsCollector; diff --git a/src/openapi/magic-router.ts b/src/openapi/magic-router.ts index 2457ecf..29b9d9a 100644 --- a/src/openapi/magic-router.ts +++ b/src/openapi/magic-router.ts @@ -10,8 +10,8 @@ import { errorResponseSchema, successResponseSchema, } from '../common/common.schema'; -import { canAccess } from '../middlewares/can-access.middleware'; -import { validateZodSchema } from '../middlewares/validate-zod-schema.middleware'; +import { canAccess } from '../middlewares/can-access'; +import { validateZodSchema } from '../core/validate'; import type { RequestExtended, RequestZodSchemaType, diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts new file mode 100644 index 0000000..5fc7cf5 --- /dev/null +++ b/src/openapi/registry.ts @@ -0,0 +1 @@ +export { registry, bearerAuth } from './swagger-instance'; diff --git a/src/openapi/swagger-doc-generator.ts b/src/openapi/swagger-doc-generator.ts index cfed439..b352d9e 100644 --- a/src/openapi/swagger-doc-generator.ts +++ b/src/openapi/swagger-doc-generator.ts @@ -3,7 +3,7 @@ import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; import * as yaml from "yaml"; import type { OpenAPIObject } from "openapi3-ts/oas30"; -import config from "../config/config.service"; +import config from "../config/env"; import { registry } from "./swagger-instance"; export const getOpenApiDocumentation = (): OpenAPIObject => { diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts new file mode 100644 index 0000000..ceba8b6 --- /dev/null +++ b/src/plugins/auth.ts @@ -0,0 +1,30 @@ +import type { ToolkitPlugin, PluginFactory } from './types'; + +export interface AuthOptions { + jwtSecret?: string; + jwtExpiration?: string; + sessionSecret?: string; +} + +export const authPlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + return { + name: 'auth', + priority: 70, + options, + + register({ app }) { + app.set('auth:configured', true); + + if (options.jwtSecret) { + app.set('auth:jwt:secret', options.jwtSecret); + } + if (options.jwtExpiration) { + app.set('auth:jwt:expiration', options.jwtExpiration); + } + }, + }; +}; + +export default authPlugin; diff --git a/src/plugins/cache.ts b/src/plugins/cache.ts new file mode 100644 index 0000000..15118cb --- /dev/null +++ b/src/plugins/cache.ts @@ -0,0 +1,28 @@ +import type { ToolkitPlugin, PluginFactory } from './types'; + +export interface CacheOptions { + enabled?: boolean; + ttl?: number; +} + +export const cachePlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + const { enabled = true, ttl = 3600 } = options; + + return { + name: 'cache', + priority: 50, + options, + + register({ app }) { + if (!enabled) { + return; + } + + app.set('cache:ttl', ttl); + }, + }; +}; + +export default cachePlugin; diff --git a/src/plugins/observability.ts b/src/plugins/observability.ts new file mode 100644 index 0000000..1a19feb --- /dev/null +++ b/src/plugins/observability.ts @@ -0,0 +1,38 @@ +import type { ToolkitPlugin, PluginFactory } from './types'; +import { requestIdMiddleware } from '../middlewares/requestId'; +import { httpLogger } from '../observability/logger'; +import { metricsMiddleware } from '../middlewares/metrics'; + +export interface ObservabilityOptions { + requestId?: boolean; + logging?: boolean; + metrics?: boolean; +} + +export const observabilityPlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + const { requestId = true, logging = true, metrics = true } = options; + + return { + name: 'observability', + priority: 90, + options, + + register({ app }) { + if (requestId) { + app.use(requestIdMiddleware); + } + + if (logging) { + app.use(httpLogger); + } + + if (metrics) { + app.use(metricsMiddleware); + } + }, + }; +}; + +export default observabilityPlugin; diff --git a/src/plugins/openapi.ts b/src/plugins/openapi.ts new file mode 100644 index 0000000..ea52b44 --- /dev/null +++ b/src/plugins/openapi.ts @@ -0,0 +1,32 @@ +import swaggerUi from 'swagger-ui-express'; +import YAML from 'yaml'; +import type { ToolkitPlugin, PluginFactory } from './types'; +import { convertDocumentationToYaml } from '../openapi/swagger-doc-generator'; + +export interface OpenApiOptions { + path?: string; + enabled?: boolean; +} + +export const openApiPlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + const { path = '/api-docs', enabled = true } = options; + + return { + name: 'openapi', + priority: 10, + options, + + register({ app }) { + if (!enabled) { + return; + } + + const swaggerDocument = YAML.parse(convertDocumentationToYaml()); + app.use(path, swaggerUi.serve, swaggerUi.setup(swaggerDocument)); + }, + }; +}; + +export default openApiPlugin; diff --git a/src/plugins/security.ts b/src/plugins/security.ts new file mode 100644 index 0000000..f636a12 --- /dev/null +++ b/src/plugins/security.ts @@ -0,0 +1,18 @@ +import type { ToolkitPlugin, PluginFactory } from './types'; +import { applySecurity, type SecurityOptions } from '../server/security'; + +export const securityPlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + return { + name: 'security', + priority: 100, + options, + + register({ app }) { + applySecurity(app, options); + }, + }; +}; + +export default securityPlugin; diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 0000000..86ae02c --- /dev/null +++ b/src/plugins/types.ts @@ -0,0 +1,27 @@ +import type { Application } from 'express'; +import type { Server } from 'http'; + +export interface AppContext { + app: Application; + server?: Server; + config?: Record; +} + +export interface ToolkitPlugin { + name: string; + priority?: number; + options?: TOptions; + + register(context: AppContext): Promise | void; + + onShutdown?: () => Promise | void; +} + +export type PluginFactory = ( + options?: TOptions, +) => ToolkitPlugin; + +export interface PluginRegistration { + plugin: ToolkitPlugin; + enabled: boolean; +} diff --git a/src/plugins/uploads.ts b/src/plugins/uploads.ts new file mode 100644 index 0000000..1f4c3c6 --- /dev/null +++ b/src/plugins/uploads.ts @@ -0,0 +1,37 @@ +import type { ToolkitPlugin, PluginFactory } from './types'; + +export interface UploadsOptions { + enabled?: boolean; + maxFileSize?: number; + allowedMimeTypes?: string[]; + destination?: string; +} + +export const uploadsPlugin: PluginFactory = ( + options = {}, +): ToolkitPlugin => { + const { + enabled = true, + maxFileSize = 10 * 1024 * 1024, + allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif'], + destination = './uploads', + } = options; + + return { + name: 'uploads', + priority: 40, + options, + + register({ app }) { + if (!enabled) { + return; + } + + app.set('uploads:maxFileSize', maxFileSize); + app.set('uploads:allowedMimeTypes', allowedMimeTypes); + app.set('uploads:destination', destination); + }, + }; +}; + +export default uploadsPlugin; diff --git a/src/routes/ops.ts b/src/routes/ops.ts new file mode 100644 index 0000000..72d0979 --- /dev/null +++ b/src/routes/ops.ts @@ -0,0 +1,79 @@ +import { Router, type Request, type Response } from 'express'; +import { metricsCollector } from '../observability/metrics'; + +export type HealthCheck = { + name: string; + check: () => Promise; +}; + +export interface OpsRoutesOptions { + healthChecks?: HealthCheck[]; + metricsEnabled?: boolean; +} + +export function createOpsRoutes(options: OpsRoutesOptions = {}): Router { + const router = Router(); + const { healthChecks = [], metricsEnabled = true } = options; + + router.get('/health', async (_req: Request, res: Response) => { + const status = { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }; + + res.status(200).json(status); + }); + + router.get('/readiness', async (_req: Request, res: Response) => { + try { + const checks = await Promise.all( + healthChecks.map(async ({ name, check }) => { + try { + const healthy = await check(); + return { name, healthy, error: null }; + } catch (error) { + return { + name, + healthy: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }), + ); + + const allHealthy = checks.every((c) => c.healthy); + const status = { + status: allHealthy ? 'ready' : 'not_ready', + timestamp: new Date().toISOString(), + checks, + }; + + res.status(allHealthy ? 200 : 503).json(status); + } catch (error) { + res.status(503).json({ + status: 'error', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + if (metricsEnabled) { + router.get('/metrics', async (_req: Request, res: Response) => { + try { + const metrics = await metricsCollector.getMetrics(); + res.set('Content-Type', metricsCollector.register.contentType); + res.send(metrics); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to collect metrics', + }); + } + }); + } + + return router; +} + +export default createOpsRoutes; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index ce98d71..542bc65 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,11 +1,11 @@ -import express from "express"; -import authRouter, { AUTH_ROUTER_ROOT } from "../modules/auth/auth.router"; +import express from 'express'; +import authRouter, { AUTH_ROUTER_ROOT } from '../modules/auth/auth.router'; import healthCheckRouter, { - HEALTH_ROUTER_ROOT, -} from "../healthcheck/healthcheck.routes"; -import userRouter, { USER_ROUTER_ROOT } from "../modules/user/user.router"; -import uploadRouter, { UPLOAD_ROUTER_ROOT } from "../upload/upload.router"; + HEALTH_ROUTER_ROOT, +} from '../healthcheck/healthcheck.routes'; +import userRouter, { USER_ROUTER_ROOT } from '../modules/user/user.router'; +import uploadRouter, { UPLOAD_ROUTER_ROOT } from '../upload/upload.router'; const router = express.Router(); diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts new file mode 100644 index 0000000..2d54748 --- /dev/null +++ b/src/server/lifecycle.ts @@ -0,0 +1,101 @@ +import type { Server } from 'http'; +import logger from '../observability/logger'; + +export type CleanupFunction = () => Promise | void; + +export class LifecycleManager { + private cleanupHandlers: CleanupFunction[] = []; + private server?: Server; + private shuttingDown = false; + private gracefulShutdownTimeout = 30000; + + constructor(options?: { gracefulShutdownTimeout?: number }) { + if (options?.gracefulShutdownTimeout) { + this.gracefulShutdownTimeout = options.gracefulShutdownTimeout; + } + } + + registerServer(server: Server): void { + this.server = server; + } + + registerCleanup(handler: CleanupFunction): void { + this.cleanupHandlers.push(handler); + } + + setupSignalHandlers(): void { + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + + for (const signal of signals) { + process.on(signal, () => { + logger.info(`Received ${signal}, starting graceful shutdown...`); + this.gracefulShutdown().catch((err) => { + logger.error({ err }, 'Error during graceful shutdown'); + process.exit(1); + }); + }); + } + + process.on('uncaughtException', (err) => { + logger.error({ err }, 'Uncaught exception'); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + logger.error({ reason, promise }, 'Unhandled rejection'); + }); + } + + private async gracefulShutdown(): Promise { + if (this.shuttingDown) { + logger.warn('Shutdown already in progress'); + return; + } + + this.shuttingDown = true; + + const shutdownTimer = setTimeout(() => { + logger.error('Graceful shutdown timeout exceeded, forcing exit'); + process.exit(1); + }, this.gracefulShutdownTimeout); + + try { + if (this.server) { + logger.info('Closing HTTP server...'); + await new Promise((resolve, reject) => { + this.server!.close((err) => { + if (err) { + logger.error({ err }, 'Error closing HTTP server'); + reject(err); + } else { + logger.info('HTTP server closed'); + resolve(); + } + }); + }); + } + + logger.info('Running cleanup handlers...'); + await Promise.all( + this.cleanupHandlers.map(async (handler, index) => { + try { + await handler(); + logger.debug(`Cleanup handler ${index + 1} completed`); + } catch (err) { + logger.error({ err, index }, `Cleanup handler ${index + 1} failed`); + } + }), + ); + + clearTimeout(shutdownTimer); + logger.info('Graceful shutdown completed'); + process.exit(0); + } catch (err) { + clearTimeout(shutdownTimer); + logger.error({ err }, 'Error during graceful shutdown'); + process.exit(1); + } + } +} + +export default LifecycleManager; diff --git a/src/server/security.ts b/src/server/security.ts new file mode 100644 index 0000000..5b884a6 --- /dev/null +++ b/src/server/security.ts @@ -0,0 +1,72 @@ +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import type { Application } from 'express'; + +export interface SecurityOptions { + corsEnabled?: boolean; + corsOrigins?: string | string[]; + corsCredentials?: boolean; + + helmetEnabled?: boolean; + helmetOptions?: Parameters[0]; + + rateLimitEnabled?: boolean; + rateLimitWindowMs?: number; + rateLimitMax?: number; + rateLimitMessage?: string; + + trustProxy?: boolean; +} + +export function applySecurity(app: Application, options: SecurityOptions = {}): void { + const { + corsEnabled = true, + corsOrigins = '*', + corsCredentials = false, + + helmetEnabled = true, + helmetOptions = {}, + + rateLimitEnabled = true, + rateLimitWindowMs = 15 * 60 * 1000, + rateLimitMax = 100, + rateLimitMessage = 'Too many requests from this IP, please try again later.', + + trustProxy = false, + } = options; + + if (trustProxy) { + app.set('trust proxy', true); + } + + if (helmetEnabled) { + app.use(helmet(helmetOptions)); + } + + if (corsEnabled) { + const corsOptions = { + origin: corsOrigins, + credentials: corsCredentials, + optionsSuccessStatus: 200, + }; + app.use(cors(corsOptions)); + } + + if (rateLimitEnabled) { + const limiter = rateLimit({ + windowMs: rateLimitWindowMs, + max: rateLimitMax, + message: rateLimitMessage, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => { + const healthPaths = ['/health', '/readiness', '/metrics']; + return healthPaths.some((path) => req.path.endsWith(path)); + }, + }); + app.use(limiter); + } +} + +export default applySecurity; diff --git a/src/upload/upload.router.ts b/src/upload/upload.router.ts index 15ae91a..93a06d8 100644 --- a/src/upload/upload.router.ts +++ b/src/upload/upload.router.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { canAccess } from '../middlewares/can-access.middleware'; -import { uploadProfile } from '../middlewares/multer-s3.middleware'; +import { canAccess } from '../middlewares/can-access'; +import { uploadProfile } from '../middlewares/multer-s3'; import MagicRouter from '../openapi/magic-router'; import { zFile } from '../openapi/zod-extend'; import { handleProfileUpload } from './upload.controller'; diff --git a/src/utils/api.utils.ts b/src/utils/api.utils.ts index 4bee0fc..f7cf477 100644 --- a/src/utils/api.utils.ts +++ b/src/utils/api.utils.ts @@ -1,70 +1,70 @@ -import type { Response } from "express"; -import { StatusCodes } from "http-status-codes"; -import config from "../config/config.service"; -import logger from "../lib/logger.service"; -import type { ResponseExtended } from "../types"; +import type { Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import config from '../config/env'; +import logger from '../lib/logger.service'; +import type { ResponseExtended } from '../types'; export const errorResponse = ( - res: ResponseExtended | Response, - message?: string, - statusCode?: StatusCodes, - payload?: unknown, - stack?: string, + res: ResponseExtended | Response, + message?: string, + statusCode?: StatusCodes, + payload?: unknown, + stack?: string, ): void => { - try { - if ("jsonValidate" in res) { - (res as ResponseExtended) - .status(statusCode ?? StatusCodes.BAD_REQUEST) - .jsonValidate({ - success: false, - message: message, - data: payload, - stack: stack, - }); - } else { - (res as ResponseExtended) - .status(statusCode ?? StatusCodes.BAD_REQUEST) - .json({ - success: false, - message: message, - data: payload, - stack: stack, - }); - } + try { + if ('jsonValidate' in res) { + (res as ResponseExtended) + .status(statusCode ?? StatusCodes.BAD_REQUEST) + .jsonValidate({ + success: false, + message: message, + data: payload, + stack: stack, + }); + } else { + (res as ResponseExtended) + .status(statusCode ?? StatusCodes.BAD_REQUEST) + .json({ + success: false, + message: message, + data: payload, + stack: stack, + }); + } - return; - } catch (err) { - logger.error(err); - } + return; + } catch (err) { + logger.error(err); + } }; export const successResponse = ( - res: ResponseExtended | Response, - message?: string, - payload?: Record, - statusCode: StatusCodes = StatusCodes.OK, + res: ResponseExtended | Response, + message?: string, + payload?: Record, + statusCode: StatusCodes = StatusCodes.OK, ): void => { - try { - if ("jsonValidate" in res) { - (res as ResponseExtended) - .status(statusCode) - .jsonValidate({ success: true, message: message, data: payload }); - } else { - (res as ResponseExtended) - .status(statusCode) - .json({ success: true, message: message, data: payload }); - } + try { + if ('jsonValidate' in res) { + (res as ResponseExtended) + .status(statusCode) + .jsonValidate({ success: true, message: message, data: payload }); + } else { + (res as ResponseExtended) + .status(statusCode) + .json({ success: true, message: message, data: payload }); + } - return; - } catch (err) { - logger.error(err); - } + return; + } catch (err) { + logger.error(err); + } }; export const generateResetPasswordLink = (token: string) => { - return `${config.CLIENT_SIDE_URL}/reset-password?token=${token}`; + return `${config.CLIENT_SIDE_URL}/reset-password?token=${token}`; }; export const generateSetPasswordLink = (token: string) => { - return `${config.CLIENT_SIDE_URL}/set-password?token=${token}`; + return `${config.CLIENT_SIDE_URL}/set-password?token=${token}`; }; diff --git a/src/utils/auth.utils.ts b/src/utils/auth.utils.ts index f1e09be..768064a 100644 --- a/src/utils/auth.utils.ts +++ b/src/utils/auth.utils.ts @@ -1,152 +1,152 @@ -import crypto from "node:crypto"; -import argon2 from "argon2"; -import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; -import config from "../config/config.service"; -import type { RoleType } from "../enums"; -import logger from "../lib/logger.service"; +import crypto from 'node:crypto'; +import argon2 from 'argon2'; +import { JsonWebTokenError, sign, verify } from 'jsonwebtoken'; +import config from '../config/env'; +import type { RoleType } from '../enums'; +import logger from '../lib/logger.service'; export interface GoogleTokenResponse { - access_token: string; - expires_in: number; - id_token: string; - refresh_token?: string; - scope: string; - token_type: string; + access_token: string; + expires_in: number; + id_token: string; + refresh_token?: string; + scope: string; + token_type: string; } export interface GoogleTokensRequestParams { - code: string; + code: string; } export type JwtPayload = { - sub: string; - email?: string | null; - phoneNo?: string | null; - username: string; - role: RoleType; + sub: string; + email?: string | null; + phoneNo?: string | null; + username: string; + role: RoleType; }; export type PasswordResetTokenPayload = { - email: string; - userId: string; + email: string; + userId: string; }; export type SetPasswordTokenPayload = { - email: string; - userId: string; + email: string; + userId: string; }; export const hashPassword = async (password: string): Promise => { - return argon2.hash(password); + return argon2.hash(password); }; export const compareHash = async ( - hashed: string, - plainPassword: string, + hashed: string, + plainPassword: string, ): Promise => { - return argon2.verify(hashed, plainPassword); + return argon2.verify(hashed, plainPassword); }; export const signToken = async (payload: JwtPayload): Promise => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: Number(config.JWT_EXPIRES_IN) * 1000, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: Number(config.JWT_EXPIRES_IN) * 1000, + }); }; export const signPasswordResetToken = async ( - payload: PasswordResetTokenPayload, + payload: PasswordResetTokenPayload, ) => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: config.PASSWORD_RESET_TOKEN_EXPIRES_IN * 1000, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: config.PASSWORD_RESET_TOKEN_EXPIRES_IN * 1000, + }); }; export const signSetPasswordToken = async ( - payload: SetPasswordTokenPayload, + payload: SetPasswordTokenPayload, ) => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: config.SET_PASSWORD_TOKEN_EXPIRES_IN, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: config.SET_PASSWORD_TOKEN_EXPIRES_IN, + }); }; export const verifyToken = async < - T extends JwtPayload | PasswordResetTokenPayload | SetPasswordTokenPayload, + T extends JwtPayload | PasswordResetTokenPayload | SetPasswordTokenPayload, >( - token: string, + token: string, ): Promise => { - try { - return verify(token, String(config.JWT_SECRET)) as T; - } catch (err) { - if (err instanceof Error) { - throw new Error(err.message); - } - - if (err instanceof JsonWebTokenError) { - throw new Error(err.message); - } - - logger.error("verifyToken", { err }); - throw err; - } + try { + return verify(token, String(config.JWT_SECRET)) as T; + } catch (err) { + if (err instanceof Error) { + throw new Error(err.message); + } + + if (err instanceof JsonWebTokenError) { + throw new Error(err.message); + } + + logger.error('verifyToken', { err }); + throw err; + } }; export const generateRandomPassword = (length = 16): string => { - return crypto.randomBytes(length).toString("hex"); + return crypto.randomBytes(length).toString('hex'); }; export const fetchGoogleTokens = async ( - params: GoogleTokensRequestParams, + params: GoogleTokensRequestParams, ): Promise => { - if ( - !config.GOOGLE_CLIENT_ID || - !config.GOOGLE_CLIENT_SECRET || - !config.GOOGLE_REDIRECT_URI - ) { - throw new Error("Google credentials are not set"); - } - - const url = "https://oauth2.googleapis.com/token"; - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - code: params.code, - client_id: config.GOOGLE_CLIENT_ID, - client_secret: config.GOOGLE_CLIENT_SECRET, - redirect_uri: config.GOOGLE_REDIRECT_URI, - grant_type: "authorization_code", - }), - }); - - if (!response.ok) { - throw new Error("Failed to exchange code for tokens"); - } - - const data: GoogleTokenResponse = await response.json(); - return data; + if ( + !config.GOOGLE_CLIENT_ID || + !config.GOOGLE_CLIENT_SECRET || + !config.GOOGLE_REDIRECT_URI + ) { + throw new Error('Google credentials are not set'); + } + + const url = 'https://oauth2.googleapis.com/token'; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code: params.code, + client_id: config.GOOGLE_CLIENT_ID, + client_secret: config.GOOGLE_CLIENT_SECRET, + redirect_uri: config.GOOGLE_REDIRECT_URI, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + throw new Error('Failed to exchange code for tokens'); + } + + const data: GoogleTokenResponse = await response.json(); + return data; }; export interface GoogleUserInfo { - id: string; - email: string; - verified_email: boolean; - name: string; - given_name: string; - family_name: string; - picture: string; - locale: string; + id: string; + email: string; + verified_email: boolean; + name: string; + given_name: string; + family_name: string; + picture: string; + locale: string; } export const getUserInfo = async (accessToken: string) => { - const userInfoResponse = await fetch( - "https://www.googleapis.com/oauth2/v2/userinfo", - { - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); - if (!userInfoResponse.ok) { - throw new Error("Error fetching user info"); - } - return userInfoResponse.json(); + const userInfoResponse = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (!userInfoResponse.ok) { + throw new Error('Error fetching user info'); + } + return userInfoResponse.json(); }; export const generateOTP = (length = 6): string => { - return crypto.randomBytes(length).toString("hex").slice(0, length); + return crypto.randomBytes(length).toString('hex').slice(0, length); }; diff --git a/src/utils/common.utils.ts b/src/utils/common.utils.ts index f4929fd..00069d2 100644 --- a/src/utils/common.utils.ts +++ b/src/utils/common.utils.ts @@ -1,85 +1,84 @@ -import path from "node:path"; -import { customAlphabet } from "nanoid"; -import config from "../config/config.service"; +import path from 'node:path'; +import { customAlphabet } from 'nanoid'; +import config from '../config/env'; -export const customNanoId = customAlphabet("0123456789", 4); +export const customNanoId = customAlphabet('0123456789', 4); -const transformableToBooleanTruthy = ["true", "TRUE", "t", "T", "1"]; -const transformableToBooleanFalsy = ["false", "FALSE", "f", "F", "0"]; +const transformableToBooleanTruthy = ['true', 'TRUE', 't', 'T', '1']; +const transformableToBooleanFalsy = ['false', 'FALSE', 'f', 'F', '0']; -export const transformableToBooleanError = `Value must be one of ${transformableToBooleanTruthy.join(", ")} or ${transformableToBooleanFalsy.join(", ")}`; +export const transformableToBooleanError = `Value must be one of ${transformableToBooleanTruthy.join(', ')} or ${transformableToBooleanFalsy.join(', ')}`; export const stringToBoolean = (value: string): boolean => { - if (transformableToBooleanTruthy.includes(value)) { - return true; - } + if (transformableToBooleanTruthy.includes(value)) { + return true; + } - if (transformableToBooleanFalsy.includes(value)) { - return false; - } + if (transformableToBooleanFalsy.includes(value)) { + return false; + } - throw new Error("Value is not transformable to boolean"); + throw new Error('Value is not transformable to boolean'); }; export const isTransformableToBoolean = (value: string) => { - if ( - !transformableToBooleanTruthy.includes(value) && - !transformableToBooleanFalsy.includes(value) - ) { - return false; - } - - return true; + if ( + !transformableToBooleanTruthy.includes(value) && + !transformableToBooleanFalsy.includes(value) + ) { + return false; + } + + return true; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const sanitizeRecord = >( - record: T, + record: T, ): T => { - try { - return Object.fromEntries( - Object.entries(record).filter( - ([_, value]) => value !== null && value !== undefined, - ), - ) as T; - } catch { - return record; - } + try { + return Object.fromEntries( + Object.entries(record).filter( + ([_, value]) => value !== null && value !== undefined, + ), + ) as T; + } catch { + return record; + } }; export const checkRecordForEmptyArrays = >( - record: T, + record: T, ): T => { - try { - return Object.fromEntries( - Object.entries(record).filter( - ([_, value]) => Array.isArray(value) && !!value.length, - ), - ) as T; - } catch { - return record; - } + try { + return Object.fromEntries( + Object.entries(record).filter( + ([_, value]) => Array.isArray(value) && !!value.length, + ), + ) as T; + } catch { + return record; + } }; export const generateRandomNumbers = (length: number): string => { - let id = ""; + let id = ''; - if (config.STATIC_OTP) { - id = "1234"; - } else { - id = customNanoId(length); - } + if (config.STATIC_OTP) { + id = '1234'; + } else { + id = customNanoId(length); + } - return id; + return id; }; export const checkFiletype = (file: Express.Multer.File): boolean => { - const filetypes = /jpeg|jpg|png/; + const filetypes = /jpeg|jpg|png/; - const checkExtname = filetypes.test( - path.extname(file.originalname).toLowerCase(), - ); - const checkMimetype = filetypes.test(file.mimetype); + const checkExtname = filetypes.test( + path.extname(file.originalname).toLowerCase(), + ); + const checkMimetype = filetypes.test(file.mimetype); - return checkExtname && checkMimetype; + return checkExtname && checkMimetype; }; diff --git a/src/utils/globalErrorHandler.ts b/src/utils/globalErrorHandler.ts index 3f3a51e..5bb1123 100644 --- a/src/utils/globalErrorHandler.ts +++ b/src/utils/globalErrorHandler.ts @@ -1,34 +1,34 @@ -import type { NextFunction, Request, Response } from "express"; -import config from "../config/config.service"; -import logger from "../lib/logger.service"; -import type { RequestExtended, ResponseExtended } from "../types"; -import { errorResponse } from "./api.utils"; +import type { NextFunction, Request, Response } from 'express'; +import config from '../config/env'; +import logger from '../lib/logger.service'; +import type { RequestExtended, ResponseExtended } from '../types'; +import { errorResponse } from './api.utils'; interface CustomError extends Error { - status?: number; - message: string; + status?: number; + message: string; } export const globalErrorHandler = ( - err: CustomError, - _: RequestExtended | Request, - res: ResponseExtended | Response, - __: NextFunction, + err: CustomError, + _: RequestExtended | Request, + res: ResponseExtended | Response, + __: NextFunction, ): void => { - const statusCode = err.status || 500; - const errorMessage = err.message || "Internal Server Error"; + const statusCode = err.status || 500; + const errorMessage = err.message || 'Internal Server Error'; - logger.error(`${statusCode}: ${errorMessage}`); + logger.error(`${statusCode}: ${errorMessage}`); - errorResponse( - res as ResponseExtended, - errorMessage, - statusCode, - err, - config.NODE_ENV === "development" ? err.stack : undefined, - ); + errorResponse( + res as ResponseExtended, + errorMessage, + statusCode, + err, + config.NODE_ENV === 'development' ? err.stack : undefined, + ); - return; + return; }; export default globalErrorHandler; From da7dae35cdc48d24c0e4df60ba7758e925b8e7f3 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 07:21:27 +0500 Subject: [PATCH 04/14] chore: remove unused dependencies and session store reference from configuration files --- README.md | 1 - package.json | 3 --- pnpm-lock.yaml | 62 -------------------------------------------------- 3 files changed, 66 deletions(-) diff --git a/README.md b/README.md index dfdca0d..a834cbd 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,6 @@ Before you get started, make sure you have the following installed on your machi │   │   ├── queue.server.ts │   │   ├── realtime.server.ts │   │   ├── redis.server.ts -│   │   └── session.store.ts │   ├── main.ts │   ├── middlewares │   │   ├── can-access.ts diff --git a/package.json b/package.json index 14f2d3c..5948c2f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@types/cors": "^2.8.13", "@types/express": "^4.17.15", "@types/express-rate-limit": "^6.0.2", - "@types/express-session": "^1.17.5", "@types/helmet": "^4.0.0", "@types/http-status-codes": "^1.2.0", "@types/jsonwebtoken": "^9.0.6", @@ -66,7 +65,6 @@ "axios": "^1.4.0", "bullmq": "^5.7.6", "compression": "^1.7.4", - "connect-redis": "^7.1.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", @@ -75,7 +73,6 @@ "express": "^4.19.2", "express-async-handler": "^1.2.0", "express-rate-limit": "^8.1.0", - "express-session": "^1.18.0", "form-data": "^4.0.4", "helmet": "^6.0.1", "http-status-codes": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b08c55..90fff16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: compression: specifier: ^1.7.4 version: 1.8.0 - connect-redis: - specifier: ^7.1.1 - version: 7.1.1(express-session@1.18.1) cookie-parser: specifier: ^1.4.6 version: 1.4.7 @@ -71,9 +68,6 @@ importers: express-rate-limit: specifier: ^8.1.0 version: 8.1.0(express@4.21.2) - express-session: - specifier: ^1.18.0 - version: 1.18.1 form-data: specifier: ^4.0.4 version: 4.0.4 @@ -171,9 +165,6 @@ importers: '@types/express-rate-limit': specifier: ^6.0.2 version: 6.0.2(express@4.21.2) - '@types/express-session': - specifier: ^1.17.5 - version: 1.18.1 '@types/helmet': specifier: ^4.0.0 version: 4.0.0 @@ -1888,9 +1879,6 @@ packages: '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} - '@types/express-session@1.18.1': - resolution: {integrity: sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==} - '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} @@ -2404,12 +2392,6 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - connect-redis@7.1.1: - resolution: {integrity: sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==} - engines: {node: '>=16'} - peerDependencies: - express-session: '>=1' - consola@3.4.0: resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2435,9 +2417,6 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} @@ -2830,10 +2809,6 @@ packages: peerDependencies: express: '>= 4.11' - express-session@1.18.1: - resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} - engines: {node: '>= 0.8.0'} - express@4.21.2: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} @@ -3929,10 +3904,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - random-bytes@1.0.0: - resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} - engines: {node: '>= 0.8'} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4435,10 +4406,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uid-safe@2.1.5: - resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} - engines: {node: '>= 0.8'} - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -6301,10 +6268,6 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.4 - '@types/express-session@1.18.1': - dependencies: - '@types/express': 4.17.21 - '@types/express@4.17.21': dependencies: '@types/body-parser': 1.19.5 @@ -6919,10 +6882,6 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 - connect-redis@7.1.1(express-session@1.18.1): - dependencies: - express-session: 1.18.1 - consola@3.4.0: {} console-control-strings@1.1.0: {} @@ -6942,8 +6901,6 @@ snapshots: cookie-signature@1.0.6: {} - cookie-signature@1.0.7: {} - cookie@0.7.1: {} cookie@0.7.2: {} @@ -7477,19 +7434,6 @@ snapshots: express: 4.21.2 ip-address: 10.0.1 - express-session@1.18.1: - dependencies: - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - on-headers: 1.0.2 - parseurl: 1.3.3 - safe-buffer: 5.2.1 - uid-safe: 2.1.5 - transitivePeerDependencies: - - supports-color - express@4.21.2: dependencies: accepts: 1.3.8 @@ -8621,8 +8565,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - random-bytes@1.0.0: {} - range-parser@1.2.1: {} raw-body@2.5.2: @@ -9290,10 +9232,6 @@ snapshots: typescript@5.7.3: {} - uid-safe@2.1.5: - dependencies: - random-bytes: 1.0.0 - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 From c27c0b549e8295bfb9e2f8ae8c5f4abc46708758 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:12:22 +0500 Subject: [PATCH 05/14] feat: implement server-managed session lifecycle with MongoDB and Redis support --- .env.development | 2 +- docs/SESSION_MANAGEMENT.md | 283 ++++++++++++++++++ modules.d.ts | 7 + session-feature.md | 83 +++++ src/app/app.ts | 8 + src/config/env.ts | 45 ++- src/middlewares/extract-jwt.ts | 45 ++- src/modules/auth/auth.controller.ts | 95 +++++- src/modules/auth/auth.router.ts | 7 + src/modules/auth/auth.service.ts | 99 ++++-- src/modules/auth/session/index.ts | 7 + .../auth/session/mongo.session.store.ts | 108 +++++++ .../auth/session/redis.session.store.ts | 164 ++++++++++ src/modules/auth/session/session.manager.ts | 162 ++++++++++ src/modules/auth/session/session.model.ts | 57 ++++ src/modules/auth/session/session.schema.ts | 52 ++++ src/modules/auth/session/session.types.ts | 51 ++++ src/modules/auth/session/session.utils.ts | 49 +++ src/plugins/auth.ts | 18 ++ src/utils/auth.utils.ts | 1 + 20 files changed, 1293 insertions(+), 50 deletions(-) create mode 100644 docs/SESSION_MANAGEMENT.md create mode 100644 session-feature.md create mode 100644 src/modules/auth/session/index.ts create mode 100644 src/modules/auth/session/mongo.session.store.ts create mode 100644 src/modules/auth/session/redis.session.store.ts create mode 100644 src/modules/auth/session/session.manager.ts create mode 100644 src/modules/auth/session/session.model.ts create mode 100644 src/modules/auth/session/session.schema.ts create mode 100644 src/modules/auth/session/session.types.ts create mode 100644 src/modules/auth/session/session.utils.ts diff --git a/.env.development b/.env.development index 2c2fc36..5e52843 100644 --- a/.env.development +++ b/.env.development @@ -6,7 +6,7 @@ CLIENT_SIDE_URL="http://localhost:3001" # JWT JWT_SECRET="some-secret" -JWT_EXPIRES_IN=3600 +JWT_EXPIRES_IN=86400 # NODE_ENV NODE_ENV="development" diff --git a/docs/SESSION_MANAGEMENT.md b/docs/SESSION_MANAGEMENT.md new file mode 100644 index 0000000..5266725 --- /dev/null +++ b/docs/SESSION_MANAGEMENT.md @@ -0,0 +1,283 @@ +# Session Management Implementation + +## Overview + +Server-managed session lifecycle integrated with JWT-based authentication, supporting both MongoDB and Redis storage backends. + +## Features + +- ✅ Dual storage backend support (MongoDB and Redis) +- ✅ Configurable session TTL and per-user session limits +- ✅ Session revocation on logout, password reset, and password change +- ✅ Secure session token hashing (SHA-256) +- ✅ Session metadata tracking (user agent, IP address) +- ✅ Automatic session eviction when max limit is reached +- ✅ RESTful session management endpoints +- ✅ Integrated with existing JWT authentication flow + +## Configuration + +Add the following environment variables to your `.env` file: + +```env +# Enable session management +SET_SESSION=true + +# Session storage driver (mongo or redis) +SESSION_DRIVER=mongo + +# Maximum number of sessions per user (default: 5) +SESSION_MAX_PER_USER=5 + +# Session expires in seconds (default: 86400 = 24 hours) +SESSION_EXPIRES_IN=86400 + +# Optional: Session idle TTL in seconds +SESSION_IDLE_TTL=3600 + +# Optional: Session absolute TTL in seconds +SESSION_ABSOLUTE_TTL=604800 + +# Enable session rotation on privilege changes +SESSION_ROTATION=false + +# Session cookie name (default: session_id) +SESSION_COOKIE_NAME=session_id + +# Enable debug logging for sessions +SESSION_DEBUG=false +``` + +## Architecture + +### Storage Implementations + +#### MongoDB Store +- Uses Mongoose model with TTL indexes for automatic expiration +- Stores sessions in `sessions` collection +- Indexed on `userId`, `expiresAt`, and `tokenHash` + +#### Redis Store +- Uses JSON serialization with TTL-based expiration +- Keys pattern: `session:{sessionId}` +- Maintains sorted sets per user: `user_sessions:{userId}` + +### Session Lifecycle + +1. **Login**: Session created with JWT containing `sid` claim +2. **Request**: Middleware validates session against stored data +3. **Logout**: Session explicitly revoked +4. **Expiration**: Automatic cleanup via TTL (MongoDB) or Redis expiration +5. **Password Change**: All user sessions revoked + +## API Endpoints + +### List User Sessions +``` +GET /auth/sessions +Authorization: Bearer + +Response: +{ + "status": "success", + "data": { + "sessions": [ + { + "sessionId": "uuid", + "userId": "user_id", + "createdAt": "2024-01-01T00:00:00.000Z", + "lastSeen": "2024-01-01T01:00:00.000Z", + "expiresAt": "2024-01-02T00:00:00.000Z", + "metadata": { + "userAgent": "Mozilla/5.0...", + "ipAddress": "192.168.1.1" + } + } + ] + } +} +``` + +### Revoke Specific Session +``` +DELETE /auth/sessions/:sessionId +Authorization: Bearer + +Response: +{ + "status": "success", + "message": "Session revoked successfully" +} +``` + +### Revoke All Sessions +``` +DELETE /auth/sessions +Authorization: Bearer + +Response: +{ + "status": "success", + "message": "All sessions revoked successfully" +} +``` + +## Usage + +### Enable in Plugin + +```typescript +import { authPlugin } from './plugins/auth'; + +app.use(authPlugin({ + session: { + enabled: true, + driver: 'mongo', // or 'redis' + maxPerUser: 5, + debug: false + } +})); +``` + +### Access Session in Request Handler + +```typescript +app.get('/protected', canAccess(), (req, res) => { + const session = req.session; // SessionRecord | undefined + const user = req.user; // JwtPayload + + res.json({ + userId: user.sub, + sessionId: session?.sessionId, + lastSeen: session?.lastSeen + }); +}); +``` + +### Programmatic Session Management + +```typescript +import { getSessionManager } from './modules/auth/session/session.manager'; + +const sessionManager = getSessionManager(); + +// Create session +const session = await sessionManager.createSession({ + userId: 'user_123', + token: 'jwt_token', + metadata: { + userAgent: 'Mozilla/5.0...', + ipAddress: '192.168.1.1' + } +}); + +// Validate session +const validation = await sessionManager.validateSession(sessionId, token); +if (validation.isValid) { + // Session is valid +} + +// Revoke session +await sessionManager.revokeSession(sessionId); + +// Revoke all user sessions +await sessionManager.revokeAllUserSessions(userId); + +// List user sessions +const sessions = await sessionManager.listUserSessions(userId); +``` + +## Security Considerations + +1. **Token Hashing**: Session tokens are hashed with SHA-256 before storage +2. **Session ID Generation**: Uses `crypto.randomUUID()` for secure random IDs +3. **Cookie Security**: + - `httpOnly` flag set to prevent XSS + - `secure` flag enabled in production + - `sameSite: lax` for CSRF protection +4. **Automatic Revocation**: Sessions revoked on password reset/change +5. **Session Limits**: Enforced per-user maximum to prevent resource exhaustion + +## MongoDB Schema + +```typescript +{ + _id: ObjectId, + userId: String (indexed), + tokenHash: String (unique), + metadata: { + userAgent?: String, + ipAddress?: String, + deviceType?: String, + browser?: String, + os?: String + }, + lastSeen: Date, + expiresAt: Date (TTL index), + isRevoked: Boolean, + createdAt: Date, + updatedAt: Date +} +``` + +## Redis Keys Structure + +``` +session:{sessionId} -> JSON serialized SessionRecord +user_sessions:{userId} -> Sorted set (score: createdAt timestamp) +``` + +## Performance + +- **MongoDB**: TTL index handles automatic cleanup, efficient queries with compound indexes +- **Redis**: Native expiration, O(1) lookups, sorted sets for user session ordering +- **Session Validation**: Single database query per request when sessions enabled + +## Observability + +Session operations are logged with contextual information: + +- Session creation: `{ sessionId, userId }` +- Session revocation: `{ sessionId }` +- Bulk revocation: `{ userId }` +- Session eviction: `{ userId, revokedSessionId }` + +Enable debug logging with `SESSION_DEBUG=true` for detailed session lifecycle tracking. + +## Migration Guide + +### Enabling Sessions on Existing Deployment + +1. Add session configuration to environment variables +2. Ensure MongoDB indexes are created (automatic on first session creation) +3. Rolling restart application servers +4. Monitor logs for session creation/validation + +### Disabling Sessions + +Set `SET_SESSION=false` or remove the environment variable. The system will fall back to stateless JWT authentication. + +## Troubleshooting + +### Sessions Not Being Created +- Verify `SET_SESSION=true` in environment +- Check auth plugin is properly registered +- Ensure database connection is established + +### Sessions Not Being Validated +- Confirm JWT contains `sid` claim +- Verify session exists in database/redis +- Check session hasn't expired or been revoked + +### Performance Issues +- Consider Redis for high-throughput scenarios +- Adjust `SESSION_MAX_PER_USER` to limit resource usage +- Enable connection pooling for database + +## Future Enhancements + +- Session rotation on privilege escalation +- Device fingerprinting for enhanced security +- Session activity tracking and analytics +- CLI commands for session inspection and management +- Configurable cleanup job scheduling diff --git a/modules.d.ts b/modules.d.ts index 9c7c238..6093678 100644 --- a/modules.d.ts +++ b/modules.d.ts @@ -1,12 +1,19 @@ import { Server } from 'socket.io'; import { JwtPayload } from '../utils/auth.utils'; import { Config } from './src/config/config.service'; +import { SessionRecord } from './src/modules/auth/session/session.types'; +import { SessionManager } from './src/modules/auth/session/session.manager'; declare global { namespace Express { export interface Request { user: JwtPayload; io: Server; + session?: SessionRecord; + } + + export interface Locals { + sessionManager?: SessionManager; } } diff --git a/session-feature.md b/session-feature.md new file mode 100644 index 0000000..5225e17 --- /dev/null +++ b/session-feature.md @@ -0,0 +1,83 @@ +# Auth Session Management Plugin Plan + +## Objectives +- Deliver server-managed session lifecycle integrated with existing auth workflows (login, logout, password changes) while keeping developer ergonomics high. +- Support MongoDB and Redis storage backends behind a single interface selectable via configuration or plugin options. +- Enforce configurable session TTL and per-user session limits throughout the authentication lifecycle. +- Implement everything in-house (no new third-party session packages) while reusing the toolkit’s existing infrastructure. + +## Assumptions & Constraints +- Current JWT-based authentication remains; sessions will bind issued JWTs to server-side state via a `sid` claim. +- `mongoose` and `ioredis` are available; no additional runtime dependencies will be introduced. +- Environment schema already contains `SESSION_EXPIRES_IN`; new session-specific knobs will extend this configuration. +- Plugin system continues to orchestrate setup, so the session manager will be wired through the existing `authPlugin`. + +## Architecture Overview +1. Session configuration object derived from env vars and plugin overrides with sane defaults. +2. Shared `SessionRecord`/`SessionMetadata` types and Zod schemas under `src/modules/auth/session`. +3. `SessionStore` interface exposing CRUD operations plus a factory that instantiates Mongo or Redis implementations. +4. `MongoSessionStore` backed by a new Mongoose model with TTL and user-scoped indexes. +5. `RedisSessionStore` powered by `ioredis`, using JSON blobs and per-user sorted sets to enforce limits. +6. `SessionManager` coordinating store operations, TTL enforcement, eviction policy, and developer-facing helpers. +7. Extended auth plugin that registers the manager on `app.locals`, exposes configuration, and hooks cleanup on shutdown. +8. Middleware and services updated so JWT extraction requires an active session before requests reach business logic. + +## Implementation Steps +### 1. Configuration & Typings +- Extend `src/config/env.ts` with `SESSION_DRIVER`, `SESSION_MAX_PER_USER`, `SESSION_IDLE_TTL`, `SESSION_ABSOLUTE_TTL`, `SESSION_ROTATION`, `SESSION_COOKIE_NAME`, and optional `SESSION_DEBUG`. +- Update `modules.d.ts` (and any config typings) so new env vars are strongly typed. +- Document defaults in env templates and ensure `config.SET_SESSION` semantics remain backward compatible. + +### 2. Session Domain Modeling +- Create `src/modules/auth/session/session.types.ts` defining `SessionRecord`, `SessionMetadata`, and `SessionValidationResult`. +- Add `session.schema.ts` with Zod schemas for session creation, validation, and plugin options. +- Introduce helper utilities (e.g., `generateSessionId`, `buildSessionCookieOptions`) under `session.utils.ts`. + +### 3. Store Implementations +- Mongo: add `session.model.ts` with schema (`sessionId`, `userId`, hashed token, metadata, `expiresAt`, `lastSeen`) and TTL indexes; implement `mongo.session.store.ts`. +- Redis: add `redis.session.store.ts` storing sessions under `session:` with expiry and maintaining `user_sessions:` sorted sets for ordering and eviction. +- Ensure both stores expose `create`, `get`, `listByUser`, `touch`, `revoke`, `revokeAllForUser`, `pruneExpired`, and share error semantics. + +### 4. Session Manager & Plugin Wiring +- Implement `SessionManager` in `session.manager.ts` to wrap a store, normalize config, enforce max-session policies, and provide developer-friendly methods. +- Extend `authPlugin` options to accept session config overrides, instantiate `SessionManager`, set `app.locals.sessionManager`, and register `onShutdown` cleanup. +- Optionally expose lightweight factory (`getSessionManager(app)`) for other modules. + +### 5. Auth Lifecycle Integration +- Update `loginUserByEmail` and Google login flows to create sessions after credential validation, embedding `sid` in JWT payloads and attaching secure cookies when `SET_SESSION` is true. +- Adjust logout handler to extract current session and revoke it before clearing cookies. +- Revoke all sessions on password reset/change to mitigate credential compromise. +- Add management endpoints (list/revoke sessions) within `auth.router.ts` guarded by authentication. + +### 6. Middleware & Request Context +- Enhance `extract-jwt` middleware to require `sid`, verify session state, attach `req.session`, and short-circuit on invalid/expired sessions. +- Add guard middleware (`requireActiveSession`) for routes needing hard session enforcement. +- Update Express typings so `Request` includes optional `session: SessionRecord`. + +### 7. Session Maintenance & Observability +- Rely on Mongo TTL indexes for pruning and add lazy pruning hooks for Redis (plus optional timed cleanup driven by config). +- Emit structured logs via existing logger for session issuance, eviction, revocation, and anomalies. +- Hook into observability plugin to expose counters/gauges (active sessions per user, revocations, evictions) when metrics are enabled. + +### 8. Testing & Validation +- Write unit tests (using `node:test` or existing setup) for `SessionManager`, Mongo store (with in-memory Mongo or mocks), and Redis store (with mock client). +- Add integration tests covering login/session issuance, max-session eviction, logout revocation, and request rejection when sessions are revoked or expired. +- Include regression tests for password reset flows to ensure sessions are properly purged. + +## Security & Risk Considerations +- Hash session tokens (e.g., SHA-256) before persistence to protect against data leaks. +- Generate session IDs with `crypto.randomUUID` or secure random bytes; avoid sequential IDs. +- Rotate session IDs on privilege changes when `SESSION_ROTATION` is enabled. +- Ensure cookies remain `httpOnly`, `secure` in production, and `sameSite` aligned with existing `COOKIE_CONFIG`. +- Fail closed if the store is unavailable; surface actionable logs and metrics for operators. + +## Developer Experience & Documentation +- Update docs with configuration reference, code samples (`req.session`, revoking sessions), and Mongo vs Redis trade-offs. +- Provide quick-start snippets showing how to enable the plugin in custom app setups. +- Optionally add helper CLI commands under `bin/tbk` for inspecting or clearing sessions during development. +- Note migration steps for existing deployments (new env vars, database indexes, rolling restart considerations). + +## Rollout & Follow-up +- Deliver in stages: default to Mongo driver first, then enable Redis once verified. +- Provide migration script or documentation to create Mongo indexes and clear legacy session data where applicable. +- Monitor logs/metrics post-merge, gather developer feedback on ergonomics, and iterate on defaults or DX improvements. diff --git a/src/app/app.ts b/src/app/app.ts index 7516b85..5f53f09 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -8,10 +8,18 @@ import { extractJwt } from '../middlewares/extract-jwt'; import { securityPlugin } from '../plugins/security'; import { observabilityPlugin } from '../plugins/observability'; import { openApiPlugin } from '../plugins/openapi'; +import { authPlugin } from '../plugins/auth'; export async function initializeApp() { const { app, server, plugins } = await createApp({ plugins: [ + authPlugin({ + session: { + enabled: config.SET_SESSION, + driver: 'mongo', + debug: true, + }, + }), securityPlugin({ corsEnabled: config.CORS_ENABLED, corsOrigins: [config.CLIENT_SIDE_URL], diff --git a/src/config/env.ts b/src/config/env.ts index 9869c35..c45a540 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -12,54 +12,67 @@ const configSchema = z.object({ NODE_ENV: z .enum(['production', 'development', 'test']) .default('development'), - + PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'), - + REDIS_URL: z.string().url(), MONGO_DATABASE_URL: z.string().url(), - + CLIENT_SIDE_URL: z.string().url(), - + JWT_SECRET: z.string().min(1), JWT_EXPIRES_IN: z.string().default('86400').transform(Number), SESSION_EXPIRES_IN: z.string().default('86400').transform(Number), - PASSWORD_RESET_TOKEN_EXPIRES_IN: z.string().default('86400').transform(Number), + PASSWORD_RESET_TOKEN_EXPIRES_IN: z + .string() + .default('86400') + .transform(Number), SET_PASSWORD_TOKEN_EXPIRES_IN: z.string().default('86400').transform(Number), - SET_SESSION: booleanString.optional(), - + SET_SESSION: booleanString.default('true'), + + SESSION_DRIVER: z.enum(['mongo', 'redis']).default('mongo'), + SESSION_MAX_PER_USER: z.string().transform(Number).default('5'), + SESSION_IDLE_TTL: z.string().transform(Number).optional(), + SESSION_ABSOLUTE_TTL: z.string().transform(Number).optional(), + SESSION_ROTATION: booleanString.default('false'), + SESSION_COOKIE_NAME: z.string().default('session_id'), + SESSION_DEBUG: booleanString.default('false'), + SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), SMTP_USERNAME: z.string().email().optional(), SMTP_PASSWORD: z.string().min(1).optional(), SMTP_FROM: z.string().min(1).optional(), EMAIL_FROM: z.string().email().optional(), - + MAILGUN_API_KEY: z.string().min(1), MAILGUN_DOMAIN: z.string().min(1), MAILGUN_FROM_EMAIL: z.string().email(), - + ADMIN_EMAIL: z.string().email(), ADMIN_PASSWORD: z.string().min(1), - + OTP_VERIFICATION_ENABLED: booleanString, STATIC_OTP: z.enum(['1', '0']).transform(Number).optional(), - + GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_REDIRECT_URI: z.string().optional(), - + APP_NAME: z.string().default('API V1'), APP_VERSION: z.string().default('1.0.0'), - - LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).optional(), + + LOG_LEVEL: z + .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']) + .optional(), METRICS_ENABLED: booleanString.default('true'), HEALTH_ENABLED: booleanString.default('true'), - + CORS_ENABLED: booleanString.default('true'), RATE_LIMIT_ENABLED: booleanString.default('true'), RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'), RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'), - + TRUST_PROXY: booleanString.default('false'), HTTPS_ENABLED: booleanString.default('false'), }); diff --git a/src/middlewares/extract-jwt.ts b/src/middlewares/extract-jwt.ts index df2b15e..cbccbea 100644 --- a/src/middlewares/extract-jwt.ts +++ b/src/middlewares/extract-jwt.ts @@ -1,6 +1,11 @@ import type { NextFunction } from 'express'; import { type JwtPayload, verifyToken } from '../utils/auth.utils'; import type { RequestAny, ResponseAny } from '../openapi/magic-router'; +import config from '../config/env'; + +import { createChildLogger } from '../observability/logger'; + +const logger = createChildLogger({ context: 'extract-jwt' }); export const extractJwt = async ( req: RequestAny, @@ -8,18 +13,56 @@ export const extractJwt = async ( next: NextFunction, ) => { try { + logger.debug('Starting JWT extraction'); const token = req.cookies?.accessToken ?? req.headers.authorization?.split(' ')[1]; if (!token) { + logger.debug('No JWT token found in cookies or headers'); return next(); } + logger.debug({ token: token ? '[REDACTED]' : undefined }, 'JWT token found, verifying'); const decode = await verifyToken(token); + if (config.SET_SESSION && req.app.locals.sessionManager) { + logger.debug('Session management enabled, validating session'); + const sessionManager = req.app.locals.sessionManager; + + if (!decode.sid) { + logger.warn('JWT does not contain a session id (sid)'); + return next(); + } + + const validation = await sessionManager.validateSession( + decode.sid, + token, + ); + + if (!validation.isValid) { + logger.warn( + { sid: decode.sid, reason: validation.reason }, + 'Session validation failed' + ); + return next(); + } + + logger.debug( + { sid: decode.sid, userId: validation.session?.userId }, + 'Session validated successfully' + ); + req.session = validation.session; + } + req.user = decode; + logger.debug( + { userId: decode.sub, sid: decode.sid }, + 'JWT decoded and user attached to request' + ); + return next(); - } catch { + } catch (err) { + logger.error({ err }, 'Error extracting or verifying JWT'); return next(); } }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index c316313..949a351 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -60,7 +60,18 @@ export const handleRegisterUser = async ( return successResponse(res, 'User has been reigstered', user); }; -export const handleLogout = async (_: Request, res: Response) => { +export const handleLogout = async (req: Request, res: Response) => { + console.log({ + setSession: config.SET_SESSION, + session: req.session, + sessionManager: req.app.locals.sessionManager, + }); + + if (config.SET_SESSION && req.session && req.app.locals.sessionManager) { + const sessionManager = req.app.locals.sessionManager; + await sessionManager.revokeSession(req.session.sessionId); + } + res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); return successResponse(res, 'Logout successful'); @@ -70,11 +81,24 @@ export const handleLoginByEmail = async ( req: Request, res: Response, ) => { - const token = await loginUserByEmail(req.body); + const metadata = { + userAgent: req.headers['user-agent'], + ipAddress: + req.ip || + (req.headers['x-forwarded-for'] as string) || + req.connection?.remoteAddress, + }; + + const result = await loginUserByEmail(req.body, metadata); + if (config.SET_SESSION) { - res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + res.cookie(AUTH_COOKIE_KEY, result.token, COOKIE_CONFIG); } - return successResponse(res, 'Login successful', { token: token }); + + return successResponse(res, 'Login successful', { + token: result.token, + sessionId: result.sessionId, + }); }; export const handleGetCurrentUser = async (req: Request, res: Response) => { @@ -95,15 +119,62 @@ export const handleGoogleCallback = async ( req: Request, res: Response, ) => { - const user = await googleLogin(req.query); - if (!user) throw new Error('Failed to login'); - res.cookie( - AUTH_COOKIE_KEY, - user.socialAccount?.[0]?.accessToken, - COOKIE_CONFIG, - ); + const metadata = { + userAgent: req.headers['user-agent'], + ipAddress: + req.ip || + (req.headers['x-forwarded-for'] as string) || + req.connection?.remoteAddress, + }; + + const result = await googleLogin(req.query, metadata); + + if (!result.user) throw new Error('Failed to login'); + + if (config.SET_SESSION) { + res.cookie(AUTH_COOKIE_KEY, result.token, COOKIE_CONFIG); + } return successResponse(res, 'Logged in successfully', { - token: user.socialAccount?.[0]?.accessToken, + token: result.token, + sessionId: result.sessionId, }); }; + +export const handleListSessions = async (req: Request, res: Response) => { + if (!config.SET_SESSION || !req.app.locals.sessionManager) { + throw new Error('Session management is not enabled'); + } + + const userId = (req.user as JwtPayload).sub; + const sessionManager = req.app.locals.sessionManager; + const sessions = await sessionManager.listUserSessions(userId); + + return successResponse(res, undefined, { sessions }); +}; + +export const handleRevokeSession = async ( + req: Request<{ sessionId: string }>, + res: Response, +) => { + if (!config.SET_SESSION || !req.app.locals.sessionManager) { + throw new Error('Session management is not enabled'); + } + + const sessionManager = req.app.locals.sessionManager; + await sessionManager.revokeSession(req.params.sessionId); + + return successResponse(res, 'Session revoked successfully'); +}; + +export const handleRevokeAllSessions = async (req: Request, res: Response) => { + if (!config.SET_SESSION || !req.app.locals.sessionManager) { + throw new Error('Session management is not enabled'); + } + + const userId = (req.user as JwtPayload).sub; + const sessionManager = req.app.locals.sessionManager; + await sessionManager.revokeAllUserSessions(userId); + + return successResponse(res, 'All sessions revoked successfully'); +}; diff --git a/src/modules/auth/auth.router.ts b/src/modules/auth/auth.router.ts index 897a014..a582ccb 100644 --- a/src/modules/auth/auth.router.ts +++ b/src/modules/auth/auth.router.ts @@ -6,10 +6,13 @@ import { handleGetCurrentUser, handleGoogleCallback, handleGoogleLogin, + handleListSessions, handleLoginByEmail, handleLogout, handleRegisterUser, handleResetPassword, + handleRevokeAllSessions, + handleRevokeSession, } from './auth.controller'; import { changePasswordSchema, @@ -61,4 +64,8 @@ authRouter.post( authRouter.get('/google', {}, handleGoogleLogin); authRouter.get('/google/callback', {}, handleGoogleCallback); +authRouter.get('/sessions', {}, canAccess(), handleListSessions); +authRouter.delete('/sessions/:sessionId', {}, canAccess(), handleRevokeSession); +authRouter.delete('/sessions', {}, canAccess(), handleRevokeAllSessions); + export default authRouter.getRouter(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index b950926..1ab61c1 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -25,6 +25,7 @@ import type { RegisterUserByEmailSchemaType, ResetPasswordSchemaType, } from './auth.schema'; +import { getSessionManager } from './session/session.manager'; export const resetPassword = async (payload: ResetPasswordSchemaType) => { const user = await getUserById(payload.userId); @@ -43,6 +44,11 @@ export const resetPassword = async (payload: ResetPasswordSchemaType) => { password: hashedPassword, passwordResetCode: null, }); + + if (config.SET_SESSION) { + const sessionManager = getSessionManager(); + await sessionManager.revokeAllUserSessions(payload.userId); + } }; export const forgetPassword = async ( @@ -83,6 +89,11 @@ export const changePassword = async ( const hashedPassword = await hashPassword(payload.newPassword); await updateUser(userId, { password: hashedPassword }); + + if (config.SET_SESSION) { + const sessionManager = getSessionManager(); + await sessionManager.revokeAllUserSessions(userId); + } }; export const registerUserByEmail = async ( @@ -105,7 +116,8 @@ export const registerUserByEmail = async ( export const loginUserByEmail = async ( payload: LoginUserByEmailSchemaType, -): Promise => { + metadata?: { userAgent?: string; ipAddress?: string }, +): Promise<{ token: string; sessionId?: string }> => { const user = await getUserByEmail(payload.email, '+password'); if (!user || !(await compareHash(String(user.password), payload.password))) { @@ -120,14 +132,34 @@ export const loginUserByEmail = async ( username: user.username, }; - const token = await signToken(jwtPayload); + let sessionId: string | undefined; + + if (config.SET_SESSION) { + const sessionManager = getSessionManager(); + + const token = await signToken(jwtPayload); + + const session = await sessionManager.createSession({ + userId: String(user._id), + token, + metadata, + }); + + sessionId = session.sessionId; + jwtPayload.sid = sessionId; + + const tokenWithSession = await signToken(jwtPayload); + return { token: tokenWithSession, sessionId }; + } - return token; + const token = await signToken(jwtPayload); + return { token }; }; export const googleLogin = async ( payload: GoogleCallbackQuery, -): Promise => { + metadata?: { userAgent?: string; ipAddress?: string }, +): Promise<{ user: UserType; token: string; sessionId?: string }> => { const { code, error } = payload; if (error) { @@ -145,10 +177,10 @@ export const googleLogin = async ( const { id, email, name, picture } = userInfoResponse; - const user = await getUserByEmail(email); + let user = await getUserByEmail(email); if (!user) { - const newUser = await createUser({ + user = await createUser({ email, username: name, avatar: picture, @@ -164,21 +196,48 @@ export const googleLogin = async ( }, ], }); - - return newUser; + } else { + user = await updateUser(user._id, { + socialAccount: [ + { + refreshToken: refresh_token, + tokenExpiry: new Date(Date.now() + expires_in * 1000), + accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, + accessToken: access_token, + accountID: id, + }, + ], + }); } - const updatedUser = await updateUser(user._id, { - socialAccount: [ - { - refreshToken: refresh_token, - tokenExpiry: new Date(Date.now() + expires_in * 1000), - accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, - accessToken: access_token, - accountID: id, - }, - ], - }); + const jwtPayload: JwtPayload = { + sub: String(user._id), + email: user.email, + phoneNo: user.phoneNo, + role: String(user.role) as RoleType, + username: user.username, + }; - return updatedUser; + let sessionId: string | undefined; + + if (config.SET_SESSION) { + const sessionManager = getSessionManager(); + + const token = await signToken(jwtPayload); + + const session = await sessionManager.createSession({ + userId: String(user._id), + token, + metadata, + }); + + sessionId = session.sessionId; + jwtPayload.sid = sessionId; + + const tokenWithSession = await signToken(jwtPayload); + return { user, token: tokenWithSession, sessionId }; + } + + const token = await signToken(jwtPayload); + return { user, token }; }; diff --git a/src/modules/auth/session/index.ts b/src/modules/auth/session/index.ts new file mode 100644 index 0000000..554e8ca --- /dev/null +++ b/src/modules/auth/session/index.ts @@ -0,0 +1,7 @@ +export * from './session.types'; +export * from './session.schema'; +export * from './session.utils'; +export * from './session.manager'; +export * from './mongo.session.store'; +export * from './redis.session.store'; +export { SessionModel } from './session.model'; diff --git a/src/modules/auth/session/mongo.session.store.ts b/src/modules/auth/session/mongo.session.store.ts new file mode 100644 index 0000000..1f66983 --- /dev/null +++ b/src/modules/auth/session/mongo.session.store.ts @@ -0,0 +1,108 @@ +import type { SessionStore, CreateSessionInput, SessionRecord } from './session.types'; +import { SessionModel } from './session.model'; +import { hashToken, calculateExpiresAt } from './session.utils'; +import { createChildLogger } from '../../../observability/logger'; + +const logger = createChildLogger({ context: 'MongoSessionStore' }); + +export class MongoSessionStore implements SessionStore { + async create(input: CreateSessionInput): Promise { + const tokenHash = hashToken(input.token); + const expiresAt = calculateExpiresAt(input.expiresIn); + + const session = await SessionModel.create({ + userId: input.userId, + tokenHash, + metadata: input.metadata, + lastSeen: new Date(), + expiresAt, + isRevoked: false, + }); + + logger.debug({ sessionId: session._id, userId: input.userId }, 'Session created'); + + return { + sessionId: session._id.toString(), + userId: session.userId, + tokenHash: session.tokenHash, + metadata: session.metadata, + createdAt: session.createdAt!, + lastSeen: session.lastSeen, + expiresAt: session.expiresAt, + isRevoked: session.isRevoked, + }; + } + + async get(sessionId: string): Promise { + const session = await SessionModel.findById(sessionId); + + if (!session) { + return null; + } + + return { + sessionId: session._id.toString(), + userId: session.userId, + tokenHash: session.tokenHash, + metadata: session.metadata, + createdAt: session.createdAt!, + lastSeen: session.lastSeen, + expiresAt: session.expiresAt, + isRevoked: session.isRevoked, + }; + } + + async listByUser(userId: string): Promise { + const sessions = await SessionModel.find({ userId, isRevoked: false }) + .sort({ createdAt: -1 }) + .lean(); + + return sessions.map(session => ({ + sessionId: session._id.toString(), + userId: session.userId, + tokenHash: session.tokenHash, + metadata: session.metadata, + createdAt: session.createdAt!, + lastSeen: session.lastSeen, + expiresAt: session.expiresAt, + isRevoked: session.isRevoked, + })); + } + + async touch(sessionId: string): Promise { + await SessionModel.findByIdAndUpdate(sessionId, { + lastSeen: new Date(), + }); + } + + async revoke(sessionId: string): Promise { + await SessionModel.findByIdAndUpdate(sessionId, { + isRevoked: true, + }); + + logger.debug({ sessionId }, 'Session revoked'); + } + + async revokeAllForUser(userId: string): Promise { + await SessionModel.updateMany( + { userId, isRevoked: false }, + { isRevoked: true }, + ); + + logger.debug({ userId }, 'All sessions revoked for user'); + } + + async pruneExpired(): Promise { + const result = await SessionModel.deleteMany({ + expiresAt: { $lt: new Date() }, + }); + + if (result.deletedCount && result.deletedCount > 0) { + logger.debug({ count: result.deletedCount }, 'Expired sessions pruned'); + } + } + + async close(): Promise { + // MongoDB connection is managed globally, no specific cleanup needed + } +} diff --git a/src/modules/auth/session/redis.session.store.ts b/src/modules/auth/session/redis.session.store.ts new file mode 100644 index 0000000..f18feb5 --- /dev/null +++ b/src/modules/auth/session/redis.session.store.ts @@ -0,0 +1,164 @@ +import type { Redis } from 'ioredis'; +import type { SessionStore, CreateSessionInput, SessionRecord } from './session.types'; +import { generateSessionId, hashToken, calculateExpiresAt } from './session.utils'; +import { createChildLogger } from '../../../observability/logger'; + +const logger = createChildLogger({ context: 'RedisSessionStore' }); + +const SESSION_PREFIX = 'session:'; +const USER_SESSIONS_PREFIX = 'user_sessions:'; + +export class RedisSessionStore implements SessionStore { + constructor(private redis: Redis) {} + + private getSessionKey(sessionId: string): string { + return `${SESSION_PREFIX}${sessionId}`; + } + + private getUserSessionsKey(userId: string): string { + return `${USER_SESSIONS_PREFIX}${userId}`; + } + + async create(input: CreateSessionInput): Promise { + const sessionId = generateSessionId(); + const tokenHash = hashToken(input.token); + const now = new Date(); + const expiresAt = calculateExpiresAt(input.expiresIn); + + const session: SessionRecord = { + sessionId, + userId: input.userId, + tokenHash, + metadata: input.metadata, + createdAt: now, + lastSeen: now, + expiresAt, + isRevoked: false, + }; + + const sessionKey = this.getSessionKey(sessionId); + const userSessionsKey = this.getUserSessionsKey(input.userId); + const ttl = Math.floor((expiresAt.getTime() - now.getTime()) / 1000); + + await this.redis + .multi() + .set(sessionKey, JSON.stringify(session), 'EX', ttl) + .zadd(userSessionsKey, now.getTime(), sessionId) + .expire(userSessionsKey, ttl) + .exec(); + + logger.debug({ sessionId, userId: input.userId }, 'Session created'); + + return session; + } + + async get(sessionId: string): Promise { + const sessionKey = this.getSessionKey(sessionId); + const data = await this.redis.get(sessionKey); + + if (!data) { + return null; + } + + return JSON.parse(data, (key, value) => { + if (key === 'createdAt' || key === 'lastSeen' || key === 'expiresAt') { + return new Date(value); + } + return value; + }); + } + + async listByUser(userId: string): Promise { + const userSessionsKey = this.getUserSessionsKey(userId); + const sessionIds = await this.redis.zrevrange(userSessionsKey, 0, -1); + + if (!sessionIds.length) { + return []; + } + + const pipeline = this.redis.pipeline(); + for (const sessionId of sessionIds) { + pipeline.get(this.getSessionKey(sessionId)); + } + + const results = await pipeline.exec(); + const sessions: SessionRecord[] = []; + + if (!results) return sessions; + + for (const [err, data] of results) { + if (!err && data) { + const session = JSON.parse(data as string, (key, value) => { + if (key === 'createdAt' || key === 'lastSeen' || key === 'expiresAt') { + return new Date(value); + } + return value; + }); + if (!session.isRevoked) { + sessions.push(session); + } + } + } + + return sessions; + } + + async touch(sessionId: string): Promise { + const session = await this.get(sessionId); + if (!session) return; + + session.lastSeen = new Date(); + const sessionKey = this.getSessionKey(sessionId); + const ttl = await this.redis.ttl(sessionKey); + + if (ttl > 0) { + await this.redis.set(sessionKey, JSON.stringify(session), 'EX', ttl); + } + } + + async revoke(sessionId: string): Promise { + const session = await this.get(sessionId); + if (!session) return; + + session.isRevoked = true; + const sessionKey = this.getSessionKey(sessionId); + const ttl = await this.redis.ttl(sessionKey); + + if (ttl > 0) { + await this.redis.set(sessionKey, JSON.stringify(session), 'EX', ttl); + } + + logger.debug({ sessionId }, 'Session revoked'); + } + + async revokeAllForUser(userId: string): Promise { + const sessions = await this.listByUser(userId); + + if (!sessions.length) return; + + const pipeline = this.redis.pipeline(); + + for (const session of sessions) { + session.isRevoked = true; + const sessionKey = this.getSessionKey(session.sessionId); + const ttl = Math.floor((session.expiresAt.getTime() - Date.now()) / 1000); + + if (ttl > 0) { + pipeline.set(sessionKey, JSON.stringify(session), 'EX', ttl); + } + } + + await pipeline.exec(); + + logger.debug({ userId }, 'All sessions revoked for user'); + } + + async pruneExpired(): Promise { + // Redis automatically handles expiration via TTL, no manual pruning needed + logger.debug('Redis handles expiration automatically via TTL'); + } + + async close(): Promise { + // Redis connection is managed globally, no specific cleanup needed + } +} diff --git a/src/modules/auth/session/session.manager.ts b/src/modules/auth/session/session.manager.ts new file mode 100644 index 0000000..a59cf76 --- /dev/null +++ b/src/modules/auth/session/session.manager.ts @@ -0,0 +1,162 @@ +import type { + SessionStore, + SessionStoreConfig, + CreateSessionInput, + SessionRecord, + SessionValidationResult, +} from './session.types'; +import { MongoSessionStore } from './mongo.session.store'; +import { RedisSessionStore } from './redis.session.store'; +import { hashToken, isSessionExpired } from './session.utils'; +import { createChildLogger } from '../../../observability/logger'; +import redisClient from '../../../lib/redis.server'; +import config from '../../../config/env'; + +const logger = createChildLogger({ context: 'SessionManager' }); + +export class SessionManager { + private store: SessionStore; + private config: SessionStoreConfig; + + constructor(storeConfig?: Partial) { + this.config = { + driver: storeConfig?.driver || config.SESSION_DRIVER, + maxPerUser: storeConfig?.maxPerUser || config.SESSION_MAX_PER_USER, + idleTTL: storeConfig?.idleTTL || config.SESSION_IDLE_TTL, + absoluteTTL: storeConfig?.absoluteTTL || config.SESSION_ABSOLUTE_TTL, + rotation: storeConfig?.rotation ?? config.SESSION_ROTATION, + debug: storeConfig?.debug ?? config.SESSION_DEBUG, + }; + + this.store = this.createStore(); + + if (this.config.debug) { + logger.info({ config: this.config }, 'SessionManager initialized'); + } + } + + private createStore(): SessionStore { + if (this.config.driver === 'redis') { + return new RedisSessionStore(redisClient); + } + return new MongoSessionStore(); + } + + async createSession(input: CreateSessionInput): Promise { + const sessions = await this.store.listByUser(input.userId); + + if (sessions.length >= this.config.maxPerUser) { + const oldestSession = sessions[sessions.length - 1]; + await this.store.revoke(oldestSession.sessionId); + logger.debug( + { userId: input.userId, revokedSessionId: oldestSession.sessionId }, + 'Evicted oldest session due to max limit', + ); + } + + const session = await this.store.create(input); + + if (this.config.debug) { + logger.info( + { sessionId: session.sessionId, userId: input.userId }, + 'Session created', + ); + } + + return session; + } + + async getSession(sessionId: string): Promise { + return this.store.get(sessionId); + } + + async validateSession( + sessionId: string, + token: string, + ): Promise { + const session = await this.store.get(sessionId); + + if (!session) { + return { isValid: false, reason: 'not_found' }; + } + + if (session.isRevoked) { + return { isValid: false, session, reason: 'revoked' }; + } + + if (isSessionExpired(session.expiresAt)) { + return { isValid: false, session, reason: 'expired' }; + } + + const tokenHash = hashToken(token); + + console.debug( + { tokenHash, sessionTokenHash: session.tokenHash }, + 'Token hash comparison', + ); + + if (session.tokenHash !== tokenHash) { + return { isValid: false, session, reason: 'invalid' }; + } + + await this.store.touch(sessionId); + + return { isValid: true, session }; + } + + async touchSession(sessionId: string): Promise { + await this.store.touch(sessionId); + } + + async revokeSession(sessionId: string): Promise { + await this.store.revoke(sessionId); + + if (this.config.debug) { + logger.info({ sessionId }, 'Session revoked'); + } + } + + async revokeAllUserSessions(userId: string): Promise { + await this.store.revokeAllForUser(userId); + + if (this.config.debug) { + logger.info({ userId }, 'All user sessions revoked'); + } + } + + async listUserSessions(userId: string): Promise { + return this.store.listByUser(userId); + } + + async pruneExpiredSessions(): Promise { + await this.store.pruneExpired(); + } + + async cleanup(): Promise { + await this.store.close(); + } + + getConfig(): SessionStoreConfig { + return { ...this.config }; + } +} + +let sessionManagerInstance: SessionManager | null = null; + +export function initializeSessionManager( + config?: Partial, +): SessionManager { + if (!sessionManagerInstance) { + sessionManagerInstance = new SessionManager(config); + } + return sessionManagerInstance; +} + +export function getSessionManager(): SessionManager { + if (!sessionManagerInstance) { + throw new Error( + 'SessionManager not initialized. Call initializeSessionManager first.', + ); + } + return sessionManagerInstance; +} diff --git a/src/modules/auth/session/session.model.ts b/src/modules/auth/session/session.model.ts new file mode 100644 index 0000000..fdcf973 --- /dev/null +++ b/src/modules/auth/session/session.model.ts @@ -0,0 +1,57 @@ +import mongoose, { Schema, type Document } from 'mongoose'; +import type { SessionRecord, SessionMetadata } from './session.types'; + +export interface SessionDocument extends Omit, Document { + _id: string; +} + +const sessionMetadataSchema = new Schema( + { + userAgent: { type: String }, + ipAddress: { type: String }, + deviceType: { type: String }, + browser: { type: String }, + os: { type: String }, + }, + { _id: false }, +); + +const sessionSchema = new Schema( + { + userId: { + type: String, + required: true, + index: true, + }, + tokenHash: { + type: String, + required: true, + unique: true, + }, + metadata: { + type: sessionMetadataSchema, + }, + lastSeen: { + type: Date, + required: true, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + index: true, + }, + isRevoked: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, + }, +); + +sessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +sessionSchema.index({ userId: 1, createdAt: -1 }); + +export const SessionModel = mongoose.model('Session', sessionSchema); diff --git a/src/modules/auth/session/session.schema.ts b/src/modules/auth/session/session.schema.ts new file mode 100644 index 0000000..f6918bf --- /dev/null +++ b/src/modules/auth/session/session.schema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +export const sessionMetadataSchema = z.object({ + userAgent: z.string().optional(), + ipAddress: z.string().optional(), + deviceType: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), +}); + +export const createSessionSchema = z.object({ + userId: z.string().min(1), + token: z.string().min(1), + metadata: sessionMetadataSchema.optional(), + expiresIn: z.number().positive().optional(), +}); + +export const sessionRecordSchema = z.object({ + sessionId: z.string(), + userId: z.string(), + tokenHash: z.string(), + metadata: sessionMetadataSchema.optional(), + createdAt: z.date(), + lastSeen: z.date(), + expiresAt: z.date(), + isRevoked: z.boolean().optional(), +}); + +export const sessionStoreConfigSchema = z.object({ + driver: z.enum(['mongo', 'redis']), + maxPerUser: z.number().positive().default(5), + idleTTL: z.number().positive().optional(), + absoluteTTL: z.number().positive().optional(), + rotation: z.boolean().default(false), + debug: z.boolean().default(false), +}); + +export const sessionPluginOptionsSchema = z.object({ + enabled: z.boolean().default(true), + driver: z.enum(['mongo', 'redis']).optional(), + maxPerUser: z.number().positive().optional(), + idleTTL: z.number().positive().optional(), + absoluteTTL: z.number().positive().optional(), + rotation: z.boolean().optional(), + debug: z.boolean().optional(), +}); + +export type SessionMetadataSchemaType = z.infer; +export type CreateSessionSchemaType = z.infer; +export type SessionRecordSchemaType = z.infer; +export type SessionStoreConfigSchemaType = z.infer; +export type SessionPluginOptionsSchemaType = z.infer; diff --git a/src/modules/auth/session/session.types.ts b/src/modules/auth/session/session.types.ts new file mode 100644 index 0000000..a79da56 --- /dev/null +++ b/src/modules/auth/session/session.types.ts @@ -0,0 +1,51 @@ +export interface SessionMetadata { + userAgent?: string; + ipAddress?: string; + deviceType?: string; + browser?: string; + os?: string; +} + +export interface SessionRecord { + sessionId: string; + userId: string; + tokenHash: string; + metadata?: SessionMetadata; + createdAt: Date; + lastSeen: Date; + expiresAt: Date; + isRevoked?: boolean; +} + +export interface SessionValidationResult { + isValid: boolean; + session?: SessionRecord; + reason?: 'expired' | 'revoked' | 'not_found' | 'invalid'; +} + +export interface SessionStoreConfig { + driver: 'mongo' | 'redis'; + maxPerUser: number; + idleTTL?: number; + absoluteTTL?: number; + rotation: boolean; + debug: boolean; +} + +export interface CreateSessionInput { + userId: string; + token: string; + metadata?: SessionMetadata; + expiresIn?: number; +} + +export interface SessionStore { + create(input: CreateSessionInput): Promise; + get(sessionId: string): Promise; + listByUser(userId: string): Promise; + touch(sessionId: string): Promise; + revoke(sessionId: string): Promise; + revokeAllForUser(userId: string): Promise; + pruneExpired(): Promise; + close(): Promise; +} diff --git a/src/modules/auth/session/session.utils.ts b/src/modules/auth/session/session.utils.ts new file mode 100644 index 0000000..d080da0 --- /dev/null +++ b/src/modules/auth/session/session.utils.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto'; +import type { CookieOptions } from 'express'; +import config from '../../../config/env'; + +export function generateSessionId(): string { + return crypto.randomUUID(); +} + +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +export function buildSessionCookieOptions(): CookieOptions { + const isProduction = config.NODE_ENV === 'production'; + + return { + httpOnly: true, + secure: isProduction || config.HTTPS_ENABLED, + sameSite: 'lax', + maxAge: config.SESSION_EXPIRES_IN * 1000, + path: '/', + }; +} + +export function extractMetadataFromRequest(req: { + headers?: Record; + ip?: string; + connection?: { remoteAddress?: string }; +}): { + userAgent?: string; + ipAddress?: string; +} { + const userAgent = req.headers?.['user-agent']; + const xForwardedFor = req.headers?.['x-forwarded-for']; + + return { + userAgent: Array.isArray(userAgent) ? userAgent[0] : userAgent, + ipAddress: req.ip || (Array.isArray(xForwardedFor) ? xForwardedFor[0] : xForwardedFor) || req.connection?.remoteAddress, + }; +} + +export function isSessionExpired(expiresAt: Date): boolean { + return new Date() > expiresAt; +} + +export function calculateExpiresAt(expiresIn?: number): Date { + const ttl = expiresIn || config.SESSION_EXPIRES_IN; + return new Date(Date.now() + ttl * 1000); +} diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts index ceba8b6..b1053dd 100644 --- a/src/plugins/auth.ts +++ b/src/plugins/auth.ts @@ -1,14 +1,20 @@ import type { ToolkitPlugin, PluginFactory } from './types'; +import { initializeSessionManager, type SessionManager } from '../modules/auth/session/session.manager'; +import type { SessionStoreConfig } from '../modules/auth/session/session.types'; +import config from '../config/env'; export interface AuthOptions { jwtSecret?: string; jwtExpiration?: string; sessionSecret?: string; + session?: Partial & { enabled?: boolean }; } export const authPlugin: PluginFactory = ( options = {}, ): ToolkitPlugin => { + let sessionManager: SessionManager | null = null; + return { name: 'auth', priority: 70, @@ -23,6 +29,18 @@ export const authPlugin: PluginFactory = ( if (options.jwtExpiration) { app.set('auth:jwt:expiration', options.jwtExpiration); } + + if (config.SET_SESSION && options.session?.enabled !== false) { + sessionManager = initializeSessionManager(options.session); + app.locals.sessionManager = sessionManager; + app.set('auth:session:enabled', true); + } + }, + + async onShutdown() { + if (sessionManager) { + await sessionManager.cleanup(); + } }, }; }; diff --git a/src/utils/auth.utils.ts b/src/utils/auth.utils.ts index 768064a..bf6fe9d 100644 --- a/src/utils/auth.utils.ts +++ b/src/utils/auth.utils.ts @@ -24,6 +24,7 @@ export type JwtPayload = { phoneNo?: string | null; username: string; role: RoleType; + sid?: string; }; export type PasswordResetTokenPayload = { From a7be4c62c223aa12fee3bd9d4df77407958858de Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:23:31 +0500 Subject: [PATCH 06/14] fix: resolve session token hash mismatch by updating hash after final token generation --- src/middlewares/extract-jwt.ts | 11 ++++--- src/modules/auth/auth.service.ts | 32 ++++++++++++++----- .../auth/session/mongo.session.store.ts | 9 ++++++ .../auth/session/redis.session.store.ts | 17 ++++++++++ src/modules/auth/session/session.manager.ts | 13 +++++--- src/modules/auth/session/session.types.ts | 1 + 6 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/middlewares/extract-jwt.ts b/src/middlewares/extract-jwt.ts index cbccbea..e6d4ad3 100644 --- a/src/middlewares/extract-jwt.ts +++ b/src/middlewares/extract-jwt.ts @@ -22,7 +22,10 @@ export const extractJwt = async ( return next(); } - logger.debug({ token: token ? '[REDACTED]' : undefined }, 'JWT token found, verifying'); + logger.debug( + { token: token ? '[REDACTED]' : undefined }, + 'JWT token found, verifying', + ); const decode = await verifyToken(token); if (config.SET_SESSION && req.app.locals.sessionManager) { @@ -42,14 +45,14 @@ export const extractJwt = async ( if (!validation.isValid) { logger.warn( { sid: decode.sid, reason: validation.reason }, - 'Session validation failed' + 'Session validation failed', ); return next(); } logger.debug( { sid: decode.sid, userId: validation.session?.userId }, - 'Session validated successfully' + 'Session validated successfully', ); req.session = validation.session; } @@ -57,7 +60,7 @@ export const extractJwt = async ( req.user = decode; logger.debug( { userId: decode.sub, sid: decode.sid }, - 'JWT decoded and user attached to request' + 'JWT decoded and user attached to request', ); return next(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 1ab61c1..6dcdf9e 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -137,19 +137,27 @@ export const loginUserByEmail = async ( if (config.SET_SESSION) { const sessionManager = getSessionManager(); - const token = await signToken(jwtPayload); + // Step 1: Generate token with placeholder sid + jwtPayload.sid = 'pending'; + const placeholderToken = await signToken(jwtPayload); + // Step 2: Create session with placeholder token hash const session = await sessionManager.createSession({ userId: String(user._id), - token, + token: placeholderToken, metadata, }); sessionId = session.sessionId; + + // Step 3: Generate final token with real session ID jwtPayload.sid = sessionId; + const finalToken = await signToken(jwtPayload); + + // Step 4: Update session with final token hash + await sessionManager.updateSessionToken(sessionId, finalToken); - const tokenWithSession = await signToken(jwtPayload); - return { token: tokenWithSession, sessionId }; + return { token: finalToken, sessionId }; } const token = await signToken(jwtPayload); @@ -223,19 +231,27 @@ export const googleLogin = async ( if (config.SET_SESSION) { const sessionManager = getSessionManager(); - const token = await signToken(jwtPayload); + // Step 1: Generate token with placeholder sid + jwtPayload.sid = 'pending'; + const placeholderToken = await signToken(jwtPayload); + // Step 2: Create session with placeholder token hash const session = await sessionManager.createSession({ userId: String(user._id), - token, + token: placeholderToken, metadata, }); sessionId = session.sessionId; + + // Step 3: Generate final token with real session ID jwtPayload.sid = sessionId; + const finalToken = await signToken(jwtPayload); + + // Step 4: Update session with final token hash + await sessionManager.updateSessionToken(sessionId, finalToken); - const tokenWithSession = await signToken(jwtPayload); - return { user, token: tokenWithSession, sessionId }; + return { user, token: finalToken, sessionId }; } const token = await signToken(jwtPayload); diff --git a/src/modules/auth/session/mongo.session.store.ts b/src/modules/auth/session/mongo.session.store.ts index 1f66983..2523536 100644 --- a/src/modules/auth/session/mongo.session.store.ts +++ b/src/modules/auth/session/mongo.session.store.ts @@ -75,6 +75,15 @@ export class MongoSessionStore implements SessionStore { }); } + async updateTokenHash(sessionId: string, token: string): Promise { + const tokenHash = hashToken(token); + await SessionModel.findByIdAndUpdate(sessionId, { + tokenHash, + }); + + logger.debug({ sessionId }, 'Session token hash updated'); + } + async revoke(sessionId: string): Promise { await SessionModel.findByIdAndUpdate(sessionId, { isRevoked: true, diff --git a/src/modules/auth/session/redis.session.store.ts b/src/modules/auth/session/redis.session.store.ts index f18feb5..e92b6ab 100644 --- a/src/modules/auth/session/redis.session.store.ts +++ b/src/modules/auth/session/redis.session.store.ts @@ -116,6 +116,23 @@ export class RedisSessionStore implements SessionStore { } } + async updateTokenHash(sessionId: string, token: string): Promise { + const session = await this.get(sessionId); + if (!session) return; + + const tokenHash = hashToken(token); + session.tokenHash = tokenHash; + + const sessionKey = this.getSessionKey(sessionId); + const ttl = await this.redis.ttl(sessionKey); + + if (ttl > 0) { + await this.redis.set(sessionKey, JSON.stringify(session), 'EX', ttl); + } + + logger.debug({ sessionId }, 'Session token hash updated'); + } + async revoke(sessionId: string): Promise { const session = await this.get(sessionId); if (!session) return; diff --git a/src/modules/auth/session/session.manager.ts b/src/modules/auth/session/session.manager.ts index a59cf76..538edd1 100644 --- a/src/modules/auth/session/session.manager.ts +++ b/src/modules/auth/session/session.manager.ts @@ -90,11 +90,6 @@ export class SessionManager { const tokenHash = hashToken(token); - console.debug( - { tokenHash, sessionTokenHash: session.tokenHash }, - 'Token hash comparison', - ); - if (session.tokenHash !== tokenHash) { return { isValid: false, session, reason: 'invalid' }; } @@ -108,6 +103,14 @@ export class SessionManager { await this.store.touch(sessionId); } + async updateSessionToken(sessionId: string, token: string): Promise { + await this.store.updateTokenHash(sessionId, token); + + if (this.config.debug) { + logger.info({ sessionId }, 'Session token updated'); + } + } + async revokeSession(sessionId: string): Promise { await this.store.revoke(sessionId); diff --git a/src/modules/auth/session/session.types.ts b/src/modules/auth/session/session.types.ts index a79da56..1ed43e9 100644 --- a/src/modules/auth/session/session.types.ts +++ b/src/modules/auth/session/session.types.ts @@ -48,4 +48,5 @@ export interface SessionStore { revokeAllForUser(userId: string): Promise; pruneExpired(): Promise; close(): Promise; + updateTokenHash(sessionId: string, token: string): Promise; } From 3b70783551528e7ee50f89a2b14bd0684310108d Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:38:47 +0500 Subject: [PATCH 07/14] feat: implement session cleanup functionality with scheduled tasks and user session management --- src/config/env.ts | 2 + src/main.ts | 14 ++++ src/modules/auth/auth.service.ts | 6 ++ .../auth/session/mongo.session.store.ts | 36 ++++++++ .../auth/session/redis.session.store.ts | 83 +++++++++++++++++++ src/modules/auth/session/session.manager.ts | 34 ++++++++ src/modules/auth/session/session.types.ts | 11 +++ src/queues/session-cleanup.queue.ts | 72 ++++++++++++++++ 8 files changed, 258 insertions(+) create mode 100644 src/queues/session-cleanup.queue.ts diff --git a/src/config/env.ts b/src/config/env.ts index c45a540..1a24063 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -37,6 +37,8 @@ const configSchema = z.object({ SESSION_ROTATION: booleanString.default('false'), SESSION_COOKIE_NAME: z.string().default('session_id'), SESSION_DEBUG: booleanString.default('false'), + SESSION_CLEANUP_ENABLED: booleanString.default('true'), + SESSION_CLEANUP_CRON: z.string().default('0 * * * *'), SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), diff --git a/src/main.ts b/src/main.ts index 3dac586..93b4eea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,10 +13,24 @@ import { createOpsRoutes } from './routes/ops'; import apiRoutes from './routes/routes'; import globalErrorHandler from './utils/globalErrorHandler'; import { getRegisteredQueues } from './lib/queue.server'; +import { scheduleSessionCleanup } from './queues/session-cleanup.queue'; +import { getSessionManager } from './modules/auth/session/session.manager'; const bootstrapServer = async () => { await connectDatabase(); + if (config.SET_SESSION) { + try { + const sessionManager = getSessionManager(); + const stats = await sessionManager.cleanupSessions('revoked'); + logger.info({ stats }, 'Startup session cleanup completed'); + } catch (err) { + logger.warn({ err }, 'Startup session cleanup failed'); + } + } + + await scheduleSessionCleanup(); + const { app, server } = await initializeApp(); const io = setupSocketIo(server); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 6dcdf9e..f08ad24 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -137,6 +137,9 @@ export const loginUserByEmail = async ( if (config.SET_SESSION) { const sessionManager = getSessionManager(); + // Lazy cleanup: remove user's expired/revoked sessions + await sessionManager.cleanupUserSessions(String(user._id)); + // Step 1: Generate token with placeholder sid jwtPayload.sid = 'pending'; const placeholderToken = await signToken(jwtPayload); @@ -231,6 +234,9 @@ export const googleLogin = async ( if (config.SET_SESSION) { const sessionManager = getSessionManager(); + // Lazy cleanup: remove user's expired/revoked sessions + await sessionManager.cleanupUserSessions(String(user._id)); + // Step 1: Generate token with placeholder sid jwtPayload.sid = 'pending'; const placeholderToken = await signToken(jwtPayload); diff --git a/src/modules/auth/session/mongo.session.store.ts b/src/modules/auth/session/mongo.session.store.ts index 2523536..cf955d6 100644 --- a/src/modules/auth/session/mongo.session.store.ts +++ b/src/modules/auth/session/mongo.session.store.ts @@ -111,6 +111,42 @@ export class MongoSessionStore implements SessionStore { } } + async deleteRevoked(): Promise { + const result = await SessionModel.deleteMany({ isRevoked: true }); + const count = result.deletedCount || 0; + + if (count > 0) { + logger.info({ count }, 'Deleted revoked sessions'); + } + + return count; + } + + async deleteExpired(): Promise { + const result = await SessionModel.deleteMany({ + expiresAt: { $lt: new Date() }, + }); + const count = result.deletedCount || 0; + + if (count > 0) { + logger.info({ count }, 'Deleted expired sessions (TTL backup)'); + } + + return count; + } + + async deleteUserExpiredSessions(userId: string): Promise { + const result = await SessionModel.deleteMany({ + userId, + $or: [ + { isRevoked: true }, + { expiresAt: { $lt: new Date() } }, + ], + }); + + return result.deletedCount || 0; + } + async close(): Promise { // MongoDB connection is managed globally, no specific cleanup needed } diff --git a/src/modules/auth/session/redis.session.store.ts b/src/modules/auth/session/redis.session.store.ts index e92b6ab..e434a3f 100644 --- a/src/modules/auth/session/redis.session.store.ts +++ b/src/modules/auth/session/redis.session.store.ts @@ -175,6 +175,89 @@ export class RedisSessionStore implements SessionStore { logger.debug('Redis handles expiration automatically via TTL'); } + async deleteRevoked(): Promise { + const userKeys = await this.redis.keys(`${USER_SESSIONS_PREFIX}*`); + let deletedCount = 0; + + for (const userKey of userKeys) { + const sessionIds = await this.redis.zrange(userKey, 0, -1); + + for (const sessionId of sessionIds) { + const session = await this.get(sessionId); + if (session?.isRevoked) { + await this.redis.del(this.getSessionKey(sessionId)); + await this.redis.zrem(userKey, sessionId); + deletedCount++; + } + } + } + + if (deletedCount > 0) { + logger.info({ count: deletedCount }, 'Deleted revoked sessions'); + } + + return deletedCount; + } + + async deleteExpired(): Promise { + const userKeys = await this.redis.keys(`${USER_SESSIONS_PREFIX}*`); + let deletedCount = 0; + + for (const userKey of userKeys) { + const sessionIds = await this.redis.zrange(userKey, 0, -1); + + for (const sessionId of sessionIds) { + const exists = await this.redis.exists(this.getSessionKey(sessionId)); + if (!exists) { + await this.redis.zrem(userKey, sessionId); + deletedCount++; + } + } + } + + if (deletedCount > 0) { + logger.info({ count: deletedCount }, 'Cleaned up expired session references'); + } + + return deletedCount; + } + + async deleteUserExpiredSessions(userId: string): Promise { + const userKey = this.getUserSessionsKey(userId); + const sessionIds = await this.redis.zrange(userKey, 0, -1); + let deletedCount = 0; + + for (const sessionId of sessionIds) { + const session = await this.get(sessionId); + if (!session || session.isRevoked || new Date() > session.expiresAt) { + await this.redis.del(this.getSessionKey(sessionId)); + await this.redis.zrem(userKey, sessionId); + deletedCount++; + } + } + + return deletedCount; + } + + async cleanupOrphanedKeys(): Promise { + const userKeys = await this.redis.keys(`${USER_SESSIONS_PREFIX}*`); + let deletedCount = 0; + + for (const userKey of userKeys) { + const count = await this.redis.zcard(userKey); + if (count === 0) { + await this.redis.del(userKey); + deletedCount++; + } + } + + if (deletedCount > 0) { + logger.info({ count: deletedCount }, 'Deleted orphaned user session keys'); + } + + return deletedCount; + } + async close(): Promise { // Redis connection is managed globally, no specific cleanup needed } diff --git a/src/modules/auth/session/session.manager.ts b/src/modules/auth/session/session.manager.ts index 538edd1..4ee5f31 100644 --- a/src/modules/auth/session/session.manager.ts +++ b/src/modules/auth/session/session.manager.ts @@ -4,6 +4,7 @@ import type { CreateSessionInput, SessionRecord, SessionValidationResult, + CleanupStats, } from './session.types'; import { MongoSessionStore } from './mongo.session.store'; import { RedisSessionStore } from './redis.session.store'; @@ -135,6 +136,39 @@ export class SessionManager { await this.store.pruneExpired(); } + async cleanupSessions(type: 'full' | 'revoked' | 'expired'): Promise { + const stats: CleanupStats = { + revokedDeleted: 0, + expiredDeleted: 0, + orphanedKeysDeleted: 0, + totalProcessed: 0, + }; + + if (type === 'full' || type === 'revoked') { + stats.revokedDeleted = await this.store.deleteRevoked(); + } + + if (type === 'full' || type === 'expired') { + stats.expiredDeleted = await this.store.deleteExpired(); + } + + if (type === 'full' && this.config.driver === 'redis') { + stats.orphanedKeysDeleted = await this.store.cleanupOrphanedKeys?.() || 0; + } + + stats.totalProcessed = stats.revokedDeleted + stats.expiredDeleted + (stats.orphanedKeysDeleted || 0); + + if (this.config.debug) { + logger.info({ stats }, 'Session cleanup stats'); + } + + return stats; + } + + async cleanupUserSessions(userId: string): Promise { + return this.store.deleteUserExpiredSessions?.(userId) || 0; + } + async cleanup(): Promise { await this.store.close(); } diff --git a/src/modules/auth/session/session.types.ts b/src/modules/auth/session/session.types.ts index 1ed43e9..450a77b 100644 --- a/src/modules/auth/session/session.types.ts +++ b/src/modules/auth/session/session.types.ts @@ -49,4 +49,15 @@ export interface SessionStore { pruneExpired(): Promise; close(): Promise; updateTokenHash(sessionId: string, token: string): Promise; + deleteRevoked(): Promise; + deleteExpired(): Promise; + deleteUserExpiredSessions?(userId: string): Promise; + cleanupOrphanedKeys?(): Promise; +} + +export interface CleanupStats { + revokedDeleted: number; + expiredDeleted: number; + orphanedKeysDeleted?: number; + totalProcessed: number; } diff --git a/src/queues/session-cleanup.queue.ts b/src/queues/session-cleanup.queue.ts new file mode 100644 index 0000000..572ef44 --- /dev/null +++ b/src/queues/session-cleanup.queue.ts @@ -0,0 +1,72 @@ +import { Queue } from '../lib/queue.server'; +import { getSessionManager } from '../modules/auth/session/session.manager'; +import { createChildLogger } from '../observability/logger'; +import config from '../config/env'; + +const logger = createChildLogger({ context: 'SessionCleanupQueue' }); + +interface SessionCleanupPayload { + type: 'full' | 'revoked' | 'expired'; +} + +export const SessionCleanupQueue = Queue( + 'SessionCleanupQueue', + async (job) => { + if (!config.SET_SESSION) { + logger.debug('Session management disabled, skipping cleanup'); + return { skipped: true }; + } + + try { + const { data } = job; + const sessionManager = getSessionManager(); + + logger.info({ type: data.type }, 'Starting session cleanup'); + + const startTime = Date.now(); + const stats = await sessionManager.cleanupSessions(data.type); + const duration = Date.now() - startTime; + + logger.info( + { + ...stats, + duration, + type: data.type + }, + 'Session cleanup completed' + ); + + return stats; + } catch (err) { + logger.error({ err }, 'Session cleanup failed'); + throw err; + } + }, +); + +export async function scheduleSessionCleanup(): Promise { + if (!config.SET_SESSION || !config.SESSION_CLEANUP_ENABLED) { + logger.info('Session cleanup disabled, skipping schedule'); + return; + } + + try { + await SessionCleanupQueue.add( + 'recurring-cleanup', + { type: 'full' }, + { + repeat: { + pattern: config.SESSION_CLEANUP_CRON, + }, + jobId: 'session-cleanup-recurring', + } + ); + + logger.info( + { pattern: config.SESSION_CLEANUP_CRON }, + 'Session cleanup job scheduled' + ); + } catch (err) { + logger.error({ err }, 'Failed to schedule session cleanup job'); + } +} From 507bdd034ea25b8dc0578d7a936af2194d69832a Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:40:37 +0500 Subject: [PATCH 08/14] refactor: enhance session management logging and code readability --- src/modules/auth/session/session.manager.ts | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/modules/auth/session/session.manager.ts b/src/modules/auth/session/session.manager.ts index 4ee5f31..3631344 100644 --- a/src/modules/auth/session/session.manager.ts +++ b/src/modules/auth/session/session.manager.ts @@ -49,10 +49,12 @@ export class SessionManager { if (sessions.length >= this.config.maxPerUser) { const oldestSession = sessions[sessions.length - 1]; await this.store.revoke(oldestSession.sessionId); - logger.debug( - { userId: input.userId, revokedSessionId: oldestSession.sessionId }, - 'Evicted oldest session due to max limit', - ); + if (this.config.debug) { + logger.debug( + { userId: input.userId, revokedSessionId: oldestSession.sessionId }, + 'Evicted oldest session due to max limit', + ); + } } const session = await this.store.create(input); @@ -106,7 +108,7 @@ export class SessionManager { async updateSessionToken(sessionId: string, token: string): Promise { await this.store.updateTokenHash(sessionId, token); - + if (this.config.debug) { logger.info({ sessionId }, 'Session token updated'); } @@ -136,7 +138,9 @@ export class SessionManager { await this.store.pruneExpired(); } - async cleanupSessions(type: 'full' | 'revoked' | 'expired'): Promise { + async cleanupSessions( + type: 'full' | 'revoked' | 'expired', + ): Promise { const stats: CleanupStats = { revokedDeleted: 0, expiredDeleted: 0, @@ -153,10 +157,14 @@ export class SessionManager { } if (type === 'full' && this.config.driver === 'redis') { - stats.orphanedKeysDeleted = await this.store.cleanupOrphanedKeys?.() || 0; + stats.orphanedKeysDeleted = + (await this.store.cleanupOrphanedKeys?.()) || 0; } - stats.totalProcessed = stats.revokedDeleted + stats.expiredDeleted + (stats.orphanedKeysDeleted || 0); + stats.totalProcessed = + stats.revokedDeleted + + stats.expiredDeleted + + (stats.orphanedKeysDeleted || 0); if (this.config.debug) { logger.info({ stats }, 'Session cleanup stats'); From 2adef36fa3a0b18c86e7259b10cbb459fa01fc89 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:46:09 +0500 Subject: [PATCH 09/14] chore: add AGENTS.md for repository guidelines and project structure documentation --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f3a29d1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `src/` contains the runtime code: `app/` for Express setup, `modules/` for domain logic, `lib/` for integrations (database, queues, email), and `routes/` for HTTP wiring. +- Shared utilities live under `src/common/`, `src/utils/`, and `src/observability/`; reuse these helpers before creating new ones to maintain consistency. +- Email assets sit in `src/email/templates/` and static assets under `public/`; align template updates with backend releases. +- Configuration sources reside in `src/config/` with environment schemas; update these alongside any new `.env` keys. + +## Build, Test & Development Commands + +- `docker compose up -d` launches MongoDB and Redis locally; run it before starting the app. +- `pnpm dev` runs the backend watcher and email preview server concurrently for day-to-day development. +- `pnpm build` compiles TypeScript via `tsup` into `dist/`, and `pnpm start` executes the resulting bundle. +- `pnpm start:dev`, `pnpm start:prod`, and `pnpm start:local` boot the server against the matching `.env` file through `dotenv-cli`. +- `pnpm typecheck` and `pnpm lint` (or `pnpm lint:fix`) gate contributions by catching type and style regressions. + +## Coding Style & Naming Conventions + +- Stick to TypeScript with 2-space indentation; follow existing import ordering and prefer named exports from shared modules. +- Use camelCase for variables/functions, PascalCase for classes, and kebab-case for file names within feature folders. +- Run ESLint before submitting; lint rules warn on `any`, enforce unused-variable cleanup, and integrate with Prettier formatting defaults. + +## Commit & Pull Request Guidelines + +- Follow Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`) as reflected in recent history; scope messages to a single change. +- Each PR should describe the problem, the solution, and rollout notes (migrations, feature flags, or ops steps) in the opening comment. +- Link relevant issues, include screenshots or logs for ops-facing changes, and mention required env vars when introducing configuration. + +## Security & Configuration Tips + +- Never commit secrets; derive new keys in `.env.sample` and validate them in `src/config/env`. +- Keep admin surfaces (`/admin/queues`, `/ops/*`) behind authentication in production deployments and document access controls when altering them. From ead344a623717a7257d3ac471f00c9bd193b2372 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:51:49 +0500 Subject: [PATCH 10/14] refactor: standardize formatting in ResetPassword email template for improved readability --- src/email/templates/ResetPassword.tsx | 154 +++++++++++++------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/src/email/templates/ResetPassword.tsx b/src/email/templates/ResetPassword.tsx index 257b909..8a09af0 100644 --- a/src/email/templates/ResetPassword.tsx +++ b/src/email/templates/ResetPassword.tsx @@ -1,108 +1,108 @@ import { - Body, - Button, - Container, - Head, - Heading, - Html, - Preview, - Section, - Text, -} from "@react-email/components"; -import * as React from "react"; + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from '@react-email/components'; +import * as React from 'react'; interface ResetPasswordEmailProps { - userName: string; - resetLink: string; + userName: string; + resetLink: string; } export const ResetPasswordEmail = ({ - userName, - resetLink, + userName, + resetLink, }: ResetPasswordEmailProps) => { - return ( - - - Reset your password - - - Password Reset Request - Hi {userName}, - - We received a request to reset your password. Click the button below - to create a new password: - -
- -
- - If you didn't request this password reset, you can safely ignore - this email. - - - This link will expire in 1 hour for security reasons. - - - If you're having trouble clicking the button, copy and paste this - URL into your web browser: {resetLink} - -
- - - ); + return ( + + + Reset your password + + + Password Reset Request + Hi {userName}, + + We received a request to reset your password. Click the button below + to create a new password: + +
+ +
+ + If you didn't request this password reset, you can safely ignore + this email. + + + This link will expire in 1 hour for security reasons. + + + If you're having trouble clicking the button, copy and paste this + URL into your web browser: {resetLink} + +
+ + + ); }; const main = { - backgroundColor: "#f6f9fc", - fontFamily: - '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + backgroundColor: '#f6f9fc', + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', }; const container = { - backgroundColor: "#ffffff", - margin: "0 auto", - padding: "20px 0 48px", - marginBottom: "64px", + backgroundColor: '#ffffff', + margin: '0 auto', + padding: '20px 0 48px', + marginBottom: '64px', }; const heading = { - fontSize: "24px", - letterSpacing: "-0.5px", - lineHeight: "1.3", - fontWeight: "400", - color: "#484848", - padding: "17px 0 0", + fontSize: '24px', + letterSpacing: '-0.5px', + lineHeight: '1.3', + fontWeight: '400', + color: '#484848', + padding: '17px 0 0', }; const text = { - margin: "0 0 12px", - fontSize: "16px", - lineHeight: "24px", - color: "#484848", + margin: '0 0 12px', + fontSize: '16px', + lineHeight: '24px', + color: '#484848', }; const buttonContainer = { - padding: "27px 0 27px", + padding: '27px 0 27px', }; const button = { - backgroundColor: "#5469d4", - borderRadius: "4px", - color: "#ffffff", - fontSize: "16px", - textDecoration: "none", - textAlign: "center" as const, - display: "block", - padding: "12px 20px", + backgroundColor: '#5469d4', + borderRadius: '4px', + color: '#ffffff', + fontSize: '16px', + textDecoration: 'none', + textAlign: 'center' as const, + display: 'block', + padding: '12px 20px', }; const footer = { - fontSize: "13px", - lineHeight: "24px", - color: "#777", - padding: "0 20px", + fontSize: '13px', + lineHeight: '24px', + color: '#777', + padding: '0 20px', }; export default ResetPasswordEmail; From becdbc99f26afc4ba2b04b97f7acc297ae923833 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:56:43 +0500 Subject: [PATCH 11/14] chore: update OpenAPI generation script to use dotenv for environment configuration and add openapi.yml to .gitignore --- .gitignore | 2 ++ package.json | 2 +- scripts/gen-openapi.ts | 10 +++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 9753d7f..b1ecf6d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ dist .claude .dump + +openapi.yml \ No newline at end of file diff --git a/package.json b/package.json index 5948c2f..1f757d5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix", - "openapi": "tsx scripts/gen-openapi.ts", + "openapi": "dotenv -e .env.development -- tsx scripts/gen-openapi.ts", "seeder": "tsx ./src/seeder.ts", "email:dev": "email dev --dir ./src/email/templates", "tbk": "tsx bin/tbk" diff --git a/scripts/gen-openapi.ts b/scripts/gen-openapi.ts index 39412c5..47dad05 100644 --- a/scripts/gen-openapi.ts +++ b/scripts/gen-openapi.ts @@ -2,18 +2,22 @@ import fs from 'fs/promises'; import path from 'path'; +import 'src/openapi/zod-extend'; +import 'src/routes/routes'; import { convertDocumentationToYaml } from '../src/openapi/swagger-doc-generator'; async function generateOpenApiSpec() { try { console.log('Generating OpenAPI specification...'); - + const yamlContent = convertDocumentationToYaml(); - + const outputPath = path.join(process.cwd(), 'openapi.yml'); await fs.writeFile(outputPath, yamlContent, 'utf-8'); - + console.log(`✓ OpenAPI spec generated successfully at: ${outputPath}`); + + process.exit(0); } catch (error) { console.error('Failed to generate OpenAPI spec:', error); process.exit(1); From f45c3c4b104f7d1da0c9eda812bb63c99780b65e Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 08:58:09 +0500 Subject: [PATCH 12/14] chore: remove session management plugin plan document as it is no longer needed --- session-feature.md | 83 ---------------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 session-feature.md diff --git a/session-feature.md b/session-feature.md deleted file mode 100644 index 5225e17..0000000 --- a/session-feature.md +++ /dev/null @@ -1,83 +0,0 @@ -# Auth Session Management Plugin Plan - -## Objectives -- Deliver server-managed session lifecycle integrated with existing auth workflows (login, logout, password changes) while keeping developer ergonomics high. -- Support MongoDB and Redis storage backends behind a single interface selectable via configuration or plugin options. -- Enforce configurable session TTL and per-user session limits throughout the authentication lifecycle. -- Implement everything in-house (no new third-party session packages) while reusing the toolkit’s existing infrastructure. - -## Assumptions & Constraints -- Current JWT-based authentication remains; sessions will bind issued JWTs to server-side state via a `sid` claim. -- `mongoose` and `ioredis` are available; no additional runtime dependencies will be introduced. -- Environment schema already contains `SESSION_EXPIRES_IN`; new session-specific knobs will extend this configuration. -- Plugin system continues to orchestrate setup, so the session manager will be wired through the existing `authPlugin`. - -## Architecture Overview -1. Session configuration object derived from env vars and plugin overrides with sane defaults. -2. Shared `SessionRecord`/`SessionMetadata` types and Zod schemas under `src/modules/auth/session`. -3. `SessionStore` interface exposing CRUD operations plus a factory that instantiates Mongo or Redis implementations. -4. `MongoSessionStore` backed by a new Mongoose model with TTL and user-scoped indexes. -5. `RedisSessionStore` powered by `ioredis`, using JSON blobs and per-user sorted sets to enforce limits. -6. `SessionManager` coordinating store operations, TTL enforcement, eviction policy, and developer-facing helpers. -7. Extended auth plugin that registers the manager on `app.locals`, exposes configuration, and hooks cleanup on shutdown. -8. Middleware and services updated so JWT extraction requires an active session before requests reach business logic. - -## Implementation Steps -### 1. Configuration & Typings -- Extend `src/config/env.ts` with `SESSION_DRIVER`, `SESSION_MAX_PER_USER`, `SESSION_IDLE_TTL`, `SESSION_ABSOLUTE_TTL`, `SESSION_ROTATION`, `SESSION_COOKIE_NAME`, and optional `SESSION_DEBUG`. -- Update `modules.d.ts` (and any config typings) so new env vars are strongly typed. -- Document defaults in env templates and ensure `config.SET_SESSION` semantics remain backward compatible. - -### 2. Session Domain Modeling -- Create `src/modules/auth/session/session.types.ts` defining `SessionRecord`, `SessionMetadata`, and `SessionValidationResult`. -- Add `session.schema.ts` with Zod schemas for session creation, validation, and plugin options. -- Introduce helper utilities (e.g., `generateSessionId`, `buildSessionCookieOptions`) under `session.utils.ts`. - -### 3. Store Implementations -- Mongo: add `session.model.ts` with schema (`sessionId`, `userId`, hashed token, metadata, `expiresAt`, `lastSeen`) and TTL indexes; implement `mongo.session.store.ts`. -- Redis: add `redis.session.store.ts` storing sessions under `session:` with expiry and maintaining `user_sessions:` sorted sets for ordering and eviction. -- Ensure both stores expose `create`, `get`, `listByUser`, `touch`, `revoke`, `revokeAllForUser`, `pruneExpired`, and share error semantics. - -### 4. Session Manager & Plugin Wiring -- Implement `SessionManager` in `session.manager.ts` to wrap a store, normalize config, enforce max-session policies, and provide developer-friendly methods. -- Extend `authPlugin` options to accept session config overrides, instantiate `SessionManager`, set `app.locals.sessionManager`, and register `onShutdown` cleanup. -- Optionally expose lightweight factory (`getSessionManager(app)`) for other modules. - -### 5. Auth Lifecycle Integration -- Update `loginUserByEmail` and Google login flows to create sessions after credential validation, embedding `sid` in JWT payloads and attaching secure cookies when `SET_SESSION` is true. -- Adjust logout handler to extract current session and revoke it before clearing cookies. -- Revoke all sessions on password reset/change to mitigate credential compromise. -- Add management endpoints (list/revoke sessions) within `auth.router.ts` guarded by authentication. - -### 6. Middleware & Request Context -- Enhance `extract-jwt` middleware to require `sid`, verify session state, attach `req.session`, and short-circuit on invalid/expired sessions. -- Add guard middleware (`requireActiveSession`) for routes needing hard session enforcement. -- Update Express typings so `Request` includes optional `session: SessionRecord`. - -### 7. Session Maintenance & Observability -- Rely on Mongo TTL indexes for pruning and add lazy pruning hooks for Redis (plus optional timed cleanup driven by config). -- Emit structured logs via existing logger for session issuance, eviction, revocation, and anomalies. -- Hook into observability plugin to expose counters/gauges (active sessions per user, revocations, evictions) when metrics are enabled. - -### 8. Testing & Validation -- Write unit tests (using `node:test` or existing setup) for `SessionManager`, Mongo store (with in-memory Mongo or mocks), and Redis store (with mock client). -- Add integration tests covering login/session issuance, max-session eviction, logout revocation, and request rejection when sessions are revoked or expired. -- Include regression tests for password reset flows to ensure sessions are properly purged. - -## Security & Risk Considerations -- Hash session tokens (e.g., SHA-256) before persistence to protect against data leaks. -- Generate session IDs with `crypto.randomUUID` or secure random bytes; avoid sequential IDs. -- Rotate session IDs on privilege changes when `SESSION_ROTATION` is enabled. -- Ensure cookies remain `httpOnly`, `secure` in production, and `sameSite` aligned with existing `COOKIE_CONFIG`. -- Fail closed if the store is unavailable; surface actionable logs and metrics for operators. - -## Developer Experience & Documentation -- Update docs with configuration reference, code samples (`req.session`, revoking sessions), and Mongo vs Redis trade-offs. -- Provide quick-start snippets showing how to enable the plugin in custom app setups. -- Optionally add helper CLI commands under `bin/tbk` for inspecting or clearing sessions during development. -- Note migration steps for existing deployments (new env vars, database indexes, rolling restart considerations). - -## Rollout & Follow-up -- Deliver in stages: default to Mongo driver first, then enable Redis once verified. -- Provide migration script or documentation to create Mongo indexes and clear legacy session data where applicable. -- Monitor logs/metrics post-merge, gather developer feedback on ergonomics, and iterate on defaults or DX improvements. From 91a3e8e35d8d67427419d2002d33b017fc5cfa45 Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 09:04:04 +0500 Subject: [PATCH 13/14] chore: standardize quote style in ESLint configuration for consistency --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index c6aa982..1c39b39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,7 @@ export default [ ...tseslint.configs.recommended, { rules: { - "@typescript-eslint/no-explicit-any": "warn", + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': [ 'error', { From 35a23e7b9b2153e0dcc4b2c5857dce3467d671ae Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Thu, 9 Oct 2025 09:27:46 +0500 Subject: [PATCH 14/14] chore: update new module creation guide to include tbk CLI usage and customization steps --- .cursor/rules/new-module.mdc | 572 +++++++++++----------------- src/main.ts | 1 - src/modules/auth/auth.controller.ts | 6 - 3 files changed, 215 insertions(+), 364 deletions(-) diff --git a/.cursor/rules/new-module.mdc b/.cursor/rules/new-module.mdc index bfc2938..41fd311 100644 --- a/.cursor/rules/new-module.mdc +++ b/.cursor/rules/new-module.mdc @@ -1,432 +1,290 @@ --- -description: Step-by-step guide for creating a new module +description: Step-by-step guide for creating a new module using the tbk CLI --- # Creating a New Module -Follow these steps to create a new module in the backend toolkit. +This guide shows how to create a new module using the `tbk` CLI tool and customize it according to project patterns. -## Step 1: Create Module Directory +## Quick Start + +### Step 1: Generate Module Scaffolding + +Use the `tbk` CLI to generate all module files automatically: ```bash -mkdir -p src/modules/module-name +pnpm exec tbk generate:module ``` -## Step 2: Create Model (`module.model.ts`) +Or with custom API path prefix: -```typescript -import { Schema, model, type Document } from 'mongoose'; - -export interface IModule extends Document { - name: string; - description: string; - status: 'active' | 'inactive'; - createdAt: Date; - updatedAt: Date; -} - -const schema = new Schema( - { - name: { type: String, required: true }, - description: { type: String }, - status: { - type: String, - enum: ['active', 'inactive'], - default: 'active', - }, - }, - { timestamps: true }, -); +```bash +pnpm exec tbk generate:module --path /api/v1 +``` + +**Example:** -export const ModuleModel = model('Module', schema); +```bash +pnpm exec tbk generate:module product +# Creates: src/modules/product/ with all required files ``` -## Step 3: Create DTOs (`module.dto.ts`) +This creates a complete module structure: -```typescript -export interface CreateModuleInput { - name: string; - description?: string; -} - -export interface UpdateModuleInput { - name?: string; - description?: string; - status?: 'active' | 'inactive'; -} - -export interface ModuleResponse { - id: string; - name: string; - description?: string; - status: string; - createdAt: string; - updatedAt: string; -} +``` +src/modules// +├── .dto.ts # TypeScript types and Zod schemas +├── .model.ts # Mongoose model +├── .schema.ts # Request/response validation schemas +├── .services.ts # Business logic and database operations +├── .controller.ts # HTTP request handlers +└── .router.ts # MagicRouter route definitions ``` -## Step 4: Create Schemas (`module.schema.ts`) +### Step 2: Customize Module Files -```typescript -import { z } from 'zod'; -import '@/openapi/zod-extend'; - -const ModuleResponseSchema = z.object({ - id: z.string().openapi({ example: '507f1f77bcf86cd799439011' }), - name: z.string().openapi({ example: 'Module Name' }), - description: z.string().optional(), - status: z.enum(['active', 'inactive']), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -const CreateModuleSchema = z.object({ - name: z.string().min(2), - description: z.string().optional(), -}); - -const UpdateModuleSchema = z.object({ - name: z.string().min(2).optional(), - description: z.string().optional(), - status: z.enum(['active', 'inactive']).optional(), -}); - -const ParamsSchema = z.object({ - id: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid ID'), -}); - -const QuerySchema = z.object({ - page: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) - .optional(), - limit: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) - .optional(), - search: z.string().optional(), -}); - -export const listSchema = { - request: { query: QuerySchema }, - response: { - 200: z.object({ - data: z.array(ModuleResponseSchema), - pagination: z.object({ - page: z.number(), - limit: z.number(), - total: z.number(), - }), - }), - }, -}; +The generated files follow project patterns but need customization for your specific use case. Refer to these rules for detailed patterns: -export const getSchema = { - request: { params: ParamsSchema }, - response: { - 200: ModuleResponseSchema, - 404: z.object({ message: z.string() }), - }, -}; +#### 2.1 Update Model (`.model.ts`) -export const createSchema = { - request: { body: CreateModuleSchema }, - response: { - 201: ModuleResponseSchema, - 400: z.object({ message: z.string() }), - }, -}; +- **Rule:** `@models` +- Add/modify fields in the Mongoose schema +- Define indexes, virtuals, and methods +- Configure schema options (timestamps, etc.) -export const updateSchema = { - request: { - params: ParamsSchema, - body: UpdateModuleSchema, - }, - response: { - 200: ModuleResponseSchema, - 404: z.object({ message: z.string() }), - }, -}; +#### 2.2 Update DTOs (`.dto.ts`) -export const deleteSchema = { - request: { params: ParamsSchema }, - response: { - 200: z.object({ message: z.string() }), - 404: z.object({ message: z.string() }), - }, -}; -``` +- Define input/output types using Zod +- Use `definePaginatedResponse` from `common.utils` for list endpoints +- Export type definitions for type safety -## Step 5: Create Service (`module.service.ts`) +#### 2.3 Update Validation Schemas (`.schema.ts`) -```typescript -import { ModuleModel } from './module.model'; -import type { CreateModuleInput, UpdateModuleInput } from './module.dto'; - -export const findAll = async (options: { - page: number; - limit: number; - search?: string; -}) => { - const { page, limit, search } = options; - const skip = (page - 1) * limit; - - const query = search ? { name: { $regex: search, $options: 'i' } } : {}; - - const [data, total] = await Promise.all([ - ModuleModel.find(query).skip(skip).limit(limit).lean(), - ModuleModel.countDocuments(query), - ]); +- **Rule:** `@schemas` +- Add/modify Zod validation for create/update operations +- Configure query parameter validation (pagination, search, filters) +- Define proper error messages and transformations - return { - data: data.map((item) => ({ - id: item._id.toString(), - name: item.name, - description: item.description, - status: item.status, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - })), - pagination: { - page, - limit, - total, - }, - }; -}; +#### 2.4 Update Services (`.services.ts`) -export const findById = async (id: string) => { - const item = await ModuleModel.findById(id).lean(); - - if (!item) { - return null; - } - - return { - id: item._id.toString(), - name: item.name, - description: item.description, - status: item.status, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - }; -}; +- **Rule:** `@services` +- Implement business logic +- Handle database operations using the model +- Use proper error handling (throw errors with descriptive messages) +- Optimize queries with proper filtering, pagination, and sorting -export const create = async (data: CreateModuleInput) => { - const item = await ModuleModel.create(data); - - return { - id: item._id.toString(), - name: item.name, - description: item.description, - status: item.status, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - }; -}; +#### 2.5 Update Controller (`.controller.ts`) -export const update = async (id: string, data: UpdateModuleInput) => { - const item = await ModuleModel.findByIdAndUpdate( - id, - { $set: data }, - { new: true }, - ).lean(); - - if (!item) { - return null; - } - - return { - id: item._id.toString(), - name: item.name, - description: item.description, - status: item.status, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - }; -}; +- **Rule:** `@controllers` +- Handle HTTP request/response +- Use `successResponse` from `utils/api.utils` +- Use proper HTTP status codes from `http-status-codes` +- Keep controllers thin - delegate logic to services -export const remove = async (id: string) => { - const item = await ModuleModel.findByIdAndDelete(id); - return !!item; -}; -``` +#### 2.6 Update Router (`.router.ts`) -## Step 6: Create Controller (`module.controller.ts`) +- **Rule:** `@routing` +- Configure MagicRouter routes +- Add proper middleware (authentication, authorization) +- Use `canAccess()` for protected routes +- Define request validation schemas + +### Step 3: Register Router + +Add the router to `src/routes/routes.ts`: ```typescript -import type { Request, Response } from 'express'; -import * as service from './module.service'; +import Router from '@/modules//.router'; -export const list = async (req: Request, res: Response) => { - const { page = 1, limit = 10, search } = req.query; +// In the registerRoutes function or where routes are registered +app.use(Router); +``` - const result = await service.findAll({ - page: Number(page), - limit: Number(limit), - search: search as string, - }); +### Step 4: Test the Module - return res.status(200).json(result); -}; +1. Start development server: -export const getById = async (req: Request, res: Response) => { - const { id } = req.params; + ```bash + pnpm dev + ``` - const item = await service.findById(id); +2. Visit Swagger UI: - if (!item) { - return res.status(404).json({ message: 'Item not found' }); - } + ``` + http://localhost:3000/api-docs + ``` - return res.status(200).json(item); -}; +3. Test all endpoints using the interactive API documentation -export const create = async (req: Request, res: Response) => { - const data = req.body; +4. Verify: + - All CRUD operations work correctly + - Validation catches invalid inputs + - Error responses are properly formatted + - OpenAPI documentation is accurate - const item = await service.create(data); +## Module File Responsibilities - return res.status(201).json(item); -}; +### 1. DTO (`*.dto.ts`) -export const update = async (req: Request, res: Response) => { - const { id } = req.params; - const data = req.body; +- Zod schemas for input/output validation +- TypeScript type definitions +- Paginated response schemas - const item = await service.update(id, data); +### 2. Model (`*.model.ts`) - if (!item) { - return res.status(404).json({ message: 'Item not found' }); - } +- Mongoose schema definition +- Database field types and constraints +- Indexes and virtuals +- Model interface extending Document - return res.status(200).json(item); -}; +### 3. Schema (`*.schema.ts`) -export const remove = async (req: Request, res: Response) => { - const { id } = req.params; +- Request validation schemas (create, update, query) +- Zod transformations and refinements +- Type exports for controllers - const deleted = await service.remove(id); +### 4. Services (`*.services.ts`) - if (!deleted) { - return res.status(404).json({ message: 'Item not found' }); - } +- Business logic implementation +- Database operations (CRUD) +- Data transformation +- Error handling - return res.status(200).json({ message: 'Item deleted successfully' }); -}; +### 5. Controller (`*.controller.ts`) + +- HTTP request/response handling +- Call service methods +- Return standardized responses +- Handle HTTP status codes + +### 6. Router (`*.router.ts`) + +- Route definitions using MagicRouter +- Middleware configuration +- Request validation binding +- OpenAPI metadata + +## Best Practices + +### Follow Project Patterns + +- **Always** use MagicRouter for automatic OpenAPI generation +- **Never** use plain Express `app.get()` or `router.get()` +- **Always** validate requests with Zod schemas +- **Always** use TypeScript strict mode - no `any` types + +### Error Handling + +- Throw descriptive errors in services +- Let global error handler format responses +- Use proper HTTP status codes + +### Type Safety + +- Export and use TypeScript types from DTOs +- Use Zod's `.infer` for type generation +- Keep runtime validation and TypeScript types in sync + +### Code Organization + +- Keep controllers thin - delegate to services +- Put business logic in services +- Use common utilities for shared functionality +- Follow the single responsibility principle + +## Advanced Customization + +### Adding Authentication + +Use `canAccess()` middleware in router: + +```typescript +import { canAccess } from '@/middlewares/can-access'; + +router.post( + '/', + { requestType: { body: createSchema } }, + canAccess(), // Add authentication + handleCreate, +); ``` -## Step 7: Create Router (`module.router.ts`) +### Adding Custom Middleware ```typescript -import { MagicRouter } from '@/openapi/magic-router'; -import { extractJwtSchema } from '@/middlewares/extract-jwt-schema'; -import * as controller from './module.controller'; -import * as schemas from './module.schema'; - -const router = MagicRouter(); - -router.get({ - path: '/modules', - schemas: schemas.listSchema, - controller: controller.list, - tags: ['Module'], - summary: 'List all modules', - middlewares: [extractJwtSchema], -}); - -router.get({ - path: '/modules/:id', - schemas: schemas.getSchema, - controller: controller.getById, - tags: ['Module'], - summary: 'Get module by ID', - middlewares: [extractJwtSchema], -}); - -router.post({ - path: '/modules', - schemas: schemas.createSchema, - controller: controller.create, - tags: ['Module'], - summary: 'Create new module', - middlewares: [extractJwtSchema], -}); - -router.put({ - path: '/modules/:id', - schemas: schemas.updateSchema, - controller: controller.update, - tags: ['Module'], - summary: 'Update module', - middlewares: [extractJwtSchema], -}); - -router.delete({ - path: '/modules/:id', - schemas: schemas.deleteSchema, - controller: controller.remove, - tags: ['Module'], - summary: 'Delete module', - middlewares: [extractJwtSchema], -}); - -export default router; +router.get( + '/:id', + {}, + canAccess(), + customMiddleware, // Your custom middleware + handleGetById, +); ``` -## Step 8: Register Router +### Adding Indexes -Add to [src/routes/routes.ts](mdc:src/routes/routes.ts): +In model file: ```typescript -import moduleRouter from '@/modules/module-name/module.router'; - -// In the registerRoutes function -app.use('/api', moduleRouter); +schema.index({ field1: 1, field2: -1 }); +schema.index({ searchField: 'text' }); // Text search ``` -## Step 9: Test +### Adding Relationships -1. Start the server: `pnpm run dev` -2. Visit API docs: `http://localhost:3000/api-docs` -3. Test endpoints using Swagger UI +```typescript +// In model +field: { type: Schema.Types.ObjectId, ref: 'OtherModel' } + +// In service +const result = await Model.find().populate('field'); +``` -## Optional: Add to Seeder +## Optional: Add Seeder -If you want seed data, create `module.seeder.ts`: +Create `.seeder.ts` for test data: ```typescript -import { ModuleModel } from './module.model'; - -export const seedModules = async () => { - const count = await ModuleModel.countDocuments(); +import Model from './.model'; - if (count > 0) { - console.log('Modules already seeded'); - return; - } +export const seed = async () => { + const count = await Model.countDocuments(); + if (count > 0) return; - await ModuleModel.create([ - { name: 'Module 1', description: 'First module' }, - { name: 'Module 2', description: 'Second module' }, + await Model.create([ + { /* seed data */ }, ]); - console.log('Modules seeded'); + console.log(' seeded'); }; ``` -Register in main seeder script. - ## Checklist -- [ ] Created model with proper schema -- [ ] Created DTOs for type safety -- [ ] Created Zod schemas with OpenAPI metadata -- [ ] Created service with business logic -- [ ] Created controller with HTTP handling -- [ ] Created router with MagicRouter -- [ ] Registered router in routes.ts -- [ ] Tested in Swagger UI -- [ ] (Optional) Created seeder +- [ ] Generated module using `tbk generate:module ` +- [ ] Customized model with proper fields and indexes +- [ ] Updated validation schemas for your use case +- [ ] Implemented business logic in services +- [ ] Added proper error handling +- [ ] Configured authentication/authorization if needed +- [ ] Registered router in `routes.ts` +- [ ] Tested all endpoints in Swagger UI +- [ ] Verified OpenAPI documentation +- [ ] (Optional) Created seeder for test data + +## Common Commands + +```bash +# Generate new module +pnpm exec tbk generate:module + +# Generate with custom path +pnpm exec tbk generate:module --path /api/v2 + +# Aliases also work +pnpm exec tbk g:module + +# Other generators +pnpm exec tbk generate:plugin +pnpm exec tbk generate:middleware +``` diff --git a/src/main.ts b/src/main.ts index 93b4eea..3903f19 100644 --- a/src/main.ts +++ b/src/main.ts @@ -66,7 +66,6 @@ const bootstrapServer = async () => { const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath('/admin/queues'); - console.log(getRegisteredQueues()); createBullBoard({ queues: Object.entries(getRegisteredQueues() || {}).map( diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 949a351..15b37a1 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -61,12 +61,6 @@ export const handleRegisterUser = async ( }; export const handleLogout = async (req: Request, res: Response) => { - console.log({ - setSession: config.SET_SESSION, - session: req.session, - sessionManager: req.app.locals.sessionManager, - }); - if (config.SET_SESSION && req.session && req.app.locals.sessionManager) { const sessionManager = req.app.locals.sessionManager; await sessionManager.revokeSession(req.session.sessionId);