feat(bundles): add editorial bundle plugins
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
---
|
||||
name: backend-dev-guidelines
|
||||
description: "You are a senior backend engineer operating production-grade services under strict architectural and reliability constraints. Use when routes, controllers, services, repositories, express middleware, or prisma database access."
|
||||
risk: unknown
|
||||
source: community
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
# Backend Development Guidelines
|
||||
|
||||
**(Node.js · Express · TypeScript · Microservices)**
|
||||
|
||||
You are a **senior backend engineer** operating production-grade services under strict architectural and reliability constraints.
|
||||
|
||||
Your goal is to build **predictable, observable, and maintainable backend systems** using:
|
||||
|
||||
* Layered architecture
|
||||
* Explicit error boundaries
|
||||
* Strong typing and validation
|
||||
* Centralized configuration
|
||||
* First-class observability
|
||||
|
||||
This skill defines **how backend code must be written**, not merely suggestions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend Feasibility & Risk Index (BFRI)
|
||||
|
||||
Before implementing or modifying a backend feature, assess feasibility.
|
||||
|
||||
### BFRI Dimensions (1–5)
|
||||
|
||||
| Dimension | Question |
|
||||
| ----------------------------- | ---------------------------------------------------------------- |
|
||||
| **Architectural Fit** | Does this follow routes → controllers → services → repositories? |
|
||||
| **Business Logic Complexity** | How complex is the domain logic? |
|
||||
| **Data Risk** | Does this affect critical data paths or transactions? |
|
||||
| **Operational Risk** | Does this impact auth, billing, messaging, or infra? |
|
||||
| **Testability** | Can this be reliably unit + integration tested? |
|
||||
|
||||
### Score Formula
|
||||
|
||||
```
|
||||
BFRI = (Architectural Fit + Testability) − (Complexity + Data Risk + Operational Risk)
|
||||
```
|
||||
|
||||
**Range:** `-10 → +10`
|
||||
|
||||
### Interpretation
|
||||
|
||||
| BFRI | Meaning | Action |
|
||||
| -------- | --------- | ---------------------- |
|
||||
| **6–10** | Safe | Proceed |
|
||||
| **3–5** | Moderate | Add tests + monitoring |
|
||||
| **0–2** | Risky | Refactor or isolate |
|
||||
| **< 0** | Dangerous | Redesign before coding |
|
||||
|
||||
---
|
||||
|
||||
## When to Use
|
||||
Automatically applies when working on:
|
||||
|
||||
* Routes, controllers, services, repositories
|
||||
* Express middleware
|
||||
* Prisma database access
|
||||
* Zod validation
|
||||
* Sentry error tracking
|
||||
* Configuration management
|
||||
* Backend refactors or migrations
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Architecture Doctrine (Non-Negotiable)
|
||||
|
||||
### 1. Layered Architecture Is Mandatory
|
||||
|
||||
```
|
||||
Routes → Controllers → Services → Repositories → Database
|
||||
```
|
||||
|
||||
* No layer skipping
|
||||
* No cross-layer leakage
|
||||
* Each layer has **one responsibility**
|
||||
|
||||
---
|
||||
|
||||
### 2. Routes Only Route
|
||||
|
||||
```ts
|
||||
// ❌ NEVER
|
||||
router.post('/create', async (req, res) => {
|
||||
await prisma.user.create(...);
|
||||
});
|
||||
|
||||
// ✅ ALWAYS
|
||||
router.post('/create', (req, res) =>
|
||||
userController.create(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
Routes must contain **zero business logic**.
|
||||
|
||||
---
|
||||
|
||||
### 3. Controllers Coordinate, Services Decide
|
||||
|
||||
* Controllers:
|
||||
|
||||
* Parse request
|
||||
* Call services
|
||||
* Handle response formatting
|
||||
* Handle errors via BaseController
|
||||
|
||||
* Services:
|
||||
|
||||
* Contain business rules
|
||||
* Are framework-agnostic
|
||||
* Use DI
|
||||
* Are unit-testable
|
||||
|
||||
---
|
||||
|
||||
### 4. All Controllers Extend `BaseController`
|
||||
|
||||
```ts
|
||||
export class UserController extends BaseController {
|
||||
async getUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = await this.userService.getById(req.params.id);
|
||||
this.handleSuccess(res, user);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'getUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No raw `res.json` calls outside BaseController helpers.
|
||||
|
||||
---
|
||||
|
||||
### 5. All Errors Go to Sentry
|
||||
|
||||
```ts
|
||||
catch (error) {
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
❌ `console.log`
|
||||
❌ silent failures
|
||||
❌ swallowed errors
|
||||
|
||||
---
|
||||
|
||||
### 6. unifiedConfig Is the Only Config Source
|
||||
|
||||
```ts
|
||||
// ❌ NEVER
|
||||
process.env.JWT_SECRET;
|
||||
|
||||
// ✅ ALWAYS
|
||||
import { config } from '@/config/unifiedConfig';
|
||||
config.auth.jwtSecret;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Validate All External Input with Zod
|
||||
|
||||
* Request bodies
|
||||
* Query params
|
||||
* Route params
|
||||
* Webhook payloads
|
||||
|
||||
```ts
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const input = schema.parse(req.body);
|
||||
```
|
||||
|
||||
No validation = bug.
|
||||
|
||||
---
|
||||
|
||||
## 4. Directory Structure (Canonical)
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # unifiedConfig
|
||||
├── controllers/ # BaseController + controllers
|
||||
├── services/ # Business logic
|
||||
├── repositories/ # Prisma access
|
||||
├── routes/ # Express routes
|
||||
├── middleware/ # Auth, validation, errors
|
||||
├── validators/ # Zod schemas
|
||||
├── types/ # Shared types
|
||||
├── utils/ # Helpers
|
||||
├── tests/ # Unit + integration tests
|
||||
├── instrument.ts # Sentry (FIRST IMPORT)
|
||||
├── app.ts # Express app
|
||||
└── server.ts # HTTP server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Naming Conventions (Strict)
|
||||
|
||||
| Layer | Convention |
|
||||
| ---------- | ------------------------- |
|
||||
| Controller | `PascalCaseController.ts` |
|
||||
| Service | `camelCaseService.ts` |
|
||||
| Repository | `PascalCaseRepository.ts` |
|
||||
| Routes | `camelCaseRoutes.ts` |
|
||||
| Validators | `camelCase.schema.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dependency Injection Rules
|
||||
|
||||
* Services receive dependencies via constructor
|
||||
* No importing repositories directly inside controllers
|
||||
* Enables mocking and testing
|
||||
|
||||
```ts
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Prisma & Repository Rules
|
||||
|
||||
* Prisma client **never used directly in controllers**
|
||||
* Repositories:
|
||||
|
||||
* Encapsulate queries
|
||||
* Handle transactions
|
||||
* Expose intent-based methods
|
||||
|
||||
```ts
|
||||
await userRepository.findActiveUsers();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Async & Error Handling
|
||||
|
||||
### asyncErrorWrapper Required
|
||||
|
||||
All async route handlers must be wrapped.
|
||||
|
||||
```ts
|
||||
router.get(
|
||||
'/users',
|
||||
asyncErrorWrapper((req, res) =>
|
||||
controller.list(req, res)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
No unhandled promise rejections.
|
||||
|
||||
---
|
||||
|
||||
## 9. Observability & Monitoring
|
||||
|
||||
### Required
|
||||
|
||||
* Sentry error tracking
|
||||
* Sentry performance tracing
|
||||
* Structured logs (where applicable)
|
||||
|
||||
Every critical path must be observable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Discipline
|
||||
|
||||
### Required Tests
|
||||
|
||||
* **Unit tests** for services
|
||||
* **Integration tests** for routes
|
||||
* **Repository tests** for complex queries
|
||||
|
||||
```ts
|
||||
describe('UserService', () => {
|
||||
it('creates a user', async () => {
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
No tests → no merge.
|
||||
|
||||
---
|
||||
|
||||
## 11. Anti-Patterns (Immediate Rejection)
|
||||
|
||||
❌ Business logic in routes
|
||||
❌ Skipping service layer
|
||||
❌ Direct Prisma in controllers
|
||||
❌ Missing validation
|
||||
❌ process.env usage
|
||||
❌ console.log instead of Sentry
|
||||
❌ Untested business logic
|
||||
|
||||
---
|
||||
|
||||
## 12. Integration With Other Skills
|
||||
|
||||
* **frontend-dev-guidelines** → API contract alignment
|
||||
* **error-tracking** → Sentry standards
|
||||
* **database-verification** → Schema correctness
|
||||
* **analytics-tracking** → Event pipelines
|
||||
* **skill-developer** → Skill governance
|
||||
|
||||
---
|
||||
|
||||
## 13. Operator Validation Checklist
|
||||
|
||||
Before finalizing backend work:
|
||||
|
||||
* [ ] BFRI ≥ 3
|
||||
* [ ] Layered architecture respected
|
||||
* [ ] Input validated
|
||||
* [ ] Errors captured in Sentry
|
||||
* [ ] unifiedConfig used
|
||||
* [ ] Tests written
|
||||
* [ ] No anti-patterns present
|
||||
|
||||
---
|
||||
|
||||
## 14. Skill Status
|
||||
|
||||
**Status:** Stable · Enforceable · Production-grade
|
||||
**Intended Use:** Long-lived Node.js microservices with real traffic and real risk
|
||||
---
|
||||
|
||||
## When to Use
|
||||
This skill is applicable to execute the workflow or actions described in the overview.
|
||||
@@ -0,0 +1,451 @@
|
||||
# Architecture Overview - Backend Services
|
||||
|
||||
Complete guide to the layered architecture pattern used in backend microservices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Layered Architecture Pattern](#layered-architecture-pattern)
|
||||
- [Request Lifecycle](#request-lifecycle)
|
||||
- [Service Comparison](#service-comparison)
|
||||
- [Directory Structure Rationale](#directory-structure-rationale)
|
||||
- [Module Organization](#module-organization)
|
||||
- [Separation of Concerns](#separation-of-concerns)
|
||||
|
||||
---
|
||||
|
||||
## Layered Architecture Pattern
|
||||
|
||||
### The Four Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ HTTP Request │
|
||||
└───────────────┬─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Layer 1: ROUTES │
|
||||
│ - Route definitions only │
|
||||
│ - Middleware registration │
|
||||
│ - Delegate to controllers │
|
||||
│ - NO business logic │
|
||||
└───────────────┬─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Layer 2: CONTROLLERS │
|
||||
│ - Request/response handling │
|
||||
│ - Input validation │
|
||||
│ - Call services │
|
||||
│ - Format responses │
|
||||
│ - Error handling │
|
||||
└───────────────┬─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Layer 3: SERVICES │
|
||||
│ - Business logic │
|
||||
│ - Orchestration │
|
||||
│ - Call repositories │
|
||||
│ - No HTTP knowledge │
|
||||
└───────────────┬─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Layer 4: REPOSITORIES │
|
||||
│ - Data access abstraction │
|
||||
│ - Prisma operations │
|
||||
│ - Query optimization │
|
||||
│ - Caching │
|
||||
└───────────────┬─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Database (MySQL) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why This Architecture?
|
||||
|
||||
**Testability:**
|
||||
- Each layer can be tested independently
|
||||
- Easy to mock dependencies
|
||||
- Clear test boundaries
|
||||
|
||||
**Maintainability:**
|
||||
- Changes isolated to specific layers
|
||||
- Business logic separate from HTTP concerns
|
||||
- Easy to locate bugs
|
||||
|
||||
**Reusability:**
|
||||
- Services can be used by routes, cron jobs, scripts
|
||||
- Repositories hide database implementation
|
||||
- Business logic not tied to HTTP
|
||||
|
||||
**Scalability:**
|
||||
- Easy to add new endpoints
|
||||
- Clear patterns to follow
|
||||
- Consistent structure
|
||||
|
||||
---
|
||||
|
||||
## Request Lifecycle
|
||||
|
||||
### Complete Flow Example
|
||||
|
||||
```typescript
|
||||
1. HTTP POST /api/users
|
||||
↓
|
||||
2. Express matches route in userRoutes.ts
|
||||
↓
|
||||
3. Middleware chain executes:
|
||||
- SSOMiddleware.verifyLoginStatus (authentication)
|
||||
- auditMiddleware (context tracking)
|
||||
↓
|
||||
4. Route handler delegates to controller:
|
||||
router.post('/users', (req, res) => userController.create(req, res))
|
||||
↓
|
||||
5. Controller validates and calls service:
|
||||
- Validate input with Zod
|
||||
- Call userService.create(data)
|
||||
- Handle success/error
|
||||
↓
|
||||
6. Service executes business logic:
|
||||
- Check business rules
|
||||
- Call userRepository.create(data)
|
||||
- Return result
|
||||
↓
|
||||
7. Repository performs database operation:
|
||||
- PrismaService.main.user.create({ data })
|
||||
- Handle database errors
|
||||
- Return created user
|
||||
↓
|
||||
8. Response flows back:
|
||||
Repository → Service → Controller → Express → Client
|
||||
```
|
||||
|
||||
### Middleware Execution Order
|
||||
|
||||
**Critical:** Middleware executes in registration order
|
||||
|
||||
```typescript
|
||||
app.use(Sentry.Handlers.requestHandler()); // 1. Sentry tracing (FIRST)
|
||||
app.use(express.json()); // 2. Body parsing
|
||||
app.use(express.urlencoded({ extended: true })); // 3. URL encoding
|
||||
app.use(cookieParser()); // 4. Cookie parsing
|
||||
app.use(SSOMiddleware.initialize()); // 5. Auth initialization
|
||||
// ... routes registered here
|
||||
app.use(auditMiddleware); // 6. Audit (if global)
|
||||
app.use(errorBoundary); // 7. Error handler (LAST)
|
||||
app.use(Sentry.Handlers.errorHandler()); // 8. Sentry errors (LAST)
|
||||
```
|
||||
|
||||
**Rule:** Error handlers must be registered AFTER routes!
|
||||
|
||||
---
|
||||
|
||||
## Service Comparison
|
||||
|
||||
### Email Service (Mature Pattern ✅)
|
||||
|
||||
**Strengths:**
|
||||
- Comprehensive BaseController with Sentry integration
|
||||
- Clean route delegation (no business logic in routes)
|
||||
- Consistent dependency injection pattern
|
||||
- Good middleware organization
|
||||
- Type-safe throughout
|
||||
- Excellent error handling
|
||||
|
||||
**Example Structure:**
|
||||
```
|
||||
email/src/
|
||||
├── controllers/
|
||||
│ ├── BaseController.ts ✅ Excellent template
|
||||
│ ├── NotificationController.ts ✅ Extends BaseController
|
||||
│ └── EmailController.ts ✅ Clean patterns
|
||||
├── routes/
|
||||
│ ├── notificationRoutes.ts ✅ Clean delegation
|
||||
│ └── emailRoutes.ts ✅ No business logic
|
||||
├── services/
|
||||
│ ├── NotificationService.ts ✅ Dependency injection
|
||||
│ └── BatchingService.ts ✅ Clear responsibility
|
||||
└── middleware/
|
||||
├── errorBoundary.ts ✅ Comprehensive
|
||||
└── DevImpersonationSSOMiddleware.ts
|
||||
```
|
||||
|
||||
**Use as template** for new services!
|
||||
|
||||
### Form Service (Transitioning ⚠️)
|
||||
|
||||
**Strengths:**
|
||||
- Excellent workflow architecture (event sourcing)
|
||||
- Good Sentry integration
|
||||
- Innovative audit middleware (AsyncLocalStorage)
|
||||
- Comprehensive permission system
|
||||
|
||||
**Weaknesses:**
|
||||
- Some routes have 200+ lines of business logic
|
||||
- Inconsistent controller naming
|
||||
- Direct process.env usage (60+ occurrences)
|
||||
- Minimal repository pattern usage
|
||||
|
||||
**Example:**
|
||||
```
|
||||
form/src/
|
||||
├── routes/
|
||||
│ ├── responseRoutes.ts ❌ Business logic in routes
|
||||
│ └── proxyRoutes.ts ✅ Good validation pattern
|
||||
├── controllers/
|
||||
│ ├── formController.ts ⚠️ Lowercase naming
|
||||
│ └── UserProfileController.ts ✅ PascalCase naming
|
||||
├── workflow/ ✅ Excellent architecture!
|
||||
│ ├── core/
|
||||
│ │ ├── WorkflowEngineV3.ts ✅ Event sourcing
|
||||
│ │ └── DryRunWrapper.ts ✅ Innovative
|
||||
│ └── services/
|
||||
└── middleware/
|
||||
└── auditMiddleware.ts ✅ AsyncLocalStorage pattern
|
||||
```
|
||||
|
||||
**Learn from:** workflow/, middleware/auditMiddleware.ts
|
||||
**Avoid:** responseRoutes.ts, direct process.env
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure Rationale
|
||||
|
||||
### Controllers Directory
|
||||
|
||||
**Purpose:** Handle HTTP request/response concerns
|
||||
|
||||
**Contents:**
|
||||
- `BaseController.ts` - Base class with common methods
|
||||
- `{Feature}Controller.ts` - Feature-specific controllers
|
||||
|
||||
**Naming:** PascalCase + Controller
|
||||
|
||||
**Responsibilities:**
|
||||
- Parse request parameters
|
||||
- Validate input (Zod)
|
||||
- Call appropriate service methods
|
||||
- Format responses
|
||||
- Handle errors (via BaseController)
|
||||
- Set HTTP status codes
|
||||
|
||||
### Services Directory
|
||||
|
||||
**Purpose:** Business logic and orchestration
|
||||
|
||||
**Contents:**
|
||||
- `{feature}Service.ts` - Feature business logic
|
||||
|
||||
**Naming:** camelCase + Service (or PascalCase + Service)
|
||||
|
||||
**Responsibilities:**
|
||||
- Implement business rules
|
||||
- Orchestrate multiple repositories
|
||||
- Transaction management
|
||||
- Business validations
|
||||
- No HTTP knowledge (Request/Response types)
|
||||
|
||||
### Repositories Directory
|
||||
|
||||
**Purpose:** Data access abstraction
|
||||
|
||||
**Contents:**
|
||||
- `{Entity}Repository.ts` - Database operations for entity
|
||||
|
||||
**Naming:** PascalCase + Repository
|
||||
|
||||
**Responsibilities:**
|
||||
- Prisma query operations
|
||||
- Query optimization
|
||||
- Database error handling
|
||||
- Caching layer
|
||||
- Hide Prisma implementation details
|
||||
|
||||
**Current Gap:** Only 1 repository exists (WorkflowRepository)
|
||||
|
||||
### Routes Directory
|
||||
|
||||
**Purpose:** Route registration ONLY
|
||||
|
||||
**Contents:**
|
||||
- `{feature}Routes.ts` - Express router for feature
|
||||
|
||||
**Naming:** camelCase + Routes
|
||||
|
||||
**Responsibilities:**
|
||||
- Register routes with Express
|
||||
- Apply middleware
|
||||
- Delegate to controllers
|
||||
- **NO business logic!**
|
||||
|
||||
### Middleware Directory
|
||||
|
||||
**Purpose:** Cross-cutting concerns
|
||||
|
||||
**Contents:**
|
||||
- Authentication middleware
|
||||
- Audit middleware
|
||||
- Error boundaries
|
||||
- Validation middleware
|
||||
- Custom middleware
|
||||
|
||||
**Naming:** camelCase
|
||||
|
||||
**Types:**
|
||||
- Request processing (before handler)
|
||||
- Response processing (after handler)
|
||||
- Error handling (error boundary)
|
||||
|
||||
### Config Directory
|
||||
|
||||
**Purpose:** Configuration management
|
||||
|
||||
**Contents:**
|
||||
- `unifiedConfig.ts` - Type-safe configuration
|
||||
- Environment-specific configs
|
||||
|
||||
**Pattern:** Single source of truth
|
||||
|
||||
### Types Directory
|
||||
|
||||
**Purpose:** TypeScript type definitions
|
||||
|
||||
**Contents:**
|
||||
- `{feature}.types.ts` - Feature-specific types
|
||||
- DTOs (Data Transfer Objects)
|
||||
- Request/Response types
|
||||
- Domain models
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
### Feature-Based Organization
|
||||
|
||||
For large features, use subdirectories:
|
||||
|
||||
```
|
||||
src/workflow/
|
||||
├── core/ # Core engine
|
||||
├── services/ # Workflow-specific services
|
||||
├── actions/ # System actions
|
||||
├── models/ # Domain models
|
||||
├── validators/ # Workflow validation
|
||||
└── utils/ # Workflow utilities
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Feature has 5+ files
|
||||
- Clear sub-domains exist
|
||||
- Logical grouping improves clarity
|
||||
|
||||
### Flat Organization
|
||||
|
||||
For simple features:
|
||||
|
||||
```
|
||||
src/
|
||||
├── controllers/UserController.ts
|
||||
├── services/userService.ts
|
||||
├── routes/userRoutes.ts
|
||||
└── repositories/UserRepository.ts
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Simple features (< 5 files)
|
||||
- No clear sub-domains
|
||||
- Flat structure is clearer
|
||||
|
||||
---
|
||||
|
||||
## Separation of Concerns
|
||||
|
||||
### What Goes Where
|
||||
|
||||
**Routes Layer:**
|
||||
- ✅ Route definitions
|
||||
- ✅ Middleware registration
|
||||
- ✅ Controller delegation
|
||||
- ❌ Business logic
|
||||
- ❌ Database operations
|
||||
- ❌ Validation logic (should be in validator or controller)
|
||||
|
||||
**Controllers Layer:**
|
||||
- ✅ Request parsing (params, body, query)
|
||||
- ✅ Input validation (Zod)
|
||||
- ✅ Service calls
|
||||
- ✅ Response formatting
|
||||
- ✅ Error handling
|
||||
- ❌ Business logic
|
||||
- ❌ Database operations
|
||||
|
||||
**Services Layer:**
|
||||
- ✅ Business logic
|
||||
- ✅ Business rules enforcement
|
||||
- ✅ Orchestration (multiple repos)
|
||||
- ✅ Transaction management
|
||||
- ❌ HTTP concerns (Request/Response)
|
||||
- ❌ Direct Prisma calls (use repositories)
|
||||
|
||||
**Repositories Layer:**
|
||||
- ✅ Prisma operations
|
||||
- ✅ Query construction
|
||||
- ✅ Database error handling
|
||||
- ✅ Caching
|
||||
- ❌ Business logic
|
||||
- ❌ HTTP concerns
|
||||
|
||||
### Example: User Creation
|
||||
|
||||
**Route:**
|
||||
```typescript
|
||||
router.post('/users',
|
||||
SSOMiddleware.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
(req, res) => userController.create(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
**Controller:**
|
||||
```typescript
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
const user = await this.userService.create(validated);
|
||||
this.handleSuccess(res, user, 'User created');
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'create');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Service:**
|
||||
```typescript
|
||||
async create(data: CreateUserDTO): Promise<User> {
|
||||
// Business rule: check if email already exists
|
||||
const existing = await this.userRepository.findByEmail(data.email);
|
||||
if (existing) throw new ConflictError('Email already exists');
|
||||
|
||||
// Create user
|
||||
return await this.userRepository.create(data);
|
||||
}
|
||||
```
|
||||
|
||||
**Repository:**
|
||||
```typescript
|
||||
async create(data: CreateUserDTO): Promise<User> {
|
||||
return PrismaService.main.user.create({ data });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return PrismaService.main.user.findUnique({ where: { email } });
|
||||
}
|
||||
```
|
||||
|
||||
**Notice:** Each layer has clear, distinct responsibilities!
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md - Main guide
|
||||
- [routing-and-controllers.md](routing-and-controllers.md) - Routes and controllers details
|
||||
- [services-and-repositories.md](services-and-repositories.md) - Service and repository patterns
|
||||
@@ -0,0 +1,307 @@
|
||||
# Async Patterns and Error Handling
|
||||
|
||||
Complete guide to async/await patterns and custom error handling.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Async/Await Best Practices](#asyncawait-best-practices)
|
||||
- [Promise Error Handling](#promise-error-handling)
|
||||
- [Custom Error Types](#custom-error-types)
|
||||
- [asyncErrorWrapper Utility](#asyncerrorwrapper-utility)
|
||||
- [Error Propagation](#error-propagation)
|
||||
- [Common Async Pitfalls](#common-async-pitfalls)
|
||||
|
||||
---
|
||||
|
||||
## Async/Await Best Practices
|
||||
|
||||
### Always Use Try-Catch
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Unhandled async errors
|
||||
async function fetchData() {
|
||||
const data = await database.query(); // If throws, unhandled!
|
||||
return data;
|
||||
}
|
||||
|
||||
// ✅ ALWAYS: Wrap in try-catch
|
||||
async function fetchData() {
|
||||
try {
|
||||
const data = await database.query();
|
||||
return data;
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid .then() Chains
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID: Promise chains
|
||||
function processData() {
|
||||
return fetchData()
|
||||
.then(data => transform(data))
|
||||
.then(transformed => save(transformed))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ PREFER: Async/await
|
||||
async function processData() {
|
||||
try {
|
||||
const data = await fetchData();
|
||||
const transformed = await transform(data);
|
||||
return await save(transformed);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Promise Error Handling
|
||||
|
||||
### Parallel Operations
|
||||
|
||||
```typescript
|
||||
// ✅ Handle errors in Promise.all
|
||||
try {
|
||||
const [users, profiles, settings] = await Promise.all([
|
||||
userService.getAll(),
|
||||
profileService.getAll(),
|
||||
settingsService.getAll(),
|
||||
]);
|
||||
} catch (error) {
|
||||
// One failure fails all
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ✅ Handle errors individually with Promise.allSettled
|
||||
const results = await Promise.allSettled([
|
||||
userService.getAll(),
|
||||
profileService.getAll(),
|
||||
settingsService.getAll(),
|
||||
]);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
Sentry.captureException(result.reason, {
|
||||
tags: { operation: ['users', 'profiles', 'settings'][index] }
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Error Types
|
||||
|
||||
### Define Custom Errors
|
||||
|
||||
```typescript
|
||||
// Base error class
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number,
|
||||
public isOperational: boolean = true
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
// Specific error types
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 'VALIDATION_ERROR', 400);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 'NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 'FORBIDDEN', 403);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 'CONFLICT', 409);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
// Throw specific errors
|
||||
if (!user) {
|
||||
throw new NotFoundError('User not found');
|
||||
}
|
||||
|
||||
if (user.age < 18) {
|
||||
throw new ValidationError('User must be 18+');
|
||||
}
|
||||
|
||||
// Error boundary handles them
|
||||
function errorBoundary(error, req, res, next) {
|
||||
if (error instanceof AppError) {
|
||||
return res.status(error.statusCode).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
code: error.code
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
Sentry.captureException(error);
|
||||
res.status(500).json({ error: { message: 'Internal server error' } });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## asyncErrorWrapper Utility
|
||||
|
||||
### Pattern
|
||||
|
||||
```typescript
|
||||
export function asyncErrorWrapper(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await handler(req, res, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
// Without wrapper - error can be unhandled
|
||||
router.get('/users', async (req, res) => {
|
||||
const users = await userService.getAll(); // If throws, unhandled!
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// With wrapper - errors caught
|
||||
router.get('/users', asyncErrorWrapper(async (req, res) => {
|
||||
const users = await userService.getAll();
|
||||
res.json(users);
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Propagation
|
||||
|
||||
### Proper Error Chains
|
||||
|
||||
```typescript
|
||||
// ✅ Propagate errors up the stack
|
||||
async function repositoryMethod() {
|
||||
try {
|
||||
return await PrismaService.main.user.findMany();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, { tags: { layer: 'repository' } });
|
||||
throw error; // Propagate to service
|
||||
}
|
||||
}
|
||||
|
||||
async function serviceMethod() {
|
||||
try {
|
||||
return await repositoryMethod();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, { tags: { layer: 'service' } });
|
||||
throw error; // Propagate to controller
|
||||
}
|
||||
}
|
||||
|
||||
async function controllerMethod(req, res) {
|
||||
try {
|
||||
const result = await serviceMethod();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'controllerMethod'); // Final handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Async Pitfalls
|
||||
|
||||
### Fire and Forget (Bad)
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Fire and forget
|
||||
async function processRequest(req, res) {
|
||||
sendEmail(user.email); // Fires async, errors unhandled!
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// ✅ ALWAYS: Await or handle
|
||||
async function processRequest(req, res) {
|
||||
try {
|
||||
await sendEmail(user.email);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
res.status(500).json({ error: 'Failed to send email' });
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ OR: Intentional background task
|
||||
async function processRequest(req, res) {
|
||||
sendEmail(user.email).catch(error => {
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
res.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
### Unhandled Rejections
|
||||
|
||||
```typescript
|
||||
// ✅ Global handler for unhandled rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
Sentry.captureException(reason, {
|
||||
tags: { type: 'unhandled_rejection' }
|
||||
});
|
||||
console.error('Unhandled Rejection:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
Sentry.captureException(error, {
|
||||
tags: { type: 'uncaught_exception' }
|
||||
});
|
||||
console.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [sentry-and-monitoring.md](sentry-and-monitoring.md)
|
||||
- [complete-examples.md](complete-examples.md)
|
||||
@@ -0,0 +1,638 @@
|
||||
# Complete Examples - Full Working Code
|
||||
|
||||
Real-world examples showing complete implementation patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Complete Controller Example](#complete-controller-example)
|
||||
- [Complete Service with DI](#complete-service-with-di)
|
||||
- [Complete Route File](#complete-route-file)
|
||||
- [Complete Repository](#complete-repository)
|
||||
- [Refactoring Example: Bad to Good](#refactoring-example-bad-to-good)
|
||||
- [End-to-End Feature Example](#end-to-end-feature-example)
|
||||
|
||||
---
|
||||
|
||||
## Complete Controller Example
|
||||
|
||||
### UserController (Following All Best Practices)
|
||||
|
||||
```typescript
|
||||
// controllers/UserController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from './BaseController';
|
||||
import { UserService } from '../services/userService';
|
||||
import { createUserSchema, updateUserSchema } from '../validators/userSchemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export class UserController extends BaseController {
|
||||
private userService: UserService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
async getUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
this.addBreadcrumb('Fetching user', 'user_controller', {
|
||||
userId: req.params.id,
|
||||
});
|
||||
|
||||
const user = await this.withTransaction(
|
||||
'user.get',
|
||||
'db.query',
|
||||
() => this.userService.findById(req.params.id)
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return this.handleError(
|
||||
new Error('User not found'),
|
||||
res,
|
||||
'getUser',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
this.handleSuccess(res, user);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'getUser');
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const users = await this.userService.getAll();
|
||||
this.handleSuccess(res, users);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'listUsers');
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Validate input with Zod
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
|
||||
// Track performance
|
||||
const user = await this.withTransaction(
|
||||
'user.create',
|
||||
'db.mutation',
|
||||
() => this.userService.create(validated)
|
||||
);
|
||||
|
||||
this.handleSuccess(res, user, 'User created successfully', 201);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return this.handleError(error, res, 'createUser', 400);
|
||||
}
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = updateUserSchema.parse(req.body);
|
||||
|
||||
const user = await this.userService.update(
|
||||
req.params.id,
|
||||
validated
|
||||
);
|
||||
|
||||
this.handleSuccess(res, user, 'User updated');
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return this.handleError(error, res, 'updateUser', 400);
|
||||
}
|
||||
this.handleError(error, res, 'updateUser');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
await this.userService.delete(req.params.id);
|
||||
this.handleSuccess(res, null, 'User deleted', 204);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'deleteUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Service with DI
|
||||
|
||||
### UserService
|
||||
|
||||
```typescript
|
||||
// services/userService.ts
|
||||
import { UserRepository } from '../repositories/UserRepository';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '../types/errors';
|
||||
import type { CreateUserDTO, UpdateUserDTO, User } from '../types/user.types';
|
||||
|
||||
export class UserService {
|
||||
private userRepository: UserRepository;
|
||||
|
||||
constructor(userRepository?: UserRepository) {
|
||||
this.userRepository = userRepository || new UserRepository();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return await this.userRepository.findById(id);
|
||||
}
|
||||
|
||||
async getAll(): Promise<User[]> {
|
||||
return await this.userRepository.findActive();
|
||||
}
|
||||
|
||||
async create(data: CreateUserDTO): Promise<User> {
|
||||
// Business rule: validate age
|
||||
if (data.age < 18) {
|
||||
throw new ValidationError('User must be 18 or older');
|
||||
}
|
||||
|
||||
// Business rule: check email uniqueness
|
||||
const existing = await this.userRepository.findByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError('Email already in use');
|
||||
}
|
||||
|
||||
// Create user with profile
|
||||
return await this.userRepository.create({
|
||||
email: data.email,
|
||||
profile: {
|
||||
create: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
age: data.age,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateUserDTO): Promise<User> {
|
||||
// Check exists
|
||||
const existing = await this.userRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError('User not found');
|
||||
}
|
||||
|
||||
// Business rule: email uniqueness if changing
|
||||
if (data.email && data.email !== existing.email) {
|
||||
const emailTaken = await this.userRepository.findByEmail(data.email);
|
||||
if (emailTaken) {
|
||||
throw new ConflictError('Email already in use');
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userRepository.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await this.userRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError('User not found');
|
||||
}
|
||||
|
||||
await this.userRepository.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Route File
|
||||
|
||||
### userRoutes.ts
|
||||
|
||||
```typescript
|
||||
// routes/userRoutes.ts
|
||||
import { Router } from 'express';
|
||||
import { UserController } from '../controllers/UserController';
|
||||
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
|
||||
import { auditMiddleware } from '../middleware/auditMiddleware';
|
||||
|
||||
const router = Router();
|
||||
const controller = new UserController();
|
||||
|
||||
// GET /users - List all users
|
||||
router.get('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.listUsers(req, res)
|
||||
);
|
||||
|
||||
// GET /users/:id - Get single user
|
||||
router.get('/:id',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.getUser(req, res)
|
||||
);
|
||||
|
||||
// POST /users - Create user
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.createUser(req, res)
|
||||
);
|
||||
|
||||
// PUT /users/:id - Update user
|
||||
router.put('/:id',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.updateUser(req, res)
|
||||
);
|
||||
|
||||
// DELETE /users/:id - Delete user
|
||||
router.delete('/:id',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.deleteUser(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Repository
|
||||
|
||||
### UserRepository
|
||||
|
||||
```typescript
|
||||
// repositories/UserRepository.ts
|
||||
import { PrismaService } from '@project-lifecycle-portal/database';
|
||||
import type { User, Prisma } from '@prisma/client';
|
||||
|
||||
export class UserRepository {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return PrismaService.main.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return PrismaService.main.user.findUnique({
|
||||
where: { email },
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findActive(): Promise<User[]> {
|
||||
return PrismaService.main.user.findMany({
|
||||
where: { isActive: true },
|
||||
include: { profile: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
return PrismaService.main.user.create({
|
||||
data,
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
|
||||
return PrismaService.main.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<User> {
|
||||
// Soft delete
|
||||
return PrismaService.main.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isActive: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Example: Bad to Good
|
||||
|
||||
### BEFORE: Business Logic in Routes ❌
|
||||
|
||||
```typescript
|
||||
// routes/postRoutes.ts (BAD - 200+ lines)
|
||||
router.post('/posts', async (req, res) => {
|
||||
try {
|
||||
const username = res.locals.claims.preferred_username;
|
||||
const responses = req.body.responses;
|
||||
const stepInstanceId = req.body.stepInstanceId;
|
||||
|
||||
// ❌ Permission check in route
|
||||
const userId = await userProfileService.getProfileByEmail(username).then(p => p.id);
|
||||
const canComplete = await permissionService.canCompleteStep(userId, stepInstanceId);
|
||||
if (!canComplete) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
// ❌ Business logic in route
|
||||
const post = await postRepository.create({
|
||||
title: req.body.title,
|
||||
content: req.body.content,
|
||||
authorId: userId
|
||||
});
|
||||
|
||||
// ❌ More business logic...
|
||||
if (res.locals.isImpersonating) {
|
||||
impersonationContextStore.storeContext(...);
|
||||
}
|
||||
|
||||
// ... 100+ more lines
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (e) {
|
||||
handler.handleException(res, e);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### AFTER: Clean Separation ✅
|
||||
|
||||
**1. Clean Route:**
|
||||
```typescript
|
||||
// routes/postRoutes.ts
|
||||
import { PostController } from '../controllers/PostController';
|
||||
|
||||
const router = Router();
|
||||
const controller = new PostController();
|
||||
|
||||
// ✅ CLEAN: 8 lines total!
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.createPost(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**2. Controller:**
|
||||
```typescript
|
||||
// controllers/PostController.ts
|
||||
export class PostController extends BaseController {
|
||||
private postService: PostService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.postService = new PostService();
|
||||
}
|
||||
|
||||
async createPost(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = createPostSchema.parse({
|
||||
...req.body,
|
||||
});
|
||||
|
||||
const result = await this.postService.createPost(
|
||||
validated,
|
||||
res.locals.userId
|
||||
);
|
||||
|
||||
this.handleSuccess(res, result, 'Post created successfully');
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'createPost');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Service:**
|
||||
```typescript
|
||||
// services/postService.ts
|
||||
export class PostService {
|
||||
async createPost(
|
||||
data: CreatePostDTO,
|
||||
userId: string
|
||||
): Promise<SubmissionResult> {
|
||||
// Permission check
|
||||
const canComplete = await permissionService.canCompleteStep(
|
||||
userId,
|
||||
data.stepInstanceId
|
||||
);
|
||||
|
||||
if (!canComplete) {
|
||||
throw new ForbiddenError('No permission to complete step');
|
||||
}
|
||||
|
||||
// Execute workflow
|
||||
const engine = await createWorkflowEngine();
|
||||
const command = new CompleteStepCommand(
|
||||
data.stepInstanceId,
|
||||
userId,
|
||||
data.responses
|
||||
);
|
||||
const events = await engine.executeCommand(command);
|
||||
|
||||
// Handle impersonation
|
||||
if (context.isImpersonating) {
|
||||
await this.handleImpersonation(data.stepInstanceId, context);
|
||||
}
|
||||
|
||||
return { events, success: true };
|
||||
}
|
||||
|
||||
private async handleImpersonation(stepInstanceId: number, context: any) {
|
||||
impersonationContextStore.storeContext(stepInstanceId, {
|
||||
originalUserId: context.originalUserId,
|
||||
effectiveUserId: context.effectiveUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Route: 8 lines (was 200+)
|
||||
- Controller: 25 lines
|
||||
- Service: 40 lines
|
||||
- **Testable, maintainable, reusable!**
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Feature Example
|
||||
|
||||
### Complete User Management Feature
|
||||
|
||||
**1. Types:**
|
||||
```typescript
|
||||
// types/user.types.ts
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
profile?: UserProfile;
|
||||
}
|
||||
|
||||
export interface CreateUserDTO {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
export interface UpdateUserDTO {
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**2. Validators:**
|
||||
```typescript
|
||||
// validators/userSchemas.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
age: z.number().int().min(18).max(120),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().min(1).max(100).optional(),
|
||||
lastName: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
**3. Repository:**
|
||||
```typescript
|
||||
// repositories/UserRepository.ts
|
||||
export class UserRepository {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return PrismaService.main.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
return PrismaService.main.user.create({
|
||||
data,
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Service:**
|
||||
```typescript
|
||||
// services/userService.ts
|
||||
export class UserService {
|
||||
private userRepository: UserRepository;
|
||||
|
||||
constructor() {
|
||||
this.userRepository = new UserRepository();
|
||||
}
|
||||
|
||||
async create(data: CreateUserDTO): Promise<User> {
|
||||
const existing = await this.userRepository.findByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError('Email already exists');
|
||||
}
|
||||
|
||||
return await this.userRepository.create({
|
||||
email: data.email,
|
||||
profile: {
|
||||
create: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
age: data.age,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Controller:**
|
||||
```typescript
|
||||
// controllers/UserController.ts
|
||||
export class UserController extends BaseController {
|
||||
private userService: UserService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
const user = await this.userService.create(validated);
|
||||
this.handleSuccess(res, user, 'User created', 201);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6. Routes:**
|
||||
```typescript
|
||||
// routes/userRoutes.ts
|
||||
const router = Router();
|
||||
const controller = new UserController();
|
||||
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => controller.createUser(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**7. Register in app.ts:**
|
||||
```typescript
|
||||
// app.ts
|
||||
import userRoutes from './routes/userRoutes';
|
||||
|
||||
app.use('/api/users', userRoutes);
|
||||
```
|
||||
|
||||
**Complete Request Flow:**
|
||||
```
|
||||
POST /api/users
|
||||
↓
|
||||
userRoutes matches /
|
||||
↓
|
||||
SSOMiddleware authenticates
|
||||
↓
|
||||
controller.createUser called
|
||||
↓
|
||||
Validates with Zod
|
||||
↓
|
||||
userService.create called
|
||||
↓
|
||||
Checks business rules
|
||||
↓
|
||||
userRepository.create called
|
||||
↓
|
||||
Prisma creates user
|
||||
↓
|
||||
Returns up the chain
|
||||
↓
|
||||
Controller formats response
|
||||
↓
|
||||
200/201 sent to client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [routing-and-controllers.md](routing-and-controllers.md)
|
||||
- [services-and-repositories.md](services-and-repositories.md)
|
||||
- [validation-patterns.md](validation-patterns.md)
|
||||
@@ -0,0 +1,275 @@
|
||||
# Configuration Management - UnifiedConfig Pattern
|
||||
|
||||
Complete guide to managing configuration in backend microservices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [UnifiedConfig Overview](#unifiedconfig-overview)
|
||||
- [NEVER Use process.env Directly](#never-use-processenv-directly)
|
||||
- [Configuration Structure](#configuration-structure)
|
||||
- [Environment-Specific Configs](#environment-specific-configs)
|
||||
- [Secrets Management](#secrets-management)
|
||||
- [Migration Guide](#migration-guide)
|
||||
|
||||
---
|
||||
|
||||
## UnifiedConfig Overview
|
||||
|
||||
### Why UnifiedConfig?
|
||||
|
||||
**Problems with process.env:**
|
||||
- ❌ No type safety
|
||||
- ❌ No validation
|
||||
- ❌ Hard to test
|
||||
- ❌ Scattered throughout code
|
||||
- ❌ No default values
|
||||
- ❌ Runtime errors for typos
|
||||
|
||||
**Benefits of unifiedConfig:**
|
||||
- ✅ Type-safe configuration
|
||||
- ✅ Single source of truth
|
||||
- ✅ Validated at startup
|
||||
- ✅ Easy to test with mocks
|
||||
- ✅ Clear structure
|
||||
- ✅ Fallback to environment variables
|
||||
|
||||
---
|
||||
|
||||
## NEVER Use process.env Directly
|
||||
|
||||
### The Rule
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER DO THIS
|
||||
const timeout = parseInt(process.env.TIMEOUT_MS || '5000');
|
||||
const dbHost = process.env.DB_HOST || 'localhost';
|
||||
|
||||
// ✅ ALWAYS DO THIS
|
||||
import { config } from './config/unifiedConfig';
|
||||
const timeout = config.timeouts.default;
|
||||
const dbHost = config.database.host;
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
**Example of problems:**
|
||||
```typescript
|
||||
// Typo in environment variable name
|
||||
const host = process.env.DB_HSOT; // undefined! No error!
|
||||
|
||||
// Type safety
|
||||
const port = process.env.PORT; // string! Need parseInt
|
||||
const timeout = parseInt(process.env.TIMEOUT); // NaN if not set!
|
||||
```
|
||||
|
||||
**With unifiedConfig:**
|
||||
```typescript
|
||||
const port = config.server.port; // number, guaranteed
|
||||
const timeout = config.timeouts.default; // number, with fallback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Structure
|
||||
|
||||
### UnifiedConfig Interface
|
||||
|
||||
```typescript
|
||||
export interface UnifiedConfig {
|
||||
database: {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
};
|
||||
server: {
|
||||
port: number;
|
||||
sessionSecret: string;
|
||||
};
|
||||
tokens: {
|
||||
jwt: string;
|
||||
inactivity: string;
|
||||
internal: string;
|
||||
};
|
||||
keycloak: {
|
||||
realm: string;
|
||||
client: string;
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
aws: {
|
||||
region: string;
|
||||
emailQueueUrl: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
};
|
||||
sentry: {
|
||||
dsn: string;
|
||||
environment: string;
|
||||
tracesSampleRate: number;
|
||||
};
|
||||
// ... more sections
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
**File:** `/blog-api/src/config/unifiedConfig.ts`
|
||||
|
||||
```typescript
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ini from 'ini';
|
||||
|
||||
const configPath = path.join(__dirname, '../../config.ini');
|
||||
const iniConfig = ini.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
|
||||
export const config: UnifiedConfig = {
|
||||
database: {
|
||||
host: iniConfig.database?.host || process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(iniConfig.database?.port || process.env.DB_PORT || '3306'),
|
||||
username: iniConfig.database?.username || process.env.DB_USER || 'root',
|
||||
password: iniConfig.database?.password || process.env.DB_PASSWORD || '',
|
||||
database: iniConfig.database?.database || process.env.DB_NAME || 'blog_dev',
|
||||
},
|
||||
server: {
|
||||
port: parseInt(iniConfig.server?.port || process.env.PORT || '3002'),
|
||||
sessionSecret: iniConfig.server?.sessionSecret || process.env.SESSION_SECRET || 'dev-secret',
|
||||
},
|
||||
// ... more configuration
|
||||
};
|
||||
|
||||
// Validate critical config
|
||||
if (!config.tokens.jwt) {
|
||||
throw new Error('JWT secret not configured!');
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Read from config.ini first
|
||||
- Fallback to process.env
|
||||
- Default values for development
|
||||
- Validation at startup
|
||||
- Type-safe access
|
||||
|
||||
---
|
||||
|
||||
## Environment-Specific Configs
|
||||
|
||||
### config.ini Structure
|
||||
|
||||
```ini
|
||||
[database]
|
||||
host = localhost
|
||||
port = 3306
|
||||
username = root
|
||||
password = password1
|
||||
database = blog_dev
|
||||
|
||||
[server]
|
||||
port = 3002
|
||||
sessionSecret = your-secret-here
|
||||
|
||||
[tokens]
|
||||
jwt = your-jwt-secret
|
||||
inactivity = 30m
|
||||
internal = internal-api-token
|
||||
|
||||
[keycloak]
|
||||
realm = myapp
|
||||
client = myapp-client
|
||||
baseUrl = http://localhost:8080
|
||||
secret = keycloak-client-secret
|
||||
|
||||
[sentry]
|
||||
dsn = https://your-sentry-dsn
|
||||
environment = development
|
||||
tracesSampleRate = 0.1
|
||||
```
|
||||
|
||||
### Environment Overrides
|
||||
|
||||
```bash
|
||||
# .env file (optional overrides)
|
||||
DB_HOST=production-db.example.com
|
||||
DB_PASSWORD=secure-password
|
||||
PORT=80
|
||||
```
|
||||
|
||||
**Precedence:**
|
||||
1. config.ini (highest priority)
|
||||
2. process.env variables
|
||||
3. Hard-coded defaults (lowest priority)
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### DO NOT Commit Secrets
|
||||
|
||||
```gitignore
|
||||
# .gitignore
|
||||
config.ini
|
||||
.env
|
||||
sentry.ini
|
||||
*.pem
|
||||
*.key
|
||||
```
|
||||
|
||||
### Use Environment Variables in Production
|
||||
|
||||
```typescript
|
||||
// Development: config.ini
|
||||
// Production: Environment variables
|
||||
|
||||
export const config: UnifiedConfig = {
|
||||
database: {
|
||||
password: process.env.DB_PASSWORD || iniConfig.database?.password || '',
|
||||
},
|
||||
tokens: {
|
||||
jwt: process.env.JWT_SECRET || iniConfig.tokens?.jwt || '',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Find All process.env Usage
|
||||
|
||||
```bash
|
||||
grep -r "process.env" blog-api/src/ --include="*.ts" | wc -l
|
||||
```
|
||||
|
||||
### Migration Example
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// Scattered throughout code
|
||||
const timeout = parseInt(process.env.OPENID_HTTP_TIMEOUT_MS || '15000');
|
||||
const keycloakUrl = process.env.KEYCLOAK_BASE_URL;
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { config } from './config/unifiedConfig';
|
||||
|
||||
const timeout = config.keycloak.timeout;
|
||||
const keycloakUrl = config.keycloak.baseUrl;
|
||||
const jwtSecret = config.tokens.jwt;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Type-safe
|
||||
- Centralized
|
||||
- Easy to test
|
||||
- Validated at startup
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [testing-guide.md](testing-guide.md)
|
||||
@@ -0,0 +1,224 @@
|
||||
# Database Patterns - Prisma Best Practices
|
||||
|
||||
Complete guide to database access patterns using Prisma in backend microservices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [PrismaService Usage](#prismaservice-usage)
|
||||
- [Repository Pattern](#repository-pattern)
|
||||
- [Transaction Patterns](#transaction-patterns)
|
||||
- [Query Optimization](#query-optimization)
|
||||
- [N+1 Query Prevention](#n1-query-prevention)
|
||||
- [Error Handling](#error-handling)
|
||||
|
||||
---
|
||||
|
||||
## PrismaService Usage
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import { PrismaService } from '@project-lifecycle-portal/database';
|
||||
|
||||
// Always use PrismaService.main
|
||||
const users = await PrismaService.main.user.findMany();
|
||||
```
|
||||
|
||||
### Check Availability
|
||||
|
||||
```typescript
|
||||
if (!PrismaService.isAvailable) {
|
||||
throw new Error('Prisma client not initialized');
|
||||
}
|
||||
|
||||
const user = await PrismaService.main.user.findUnique({ where: { id } });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
### Why Use Repositories
|
||||
|
||||
✅ **Use repositories when:**
|
||||
- Complex queries with joins/includes
|
||||
- Query used in multiple places
|
||||
- Need caching layer
|
||||
- Want to mock for testing
|
||||
|
||||
❌ **Skip repositories for:**
|
||||
- Simple one-off queries
|
||||
- Prototyping (can refactor later)
|
||||
|
||||
### Repository Template
|
||||
|
||||
```typescript
|
||||
export class UserRepository {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return PrismaService.main.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findActive(): Promise<User[]> {
|
||||
return PrismaService.main.user.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
return PrismaService.main.user.create({ data });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transaction Patterns
|
||||
|
||||
### Simple Transaction
|
||||
|
||||
```typescript
|
||||
const result = await PrismaService.main.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({ data: userData });
|
||||
const profile = await tx.userProfile.create({ data: { userId: user.id } });
|
||||
return { user, profile };
|
||||
});
|
||||
```
|
||||
|
||||
### Interactive Transaction
|
||||
|
||||
```typescript
|
||||
const result = await PrismaService.main.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id } });
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
return await tx.user.update({
|
||||
where: { id },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
},
|
||||
{
|
||||
maxWait: 5000,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Optimization
|
||||
|
||||
### Use select to Limit Fields
|
||||
|
||||
```typescript
|
||||
// ❌ Fetches all fields
|
||||
const users = await PrismaService.main.user.findMany();
|
||||
|
||||
// ✅ Only fetch needed fields
|
||||
const users = await PrismaService.main.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Use include Carefully
|
||||
|
||||
```typescript
|
||||
// ❌ Excessive includes
|
||||
const user = await PrismaService.main.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
profile: true,
|
||||
posts: { include: { comments: true } },
|
||||
workflows: { include: { steps: { include: { actions: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Only include what you need
|
||||
const user = await PrismaService.main.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## N+1 Query Prevention
|
||||
|
||||
### Problem: N+1 Queries
|
||||
|
||||
```typescript
|
||||
// ❌ N+1 Query Problem
|
||||
const users = await PrismaService.main.user.findMany(); // 1 query
|
||||
|
||||
for (const user of users) {
|
||||
// N queries (one per user)
|
||||
const profile = await PrismaService.main.userProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Solution: Use include or Batching
|
||||
|
||||
```typescript
|
||||
// ✅ Single query with include
|
||||
const users = await PrismaService.main.user.findMany({
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
// ✅ Or batch query
|
||||
const userIds = users.map(u => u.id);
|
||||
const profiles = await PrismaService.main.userProfile.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Prisma Error Types
|
||||
|
||||
```typescript
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
try {
|
||||
await PrismaService.main.user.create({ data });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// Unique constraint violation
|
||||
if (error.code === 'P2002') {
|
||||
throw new ConflictError('Email already exists');
|
||||
}
|
||||
|
||||
// Foreign key constraint
|
||||
if (error.code === 'P2003') {
|
||||
throw new ValidationError('Invalid reference');
|
||||
}
|
||||
|
||||
// Record not found
|
||||
if (error.code === 'P2025') {
|
||||
throw new NotFoundError('Record not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [services-and-repositories.md](services-and-repositories.md)
|
||||
- [async-and-errors.md](async-and-errors.md)
|
||||
@@ -0,0 +1,213 @@
|
||||
# Middleware Guide - Express Middleware Patterns
|
||||
|
||||
Complete guide to creating and using middleware in backend microservices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Authentication Middleware](#authentication-middleware)
|
||||
- [Audit Middleware with AsyncLocalStorage](#audit-middleware-with-asynclocalstorage)
|
||||
- [Error Boundary Middleware](#error-boundary-middleware)
|
||||
- [Validation Middleware](#validation-middleware)
|
||||
- [Composable Middleware](#composable-middleware)
|
||||
- [Middleware Ordering](#middleware-ordering)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Middleware
|
||||
|
||||
### SSOMiddleware Pattern
|
||||
|
||||
**File:** `/form/src/middleware/SSOMiddleware.ts`
|
||||
|
||||
```typescript
|
||||
export class SSOMiddlewareClient {
|
||||
static verifyLoginStatus(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies.refresh_token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.tokens.jwt);
|
||||
res.locals.claims = decoded;
|
||||
res.locals.effectiveUserId = decoded.sub;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Middleware with AsyncLocalStorage
|
||||
|
||||
### Excellent Pattern from Blog API
|
||||
|
||||
**File:** `/form/src/middleware/auditMiddleware.ts`
|
||||
|
||||
```typescript
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export interface AuditContext {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
impersonatedBy?: string;
|
||||
sessionId?: string;
|
||||
timestamp: Date;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export const auditContextStorage = new AsyncLocalStorage<AuditContext>();
|
||||
|
||||
export function auditMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const context: AuditContext = {
|
||||
userId: res.locals.effectiveUserId || 'anonymous',
|
||||
userName: res.locals.claims?.preferred_username,
|
||||
impersonatedBy: res.locals.isImpersonating ? res.locals.originalUserId : undefined,
|
||||
timestamp: new Date(),
|
||||
requestId: req.id || uuidv4(),
|
||||
};
|
||||
|
||||
auditContextStorage.run(context, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Getter for current context
|
||||
export function getAuditContext(): AuditContext | null {
|
||||
return auditContextStorage.getStore() || null;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Context propagates through entire request
|
||||
- No need to pass context through every function
|
||||
- Automatically available in services, repositories
|
||||
- Type-safe context access
|
||||
|
||||
**Usage in Services:**
|
||||
```typescript
|
||||
import { getAuditContext } from '../middleware/auditMiddleware';
|
||||
|
||||
async function someOperation() {
|
||||
const context = getAuditContext();
|
||||
console.log('Operation by:', context?.userId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Boundary Middleware
|
||||
|
||||
### Comprehensive Error Handler
|
||||
|
||||
**File:** `/form/src/middleware/errorBoundary.ts`
|
||||
|
||||
```typescript
|
||||
export function errorBoundary(
|
||||
error: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
// Determine status code
|
||||
const statusCode = getStatusCodeForError(error);
|
||||
|
||||
// Capture to Sentry
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel(statusCode >= 500 ? 'error' : 'warning');
|
||||
scope.setTag('error_type', error.name);
|
||||
scope.setContext('error_details', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
|
||||
// User-friendly response
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: getUserFriendlyMessage(error),
|
||||
code: error.name,
|
||||
},
|
||||
requestId: Sentry.getCurrentScope().getPropagationContext().traceId,
|
||||
});
|
||||
}
|
||||
|
||||
// Async wrapper
|
||||
export function asyncErrorWrapper(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await handler(req, res, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composable Middleware
|
||||
|
||||
### withAuthAndAudit Pattern
|
||||
|
||||
```typescript
|
||||
export function withAuthAndAudit(...authMiddleware: any[]) {
|
||||
return [
|
||||
...authMiddleware,
|
||||
auditMiddleware,
|
||||
];
|
||||
}
|
||||
|
||||
// Usage
|
||||
router.post('/:formID/submit',
|
||||
...withAuthAndAudit(SSOMiddlewareClient.verifyLoginStatus),
|
||||
async (req, res) => controller.submit(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Middleware Ordering
|
||||
|
||||
### Critical Order (Must Follow)
|
||||
|
||||
```typescript
|
||||
// 1. Sentry request handler (FIRST)
|
||||
app.use(Sentry.Handlers.requestHandler());
|
||||
|
||||
// 2. Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 3. Cookie parsing
|
||||
app.use(cookieParser());
|
||||
|
||||
// 4. Auth initialization
|
||||
app.use(SSOMiddleware.initialize());
|
||||
|
||||
// 5. Routes registered here
|
||||
app.use('/api/users', userRoutes);
|
||||
|
||||
// 6. Error handler (AFTER routes)
|
||||
app.use(errorBoundary);
|
||||
|
||||
// 7. Sentry error handler (LAST)
|
||||
app.use(Sentry.Handlers.errorHandler());
|
||||
```
|
||||
|
||||
**Rule:** Error handlers MUST be registered AFTER all routes!
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [routing-and-controllers.md](routing-and-controllers.md)
|
||||
- [async-and-errors.md](async-and-errors.md)
|
||||
@@ -0,0 +1,756 @@
|
||||
# Routing and Controllers - Best Practices
|
||||
|
||||
Complete guide to clean route definitions and controller patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Routes: Routing Only](#routes-routing-only)
|
||||
- [BaseController Pattern](#basecontroller-pattern)
|
||||
- [Good Examples](#good-examples)
|
||||
- [Anti-Patterns](#anti-patterns)
|
||||
- [Refactoring Guide](#refactoring-guide)
|
||||
- [Error Handling](#error-handling)
|
||||
- [HTTP Status Codes](#http-status-codes)
|
||||
|
||||
---
|
||||
|
||||
## Routes: Routing Only
|
||||
|
||||
### The Golden Rule
|
||||
|
||||
**Routes should ONLY:**
|
||||
- ✅ Define route paths
|
||||
- ✅ Register middleware
|
||||
- ✅ Delegate to controllers
|
||||
|
||||
**Routes should NEVER:**
|
||||
- ❌ Contain business logic
|
||||
- ❌ Access database directly
|
||||
- ❌ Implement validation logic (use Zod + controller)
|
||||
- ❌ Format complex responses
|
||||
- ❌ Handle complex error scenarios
|
||||
|
||||
### Clean Route Pattern
|
||||
|
||||
```typescript
|
||||
// routes/userRoutes.ts
|
||||
import { Router } from 'express';
|
||||
import { UserController } from '../controllers/UserController';
|
||||
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
|
||||
import { auditMiddleware } from '../middleware/auditMiddleware';
|
||||
|
||||
const router = Router();
|
||||
const controller = new UserController();
|
||||
|
||||
// ✅ CLEAN: Route definition only
|
||||
router.get('/:id',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.getUser(req, res)
|
||||
);
|
||||
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.createUser(req, res)
|
||||
);
|
||||
|
||||
router.put('/:id',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.updateUser(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Each route: method, path, middleware chain, controller delegation
|
||||
- No try-catch needed (controller handles errors)
|
||||
- Clean, readable, maintainable
|
||||
- Easy to see all endpoints at a glance
|
||||
|
||||
---
|
||||
|
||||
## BaseController Pattern
|
||||
|
||||
### Why BaseController?
|
||||
|
||||
**Benefits:**
|
||||
- Consistent error handling across all controllers
|
||||
- Automatic Sentry integration
|
||||
- Standardized response formats
|
||||
- Reusable helper methods
|
||||
- Performance tracking utilities
|
||||
- Logging and breadcrumb helpers
|
||||
|
||||
### BaseController Pattern (Template)
|
||||
|
||||
**File:** `/email/src/controllers/BaseController.ts`
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Response } from 'express';
|
||||
|
||||
export abstract class BaseController {
|
||||
/**
|
||||
* Handle errors with Sentry integration
|
||||
*/
|
||||
protected handleError(
|
||||
error: unknown,
|
||||
res: Response,
|
||||
context: string,
|
||||
statusCode = 500
|
||||
): void {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('controller', this.constructor.name);
|
||||
scope.setTag('operation', context);
|
||||
scope.setUser({ id: res.locals?.claims?.userId });
|
||||
|
||||
if (error instanceof Error) {
|
||||
scope.setContext('error_details', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'An error occurred',
|
||||
code: statusCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle success responses
|
||||
*/
|
||||
protected handleSuccess<T>(
|
||||
res: Response,
|
||||
data: T,
|
||||
message?: string,
|
||||
statusCode = 200
|
||||
): void {
|
||||
res.status(statusCode).json({
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance tracking wrapper
|
||||
*/
|
||||
protected async withTransaction<T>(
|
||||
name: string,
|
||||
operation: string,
|
||||
callback: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return await Sentry.startSpan(
|
||||
{ name, op: operation },
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
*/
|
||||
protected validateRequest(
|
||||
required: string[],
|
||||
actual: Record<string, any>,
|
||||
res: Response
|
||||
): boolean {
|
||||
const missing = required.filter((field) => !actual[field]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
Sentry.captureMessage(
|
||||
`Missing required fields: ${missing.join(', ')}`,
|
||||
'warning'
|
||||
);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Missing required fields',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: { missing },
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging helpers
|
||||
*/
|
||||
protected logInfo(message: string, context?: Record<string, any>): void {
|
||||
Sentry.addBreadcrumb({
|
||||
category: this.constructor.name,
|
||||
message,
|
||||
level: 'info',
|
||||
data: context,
|
||||
});
|
||||
}
|
||||
|
||||
protected logWarning(message: string, context?: Record<string, any>): void {
|
||||
Sentry.captureMessage(message, {
|
||||
level: 'warning',
|
||||
tags: { controller: this.constructor.name },
|
||||
extra: context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Sentry breadcrumb
|
||||
*/
|
||||
protected addBreadcrumb(
|
||||
message: string,
|
||||
category: string,
|
||||
data?: Record<string, any>
|
||||
): void {
|
||||
Sentry.addBreadcrumb({ message, category, level: 'info', data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture custom metric
|
||||
*/
|
||||
protected captureMetric(name: string, value: number, unit: string): void {
|
||||
Sentry.metrics.gauge(name, value, { unit });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using BaseController
|
||||
|
||||
```typescript
|
||||
// controllers/UserController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from './BaseController';
|
||||
import { UserService } from '../services/userService';
|
||||
import { createUserSchema } from '../validators/userSchemas';
|
||||
|
||||
export class UserController extends BaseController {
|
||||
private userService: UserService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
async getUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
this.addBreadcrumb('Fetching user', 'user_controller', { userId: req.params.id });
|
||||
|
||||
const user = await this.userService.findById(req.params.id);
|
||||
|
||||
if (!user) {
|
||||
return this.handleError(
|
||||
new Error('User not found'),
|
||||
res,
|
||||
'getUser',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
this.handleSuccess(res, user);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'getUser');
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Validate input
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
|
||||
// Track performance
|
||||
const user = await this.withTransaction(
|
||||
'user.create',
|
||||
'db.query',
|
||||
() => this.userService.create(validated)
|
||||
);
|
||||
|
||||
this.handleSuccess(res, user, 'User created successfully', 201);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = updateUserSchema.parse(req.body);
|
||||
const user = await this.userService.update(req.params.id, validated);
|
||||
this.handleSuccess(res, user, 'User updated');
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'updateUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Consistent error handling
|
||||
- Automatic Sentry integration
|
||||
- Performance tracking
|
||||
- Clean, readable code
|
||||
- Easy to test
|
||||
|
||||
---
|
||||
|
||||
## Good Examples
|
||||
|
||||
### Example 1: Email Notification Routes (Excellent ✅)
|
||||
|
||||
**File:** `/email/src/routes/notificationRoutes.ts`
|
||||
|
||||
```typescript
|
||||
import { Router } from 'express';
|
||||
import { NotificationController } from '../controllers/NotificationController';
|
||||
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
|
||||
|
||||
const router = Router();
|
||||
const controller = new NotificationController();
|
||||
|
||||
// ✅ EXCELLENT: Clean delegation
|
||||
router.get('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => controller.getNotifications(req, res)
|
||||
);
|
||||
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => controller.createNotification(req, res)
|
||||
);
|
||||
|
||||
router.put('/:id/read',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => controller.markAsRead(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**What Makes This Excellent:**
|
||||
- Zero business logic in routes
|
||||
- Clear middleware chain
|
||||
- Consistent pattern
|
||||
- Easy to understand
|
||||
|
||||
### Example 2: Proxy Routes with Validation (Good ✅)
|
||||
|
||||
**File:** `/form/src/routes/proxyRoutes.ts`
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const createProxySchema = z.object({
|
||||
originalUserID: z.string().min(1),
|
||||
proxyUserID: z.string().min(1),
|
||||
startsAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const validated = createProxySchema.parse(req.body);
|
||||
const proxy = await proxyService.createProxyRelationship(validated);
|
||||
res.status(201).json({ success: true, data: proxy });
|
||||
} catch (error) {
|
||||
handler.handleException(res, error);
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**What Makes This Good:**
|
||||
- Zod validation
|
||||
- Delegates to service
|
||||
- Proper HTTP status codes
|
||||
- Error handling
|
||||
|
||||
**Could Be Better:**
|
||||
- Move validation to controller
|
||||
- Use BaseController
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: Business Logic in Routes (Bad ❌)
|
||||
|
||||
**File:** `/form/src/routes/responseRoutes.ts` (actual production code)
|
||||
|
||||
```typescript
|
||||
// ❌ ANTI-PATTERN: 200+ lines of business logic in route
|
||||
router.post('/:formID/submit', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const username = res.locals.claims.preferred_username;
|
||||
const responses = req.body.responses;
|
||||
const stepInstanceId = req.body.stepInstanceId;
|
||||
|
||||
// ❌ Permission checking in route
|
||||
const userId = await userProfileService.getProfileByEmail(username).then(p => p.id);
|
||||
const canComplete = await permissionService.canCompleteStep(userId, stepInstanceId);
|
||||
if (!canComplete) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
// ❌ Workflow logic in route
|
||||
const { createWorkflowEngine, CompleteStepCommand } = require('../workflow/core/WorkflowEngineV3');
|
||||
const engine = await createWorkflowEngine();
|
||||
const command = new CompleteStepCommand(
|
||||
stepInstanceId,
|
||||
userId,
|
||||
responses,
|
||||
additionalContext
|
||||
);
|
||||
const events = await engine.executeCommand(command);
|
||||
|
||||
// ❌ Impersonation handling in route
|
||||
if (res.locals.isImpersonating) {
|
||||
impersonationContextStore.storeContext(stepInstanceId, {
|
||||
originalUserId: res.locals.originalUserId,
|
||||
effectiveUserId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ Response processing in route
|
||||
const post = await PrismaService.main.post.findUnique({
|
||||
where: { id: postData.id },
|
||||
include: { comments: true },
|
||||
});
|
||||
|
||||
// ❌ Permission check in route
|
||||
await checkPostPermissions(post, userId);
|
||||
|
||||
// ... 100+ more lines of business logic
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (e) {
|
||||
handler.handleException(res, e);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Why This Is Terrible:**
|
||||
- 200+ lines of business logic
|
||||
- Hard to test (requires HTTP mocking)
|
||||
- Hard to reuse (tied to route)
|
||||
- Mixed responsibilities
|
||||
- Difficult to debug
|
||||
- Performance tracking difficult
|
||||
|
||||
### How to Refactor (Step-by-Step)
|
||||
|
||||
**Step 1: Create Controller**
|
||||
|
||||
```typescript
|
||||
// controllers/PostController.ts
|
||||
export class PostController extends BaseController {
|
||||
private postService: PostService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.postService = new PostService();
|
||||
}
|
||||
|
||||
async createPost(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = createPostSchema.parse({
|
||||
...req.body,
|
||||
});
|
||||
|
||||
const result = await this.postService.createPost(
|
||||
validated,
|
||||
res.locals.userId
|
||||
);
|
||||
|
||||
this.handleSuccess(res, result, 'Post created successfully');
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'createPost');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create Service**
|
||||
|
||||
```typescript
|
||||
// services/postService.ts
|
||||
export class PostService {
|
||||
async createPost(
|
||||
data: CreatePostDTO,
|
||||
userId: string
|
||||
): Promise<PostResult> {
|
||||
// Permission check
|
||||
const canCreate = await permissionService.canCreatePost(userId);
|
||||
if (!canCreate) {
|
||||
throw new ForbiddenError('No permission to create post');
|
||||
}
|
||||
|
||||
// Execute workflow
|
||||
const engine = await createWorkflowEngine();
|
||||
const command = new CompleteStepCommand(/* ... */);
|
||||
const events = await engine.executeCommand(command);
|
||||
|
||||
// Handle impersonation if needed
|
||||
if (context.isImpersonating) {
|
||||
await this.handleImpersonation(data.stepInstanceId, context);
|
||||
}
|
||||
|
||||
// Synchronize roles
|
||||
await this.synchronizeRoles(events, userId);
|
||||
|
||||
return { events, success: true };
|
||||
}
|
||||
|
||||
private async handleImpersonation(stepInstanceId: number, context: any) {
|
||||
impersonationContextStore.storeContext(stepInstanceId, {
|
||||
originalUserId: context.originalUserId,
|
||||
effectiveUserId: context.effectiveUserId,
|
||||
});
|
||||
}
|
||||
|
||||
private async synchronizeRoles(events: WorkflowEvent[], userId: string) {
|
||||
// Role synchronization logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update Route**
|
||||
|
||||
```typescript
|
||||
// routes/postRoutes.ts
|
||||
import { PostController } from '../controllers/PostController';
|
||||
|
||||
const router = Router();
|
||||
const controller = new PostController();
|
||||
|
||||
// ✅ CLEAN: Just routing
|
||||
router.post('/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
auditMiddleware,
|
||||
async (req, res) => controller.createPost(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Route: 8 lines (was 200+)
|
||||
- Controller: 25 lines (request handling)
|
||||
- Service: 50 lines (business logic)
|
||||
- Testable, reusable, maintainable!
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Controller Error Handling
|
||||
|
||||
```typescript
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await this.userService.create(req.body);
|
||||
this.handleSuccess(res, result, 'User created', 201);
|
||||
} catch (error) {
|
||||
// BaseController.handleError automatically:
|
||||
// - Captures to Sentry with context
|
||||
// - Sets appropriate status code
|
||||
// - Returns formatted error response
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Error Status Codes
|
||||
|
||||
```typescript
|
||||
async getUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = await this.userService.findById(req.params.id);
|
||||
|
||||
if (!user) {
|
||||
// Custom 404 status
|
||||
return this.handleError(
|
||||
new Error('User not found'),
|
||||
res,
|
||||
'getUser',
|
||||
404 // Custom status code
|
||||
);
|
||||
}
|
||||
|
||||
this.handleSuccess(res, user);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'getUser');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```typescript
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
const user = await this.userService.create(validated);
|
||||
this.handleSuccess(res, user, 'User created', 201);
|
||||
} catch (error) {
|
||||
// Zod errors get 400 status
|
||||
if (error instanceof z.ZodError) {
|
||||
return this.handleError(error, res, 'createUser', 400);
|
||||
}
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
### Standard Codes
|
||||
|
||||
| Code | Use Case | Example |
|
||||
|------|----------|---------|
|
||||
| 200 | Success (GET, PUT) | User retrieved, Updated |
|
||||
| 201 | Created (POST) | User created |
|
||||
| 204 | No Content (DELETE) | User deleted |
|
||||
| 400 | Bad Request | Invalid input data |
|
||||
| 401 | Unauthorized | Not authenticated |
|
||||
| 403 | Forbidden | No permission |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 409 | Conflict | Duplicate resource |
|
||||
| 422 | Unprocessable Entity | Validation failed |
|
||||
| 500 | Internal Server Error | Unexpected error |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// 200 - Success (default)
|
||||
this.handleSuccess(res, user);
|
||||
|
||||
// 201 - Created
|
||||
this.handleSuccess(res, user, 'Created', 201);
|
||||
|
||||
// 400 - Bad Request
|
||||
this.handleError(error, res, 'operation', 400);
|
||||
|
||||
// 404 - Not Found
|
||||
this.handleError(new Error('Not found'), res, 'operation', 404);
|
||||
|
||||
// 403 - Forbidden
|
||||
this.handleError(new ForbiddenError('No permission'), res, 'operation', 403);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Guide
|
||||
|
||||
### Identify Routes Needing Refactoring
|
||||
|
||||
**Red Flags:**
|
||||
- Route file > 100 lines
|
||||
- Multiple try-catch blocks in one route
|
||||
- Direct database access (Prisma calls)
|
||||
- Complex business logic (if statements, loops)
|
||||
- Permission checks in routes
|
||||
|
||||
**Check your routes:**
|
||||
```bash
|
||||
# Find large route files
|
||||
wc -l form/src/routes/*.ts | sort -n
|
||||
|
||||
# Find routes with Prisma usage
|
||||
grep -r "PrismaService" form/src/routes/
|
||||
```
|
||||
|
||||
### Refactoring Process
|
||||
|
||||
**1. Extract to Controller:**
|
||||
```typescript
|
||||
// Before: Route with logic
|
||||
router.post('/action', async (req, res) => {
|
||||
try {
|
||||
// 50 lines of logic
|
||||
} catch (e) {
|
||||
handler.handleException(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// After: Clean route
|
||||
router.post('/action', (req, res) => controller.performAction(req, res));
|
||||
|
||||
// New controller method
|
||||
async performAction(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await this.service.performAction(req.body);
|
||||
this.handleSuccess(res, result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'performAction');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Extract to Service:**
|
||||
```typescript
|
||||
// Controller stays thin
|
||||
async performAction(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const validated = actionSchema.parse(req.body);
|
||||
const result = await this.actionService.execute(validated);
|
||||
this.handleSuccess(res, result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res, 'performAction');
|
||||
}
|
||||
}
|
||||
|
||||
// Service contains business logic
|
||||
export class ActionService {
|
||||
async execute(data: ActionDTO): Promise<Result> {
|
||||
// All business logic here
|
||||
// Permission checks
|
||||
// Database operations
|
||||
// Complex transformations
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Add Repository (if needed):**
|
||||
```typescript
|
||||
// Service calls repository
|
||||
export class ActionService {
|
||||
constructor(private actionRepository: ActionRepository) {}
|
||||
|
||||
async execute(data: ActionDTO): Promise<Result> {
|
||||
// Business logic
|
||||
const entity = await this.actionRepository.findById(data.id);
|
||||
// More logic
|
||||
return await this.actionRepository.update(data.id, changes);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository handles data access
|
||||
export class ActionRepository {
|
||||
async findById(id: number): Promise<Entity | null> {
|
||||
return PrismaService.main.entity.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Entity>): Promise<Entity> {
|
||||
return PrismaService.main.entity.update({ where: { id }, data });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md - Main guide
|
||||
- [services-and-repositories.md](services-and-repositories.md) - Service layer details
|
||||
- [complete-examples.md](complete-examples.md) - Full refactoring examples
|
||||
@@ -0,0 +1,336 @@
|
||||
# Sentry Integration and Monitoring
|
||||
|
||||
Complete guide to error tracking and performance monitoring with Sentry v8.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Principles](#core-principles)
|
||||
- [Sentry Initialization](#sentry-initialization)
|
||||
- [Error Capture Patterns](#error-capture-patterns)
|
||||
- [Performance Monitoring](#performance-monitoring)
|
||||
- [Cron Job Monitoring](#cron-job-monitoring)
|
||||
- [Error Context Best Practices](#error-context-best-practices)
|
||||
- [Common Mistakes](#common-mistakes)
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
**MANDATORY**: All errors MUST be captured to Sentry. No exceptions.
|
||||
|
||||
**ALL ERRORS MUST BE CAPTURED** - Use Sentry v8 with comprehensive error tracking across all services.
|
||||
|
||||
---
|
||||
|
||||
## Sentry Initialization
|
||||
|
||||
### instrument.ts Pattern
|
||||
|
||||
**Location:** `src/instrument.ts` (MUST be first import in server.ts and all cron jobs)
|
||||
|
||||
**Template for Microservices:**
|
||||
|
||||
```typescript
|
||||
import * as Sentry from '@sentry/node';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ini from 'ini';
|
||||
|
||||
const sentryConfigPath = path.join(__dirname, '../sentry.ini');
|
||||
const sentryConfig = ini.parse(fs.readFileSync(sentryConfigPath, 'utf-8'));
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryConfig.sentry?.dsn,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
tracesSampleRate: parseFloat(sentryConfig.sentry?.tracesSampleRate || '0.1'),
|
||||
profilesSampleRate: parseFloat(sentryConfig.sentry?.profilesSampleRate || '0.1'),
|
||||
|
||||
integrations: [
|
||||
...Sentry.getDefaultIntegrations({}),
|
||||
Sentry.extraErrorDataIntegration({ depth: 5 }),
|
||||
Sentry.localVariablesIntegration(),
|
||||
Sentry.requestDataIntegration({
|
||||
include: {
|
||||
cookies: false,
|
||||
data: true,
|
||||
headers: true,
|
||||
ip: true,
|
||||
query_string: true,
|
||||
url: true,
|
||||
user: { id: true, email: true, username: true },
|
||||
},
|
||||
}),
|
||||
Sentry.consoleIntegration(),
|
||||
Sentry.contextLinesIntegration(),
|
||||
Sentry.prismaIntegration(),
|
||||
],
|
||||
|
||||
beforeSend(event, hint) {
|
||||
// Filter health checks
|
||||
if (event.request?.url?.includes('/healthcheck')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scrub sensitive headers
|
||||
if (event.request?.headers) {
|
||||
delete event.request.headers['authorization'];
|
||||
delete event.request.headers['cookie'];
|
||||
}
|
||||
|
||||
// Mask emails for PII
|
||||
if (event.user?.email) {
|
||||
event.user.email = event.user.email.replace(/^(.{2}).*(@.*)$/, '$1***$2');
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
ignoreErrors: [
|
||||
/^Invalid JWT/,
|
||||
/^JWT expired/,
|
||||
'NetworkError',
|
||||
],
|
||||
});
|
||||
|
||||
// Set service context
|
||||
Sentry.setTags({
|
||||
service: 'form',
|
||||
version: '1.0.1',
|
||||
});
|
||||
|
||||
Sentry.setContext('runtime', {
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
});
|
||||
```
|
||||
|
||||
**Critical Points:**
|
||||
- PII protection built-in (beforeSend)
|
||||
- Filter non-critical errors
|
||||
- Comprehensive integrations
|
||||
- Prisma instrumentation
|
||||
- Service-specific tagging
|
||||
|
||||
---
|
||||
|
||||
## Error Capture Patterns
|
||||
|
||||
### 1. BaseController Pattern
|
||||
|
||||
```typescript
|
||||
// Use BaseController.handleError
|
||||
protected handleError(error: unknown, res: Response, context: string, statusCode = 500): void {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('controller', this.constructor.name);
|
||||
scope.setTag('operation', context);
|
||||
scope.setUser({ id: res.locals?.claims?.userId });
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: { message: error instanceof Error ? error.message : 'Error occurred' }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Workflow Error Handling
|
||||
|
||||
```typescript
|
||||
import { SentryHelper } from '../utils/sentryHelper';
|
||||
|
||||
try {
|
||||
await businessOperation();
|
||||
} catch (error) {
|
||||
SentryHelper.captureOperationError(error, {
|
||||
operationType: 'POST_CREATION',
|
||||
entityId: 123,
|
||||
userId: 'user-123',
|
||||
operation: 'createPost',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Service Layer Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await someOperation();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
service: 'form',
|
||||
operation: 'someOperation'
|
||||
},
|
||||
extra: {
|
||||
userId: currentUser.id,
|
||||
entityId: 123
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Database Performance Tracking
|
||||
|
||||
```typescript
|
||||
import { DatabasePerformanceMonitor } from '../utils/databasePerformance';
|
||||
|
||||
const result = await DatabasePerformanceMonitor.withPerformanceTracking(
|
||||
'findMany',
|
||||
'UserProfile',
|
||||
async () => {
|
||||
return await PrismaService.main.userProfile.findMany({ take: 5 });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### API Endpoint Spans
|
||||
|
||||
```typescript
|
||||
router.post('/operation', async (req, res) => {
|
||||
return await Sentry.startSpan({
|
||||
name: 'operation.execute',
|
||||
op: 'http.server',
|
||||
attributes: {
|
||||
'http.method': 'POST',
|
||||
'http.route': '/operation'
|
||||
}
|
||||
}, async () => {
|
||||
const result = await performOperation();
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron Job Monitoring
|
||||
|
||||
### Mandatory Pattern
|
||||
|
||||
```typescript
|
||||
#!/usr/bin/env node
|
||||
import '../instrument'; // FIRST LINE after shebang
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
async function main() {
|
||||
return await Sentry.startSpan({
|
||||
name: 'cron.job-name',
|
||||
op: 'cron',
|
||||
attributes: {
|
||||
'cron.job': 'job-name',
|
||||
'cron.startTime': new Date().toISOString(),
|
||||
}
|
||||
}, async () => {
|
||||
try {
|
||||
// Cron job logic here
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
'cron.job': 'job-name',
|
||||
'error.type': 'execution_error'
|
||||
}
|
||||
});
|
||||
console.error('[Cron] Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
console.log('[Cron] Completed successfully');
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error('[Cron] Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Context Best Practices
|
||||
|
||||
### Rich Context Example
|
||||
|
||||
```typescript
|
||||
Sentry.withScope((scope) => {
|
||||
// User context
|
||||
scope.setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
// Tags for filtering
|
||||
scope.setTag('service', 'form');
|
||||
scope.setTag('endpoint', req.path);
|
||||
scope.setTag('method', req.method);
|
||||
|
||||
// Structured context
|
||||
scope.setContext('operation', {
|
||||
type: 'workflow.complete',
|
||||
workflowId: 123,
|
||||
stepId: 456
|
||||
});
|
||||
|
||||
// Breadcrumbs for timeline
|
||||
scope.addBreadcrumb({
|
||||
category: 'workflow',
|
||||
message: 'Starting step completion',
|
||||
level: 'info',
|
||||
data: { stepId: 456 }
|
||||
});
|
||||
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
```typescript
|
||||
// ❌ Swallowing errors
|
||||
try {
|
||||
await riskyOperation();
|
||||
} catch (error) {
|
||||
// Silent failure
|
||||
}
|
||||
|
||||
// ❌ Generic error messages
|
||||
throw new Error('Error occurred');
|
||||
|
||||
// ❌ Exposing sensitive data
|
||||
Sentry.captureException(error, {
|
||||
extra: { password: user.password } // NEVER
|
||||
});
|
||||
|
||||
// ❌ Missing async error handling
|
||||
async function bad() {
|
||||
fetchData().then(data => processResult(data)); // Unhandled
|
||||
}
|
||||
|
||||
// ✅ Proper async handling
|
||||
async function good() {
|
||||
try {
|
||||
const data = await fetchData();
|
||||
processResult(data);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [routing-and-controllers.md](routing-and-controllers.md)
|
||||
- [async-and-errors.md](async-and-errors.md)
|
||||
@@ -0,0 +1,789 @@
|
||||
# Services and Repositories - Business Logic Layer
|
||||
|
||||
Complete guide to organizing business logic with services and data access with repositories.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Service Layer Overview](#service-layer-overview)
|
||||
- [Dependency Injection Pattern](#dependency-injection-pattern)
|
||||
- [Singleton Pattern](#singleton-pattern)
|
||||
- [Repository Pattern](#repository-pattern)
|
||||
- [Service Design Principles](#service-design-principles)
|
||||
- [Caching Strategies](#caching-strategies)
|
||||
- [Testing Services](#testing-services)
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Overview
|
||||
|
||||
### Purpose of Services
|
||||
|
||||
**Services contain business logic** - the 'what' and 'why' of your application:
|
||||
|
||||
```
|
||||
Controller asks: "Should I do this?"
|
||||
Service answers: "Yes/No, here's why, and here's what happens"
|
||||
Repository executes: "Here's the data you requested"
|
||||
```
|
||||
|
||||
**Services are responsible for:**
|
||||
- ✅ Business rules enforcement
|
||||
- ✅ Orchestrating multiple repositories
|
||||
- ✅ Transaction management
|
||||
- ✅ Complex calculations
|
||||
- ✅ External service integration
|
||||
- ✅ Business validations
|
||||
|
||||
**Services should NOT:**
|
||||
- ❌ Know about HTTP (Request/Response)
|
||||
- ❌ Direct Prisma access (use repositories)
|
||||
- ❌ Handle route-specific logic
|
||||
- ❌ Format HTTP responses
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Pattern
|
||||
|
||||
### Why Dependency Injection?
|
||||
|
||||
**Benefits:**
|
||||
- Easy to test (inject mocks)
|
||||
- Clear dependencies
|
||||
- Flexible configuration
|
||||
- Promotes loose coupling
|
||||
|
||||
### Excellent Example: NotificationService
|
||||
|
||||
**File:** `/blog-api/src/services/NotificationService.ts`
|
||||
|
||||
```typescript
|
||||
// Define dependencies interface for clarity
|
||||
export interface NotificationServiceDependencies {
|
||||
prisma: PrismaClient;
|
||||
batchingService: BatchingService;
|
||||
emailComposer: EmailComposer;
|
||||
}
|
||||
|
||||
// Service with dependency injection
|
||||
export class NotificationService {
|
||||
private prisma: PrismaClient;
|
||||
private batchingService: BatchingService;
|
||||
private emailComposer: EmailComposer;
|
||||
private preferencesCache: Map<string, { preferences: UserPreference; timestamp: number }> = new Map();
|
||||
private CACHE_TTL = (notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;
|
||||
|
||||
// Dependencies injected via constructor
|
||||
constructor(dependencies: NotificationServiceDependencies) {
|
||||
this.prisma = dependencies.prisma;
|
||||
this.batchingService = dependencies.batchingService;
|
||||
this.emailComposer = dependencies.emailComposer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification and route it appropriately
|
||||
*/
|
||||
async createNotification(params: CreateNotificationParams) {
|
||||
const { recipientID, type, title, message, link, context = {}, channel = 'both', priority = NotificationPriority.NORMAL } = params;
|
||||
|
||||
try {
|
||||
// Get template and render content
|
||||
const template = getNotificationTemplate(type);
|
||||
const rendered = renderNotificationContent(template, context);
|
||||
|
||||
// Create in-app notification record
|
||||
const notificationId = await createNotificationRecord({
|
||||
instanceId: parseInt(context.instanceId || '0', 10),
|
||||
template: type,
|
||||
recipientUserId: recipientID,
|
||||
channel: channel === 'email' ? 'email' : 'inApp',
|
||||
contextData: context,
|
||||
title: finalTitle,
|
||||
message: finalMessage,
|
||||
link: finalLink,
|
||||
});
|
||||
|
||||
// Route notification based on channel
|
||||
if (channel === 'email' || channel === 'both') {
|
||||
await this.routeNotification({
|
||||
notificationId,
|
||||
userId: recipientID,
|
||||
type,
|
||||
priority,
|
||||
title: finalTitle,
|
||||
message: finalMessage,
|
||||
link: finalLink,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
ErrorLogger.log(error, {
|
||||
context: {
|
||||
'[NotificationService] createNotification': {
|
||||
type: params.type,
|
||||
recipientID: params.recipientID,
|
||||
},
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification based on user preferences
|
||||
*/
|
||||
private async routeNotification(params: { notificationId: number; userId: string; type: string; priority: NotificationPriority; title: string; message: string; link?: string; context?: Record<string, any> }) {
|
||||
// Get user preferences with caching
|
||||
const preferences = await this.getUserPreferences(params.userId);
|
||||
|
||||
// Check if we should batch or send immediately
|
||||
if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
|
||||
await this.batchingService.queueNotificationForBatch({
|
||||
notificationId: params.notificationId,
|
||||
userId: params.userId,
|
||||
userPreference: preferences,
|
||||
priority: params.priority,
|
||||
});
|
||||
} else {
|
||||
// Send immediately via EmailComposer
|
||||
await this.sendImmediateEmail({
|
||||
userId: params.userId,
|
||||
title: params.title,
|
||||
message: params.message,
|
||||
link: params.link,
|
||||
context: params.context,
|
||||
type: params.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if email should be batched
|
||||
*/
|
||||
shouldBatchEmail(preferences: UserPreference, notificationType: string, priority: NotificationPriority): boolean {
|
||||
// HIGH priority always immediate
|
||||
if (priority === NotificationPriority.HIGH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check batch mode
|
||||
const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
|
||||
return batchMode !== BatchMode.IMMEDIATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user preferences with caching
|
||||
*/
|
||||
async getUserPreferences(userId: string): Promise<UserPreference> {
|
||||
// Check cache first
|
||||
const cached = this.preferencesCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.preferences;
|
||||
}
|
||||
|
||||
const preference = await this.prisma.userPreference.findUnique({
|
||||
where: { userID: userId },
|
||||
});
|
||||
|
||||
const finalPreferences = preference || DEFAULT_PREFERENCES;
|
||||
|
||||
// Update cache
|
||||
this.preferencesCache.set(userId, {
|
||||
preferences: finalPreferences,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return finalPreferences;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in Controller:**
|
||||
|
||||
```typescript
|
||||
// Instantiate with dependencies
|
||||
const notificationService = new NotificationService({
|
||||
prisma: PrismaService.main,
|
||||
batchingService: new BatchingService(PrismaService.main),
|
||||
emailComposer: new EmailComposer(),
|
||||
});
|
||||
|
||||
// Use in controller
|
||||
const notification = await notificationService.createNotification({
|
||||
recipientID: 'user-123',
|
||||
type: 'AFRLWorkflowNotification',
|
||||
context: { workflowName: 'AFRL Monthly Report' },
|
||||
});
|
||||
```
|
||||
|
||||
**Key Takeaways:**
|
||||
- Dependencies passed via constructor
|
||||
- Clear interface defines required dependencies
|
||||
- Easy to test (inject mocks)
|
||||
- Encapsulated caching logic
|
||||
- Business rules isolated from HTTP
|
||||
|
||||
---
|
||||
|
||||
## Singleton Pattern
|
||||
|
||||
### When to Use Singletons
|
||||
|
||||
**Use for:**
|
||||
- Services with expensive initialization
|
||||
- Services with shared state (caching)
|
||||
- Services accessed from many places
|
||||
- Permission services
|
||||
- Configuration services
|
||||
|
||||
### Example: PermissionService (Singleton)
|
||||
|
||||
**File:** `/blog-api/src/services/permissionService.ts`
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
class PermissionService {
|
||||
private static instance: PermissionService;
|
||||
private prisma: PrismaClient;
|
||||
private permissionCache: Map<string, { canAccess: boolean; timestamp: number }> = new Map();
|
||||
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Private constructor prevents direct instantiation
|
||||
private constructor() {
|
||||
this.prisma = PrismaService.main;
|
||||
}
|
||||
|
||||
// Get singleton instance
|
||||
public static getInstance(): PermissionService {
|
||||
if (!PermissionService.instance) {
|
||||
PermissionService.instance = new PermissionService();
|
||||
}
|
||||
return PermissionService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can complete a workflow step
|
||||
*/
|
||||
async canCompleteStep(userId: string, stepInstanceId: number): Promise<boolean> {
|
||||
const cacheKey = `${userId}:${stepInstanceId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = this.permissionCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.canAccess;
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await this.prisma.post.findUnique({
|
||||
where: { id: postId },
|
||||
include: {
|
||||
author: true,
|
||||
comments: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has permission
|
||||
const canEdit = post.authorId === userId ||
|
||||
await this.isUserAdmin(userId);
|
||||
|
||||
// Cache result
|
||||
this.permissionCache.set(cacheKey, {
|
||||
canAccess: isAssigned,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return isAssigned;
|
||||
} catch (error) {
|
||||
console.error('[PermissionService] Error checking step permission:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for user
|
||||
*/
|
||||
clearUserCache(userId: string): void {
|
||||
for (const [key] of this.permissionCache) {
|
||||
if (key.startsWith(`${userId}:`)) {
|
||||
this.permissionCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.permissionCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const permissionService = PermissionService.getInstance();
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { permissionService } from '../services/permissionService';
|
||||
|
||||
// Use anywhere in the codebase
|
||||
const canComplete = await permissionService.canCompleteStep(userId, stepId);
|
||||
|
||||
if (!canComplete) {
|
||||
throw new ForbiddenError('You do not have permission to complete this step');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
### Purpose of Repositories
|
||||
|
||||
**Repositories abstract data access** - the 'how' of data operations:
|
||||
|
||||
```
|
||||
Service: "Get me all active users sorted by name"
|
||||
Repository: "Here's the Prisma query that does that"
|
||||
```
|
||||
|
||||
**Repositories are responsible for:**
|
||||
- ✅ All Prisma operations
|
||||
- ✅ Query construction
|
||||
- ✅ Query optimization (select, include)
|
||||
- ✅ Database error handling
|
||||
- ✅ Caching database results
|
||||
|
||||
**Repositories should NOT:**
|
||||
- ❌ Contain business logic
|
||||
- ❌ Know about HTTP
|
||||
- ❌ Make decisions (that's service layer)
|
||||
|
||||
### Repository Template
|
||||
|
||||
```typescript
|
||||
// repositories/UserRepository.ts
|
||||
import { PrismaService } from '@project-lifecycle-portal/database';
|
||||
import type { User, Prisma } from '@project-lifecycle-portal/database';
|
||||
|
||||
export class UserRepository {
|
||||
/**
|
||||
* Find user by ID with optimized query
|
||||
*/
|
||||
async findById(userId: string): Promise<User | null> {
|
||||
try {
|
||||
return await PrismaService.main.user.findUnique({
|
||||
where: { userID: userId },
|
||||
select: {
|
||||
userID: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isActive: true,
|
||||
roles: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error finding user by ID:', error);
|
||||
throw new Error(`Failed to find user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active users
|
||||
*/
|
||||
async findActive(options?: { orderBy?: Prisma.UserOrderByWithRelationInput }): Promise<User[]> {
|
||||
try {
|
||||
return await PrismaService.main.user.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: options?.orderBy || { name: 'asc' },
|
||||
select: {
|
||||
userID: true,
|
||||
email: true,
|
||||
name: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error finding active users:', error);
|
||||
throw new Error('Failed to find active users');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
try {
|
||||
return await PrismaService.main.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error finding user by email:', error);
|
||||
throw new Error(`Failed to find user with email: ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
*/
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.create({ data });
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error creating user:', error);
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
*/
|
||||
async update(userId: string, data: Prisma.UserUpdateInput): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.update({
|
||||
where: { userID: userId },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error updating user:', error);
|
||||
throw new Error(`Failed to update user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (soft delete by setting isActive = false)
|
||||
*/
|
||||
async delete(userId: string): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.update({
|
||||
where: { userID: userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error deleting user:', error);
|
||||
throw new Error(`Failed to delete user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email exists
|
||||
*/
|
||||
async emailExists(email: string): Promise<boolean> {
|
||||
try {
|
||||
const count = await PrismaService.main.user.count({
|
||||
where: { email },
|
||||
});
|
||||
return count > 0;
|
||||
} catch (error) {
|
||||
console.error('[UserRepository] Error checking email exists:', error);
|
||||
throw new Error('Failed to check if email exists');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const userRepository = new UserRepository();
|
||||
```
|
||||
|
||||
**Using Repository in Service:**
|
||||
|
||||
```typescript
|
||||
// services/userService.ts
|
||||
import { userRepository } from '../repositories/UserRepository';
|
||||
import { ConflictError, NotFoundError } from '../utils/errors';
|
||||
|
||||
export class UserService {
|
||||
/**
|
||||
* Create new user with business rules
|
||||
*/
|
||||
async createUser(data: { email: string; name: string; roles: string[] }): Promise<User> {
|
||||
// Business rule: Check if email already exists
|
||||
const emailExists = await userRepository.emailExists(data.email);
|
||||
if (emailExists) {
|
||||
throw new ConflictError('Email already exists');
|
||||
}
|
||||
|
||||
// Business rule: Validate roles
|
||||
const validRoles = ['admin', 'operations', 'user'];
|
||||
const invalidRoles = data.roles.filter((role) => !validRoles.includes(role));
|
||||
if (invalidRoles.length > 0) {
|
||||
throw new ValidationError(`Invalid roles: ${invalidRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
// Create user via repository
|
||||
return await userRepository.create({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
roles: data.roles,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const user = await userRepository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Design Principles
|
||||
|
||||
### 1. Single Responsibility
|
||||
|
||||
Each service should have ONE clear purpose:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Single responsibility
|
||||
class UserService {
|
||||
async createUser() {}
|
||||
async updateUser() {}
|
||||
async deleteUser() {}
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
async sendEmail() {}
|
||||
async sendBulkEmails() {}
|
||||
}
|
||||
|
||||
// ❌ BAD - Too many responsibilities
|
||||
class UserService {
|
||||
async createUser() {}
|
||||
async sendWelcomeEmail() {} // Should be EmailService
|
||||
async logUserActivity() {} // Should be AuditService
|
||||
async processPayment() {} // Should be PaymentService
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Clear Method Names
|
||||
|
||||
Method names should describe WHAT they do:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Clear intent
|
||||
async createNotification()
|
||||
async getUserPreferences()
|
||||
async shouldBatchEmail()
|
||||
async routeNotification()
|
||||
|
||||
// ❌ BAD - Vague or misleading
|
||||
async process()
|
||||
async handle()
|
||||
async doIt()
|
||||
async execute()
|
||||
```
|
||||
|
||||
### 3. Return Types
|
||||
|
||||
Always use explicit return types:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Explicit types
|
||||
async createUser(data: CreateUserDTO): Promise<User> {}
|
||||
async findUsers(): Promise<User[]> {}
|
||||
async deleteUser(id: string): Promise<void> {}
|
||||
|
||||
// ❌ BAD - Implicit any
|
||||
async createUser(data) {} // No types!
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
Services should throw meaningful errors:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Meaningful errors
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
throw new ConflictError('Email already exists');
|
||||
}
|
||||
|
||||
// ❌ BAD - Generic errors
|
||||
if (!user) {
|
||||
throw new Error('Error'); // What error?
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Avoid God Services
|
||||
|
||||
Don't create services that do everything:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - God service
|
||||
class WorkflowService {
|
||||
async startWorkflow() {}
|
||||
async completeStep() {}
|
||||
async assignRoles() {}
|
||||
async sendNotifications() {} // Should be NotificationService
|
||||
async validatePermissions() {} // Should be PermissionService
|
||||
async logAuditTrail() {} // Should be AuditService
|
||||
// ... 50 more methods
|
||||
}
|
||||
|
||||
// ✅ GOOD - Focused services
|
||||
class WorkflowService {
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
private permissionService: PermissionService,
|
||||
private auditService: AuditService
|
||||
) {}
|
||||
|
||||
async startWorkflow() {
|
||||
// Orchestrate other services
|
||||
await this.permissionService.checkPermission();
|
||||
await this.workflowRepository.create();
|
||||
await this.notificationService.notify();
|
||||
await this.auditService.log();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### 1. In-Memory Caching
|
||||
|
||||
```typescript
|
||||
class UserService {
|
||||
private cache: Map<string, { user: User; timestamp: number }> = new Map();
|
||||
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async getUser(userId: string): Promise<User> {
|
||||
// Check cache
|
||||
const cached = this.cache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const user = await userRepository.findById(userId);
|
||||
|
||||
// Update cache
|
||||
if (user) {
|
||||
this.cache.set(userId, { user, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
clearUserCache(userId: string): void {
|
||||
this.cache.delete(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Cache Invalidation
|
||||
|
||||
```typescript
|
||||
class UserService {
|
||||
async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
|
||||
// Update in database
|
||||
const user = await userRepository.update(userId, data);
|
||||
|
||||
// Invalidate cache
|
||||
this.clearUserCache(userId);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Services
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
// tests/userService.test.ts
|
||||
import { UserService } from '../services/userService';
|
||||
import { userRepository } from '../repositories/UserRepository';
|
||||
import { ConflictError } from '../utils/errors';
|
||||
|
||||
// Mock repository
|
||||
jest.mock('../repositories/UserRepository');
|
||||
|
||||
describe('UserService', () => {
|
||||
let userService: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
userService = new UserService();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create user when email does not exist', async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(false);
|
||||
(userRepository.create as jest.Mock).mockResolvedValue({
|
||||
userID: '123',
|
||||
...userData,
|
||||
});
|
||||
|
||||
// Act
|
||||
const user = await userService.createUser(userData);
|
||||
|
||||
// Assert
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toBe(userData.email);
|
||||
expect(userRepository.emailExists).toHaveBeenCalledWith(userData.email);
|
||||
expect(userRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ConflictError when email exists', async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
email: 'existing@example.com',
|
||||
name: 'Test User',
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
// Act & Assert
|
||||
await expect(userService.createUser(userData)).rejects.toThrow(ConflictError);
|
||||
expect(userRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md - Main guide
|
||||
- [routing-and-controllers.md](routing-and-controllers.md) - Controllers that use services
|
||||
- [database-patterns.md](database-patterns.md) - Prisma and repository patterns
|
||||
- [complete-examples.md](complete-examples.md) - Full service/repository examples
|
||||
@@ -0,0 +1,235 @@
|
||||
# Testing Guide - Backend Testing Strategies
|
||||
|
||||
Complete guide to testing backend services with Jest and best practices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Unit Testing](#unit-testing)
|
||||
- [Integration Testing](#integration-testing)
|
||||
- [Mocking Strategies](#mocking-strategies)
|
||||
- [Test Data Management](#test-data-management)
|
||||
- [Testing Authenticated Routes](#testing-authenticated-routes)
|
||||
- [Coverage Targets](#coverage-targets)
|
||||
|
||||
---
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
```typescript
|
||||
// services/userService.test.ts
|
||||
import { UserService } from './userService';
|
||||
import { UserRepository } from '../repositories/UserRepository';
|
||||
|
||||
jest.mock('../repositories/UserRepository');
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let mockRepository: jest.Mocked<UserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
findByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
} as any;
|
||||
|
||||
service = new UserService();
|
||||
(service as any).userRepository = mockRepository;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw error if email exists', async () => {
|
||||
mockRepository.findByEmail.mockResolvedValue({ id: '123' } as any);
|
||||
|
||||
await expect(
|
||||
service.create({ email: 'test@test.com' })
|
||||
).rejects.toThrow('Email already in use');
|
||||
});
|
||||
|
||||
it('should create user if email is unique', async () => {
|
||||
mockRepository.findByEmail.mockResolvedValue(null);
|
||||
mockRepository.create.mockResolvedValue({ id: '123' } as any);
|
||||
|
||||
const user = await service.create({
|
||||
email: 'test@test.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'test@test.com'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Test with Real Database
|
||||
|
||||
```typescript
|
||||
import { PrismaService } from '@project-lifecycle-portal/database';
|
||||
|
||||
describe('UserService Integration', () => {
|
||||
let testUser: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test data
|
||||
testUser = await PrismaService.main.user.create({
|
||||
data: {
|
||||
email: 'test@test.com',
|
||||
profile: { create: { firstName: 'Test', lastName: 'User' } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await PrismaService.main.user.delete({ where: { id: testUser.id } });
|
||||
});
|
||||
|
||||
it('should find user by email', async () => {
|
||||
const user = await userService.findByEmail('test@test.com');
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.email).toBe('test@test.com');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### Mock PrismaService
|
||||
|
||||
```typescript
|
||||
jest.mock('@project-lifecycle-portal/database', () => ({
|
||||
PrismaService: {
|
||||
main: {
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
isAvailable: true,
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Mock Services
|
||||
|
||||
```typescript
|
||||
const mockUserService = {
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as jest.Mocked<UserService>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Data Management
|
||||
|
||||
### Setup and Teardown
|
||||
|
||||
```typescript
|
||||
describe('PermissionService', () => {
|
||||
let instanceId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test post
|
||||
const post = await PrismaService.main.post.create({
|
||||
data: { title: 'Test Post', content: 'Test', authorId: 'test-user' },
|
||||
});
|
||||
instanceId = post.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await PrismaService.main.post.delete({
|
||||
where: { id: instanceId },
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear caches
|
||||
permissionService.clearCache();
|
||||
});
|
||||
|
||||
it('should check permissions', async () => {
|
||||
const hasPermission = await permissionService.checkPermission(
|
||||
'user-id',
|
||||
instanceId,
|
||||
'VIEW_WORKFLOW'
|
||||
);
|
||||
expect(hasPermission).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Authenticated Routes
|
||||
|
||||
### Using test-auth-route.js
|
||||
|
||||
```bash
|
||||
# Test authenticated endpoint
|
||||
node scripts/test-auth-route.js http://localhost:3002/form/api/users
|
||||
|
||||
# Test with POST data
|
||||
node scripts/test-auth-route.js http://localhost:3002/form/api/users POST '{"email":"test@test.com"}'
|
||||
```
|
||||
|
||||
### Mock Authentication in Tests
|
||||
|
||||
```typescript
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/SSOMiddleware', () => ({
|
||||
SSOMiddlewareClient: {
|
||||
verifyLoginStatus: (req, res, next) => {
|
||||
res.locals.claims = {
|
||||
sub: 'test-user-id',
|
||||
preferred_username: 'testuser',
|
||||
};
|
||||
next();
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
### Recommended Coverage
|
||||
|
||||
- **Unit Tests**: 70%+ coverage
|
||||
- **Integration Tests**: Critical paths covered
|
||||
- **E2E Tests**: Happy paths covered
|
||||
|
||||
### Run Coverage
|
||||
|
||||
```bash
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md
|
||||
- [services-and-repositories.md](services-and-repositories.md)
|
||||
- [complete-examples.md](complete-examples.md)
|
||||
@@ -0,0 +1,754 @@
|
||||
# Validation Patterns - Input Validation with Zod
|
||||
|
||||
Complete guide to input validation using Zod schemas for type-safe validation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Zod?](#why-zod)
|
||||
- [Basic Zod Patterns](#basic-zod-patterns)
|
||||
- [Schema Examples from Codebase](#schema-examples-from-codebase)
|
||||
- [Route-Level Validation](#route-level-validation)
|
||||
- [Controller Validation](#controller-validation)
|
||||
- [DTO Pattern](#dto-pattern)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Advanced Patterns](#advanced-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Why Zod?
|
||||
|
||||
### Benefits Over Joi/Other Libraries
|
||||
|
||||
**Type Safety:**
|
||||
- ✅ Full TypeScript inference
|
||||
- ✅ Runtime + compile-time validation
|
||||
- ✅ Automatic type generation
|
||||
|
||||
**Developer Experience:**
|
||||
- ✅ Intuitive API
|
||||
- ✅ Composable schemas
|
||||
- ✅ Excellent error messages
|
||||
|
||||
**Performance:**
|
||||
- ✅ Fast validation
|
||||
- ✅ Small bundle size
|
||||
- ✅ Tree-shakeable
|
||||
|
||||
### Migration from Joi
|
||||
|
||||
Modern validation uses Zod instead of Joi:
|
||||
|
||||
```typescript
|
||||
// ❌ OLD - Joi (being phased out)
|
||||
const schema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
name: Joi.string().min(3).required(),
|
||||
});
|
||||
|
||||
// ✅ NEW - Zod (preferred)
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(3),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Zod Patterns
|
||||
|
||||
### Primitive Types
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Strings
|
||||
const nameSchema = z.string();
|
||||
const emailSchema = z.string().email();
|
||||
const urlSchema = z.string().url();
|
||||
const uuidSchema = z.string().uuid();
|
||||
const minLengthSchema = z.string().min(3);
|
||||
const maxLengthSchema = z.string().max(100);
|
||||
|
||||
// Numbers
|
||||
const ageSchema = z.number().int().positive();
|
||||
const priceSchema = z.number().positive();
|
||||
const rangeSchema = z.number().min(0).max(100);
|
||||
|
||||
// Booleans
|
||||
const activeSchema = z.boolean();
|
||||
|
||||
// Dates
|
||||
const dateSchema = z.string().datetime(); // ISO 8601 string
|
||||
const nativeDateSchema = z.date(); // Native Date object
|
||||
|
||||
// Enums
|
||||
const roleSchema = z.enum(['admin', 'operations', 'user']);
|
||||
const statusSchema = z.enum(['PENDING', 'APPROVED', 'REJECTED']);
|
||||
```
|
||||
|
||||
### Objects
|
||||
|
||||
```typescript
|
||||
// Simple object
|
||||
const userSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
age: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// Nested objects
|
||||
const addressSchema = z.object({
|
||||
street: z.string(),
|
||||
city: z.string(),
|
||||
zipCode: z.string().regex(/^\d{5}$/),
|
||||
});
|
||||
|
||||
const userWithAddressSchema = z.object({
|
||||
name: z.string(),
|
||||
address: addressSchema,
|
||||
});
|
||||
|
||||
// Optional fields
|
||||
const userSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
// Nullable fields
|
||||
const userSchema = z.object({
|
||||
name: z.string(),
|
||||
middleName: z.string().nullable(),
|
||||
});
|
||||
```
|
||||
|
||||
### Arrays
|
||||
|
||||
```typescript
|
||||
// Array of primitives
|
||||
const rolesSchema = z.array(z.string());
|
||||
const numbersSchema = z.array(z.number());
|
||||
|
||||
// Array of objects
|
||||
const usersSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
// Array with constraints
|
||||
const tagsSchema = z.array(z.string()).min(1).max(10);
|
||||
const nonEmptyArray = z.array(z.string()).nonempty();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema Examples from Codebase
|
||||
|
||||
### Form Validation Schemas
|
||||
|
||||
**File:** `/form/src/helpers/zodSchemas.ts`
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Question types enum
|
||||
export const questionTypeSchema = z.enum([
|
||||
'input',
|
||||
'textbox',
|
||||
'editor',
|
||||
'dropdown',
|
||||
'autocomplete',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'upload',
|
||||
]);
|
||||
|
||||
// Upload types
|
||||
export const uploadTypeSchema = z.array(
|
||||
z.enum(['pdf', 'image', 'excel', 'video', 'powerpoint', 'word']).nullable()
|
||||
);
|
||||
|
||||
// Input types
|
||||
export const inputTypeSchema = z
|
||||
.enum(['date', 'number', 'input', 'currency'])
|
||||
.nullable();
|
||||
|
||||
// Question option
|
||||
export const questionOptionSchema = z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
controlTag: z.string().max(150).nullable().optional(),
|
||||
label: z.string().max(100).nullable().optional(),
|
||||
order: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
// Question schema
|
||||
export const questionSchema = z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
formID: z.number().int().positive(),
|
||||
sectionID: z.number().int().positive().optional(),
|
||||
options: z.array(questionOptionSchema).optional(),
|
||||
label: z.string().max(500),
|
||||
description: z.string().max(5000).optional(),
|
||||
type: questionTypeSchema,
|
||||
uploadTypes: uploadTypeSchema.optional(),
|
||||
inputType: inputTypeSchema.optional(),
|
||||
tags: z.array(z.string().max(150)).optional(),
|
||||
required: z.boolean(),
|
||||
isStandard: z.boolean().optional(),
|
||||
deprecatedKey: z.string().nullable().optional(),
|
||||
maxLength: z.number().int().positive().nullable().optional(),
|
||||
isOptionsSorted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Form section schema
|
||||
export const formSectionSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
formID: z.number().int().positive(),
|
||||
questions: z.array(questionSchema).optional(),
|
||||
label: z.string().max(500),
|
||||
description: z.string().max(5000).optional(),
|
||||
isStandard: z.boolean(),
|
||||
});
|
||||
|
||||
// Create form schema
|
||||
export const createFormSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().max(150),
|
||||
description: z.string().max(6000).nullable().optional(),
|
||||
isPhase: z.boolean().optional(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
// Update order schema
|
||||
export const updateOrderSchema = z.object({
|
||||
source: z.object({
|
||||
index: z.number().int().min(0),
|
||||
sectionID: z.number().int().min(0),
|
||||
}),
|
||||
destination: z.object({
|
||||
index: z.number().int().min(0),
|
||||
sectionID: z.number().int().min(0),
|
||||
}),
|
||||
});
|
||||
|
||||
// Controller-specific validation schemas
|
||||
export const createQuestionValidationSchema = z.object({
|
||||
formID: z.number().int().positive(),
|
||||
sectionID: z.number().int().positive(),
|
||||
question: questionSchema,
|
||||
index: z.number().int().min(0).nullable().optional(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
export const updateQuestionValidationSchema = z.object({
|
||||
questionID: z.number().int().positive(),
|
||||
username: z.string(),
|
||||
question: questionSchema,
|
||||
});
|
||||
```
|
||||
|
||||
### Proxy Relationship Schema
|
||||
|
||||
```typescript
|
||||
// Proxy relationship validation
|
||||
const createProxySchema = z.object({
|
||||
originalUserID: z.string().min(1),
|
||||
proxyUserID: z.string().min(1),
|
||||
startsAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// With custom validation
|
||||
const createProxySchemaWithValidation = createProxySchema.refine(
|
||||
(data) => new Date(data.expiresAt) > new Date(data.startsAt),
|
||||
{
|
||||
message: 'expiresAt must be after startsAt',
|
||||
path: ['expiresAt'],
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Workflow Validation
|
||||
|
||||
```typescript
|
||||
// Workflow start schema
|
||||
const startWorkflowSchema = z.object({
|
||||
workflowCode: z.string().min(1),
|
||||
entityType: z.enum(['Post', 'User', 'Comment']),
|
||||
entityID: z.number().int().positive(),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// Workflow step completion schema
|
||||
const completeStepSchema = z.object({
|
||||
stepInstanceID: z.number().int().positive(),
|
||||
answers: z.record(z.string(), z.any()),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route-Level Validation
|
||||
|
||||
### Pattern 1: Inline Validation
|
||||
|
||||
```typescript
|
||||
// routes/proxyRoutes.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
const createProxySchema = z.object({
|
||||
originalUserID: z.string().min(1),
|
||||
proxyUserID: z.string().min(1),
|
||||
startsAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
SSOMiddlewareClient.verifyLoginStatus,
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Validate at route level
|
||||
const validated = createProxySchema.parse(req.body);
|
||||
|
||||
// Delegate to service
|
||||
const proxy = await proxyService.createProxyRelationship(validated);
|
||||
|
||||
res.status(201).json({ success: true, data: proxy });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Validation failed',
|
||||
details: error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
handler.handleException(res, error);
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Quick and simple
|
||||
- Good for simple routes
|
||||
|
||||
**Cons:**
|
||||
- Validation logic in routes
|
||||
- Harder to test
|
||||
- Not reusable
|
||||
|
||||
---
|
||||
|
||||
## Controller Validation
|
||||
|
||||
### Pattern 2: Controller Validation (Recommended)
|
||||
|
||||
```typescript
|
||||
// validators/userSchemas.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(2).max(100),
|
||||
roles: z.array(z.enum(['admin', 'operations', 'user'])),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(2).max(100).optional(),
|
||||
roles: z.array(z.enum(['admin', 'operations', 'user'])).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateUserDTO = z.infer<typeof createUserSchema>;
|
||||
export type UpdateUserDTO = z.infer<typeof updateUserSchema>;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// controllers/UserController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseController } from './BaseController';
|
||||
import { UserService } from '../services/userService';
|
||||
import { createUserSchema, updateUserSchema } from '../validators/userSchemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export class UserController extends BaseController {
|
||||
private userService: UserService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
async createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Validate input
|
||||
const validated = createUserSchema.parse(req.body);
|
||||
|
||||
// Call service
|
||||
const user = await this.userService.createUser(validated);
|
||||
|
||||
this.handleSuccess(res, user, 'User created successfully', 201);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
// Handle validation errors with 400 status
|
||||
return this.handleError(error, res, 'createUser', 400);
|
||||
}
|
||||
this.handleError(error, res, 'createUser');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Validate params and body
|
||||
const userId = req.params.id;
|
||||
const validated = updateUserSchema.parse(req.body);
|
||||
|
||||
const user = await this.userService.updateUser(userId, validated);
|
||||
|
||||
this.handleSuccess(res, user, 'User updated successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return this.handleError(error, res, 'updateUser', 400);
|
||||
}
|
||||
this.handleError(error, res, 'updateUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Clean separation
|
||||
- Reusable schemas
|
||||
- Easy to test
|
||||
- Type-safe DTOs
|
||||
|
||||
**Cons:**
|
||||
- More files to manage
|
||||
|
||||
---
|
||||
|
||||
## DTO Pattern
|
||||
|
||||
### Type Inference from Schemas
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define schema
|
||||
const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
age: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// Infer TypeScript type from schema
|
||||
type CreateUserDTO = z.infer<typeof createUserSchema>;
|
||||
|
||||
// Equivalent to:
|
||||
// type CreateUserDTO = {
|
||||
// email: string;
|
||||
// name: string;
|
||||
// age: number;
|
||||
// }
|
||||
|
||||
// Use in service
|
||||
class UserService {
|
||||
async createUser(data: CreateUserDTO): Promise<User> {
|
||||
// data is fully typed!
|
||||
console.log(data.email); // ✅ TypeScript knows this exists
|
||||
console.log(data.invalid); // ❌ TypeScript error!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Input vs Output Types
|
||||
|
||||
```typescript
|
||||
// Input schema (what API receives)
|
||||
const createUserInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
// Output schema (what API returns)
|
||||
const userOutputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
// password excluded!
|
||||
});
|
||||
|
||||
type CreateUserInput = z.infer<typeof createUserInputSchema>;
|
||||
type UserOutput = z.infer<typeof userOutputSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Zod Error Format
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const validated = schema.parse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(error.errors);
|
||||
// [
|
||||
// {
|
||||
// code: 'invalid_type',
|
||||
// expected: 'string',
|
||||
// received: 'number',
|
||||
// path: ['email'],
|
||||
// message: 'Expected string, received number'
|
||||
// }
|
||||
// ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Error Messages
|
||||
|
||||
```typescript
|
||||
const userSchema = z.object({
|
||||
email: z.string().email({ message: 'Please provide a valid email address' }),
|
||||
name: z.string().min(2, { message: 'Name must be at least 2 characters' }),
|
||||
age: z.number().int().positive({ message: 'Age must be a positive number' }),
|
||||
});
|
||||
```
|
||||
|
||||
### Formatted Error Response
|
||||
|
||||
```typescript
|
||||
// Helper function to format Zod errors
|
||||
function formatZodError(error: z.ZodError) {
|
||||
return {
|
||||
message: 'Validation failed',
|
||||
errors: error.errors.map((err) => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// In controller
|
||||
catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: formatZodError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Response example:
|
||||
// {
|
||||
// "success": false,
|
||||
// "error": {
|
||||
// "message": "Validation failed",
|
||||
// "errors": [
|
||||
// {
|
||||
// "field": "email",
|
||||
// "message": "Invalid email",
|
||||
// "code": "invalid_string"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Validation
|
||||
|
||||
```typescript
|
||||
// Validate based on other field values
|
||||
const submissionSchema = z.object({
|
||||
type: z.enum(['NEW', 'UPDATE']),
|
||||
postId: z.number().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// If type is UPDATE, postId is required
|
||||
if (data.type === 'UPDATE') {
|
||||
return data.postId !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'postId is required when type is UPDATE',
|
||||
path: ['postId'],
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Transform Data
|
||||
|
||||
```typescript
|
||||
// Transform strings to numbers
|
||||
const userSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.string().transform((val) => parseInt(val, 10)),
|
||||
});
|
||||
|
||||
// Transform dates
|
||||
const eventSchema = z.object({
|
||||
name: z.string(),
|
||||
date: z.string().transform((str) => new Date(str)),
|
||||
});
|
||||
```
|
||||
|
||||
### Preprocess Data
|
||||
|
||||
```typescript
|
||||
// Trim strings before validation
|
||||
const userSchema = z.object({
|
||||
email: z.preprocess(
|
||||
(val) => typeof val === 'string' ? val.trim().toLowerCase() : val,
|
||||
z.string().email()
|
||||
),
|
||||
name: z.preprocess(
|
||||
(val) => typeof val === 'string' ? val.trim() : val,
|
||||
z.string().min(2)
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
### Union Types
|
||||
|
||||
```typescript
|
||||
// Multiple possible types
|
||||
const idSchema = z.union([z.string(), z.number()]);
|
||||
|
||||
// Discriminated unions
|
||||
const notificationSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('email'),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sms'),
|
||||
phoneNumber: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
### Recursive Schemas
|
||||
|
||||
```typescript
|
||||
// For nested structures like trees
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
children?: Category[];
|
||||
};
|
||||
|
||||
const categorySchema: z.ZodType<Category> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
children: z.array(categorySchema).optional(),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Schema Composition
|
||||
|
||||
```typescript
|
||||
// Base schemas
|
||||
const timestampsSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
const auditSchema = z.object({
|
||||
createdBy: z.string(),
|
||||
updatedBy: z.string(),
|
||||
});
|
||||
|
||||
// Compose schemas
|
||||
const userSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
}).merge(timestampsSchema).merge(auditSchema);
|
||||
|
||||
// Extend schemas
|
||||
const adminUserSchema = userSchema.extend({
|
||||
adminLevel: z.number().int().min(1).max(5),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
|
||||
// Pick specific fields
|
||||
const publicUserSchema = userSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
// email excluded
|
||||
});
|
||||
|
||||
// Omit fields
|
||||
const userWithoutTimestamps = userSchema.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Validation Middleware
|
||||
|
||||
```typescript
|
||||
// Create reusable validation middleware
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function validateBody<T extends z.ZodType>(schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Validation failed',
|
||||
details: error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
router.post('/users',
|
||||
validateBody(createUserSchema),
|
||||
async (req, res) => {
|
||||
// req.body is validated and typed!
|
||||
const user = await userService.createUser(req.body);
|
||||
res.json({ success: true, data: user });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- SKILL.md - Main guide
|
||||
- [routing-and-controllers.md](routing-and-controllers.md) - Using validation in controllers
|
||||
- [services-and-repositories.md](services-and-repositories.md) - Using DTOs in services
|
||||
- [async-and-errors.md](async-and-errors.md) - Error handling patterns
|
||||
Reference in New Issue
Block a user