Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
805 lines
21 KiB
Markdown
805 lines
21 KiB
Markdown
# Code Patterns
|
|
|
|
Common code patterns extracted from Architecture Decision Records (ADRs). Use these as templates when writing new code.
|
|
|
|
## Quick Reference
|
|
|
|
| Pattern | Key Function/Class | Import From |
|
|
| -------------------- | ------------------------------------------------- | ------------------------------------- |
|
|
| **tsoa Controllers** | `BaseController`, `@Route`, `@Security` | `src/controllers/base.controller.ts` |
|
|
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
|
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
|
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
|
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
|
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
|
| Authentication | `authenticateJWT`, `@Security('bearerAuth')` | `src/middleware/auth.ts` |
|
|
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
|
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
|
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [tsoa Controllers](#tsoa-controllers)
|
|
- [Error Handling](#error-handling)
|
|
- [Repository Patterns](#repository-patterns)
|
|
- [API Response Patterns](#api-response-patterns)
|
|
- [Transaction Management](#transaction-management)
|
|
- [Input Validation](#input-validation)
|
|
- [Authentication](#authentication)
|
|
- [Caching](#caching)
|
|
- [Background Jobs](#background-jobs)
|
|
- [Feature Flags](#feature-flags)
|
|
|
|
---
|
|
|
|
## tsoa Controllers
|
|
|
|
**ADR**: [ADR-018](../adr/0018-api-documentation-strategy.md), [ADR-059](../adr/0059-dependency-modernization.md)
|
|
|
|
All API endpoints are implemented as tsoa controller classes that extend `BaseController`. This pattern provides type-safe OpenAPI documentation generation and standardized response formatting.
|
|
|
|
### Basic Controller Structure
|
|
|
|
```typescript
|
|
import {
|
|
Route,
|
|
Tags,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Path,
|
|
Query,
|
|
Security,
|
|
SuccessResponse,
|
|
Response,
|
|
} from 'tsoa';
|
|
import type { Request as ExpressRequest } from 'express';
|
|
import {
|
|
BaseController,
|
|
SuccessResponse as SuccessResponseType,
|
|
ErrorResponse,
|
|
} from './base.controller';
|
|
|
|
interface CreateItemRequest {
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface ItemResponse {
|
|
id: number;
|
|
name: string;
|
|
created_at: string;
|
|
}
|
|
|
|
@Route('items')
|
|
@Tags('Items')
|
|
export class ItemController extends BaseController {
|
|
/**
|
|
* Get an item by ID.
|
|
* @summary Get item
|
|
* @param id Item ID
|
|
*/
|
|
@Get('{id}')
|
|
@SuccessResponse(200, 'Item retrieved')
|
|
@Response<ErrorResponse>(404, 'Item not found')
|
|
public async getItem(@Path() id: number): Promise<SuccessResponseType<ItemResponse>> {
|
|
const item = await itemService.getItemById(id);
|
|
return this.success(item);
|
|
}
|
|
|
|
/**
|
|
* Create a new item. Requires authentication.
|
|
* @summary Create item
|
|
*/
|
|
@Post()
|
|
@Security('bearerAuth')
|
|
@SuccessResponse(201, 'Item created')
|
|
@Response<ErrorResponse>(401, 'Not authenticated')
|
|
public async createItem(
|
|
@Body() body: CreateItemRequest,
|
|
@Request() request: ExpressRequest,
|
|
): Promise<SuccessResponseType<ItemResponse>> {
|
|
const user = request.user as UserProfile;
|
|
const item = await itemService.createItem(body, user.user.user_id);
|
|
return this.created(item);
|
|
}
|
|
}
|
|
```
|
|
|
|
### BaseController Response Helpers
|
|
|
|
```typescript
|
|
// Success response (200)
|
|
return this.success(data);
|
|
|
|
// Created response (201)
|
|
return this.created(data);
|
|
|
|
// Paginated response
|
|
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
|
return this.paginated(items, { page, limit, total });
|
|
|
|
// Message-only response
|
|
return this.message('Operation completed');
|
|
|
|
// No content (204)
|
|
return this.noContent();
|
|
```
|
|
|
|
### Authentication with @Security
|
|
|
|
```typescript
|
|
import { Security, Request } from 'tsoa';
|
|
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
|
|
|
// Require authentication
|
|
@Get('profile')
|
|
@Security('bearerAuth')
|
|
public async getProfile(@Request() req: ExpressRequest): Promise<...> {
|
|
const user = req.user as UserProfile;
|
|
return this.success(user);
|
|
}
|
|
|
|
// Require admin role
|
|
@Delete('users/{id}')
|
|
@Security('bearerAuth')
|
|
public async deleteUser(@Path() id: string, @Request() req: ExpressRequest): Promise<void> {
|
|
requireAdminRole(req.user as UserProfile);
|
|
await userService.deleteUser(id);
|
|
return this.noContent();
|
|
}
|
|
```
|
|
|
|
### Error Handling in Controllers
|
|
|
|
```typescript
|
|
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
|
|
|
// Throw errors - they're handled by the global error handler
|
|
throw new NotFoundError('Item', id); // 404
|
|
throw new ValidationError([], 'Invalid'); // 400
|
|
throw new ForbiddenError('Admin only'); // 403
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
```typescript
|
|
import { Middlewares } from 'tsoa';
|
|
import { loginLimiter } from '../config/rateLimiters';
|
|
|
|
@Post('login')
|
|
@Middlewares(loginLimiter)
|
|
@Response<ErrorResponse>(429, 'Too many attempts')
|
|
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
|
```
|
|
|
|
### Regenerating Routes
|
|
|
|
After modifying controllers, regenerate the tsoa routes:
|
|
|
|
```bash
|
|
npm run tsoa:spec && npm run tsoa:routes
|
|
```
|
|
|
|
**Full Guide**: See [TSOA-MIGRATION-GUIDE.md](./TSOA-MIGRATION-GUIDE.md) for comprehensive documentation.
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
**ADR**: [ADR-001](../adr/0001-standardized-error-handling.md)
|
|
|
|
### Repository Layer Error Handling
|
|
|
|
```typescript
|
|
import { handleDbError, NotFoundError } from '../services/db/errors.db';
|
|
import { PoolClient } from 'pg';
|
|
|
|
export async function getFlyerById(id: number, client?: PoolClient): Promise<Flyer> {
|
|
const db = client || pool;
|
|
|
|
try {
|
|
const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new NotFoundError('Flyer', id);
|
|
}
|
|
|
|
return result.rows[0];
|
|
} catch (error) {
|
|
throw handleDbError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Route Layer Error Handling
|
|
|
|
```typescript
|
|
import { sendError } from '../utils/apiResponse';
|
|
|
|
app.get('/api/v1/flyers/:id', async (req, res) => {
|
|
try {
|
|
const flyer = await flyerDb.getFlyerById(parseInt(req.params.id));
|
|
return sendSuccess(res, flyer);
|
|
} catch (error) {
|
|
// IMPORTANT: Use req.originalUrl for dynamic path logging (not hardcoded paths)
|
|
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
|
return sendError(res, error);
|
|
}
|
|
});
|
|
```
|
|
|
|
**Best Practice**: Always use `req.originalUrl.split('?')[0]` in error log messages instead of hardcoded paths. This ensures logs reflect the actual request URL including version prefixes (`/api/v1/`). See [Error Logging Path Patterns](ERROR-LOGGING-PATHS.md) for details.
|
|
|
|
### Custom Error Types
|
|
|
|
```typescript
|
|
// NotFoundError - Entity not found
|
|
throw new NotFoundError('Flyer', id);
|
|
|
|
// ValidationError - Invalid input
|
|
throw new ValidationError('Invalid email format');
|
|
|
|
// DatabaseError - Database operation failed
|
|
throw new DatabaseError('Failed to insert flyer', originalError);
|
|
```
|
|
|
|
---
|
|
|
|
## Repository Patterns
|
|
|
|
**ADR**: [ADR-034](../adr/0034-repository-pattern-standards.md)
|
|
|
|
### Method Naming Conventions
|
|
|
|
| Prefix | Returns | Not Found Behavior | Use Case |
|
|
| ------- | -------------- | -------------------- | ------------------------- |
|
|
| `get*` | Entity | Throws NotFoundError | When entity must exist |
|
|
| `find*` | Entity \| null | Returns null | When entity may not exist |
|
|
| `list*` | Array | Returns [] | When returning multiple |
|
|
|
|
### Get Method (Must Exist)
|
|
|
|
```typescript
|
|
/**
|
|
* Get a flyer by ID. Throws NotFoundError if not found.
|
|
*/
|
|
export async function getFlyerById(id: number, client?: PoolClient): Promise<Flyer> {
|
|
const db = client || pool;
|
|
|
|
try {
|
|
const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new NotFoundError('Flyer', id);
|
|
}
|
|
|
|
return result.rows[0];
|
|
} catch (error) {
|
|
throw handleDbError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Find Method (May Not Exist)
|
|
|
|
```typescript
|
|
/**
|
|
* Find a flyer by ID. Returns null if not found.
|
|
*/
|
|
export async function findFlyerById(id: number, client?: PoolClient): Promise<Flyer | null> {
|
|
const db = client || pool;
|
|
|
|
try {
|
|
const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);
|
|
|
|
return result.rows[0] || null;
|
|
} catch (error) {
|
|
throw handleDbError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
### List Method (Multiple Results)
|
|
|
|
```typescript
|
|
/**
|
|
* List all active flyers. Returns empty array if none found.
|
|
*/
|
|
export async function listActiveFlyers(client?: PoolClient): Promise<Flyer[]> {
|
|
const db = client || pool;
|
|
|
|
try {
|
|
const result = await db.query(
|
|
'SELECT * FROM flyers WHERE end_date >= CURRENT_DATE ORDER BY start_date DESC',
|
|
);
|
|
|
|
return result.rows;
|
|
} catch (error) {
|
|
throw handleDbError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API Response Patterns
|
|
|
|
**ADR**: [ADR-028](../adr/0028-api-response-standardization.md)
|
|
|
|
### Success Response
|
|
|
|
```typescript
|
|
import { sendSuccess } from '../utils/apiResponse';
|
|
|
|
app.post('/api/v1/flyers', async (req, res) => {
|
|
const flyer = await flyerService.createFlyer(req.body);
|
|
// sendSuccess(res, data, statusCode?, meta?)
|
|
return sendSuccess(res, flyer, 201);
|
|
});
|
|
```
|
|
|
|
### Paginated Response
|
|
|
|
```typescript
|
|
import { sendPaginated } from '../utils/apiResponse';
|
|
|
|
app.get('/api/v1/flyers', async (req, res) => {
|
|
const page = parseInt(req.query.page as string) || 1;
|
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
const { items, total } = await flyerService.listFlyers(page, limit);
|
|
|
|
// sendPaginated(res, data[], { page, limit, total }, meta?)
|
|
return sendPaginated(res, items, { page, limit, total });
|
|
});
|
|
```
|
|
|
|
### Error Response
|
|
|
|
```typescript
|
|
import { sendError, sendSuccess, ErrorCode } from '../utils/apiResponse';
|
|
|
|
app.get('/api/v1/flyers/:id', async (req, res) => {
|
|
try {
|
|
const flyer = await flyerDb.getFlyerById(parseInt(req.params.id));
|
|
return sendSuccess(res, flyer);
|
|
} catch (error) {
|
|
// sendError(res, code, message, statusCode?, details?, meta?)
|
|
if (error instanceof NotFoundError) {
|
|
return sendError(res, ErrorCode.NOT_FOUND, error.message, 404);
|
|
}
|
|
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
|
|
return sendError(res, ErrorCode.INTERNAL_ERROR, 'An error occurred', 500);
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Transaction Management
|
|
|
|
**ADR**: [ADR-002](../adr/0002-standardized-transaction-management.md)
|
|
|
|
### Basic Transaction
|
|
|
|
```typescript
|
|
import { withTransaction } from '../services/db/connection.db';
|
|
|
|
export async function createFlyerWithItems(
|
|
flyerData: FlyerInput,
|
|
items: FlyerItemInput[],
|
|
): Promise<Flyer> {
|
|
return withTransaction(async (client) => {
|
|
// Create flyer
|
|
const flyer = await flyerDb.createFlyer(flyerData, client);
|
|
|
|
// Create items
|
|
const createdItems = await flyerItemDb.createItems(
|
|
items.map((item) => ({ ...item, flyer_id: flyer.id })),
|
|
client,
|
|
);
|
|
|
|
// Automatically commits on success, rolls back on error
|
|
return { ...flyer, items: createdItems };
|
|
});
|
|
}
|
|
```
|
|
|
|
### Nested Transactions
|
|
|
|
```typescript
|
|
export async function bulkImportFlyers(flyersData: FlyerInput[]): Promise<ImportResult> {
|
|
return withTransaction(async (client) => {
|
|
const results = [];
|
|
|
|
for (const flyerData of flyersData) {
|
|
try {
|
|
// Each flyer import is atomic
|
|
const flyer = await createFlyerWithItems(
|
|
flyerData,
|
|
flyerData.items,
|
|
client, // Pass transaction client
|
|
);
|
|
results.push({ success: true, flyer });
|
|
} catch (error) {
|
|
results.push({ success: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Input Validation
|
|
|
|
**ADR**: [ADR-003](../adr/0003-standardized-input-validation-using-middleware.md)
|
|
|
|
### Zod Schema Definition
|
|
|
|
```typescript
|
|
// src/schemas/flyer.schemas.ts
|
|
import { z } from 'zod';
|
|
|
|
export const createFlyerSchema = z.object({
|
|
store_id: z.number().int().positive(),
|
|
image_url: z
|
|
.string()
|
|
.url()
|
|
.regex(/^https?:\/\/.*/),
|
|
start_date: z.string().datetime(),
|
|
end_date: z.string().datetime(),
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
name: z.string().min(1).max(255),
|
|
price: z.number().positive(),
|
|
quantity: z.string().optional(),
|
|
}),
|
|
)
|
|
.min(1),
|
|
});
|
|
|
|
export type CreateFlyerInput = z.infer<typeof createFlyerSchema>;
|
|
```
|
|
|
|
### Route Validation Middleware
|
|
|
|
```typescript
|
|
import { validateRequest } from '../middleware/validation';
|
|
import { createFlyerSchema } from '../schemas/flyer.schemas';
|
|
|
|
app.post('/api/v1/flyers', validateRequest(createFlyerSchema), async (req, res) => {
|
|
// req.body is now type-safe and validated
|
|
const flyer = await flyerService.createFlyer(req.body);
|
|
return sendSuccess(res, flyer, 201);
|
|
});
|
|
```
|
|
|
|
### Manual Validation
|
|
|
|
```typescript
|
|
import { createFlyerSchema } from '../schemas/flyer.schemas';
|
|
|
|
export async function processFlyer(data: unknown): Promise<Flyer> {
|
|
// Validate and parse input
|
|
const validated = createFlyerSchema.parse(data);
|
|
|
|
// Type-safe from here on
|
|
return flyerDb.createFlyer(validated);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Authentication
|
|
|
|
**ADR**: [ADR-048](../adr/0048-authentication-strategy.md)
|
|
|
|
### Protected Route with JWT
|
|
|
|
```typescript
|
|
import { authenticateJWT } from '../middleware/auth';
|
|
|
|
app.get(
|
|
'/api/v1/profile',
|
|
authenticateJWT, // Middleware adds req.user
|
|
async (req, res) => {
|
|
// req.user is guaranteed to exist
|
|
const user = await userDb.getUserById(req.user.id);
|
|
return sendSuccess(res, user);
|
|
},
|
|
);
|
|
```
|
|
|
|
### Optional Authentication
|
|
|
|
```typescript
|
|
import { optionalAuth } from '../middleware/auth';
|
|
|
|
app.get(
|
|
'/api/v1/flyers',
|
|
optionalAuth, // req.user may or may not exist
|
|
async (req, res) => {
|
|
const flyers = req.user
|
|
? await flyerDb.listFlyersForUser(req.user.id)
|
|
: await flyerDb.listPublicFlyers();
|
|
|
|
return sendSuccess(res, flyers);
|
|
},
|
|
);
|
|
```
|
|
|
|
### Generate JWT Token
|
|
|
|
```typescript
|
|
import jwt from 'jsonwebtoken';
|
|
import { env } from '../config/env';
|
|
|
|
export function generateToken(user: User): string {
|
|
return jwt.sign({ id: user.id, email: user.email }, env.JWT_SECRET, { expiresIn: '7d' });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Caching
|
|
|
|
**ADR**: [ADR-009](../adr/0009-caching-strategy-for-read-heavy-operations.md)
|
|
|
|
### Cache Pattern
|
|
|
|
```typescript
|
|
import { cacheService } from '../services/cache.server';
|
|
|
|
export async function getFlyer(id: number): Promise<Flyer> {
|
|
// Try cache first
|
|
const cached = await cacheService.get<Flyer>(`flyer:${id}`);
|
|
if (cached) return cached;
|
|
|
|
// Cache miss - fetch from database
|
|
const flyer = await flyerDb.getFlyerById(id);
|
|
|
|
// Store in cache (1 hour TTL)
|
|
await cacheService.set(`flyer:${id}`, flyer, 3600);
|
|
|
|
return flyer;
|
|
}
|
|
```
|
|
|
|
### Cache Invalidation
|
|
|
|
```typescript
|
|
export async function updateFlyer(id: number, data: UpdateFlyerInput): Promise<Flyer> {
|
|
const flyer = await flyerDb.updateFlyer(id, data);
|
|
|
|
// Invalidate cache
|
|
await cacheService.delete(`flyer:${id}`);
|
|
await cacheService.invalidatePattern('flyers:list:*');
|
|
|
|
return flyer;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Background Jobs
|
|
|
|
**ADR**: [ADR-006](../adr/0006-background-job-processing-and-task-queues.md)
|
|
|
|
### Queue Job
|
|
|
|
```typescript
|
|
import { flyerProcessingQueue } from '../services/queues.server';
|
|
|
|
export async function enqueueFlyerProcessing(flyerId: number): Promise<void> {
|
|
await flyerProcessingQueue.add(
|
|
'process-flyer',
|
|
{
|
|
flyerId,
|
|
timestamp: Date.now(),
|
|
},
|
|
{
|
|
attempts: 3,
|
|
backoff: {
|
|
type: 'exponential',
|
|
delay: 2000,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
### Process Job
|
|
|
|
```typescript
|
|
// src/services/workers.server.ts
|
|
import { Worker } from 'bullmq';
|
|
|
|
const flyerWorker = new Worker(
|
|
'flyer-processing',
|
|
async (job) => {
|
|
const { flyerId } = job.data;
|
|
|
|
try {
|
|
// Process flyer
|
|
const result = await aiService.extractFlyerData(flyerId);
|
|
await flyerDb.updateFlyerWithData(flyerId, result);
|
|
|
|
// Update progress
|
|
await job.updateProgress(100);
|
|
|
|
return { success: true, itemCount: result.items.length };
|
|
} catch (error) {
|
|
logger.error('Flyer processing failed', { flyerId, error });
|
|
throw error; // Will retry automatically
|
|
}
|
|
},
|
|
{
|
|
connection: redisConnection,
|
|
concurrency: 5,
|
|
},
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Flags
|
|
|
|
**ADR**: [ADR-024](../adr/0024-feature-flagging-strategy.md)
|
|
|
|
Feature flags enable controlled feature rollout, A/B testing, and quick production disablement without redeployment. All flags default to `false` (opt-in model).
|
|
|
|
### Backend Usage
|
|
|
|
```typescript
|
|
import { isFeatureEnabled, getFeatureFlags } from '../services/featureFlags.server';
|
|
|
|
// Check a specific flag in route handler
|
|
router.get('/dashboard', async (req, res) => {
|
|
if (isFeatureEnabled('newDashboard')) {
|
|
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
|
|
}
|
|
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
|
|
});
|
|
|
|
// Check flag in service layer
|
|
function processFlyer(flyer: Flyer): ProcessedFlyer {
|
|
if (isFeatureEnabled('experimentalAi')) {
|
|
return processWithExperimentalAi(flyer);
|
|
}
|
|
return processWithStandardAi(flyer);
|
|
}
|
|
|
|
// Get all flags (admin endpoint)
|
|
router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
|
|
sendSuccess(res, { flags: getFeatureFlags() });
|
|
});
|
|
```
|
|
|
|
### Frontend Usage
|
|
|
|
```tsx
|
|
import { useFeatureFlag, useAllFeatureFlags } from '../hooks/useFeatureFlag';
|
|
import { FeatureFlag } from '../components/FeatureFlag';
|
|
|
|
// Hook approach - for logic beyond rendering
|
|
function Dashboard() {
|
|
const isNewDashboard = useFeatureFlag('newDashboard');
|
|
|
|
useEffect(() => {
|
|
if (isNewDashboard) {
|
|
analytics.track('new_dashboard_viewed');
|
|
}
|
|
}, [isNewDashboard]);
|
|
|
|
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
|
|
}
|
|
|
|
// Declarative component approach
|
|
function App() {
|
|
return (
|
|
<FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
|
|
<NewDashboard />
|
|
</FeatureFlag>
|
|
);
|
|
}
|
|
|
|
// Debug panel showing all flags
|
|
function DebugPanel() {
|
|
const flags = useAllFeatureFlags();
|
|
return (
|
|
<ul>
|
|
{Object.entries(flags).map(([name, enabled]) => (
|
|
<li key={name}>
|
|
{name}: {enabled ? 'ON' : 'OFF'}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Adding a New Flag
|
|
|
|
1. **Backend** (`src/config/env.ts`):
|
|
|
|
```typescript
|
|
// In featureFlagsSchema
|
|
myNewFeature: booleanString(false), // FEATURE_MY_NEW_FEATURE
|
|
|
|
// In loadEnvVars()
|
|
myNewFeature: process.env.FEATURE_MY_NEW_FEATURE,
|
|
```
|
|
|
|
2. **Frontend** (`src/config.ts` and `src/vite-env.d.ts`):
|
|
|
|
```typescript
|
|
// In config.ts featureFlags section
|
|
myNewFeature: import.meta.env.VITE_FEATURE_MY_NEW_FEATURE === 'true',
|
|
|
|
// In vite-env.d.ts
|
|
readonly VITE_FEATURE_MY_NEW_FEATURE?: string;
|
|
```
|
|
|
|
3. **Environment** (`.env.example`):
|
|
|
|
```bash
|
|
# FEATURE_MY_NEW_FEATURE=false
|
|
# VITE_FEATURE_MY_NEW_FEATURE=false
|
|
```
|
|
|
|
### Testing Feature Flags
|
|
|
|
```typescript
|
|
// Backend - reset modules to test different states
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
process.env.FEATURE_NEW_DASHBOARD = 'true';
|
|
});
|
|
|
|
// Frontend - mock config module
|
|
vi.mock('../config', () => ({
|
|
default: {
|
|
featureFlags: {
|
|
newDashboard: true,
|
|
betaRecipes: false,
|
|
},
|
|
},
|
|
}));
|
|
```
|
|
|
|
### Flag Lifecycle
|
|
|
|
| Phase | Actions |
|
|
| ---------- | -------------------------------------------------------------- |
|
|
| **Add** | Add to schemas (backend + frontend), default `false`, document |
|
|
| **Enable** | Set env var `='true'`, restart application |
|
|
| **Remove** | Remove conditional code, remove from schemas, remove env vars |
|
|
| **Sunset** | Max 3 months after full rollout - remove flag |
|
|
|
|
### Current Flags
|
|
|
|
| Flag | Backend Env Var | Frontend Env Var | Purpose |
|
|
| ---------------- | ------------------------- | ------------------------------ | ------------------------ |
|
|
| `bugsinkSync` | `FEATURE_BUGSINK_SYNC` | `VITE_FEATURE_BUGSINK_SYNC` | Bugsink error sync |
|
|
| `advancedRbac` | `FEATURE_ADVANCED_RBAC` | `VITE_FEATURE_ADVANCED_RBAC` | Advanced RBAC features |
|
|
| `newDashboard` | `FEATURE_NEW_DASHBOARD` | `VITE_FEATURE_NEW_DASHBOARD` | New dashboard experience |
|
|
| `betaRecipes` | `FEATURE_BETA_RECIPES` | `VITE_FEATURE_BETA_RECIPES` | Beta recipe features |
|
|
| `experimentalAi` | `FEATURE_EXPERIMENTAL_AI` | `VITE_FEATURE_EXPERIMENTAL_AI` | Experimental AI features |
|
|
| `debugMode` | `FEATURE_DEBUG_MODE` | `VITE_FEATURE_DEBUG_MODE` | Debug mode |
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [ADR Index](../adr/index.md) - All architecture decision records
|
|
- [TESTING.md](TESTING.md) - Testing patterns
|
|
- [DEBUGGING.md](DEBUGGING.md) - Debugging strategies
|
|
- [Database Guide](../subagents/DATABASE-GUIDE.md) - Database patterns
|
|
- [Coder Reference](../SUBAGENT-CODER-REFERENCE.md) - Quick reference for AI agents
|