[Sprint-2] feat: Add Feature Flags module
- Created feature-flags.service.ts with full CRUD + evaluation - Created feature-flags.controller.ts with 12 endpoints - Created feature-flags.routes.ts with auth/admin middleware - Registered routes in main index.ts Endpoints: - GET/POST /feature-flags (admin) - GET /feature-flags/evaluate (user) - GET /feature-flags/check/:code (user) - User override management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c91b8e5419
commit
504eb082c8
@ -41,6 +41,7 @@ import { marketDataRouter } from './modules/market-data/index.js';
|
||||
import { currencyRouter } from './modules/currency/index.js';
|
||||
import { auditRouter } from './modules/audit/index.js';
|
||||
import { proxyRoutes } from './modules/proxy/index.js';
|
||||
import featureFlagsRouter from './modules/feature-flags/feature-flags.routes.js';
|
||||
|
||||
// Service clients for health checks
|
||||
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
|
||||
@ -161,6 +162,7 @@ apiRouter.use('/notifications', notificationRouter);
|
||||
apiRouter.use('/market-data', marketDataRouter);
|
||||
apiRouter.use('/currency', currencyRouter);
|
||||
apiRouter.use('/audit', auditRouter);
|
||||
apiRouter.use('/feature-flags', featureFlagsRouter); // Sprint 2: Feature Flags
|
||||
apiRouter.use('/proxy', proxyRoutes); // ARCH-001: Gateway to Python services
|
||||
|
||||
// Mount API router
|
||||
|
||||
208
src/modules/feature-flags/feature-flags.controller.ts
Normal file
208
src/modules/feature-flags/feature-flags.controller.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Feature Flags Controller
|
||||
* API endpoints for feature flag management
|
||||
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { featureFlagsService } from './feature-flags.service';
|
||||
|
||||
// ============================================================================
|
||||
// Flag Management (Admin)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /feature-flags
|
||||
* List all feature flags
|
||||
*/
|
||||
export const getAllFlags = async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flags = await featureFlagsService.getAllFlags();
|
||||
res.json({ success: true, data: flags });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /feature-flags/:id
|
||||
* Get a single flag by ID
|
||||
*/
|
||||
export const getFlagById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flag = await featureFlagsService.getFlagById(id);
|
||||
|
||||
if (!flag) {
|
||||
return res.status(404).json({ success: false, error: 'Flag not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: flag });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /feature-flags
|
||||
* Create a new flag
|
||||
*/
|
||||
export const createFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flag = await featureFlagsService.createFlag({
|
||||
...req.body,
|
||||
createdBy: req.user?.email || 'system',
|
||||
});
|
||||
res.status(201).json({ success: true, data: flag });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /feature-flags/:id
|
||||
* Update a flag
|
||||
*/
|
||||
export const updateFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flag = await featureFlagsService.updateFlag(id, req.body);
|
||||
res.json({ success: true, data: flag });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /feature-flags/:id
|
||||
* Delete a flag
|
||||
*/
|
||||
export const deleteFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await featureFlagsService.deleteFlag(id);
|
||||
res.json({ success: true, message: 'Flag deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /feature-flags/:id/toggle
|
||||
* Toggle flag status
|
||||
*/
|
||||
export const toggleFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { enabled } = req.query;
|
||||
|
||||
const flag = await featureFlagsService.toggleFlag(id, enabled === 'true');
|
||||
res.json({ success: true, data: flag });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Flag Evaluation (User-facing)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /feature-flags/evaluate/:code
|
||||
* Evaluate a single flag for the current user
|
||||
*/
|
||||
export const evaluateFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
const enabled = await featureFlagsService.evaluateFlag(code, userId);
|
||||
res.json({ success: true, data: { code, enabled } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /feature-flags/evaluate
|
||||
* Evaluate all flags for the current user
|
||||
*/
|
||||
export const evaluateAllFlags = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const evaluations = await featureFlagsService.evaluateAllFlags(userId);
|
||||
res.json({ success: true, data: evaluations });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /feature-flags/check/:code
|
||||
* Quick check if flag is enabled (simplified response)
|
||||
*/
|
||||
export const checkFlag = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
const enabled = await featureFlagsService.checkFlag(code, userId);
|
||||
res.json({ enabled });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Overrides
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /feature-flags/user/:userId/overrides
|
||||
* Get user's flag overrides
|
||||
*/
|
||||
export const getUserOverrides = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const overrides = await featureFlagsService.getUserOverrides(userId);
|
||||
res.json({ success: true, data: overrides });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /feature-flags/user/override
|
||||
* Set a user override
|
||||
*/
|
||||
export const setUserOverride = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { userId, flagCode, isEnabled, reason, expiresAt } = req.body;
|
||||
|
||||
const override = await featureFlagsService.setUserOverride(
|
||||
userId,
|
||||
flagCode,
|
||||
isEnabled,
|
||||
reason,
|
||||
expiresAt ? new Date(expiresAt) : undefined
|
||||
);
|
||||
|
||||
res.json({ success: true, data: override });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /feature-flags/user/:userId/override/:flagCode
|
||||
* Remove a user override
|
||||
*/
|
||||
export const removeUserOverride = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { userId, flagCode } = req.params;
|
||||
await featureFlagsService.removeUserOverride(userId, flagCode);
|
||||
res.json({ success: true, message: 'Override removed' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
38
src/modules/feature-flags/feature-flags.routes.ts
Normal file
38
src/modules/feature-flags/feature-flags.routes.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Feature Flags Routes
|
||||
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { authMiddleware, adminMiddleware } from '../../core/middleware/auth.middleware';
|
||||
import * as controller from './feature-flags.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============================================================================
|
||||
// Public/User Routes (require auth)
|
||||
// ============================================================================
|
||||
|
||||
// Evaluate flags for current user
|
||||
router.get('/evaluate', authMiddleware, controller.evaluateAllFlags);
|
||||
router.get('/evaluate/:code', authMiddleware, controller.evaluateFlag);
|
||||
router.get('/check/:code', authMiddleware, controller.checkFlag);
|
||||
|
||||
// ============================================================================
|
||||
// Admin Routes (require admin role)
|
||||
// ============================================================================
|
||||
|
||||
// Flag CRUD
|
||||
router.get('/', authMiddleware, adminMiddleware, controller.getAllFlags);
|
||||
router.get('/:id', authMiddleware, adminMiddleware, controller.getFlagById);
|
||||
router.post('/', authMiddleware, adminMiddleware, controller.createFlag);
|
||||
router.put('/:id', authMiddleware, adminMiddleware, controller.updateFlag);
|
||||
router.delete('/:id', authMiddleware, adminMiddleware, controller.deleteFlag);
|
||||
router.post('/:id/toggle', authMiddleware, adminMiddleware, controller.toggleFlag);
|
||||
|
||||
// User overrides (admin only)
|
||||
router.get('/user/:userId/overrides', authMiddleware, adminMiddleware, controller.getUserOverrides);
|
||||
router.post('/user/override', authMiddleware, adminMiddleware, controller.setUserOverride);
|
||||
router.delete('/user/:userId/override/:flagCode', authMiddleware, adminMiddleware, controller.removeUserOverride);
|
||||
|
||||
export default router;
|
||||
447
src/modules/feature-flags/feature-flags.service.ts
Normal file
447
src/modules/feature-flags/feature-flags.service.ts
Normal file
@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Feature Flags Service
|
||||
* Manages feature flags for gradual rollouts and A/B testing
|
||||
* @created Sprint 2 - TASK-2026-01-30-ANALISIS-INTEGRACION
|
||||
*/
|
||||
|
||||
import { db } from '../../shared/database';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type FlagStatus = 'disabled' | 'enabled' | 'percentage';
|
||||
export type RolloutStage = 'development' | 'beta' | 'production';
|
||||
|
||||
export interface FeatureFlag {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
status: FlagStatus;
|
||||
rolloutStage: RolloutStage;
|
||||
rolloutPercentage: number;
|
||||
defaultValue: boolean;
|
||||
targetingRules: TargetingRule[];
|
||||
metadata: Record<string, unknown>;
|
||||
tags: string[];
|
||||
isPermanent: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export interface TargetingRule {
|
||||
type: 'plan' | 'role' | 'user_attribute';
|
||||
operator: 'eq' | 'neq' | 'in' | 'not_in' | 'gt' | 'lt';
|
||||
attribute?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
export interface UserFlagOverride {
|
||||
id: string;
|
||||
userId: string;
|
||||
flagId: string;
|
||||
isEnabled: boolean;
|
||||
reason: string | null;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateFlagInput {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
status?: FlagStatus;
|
||||
rolloutStage?: RolloutStage;
|
||||
rolloutPercentage?: number;
|
||||
defaultValue?: boolean;
|
||||
targetingRules?: TargetingRule[];
|
||||
metadata?: Record<string, unknown>;
|
||||
tags?: string[];
|
||||
isPermanent?: boolean;
|
||||
expiresAt?: Date;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFlagInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
status?: FlagStatus;
|
||||
rolloutStage?: RolloutStage;
|
||||
rolloutPercentage?: number;
|
||||
defaultValue?: boolean;
|
||||
targetingRules?: TargetingRule[];
|
||||
metadata?: Record<string, unknown>;
|
||||
tags?: string[];
|
||||
isPermanent?: boolean;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface FlagEvaluation {
|
||||
flagCode: string;
|
||||
enabled: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Row Types
|
||||
// ============================================================================
|
||||
|
||||
interface FlagRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
status: string;
|
||||
rollout_stage: string;
|
||||
rollout_percentage: number;
|
||||
default_value: boolean;
|
||||
targeting_rules: string;
|
||||
metadata: string;
|
||||
tags: string;
|
||||
is_permanent: boolean;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
interface UserFlagRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
flag_id: string;
|
||||
is_enabled: boolean;
|
||||
reason: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
class FeatureFlagsService {
|
||||
/**
|
||||
* Get all feature flags
|
||||
*/
|
||||
async getAllFlags(): Promise<FeatureFlag[]> {
|
||||
const result = await db.query<FlagRow>(
|
||||
`SELECT * FROM feature_flags.flags ORDER BY category, name`
|
||||
);
|
||||
return result.rows.map(this.transformFlag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flag by ID
|
||||
*/
|
||||
async getFlagById(id: string): Promise<FeatureFlag | null> {
|
||||
const result = await db.query<FlagRow>(
|
||||
`SELECT * FROM feature_flags.flags WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? this.transformFlag(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flag by code
|
||||
*/
|
||||
async getFlagByCode(code: string): Promise<FeatureFlag | null> {
|
||||
const result = await db.query<FlagRow>(
|
||||
`SELECT * FROM feature_flags.flags WHERE code = $1`,
|
||||
[code]
|
||||
);
|
||||
return result.rows[0] ? this.transformFlag(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new feature flag
|
||||
*/
|
||||
async createFlag(input: CreateFlagInput): Promise<FeatureFlag> {
|
||||
const {
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
category = 'general',
|
||||
status = 'disabled',
|
||||
rolloutStage = 'development',
|
||||
rolloutPercentage = 0,
|
||||
defaultValue = false,
|
||||
targetingRules = [],
|
||||
metadata = {},
|
||||
tags = [],
|
||||
isPermanent = false,
|
||||
expiresAt,
|
||||
createdBy,
|
||||
} = input;
|
||||
|
||||
const result = await db.query<FlagRow>(
|
||||
`INSERT INTO feature_flags.flags (
|
||||
code, name, description, category, status, rollout_stage,
|
||||
rollout_percentage, default_value, targeting_rules, metadata,
|
||||
tags, is_permanent, expires_at, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
code,
|
||||
name,
|
||||
description || null,
|
||||
category,
|
||||
status,
|
||||
rolloutStage,
|
||||
rolloutPercentage,
|
||||
defaultValue,
|
||||
JSON.stringify(targetingRules),
|
||||
JSON.stringify(metadata),
|
||||
JSON.stringify(tags),
|
||||
isPermanent,
|
||||
expiresAt || null,
|
||||
createdBy || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info('[FeatureFlagsService] Flag created:', { code, status });
|
||||
return this.transformFlag(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feature flag
|
||||
*/
|
||||
async updateFlag(id: string, input: UpdateFlagInput): Promise<FeatureFlag> {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(input.name);
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex++}`);
|
||||
values.push(input.description);
|
||||
}
|
||||
if (input.category !== undefined) {
|
||||
updates.push(`category = $${paramIndex++}`);
|
||||
values.push(input.category);
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex++}`);
|
||||
values.push(input.status);
|
||||
}
|
||||
if (input.rolloutStage !== undefined) {
|
||||
updates.push(`rollout_stage = $${paramIndex++}`);
|
||||
values.push(input.rolloutStage);
|
||||
}
|
||||
if (input.rolloutPercentage !== undefined) {
|
||||
updates.push(`rollout_percentage = $${paramIndex++}`);
|
||||
values.push(input.rolloutPercentage);
|
||||
}
|
||||
if (input.defaultValue !== undefined) {
|
||||
updates.push(`default_value = $${paramIndex++}`);
|
||||
values.push(input.defaultValue);
|
||||
}
|
||||
if (input.targetingRules !== undefined) {
|
||||
updates.push(`targeting_rules = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(input.targetingRules));
|
||||
}
|
||||
if (input.metadata !== undefined) {
|
||||
updates.push(`metadata = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(input.metadata));
|
||||
}
|
||||
if (input.tags !== undefined) {
|
||||
updates.push(`tags = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(input.tags));
|
||||
}
|
||||
if (input.isPermanent !== undefined) {
|
||||
updates.push(`is_permanent = $${paramIndex++}`);
|
||||
values.push(input.isPermanent);
|
||||
}
|
||||
if (input.expiresAt !== undefined) {
|
||||
updates.push(`expires_at = $${paramIndex++}`);
|
||||
values.push(input.expiresAt);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const result = await db.query<FlagRow>(
|
||||
`UPDATE feature_flags.flags SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Flag not found');
|
||||
}
|
||||
|
||||
logger.info('[FeatureFlagsService] Flag updated:', { id });
|
||||
return this.transformFlag(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feature flag
|
||||
*/
|
||||
async deleteFlag(id: string): Promise<void> {
|
||||
const result = await db.query(
|
||||
`DELETE FROM feature_flags.flags WHERE id = $1 AND is_permanent = false`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Flag not found or is permanent');
|
||||
}
|
||||
|
||||
logger.info('[FeatureFlagsService] Flag deleted:', { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle flag status
|
||||
*/
|
||||
async toggleFlag(id: string, enabled: boolean): Promise<FeatureFlag> {
|
||||
return this.updateFlag(id, { status: enabled ? 'enabled' : 'disabled' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a flag for a user using database function
|
||||
*/
|
||||
async evaluateFlag(code: string, userId?: string): Promise<boolean> {
|
||||
const result = await db.query<{ evaluate_flag: boolean }>(
|
||||
`SELECT feature_flags.evaluate_flag($1, $2) as evaluate_flag`,
|
||||
[code, userId || null]
|
||||
);
|
||||
return result.rows[0]?.evaluate_flag || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all flags for a user
|
||||
*/
|
||||
async evaluateAllFlags(userId?: string): Promise<FlagEvaluation[]> {
|
||||
const flags = await this.getAllFlags();
|
||||
const evaluations: FlagEvaluation[] = [];
|
||||
|
||||
for (const flag of flags) {
|
||||
const enabled = await this.evaluateFlag(flag.code, userId);
|
||||
evaluations.push({
|
||||
flagCode: flag.code,
|
||||
enabled,
|
||||
reason: flag.status === 'percentage' ? 'percentage' : 'default',
|
||||
});
|
||||
}
|
||||
|
||||
return evaluations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if flag is enabled (cached in memory)
|
||||
*/
|
||||
async checkFlag(code: string, userId?: string): Promise<boolean> {
|
||||
return this.evaluateFlag(code, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user override
|
||||
*/
|
||||
async setUserOverride(
|
||||
userId: string,
|
||||
flagCode: string,
|
||||
isEnabled: boolean,
|
||||
reason?: string,
|
||||
expiresAt?: Date
|
||||
): Promise<UserFlagOverride> {
|
||||
const flag = await this.getFlagByCode(flagCode);
|
||||
if (!flag) {
|
||||
throw new Error('Flag not found');
|
||||
}
|
||||
|
||||
const result = await db.query<UserFlagRow>(
|
||||
`INSERT INTO feature_flags.user_flags (user_id, flag_id, is_enabled, reason, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, flag_id) DO UPDATE SET
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
reason = EXCLUDED.reason,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[userId, flag.id, isEnabled, reason || null, expiresAt || null]
|
||||
);
|
||||
|
||||
logger.info('[FeatureFlagsService] User override set:', { userId, flagCode, isEnabled });
|
||||
return this.transformUserFlag(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user override
|
||||
*/
|
||||
async removeUserOverride(userId: string, flagCode: string): Promise<void> {
|
||||
const flag = await this.getFlagByCode(flagCode);
|
||||
if (!flag) {
|
||||
throw new Error('Flag not found');
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`DELETE FROM feature_flags.user_flags WHERE user_id = $1 AND flag_id = $2`,
|
||||
[userId, flag.id]
|
||||
);
|
||||
|
||||
logger.info('[FeatureFlagsService] User override removed:', { userId, flagCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user overrides
|
||||
*/
|
||||
async getUserOverrides(userId: string): Promise<UserFlagOverride[]> {
|
||||
const result = await db.query<UserFlagRow>(
|
||||
`SELECT * FROM feature_flags.user_flags WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map(this.transformUserFlag);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transform Functions
|
||||
// ============================================================================
|
||||
|
||||
private transformFlag(row: FlagRow): FeatureFlag {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
status: row.status as FlagStatus,
|
||||
rolloutStage: row.rollout_stage as RolloutStage,
|
||||
rolloutPercentage: row.rollout_percentage,
|
||||
defaultValue: row.default_value,
|
||||
targetingRules: JSON.parse(row.targeting_rules || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
isPermanent: row.is_permanent,
|
||||
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
createdBy: row.created_by,
|
||||
};
|
||||
}
|
||||
|
||||
private transformUserFlag(row: UserFlagRow): UserFlagOverride {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
flagId: row.flag_id,
|
||||
isEnabled: row.is_enabled,
|
||||
reason: row.reason,
|
||||
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const featureFlagsService = new FeatureFlagsService();
|
||||
Loading…
Reference in New Issue
Block a user