diff --git a/prisma/migrations/20260827000000_add_feature_flags_and_cron_jobs/migration.sql b/prisma/migrations/20260827000000_add_feature_flags_and_cron_jobs/migration.sql new file mode 100644 index 0000000..1673c1d --- /dev/null +++ b/prisma/migrations/20260827000000_add_feature_flags_and_cron_jobs/migration.sql @@ -0,0 +1,41 @@ +-- Migration: add_feature_flags_and_cron_jobs +-- Adds FeatureFlag and CronJobRun models + +-- FeatureFlag: stores named feature toggles with optional rollout percentage +CREATE TABLE "feature_flags" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "description" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "rolloutPercent" INTEGER NOT NULL DEFAULT 100, + "allowedRoles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "allowedUserIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "feature_flags_key_key" ON "feature_flags"("key"); + +-- CronJobRun: one row per execution of a named cron job +CREATE TYPE "CronJobStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED', 'SKIPPED'); + +CREATE TABLE "cron_job_runs" ( + "id" TEXT NOT NULL, + "jobName" TEXT NOT NULL, + "status" "CronJobStatus" NOT NULL DEFAULT 'RUNNING', + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "durationMs" INTEGER, + "message" TEXT, + "error" TEXT, + "metadata" JSONB, + + CONSTRAINT "cron_job_runs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "cron_job_runs_jobName_idx" ON "cron_job_runs"("jobName"); +CREATE INDEX "cron_job_runs_startedAt_idx" ON "cron_job_runs"("startedAt"); +CREATE INDEX "cron_job_runs_status_idx" ON "cron_job_runs"("status"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d895353..5cdf033 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1359,3 +1359,50 @@ model CourseDraft { @@map("course_drafts") } + +// ============================================================ +// FEATURE FLAGS +// ============================================================ + +model FeatureFlag { + id String @id @default(uuid()) + key String @unique // e.g. "new_payment_flow", "stellar_v2_calls" + description String? + enabled Boolean @default(false) + rolloutPercent Int @default(100) // 0-100 — percentage of users that see the flag + allowedRoles String[] @default([]) // restrict to specific roles (empty = all roles) + allowedUserIds String[] @default([]) // explicit user allow-list (empty = no extra restriction) + metadata Json? // arbitrary extra config for the flag + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("feature_flags") +} + +// ============================================================ +// CRON JOB MONITORING +// ============================================================ + +enum CronJobStatus { + RUNNING + SUCCESS + FAILED + SKIPPED +} + +model CronJobRun { + id String @id @default(uuid()) + jobName String // e.g. "purge-audit-logs", "payout-scheduler" + status CronJobStatus @default(RUNNING) + startedAt DateTime @default(now()) + finishedAt DateTime? + durationMs Int? // wall-clock time in milliseconds + message String? // human-readable outcome summary + error String? // error message / stack if FAILED + metadata Json? // job-specific output (rows affected, etc.) + + @@index([jobName]) + @@index([startedAt]) + @@index([status]) + @@map("cron_job_runs") +} diff --git a/src/modules/cron-monitor/cron-monitor.controller.ts b/src/modules/cron-monitor/cron-monitor.controller.ts new file mode 100644 index 0000000..b810657 --- /dev/null +++ b/src/modules/cron-monitor/cron-monitor.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Roles, RolesGuard } from '../../common/guards/roles.guard'; +import { CronMonitorService } from './cron-monitor.service'; +import { QueryCronRunsDto } from './dto/query-cron-runs.dto'; + +@ApiTags('cron-monitor') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@Controller('cron-monitor') +export class CronMonitorController { + constructor(private readonly cronMonitorService: CronMonitorService) {} + + @Get('runs') + @ApiOperation({ + summary: 'List cron job run history with pagination and filters (admin)', + }) + findAll(@Query() query: QueryCronRunsDto) { + return this.cronMonitorService.findAll(query); + } + + @Get('stats') + @ApiOperation({ + summary: + 'Aggregated stats per job: success rate, avg duration, last run/success/failure (30-day window)', + }) + getStats() { + return this.cronMonitorService.getStats(); + } + + @Get('stale') + @ApiOperation({ + summary: + 'List RUNNING cron job runs that have been active longer than the stale threshold (admin)', + }) + @ApiQuery({ + name: 'thresholdMinutes', + required: false, + description: 'Minutes after which a RUNNING run is considered stale (default: 30)', + }) + findStale(@Query('thresholdMinutes') thresholdMinutes?: string) { + return this.cronMonitorService.findStaleRuns( + thresholdMinutes ? parseInt(thresholdMinutes, 10) : undefined, + ); + } + + @Post('stale/mark-failed') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Manually trigger stale-run cleanup — marks zombie RUNNING runs as FAILED (admin)', + }) + @ApiQuery({ + name: 'thresholdMinutes', + required: false, + description: 'Minutes threshold (default: 30)', + }) + markStaleFailed(@Query('thresholdMinutes') thresholdMinutes?: string) { + return this.cronMonitorService.markStaleRunsFailed( + thresholdMinutes ? parseInt(thresholdMinutes, 10) : undefined, + ); + } + + @Get('runs/:id') + @ApiOperation({ summary: 'Get a single cron job run by ID (admin)' }) + @ApiParam({ name: 'id', description: 'CronJobRun UUID' }) + findOne(@Param('id') id: string) { + return this.cronMonitorService.findOne(id); + } +} diff --git a/src/modules/cron-monitor/cron-monitor.module.ts b/src/modules/cron-monitor/cron-monitor.module.ts new file mode 100644 index 0000000..5dae4d5 --- /dev/null +++ b/src/modules/cron-monitor/cron-monitor.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CronMonitorService } from './cron-monitor.service'; +import { CronMonitorController } from './cron-monitor.controller'; +import { CronMonitorScheduler } from './cron-monitor.scheduler'; + +@Module({ + imports: [ConfigModule], + controllers: [CronMonitorController], + providers: [CronMonitorService, CronMonitorScheduler], + exports: [CronMonitorService], // export so any cron service can call .start() / .run() +}) +export class CronMonitorModule {} diff --git a/src/modules/cron-monitor/cron-monitor.scheduler.ts b/src/modules/cron-monitor/cron-monitor.scheduler.ts new file mode 100644 index 0000000..0690795 --- /dev/null +++ b/src/modules/cron-monitor/cron-monitor.scheduler.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { CronMonitorService } from './cron-monitor.service'; + +/** + * CronMonitorScheduler + * + * Houses the housekeeping cron jobs for the cron monitor itself: + * - Every 15 minutes: mark zombie RUNNING runs as FAILED + * - Daily at midnight: purge old run history (configurable retention) + */ +@Injectable() +export class CronMonitorScheduler { + private readonly logger = new Logger(CronMonitorScheduler.name); + + constructor( + private readonly cronMonitor: CronMonitorService, + private readonly config: ConfigService, + ) {} + + @Cron('0 */15 * * * *', { name: 'cron-monitor-stale-cleanup' }) + async cleanupStaleRuns() { + const thresholdMinutes = this.config.get( + 'CRON_STALE_THRESHOLD_MINUTES', + 30, + ); + const result = await this.cronMonitor.markStaleRunsFailed(thresholdMinutes); + if (result.marked > 0) { + this.logger.warn(`Cleaned up ${result.marked} stale cron run(s)`); + } + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { name: 'cron-monitor-purge' }) + async purgeHistory() { + const retentionDays = this.config.get( + 'CRON_RUN_RETENTION_DAYS', + 90, + ); + await this.cronMonitor.run('cron-monitor-purge', async () => { + const result = await this.cronMonitor.purgeOldRuns(retentionDays); + this.logger.log(`Purged ${result.deleted} old cron run records`); + return result; + }); + } +} diff --git a/src/modules/cron-monitor/cron-monitor.service.ts b/src/modules/cron-monitor/cron-monitor.service.ts new file mode 100644 index 0000000..ecb3548 --- /dev/null +++ b/src/modules/cron-monitor/cron-monitor.service.ts @@ -0,0 +1,325 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { CronJobStatus, Prisma } from '@prisma/client'; +import { QueryCronRunsDto } from './dto/query-cron-runs.dto'; + +export interface CronRunHandle { + /** Call this when the job finishes successfully */ + success(message?: string, metadata?: Record): Promise; + /** Call this when the job fails */ + fail(error: unknown, metadata?: Record): Promise; + /** Call this when the job was skipped (e.g. nothing to process) */ + skip(message?: string): Promise; +} + +/** + * CronMonitorService + * + * Wraps every cron job execution in a CronJobRun database record so you can: + * - See the full history of every scheduled job + * - Spot missed or failing runs at a glance + * - Track average duration and last success/failure time + * - Alert when a job exceeds a duration threshold + * + * Usage in a cron service: + * + * const run = await this.cronMonitor.start('my-job-name'); + * try { + * // ... do work ... + * await run.success('Processed 42 rows', { rows: 42 }); + * } catch (err) { + * await run.fail(err); + * } + */ +@Injectable() +export class CronMonitorService { + private readonly logger = new Logger(CronMonitorService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ---------------------------------------------------------- + // INSTRUMENTATION API + // ---------------------------------------------------------- + + /** + * Record the start of a cron job run. + * Returns a handle with success / fail / skip methods to close the run. + */ + async start(jobName: string): Promise { + const run = await this.prisma.cronJobRun.create({ + data: { jobName, status: CronJobStatus.RUNNING }, + }); + const startedAt = run.startedAt; + this.logger.debug(`[CronMonitor] ${jobName} started (id=${run.id})`); + + const finish = async ( + status: CronJobStatus, + message?: string, + error?: string, + metadata?: Record, + ) => { + const finishedAt = new Date(); + const durationMs = finishedAt.getTime() - startedAt.getTime(); + + await this.prisma.cronJobRun.update({ + where: { id: run.id }, + data: { + status, + finishedAt, + durationMs, + message: message ?? null, + error: error ?? null, + metadata: (metadata as Prisma.InputJsonValue) ?? Prisma.JsonNull, + }, + }); + + const logMsg = `[CronMonitor] ${jobName} ${status} in ${durationMs}ms`; + if (status === CronJobStatus.FAILED) { + this.logger.error(logMsg + (error ? ` — ${error}` : '')); + } else { + this.logger.log(logMsg); + } + }; + + return { + success: (message, metadata) => + finish(CronJobStatus.SUCCESS, message, undefined, metadata), + fail: (err, metadata) => + finish( + CronJobStatus.FAILED, + undefined, + err instanceof Error ? err.message : String(err), + metadata, + ), + skip: (message) => + finish(CronJobStatus.SKIPPED, message ?? 'skipped'), + }; + } + + /** + * Convenience wrapper: runs an async callback and automatically records + * success or failure. Rethrows the error after recording it. + */ + async run( + jobName: string, + callback: () => Promise, + options?: { skipIf?: () => boolean | Promise }, + ): Promise { + if (options?.skipIf) { + const shouldSkip = await options.skipIf(); + if (shouldSkip) { + const handle = await this.start(jobName); + await handle.skip('skipIf condition was true'); + return undefined as unknown as T; + } + } + + const handle = await this.start(jobName); + try { + const result = await callback(); + await handle.success(); + return result; + } catch (err) { + await handle.fail(err); + throw err; + } + } + + // ---------------------------------------------------------- + // QUERY API + // ---------------------------------------------------------- + + async findAll(query: QueryCronRunsDto) { + const page = Math.max(1, query.page ?? 1); + const limit = Math.min(200, Math.max(1, query.limit ?? 50)); + const skip = (page - 1) * limit; + + const where: Prisma.CronJobRunWhereInput = {}; + if (query.jobName) where.jobName = query.jobName; + if (query.status) where.status = query.status; + if (query.from || query.to) { + where.startedAt = {}; + if (query.from) where.startedAt.gte = new Date(query.from); + if (query.to) where.startedAt.lte = new Date(query.to); + } + + const [data, total] = await Promise.all([ + this.prisma.cronJobRun.findMany({ + where, + skip, + take: limit, + orderBy: { startedAt: 'desc' }, + }), + this.prisma.cronJobRun.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 0, + }, + }; + } + + async findOne(id: string) { + const run = await this.prisma.cronJobRun.findUnique({ where: { id } }); + if (!run) throw new NotFoundException(`CronJobRun "${id}" not found`); + return run; + } + + /** + * Aggregated stats per job name: last run, last success, last failure, + * success rate, average duration over the last 30 days. + */ + async getStats() { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days + + const runs = await this.prisma.cronJobRun.findMany({ + where: { startedAt: { gte: since } }, + orderBy: { startedAt: 'desc' }, + }); + + // Group by jobName + const grouped = new Map< + string, + { + total: number; + success: number; + failed: number; + skipped: number; + running: number; + totalDurationMs: number; + durationCount: number; + lastRun: Date | null; + lastSuccess: Date | null; + lastFailure: Date | null; + lastError: string | null; + } + >(); + + for (const run of runs) { + if (!grouped.has(run.jobName)) { + grouped.set(run.jobName, { + total: 0, + success: 0, + failed: 0, + skipped: 0, + running: 0, + totalDurationMs: 0, + durationCount: 0, + lastRun: null, + lastSuccess: null, + lastFailure: null, + lastError: null, + }); + } + const g = grouped.get(run.jobName)!; + g.total++; + + switch (run.status) { + case CronJobStatus.SUCCESS: g.success++; break; + case CronJobStatus.FAILED: g.failed++; break; + case CronJobStatus.SKIPPED: g.skipped++; break; + case CronJobStatus.RUNNING: g.running++; break; + } + + if (run.durationMs != null) { + g.totalDurationMs += run.durationMs; + g.durationCount++; + } + + if (!g.lastRun || run.startedAt > g.lastRun) g.lastRun = run.startedAt; + + if (run.status === CronJobStatus.SUCCESS) { + if (!g.lastSuccess || run.startedAt > g.lastSuccess) + g.lastSuccess = run.startedAt; + } + if (run.status === CronJobStatus.FAILED) { + if (!g.lastFailure || run.startedAt > g.lastFailure) { + g.lastFailure = run.startedAt; + g.lastError = run.error; + } + } + } + + return Array.from(grouped.entries()) + .map(([jobName, g]) => ({ + jobName, + periodDays: 30, + total: g.total, + success: g.success, + failed: g.failed, + skipped: g.skipped, + running: g.running, + successRate: + g.total > 0 + ? Math.round((g.success / (g.total - g.running - g.skipped)) * 100 * 10) / 10 + : null, + avgDurationMs: + g.durationCount > 0 + ? Math.round(g.totalDurationMs / g.durationCount) + : null, + lastRun: g.lastRun, + lastSuccess: g.lastSuccess, + lastFailure: g.lastFailure, + lastError: g.lastError, + })) + .sort((a, b) => a.jobName.localeCompare(b.jobName)); + } + + /** + * Find stale RUNNING runs (started more than `thresholdMinutes` ago). + * These are likely zombies from a crashed process. + */ + async findStaleRuns(thresholdMinutes = 30) { + const threshold = new Date(Date.now() - thresholdMinutes * 60 * 1000); + return this.prisma.cronJobRun.findMany({ + where: { + status: CronJobStatus.RUNNING, + startedAt: { lt: threshold }, + }, + orderBy: { startedAt: 'asc' }, + }); + } + + /** + * Mark stale RUNNING runs as FAILED. + * Called by a cleanup cron job (defined inside this service). + */ + async markStaleRunsFailed(thresholdMinutes = 30) { + const stale = await this.findStaleRuns(thresholdMinutes); + if (stale.length === 0) return { marked: 0 }; + + const ids = stale.map((r) => r.id); + await this.prisma.cronJobRun.updateMany({ + where: { id: { in: ids } }, + data: { + status: CronJobStatus.FAILED, + finishedAt: new Date(), + error: `Marked as FAILED by stale-run cleanup (threshold: ${thresholdMinutes} min)`, + }, + }); + + this.logger.warn( + `[CronMonitor] Marked ${stale.length} stale RUNNING run(s) as FAILED: ${ids.join(', ')}`, + ); + return { marked: stale.length, ids }; + } + + /** + * Purge old cron run history (keeps the last `retentionDays` days). + */ + async purgeOldRuns(retentionDays = 90) { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); + const result = await this.prisma.cronJobRun.deleteMany({ + where: { startedAt: { lt: cutoff } }, + }); + this.logger.log( + `[CronMonitor] Purged ${result.count} cron run records older than ${retentionDays} days`, + ); + return { deleted: result.count }; + } +} diff --git a/src/modules/cron-monitor/dto/query-cron-runs.dto.ts b/src/modules/cron-monitor/dto/query-cron-runs.dto.ts new file mode 100644 index 0000000..89b1c29 --- /dev/null +++ b/src/modules/cron-monitor/dto/query-cron-runs.dto.ts @@ -0,0 +1,41 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { CronJobStatus } from '@prisma/client'; + +export class QueryCronRunsDto { + @ApiPropertyOptional({ description: 'Filter by job name' }) + @IsOptional() + @IsString() + jobName?: string; + + @ApiPropertyOptional({ enum: CronJobStatus, description: 'Filter by run status' }) + @IsOptional() + @IsEnum(CronJobStatus) + status?: CronJobStatus; + + @ApiPropertyOptional({ description: 'ISO date-time lower bound for startedAt' }) + @IsOptional() + @IsString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO date-time upper bound for startedAt' }) + @IsOptional() + @IsString() + to?: string; + + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(200) + @Type(() => Number) + limit?: number; +} diff --git a/src/modules/feature-flags/dto/create-feature-flag.dto.ts b/src/modules/feature-flags/dto/create-feature-flag.dto.ts new file mode 100644 index 0000000..496d036 --- /dev/null +++ b/src/modules/feature-flags/dto/create-feature-flag.dto.ts @@ -0,0 +1,85 @@ +import { + IsArray, + IsBoolean, + IsInt, + IsJSON, + IsOptional, + IsString, + Length, + Matches, + Max, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; + +export class CreateFeatureFlagDto { + @ApiProperty({ + description: + 'Unique machine-readable key for the flag (snake_case, alphanumeric + underscores)', + example: 'new_payment_flow', + }) + @IsString() + @Length(1, 100) + @Matches(/^[a-z0-9_]+$/, { + message: 'key must be lowercase alphanumeric with underscores only', + }) + key: string; + + @ApiPropertyOptional({ description: 'Human-readable description of the flag' }) + @IsOptional() + @IsString() + @Length(0, 500) + description?: string; + + @ApiPropertyOptional({ + description: 'Whether the flag is active', + default: false, + }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiPropertyOptional({ + description: + 'Percentage of users (0–100) to expose the flag to when enabled', + default: 100, + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + @Type(() => Number) + rolloutPercent?: number; + + @ApiPropertyOptional({ + description: + 'Restrict the flag to these roles only (STUDENT | INSTRUCTOR | ADMIN). Empty = no role restriction.', + type: [String], + example: ['ADMIN', 'INSTRUCTOR'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedRoles?: string[]; + + @ApiPropertyOptional({ + description: + 'Explicit allow-list of user IDs regardless of rollout percentage', + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedUserIds?: string[]; + + @ApiPropertyOptional({ + description: 'Arbitrary JSON metadata for flag configuration', + example: { variant: 'A', minVersion: '2.1.0' }, + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'object' ? value : JSON.parse(value), + ) + metadata?: Record; +} diff --git a/src/modules/feature-flags/dto/evaluate-feature-flag.dto.ts b/src/modules/feature-flags/dto/evaluate-feature-flag.dto.ts new file mode 100644 index 0000000..a696761 --- /dev/null +++ b/src/modules/feature-flags/dto/evaluate-feature-flag.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class EvaluateFeatureFlagDto { + @ApiPropertyOptional({ + description: 'User ID to evaluate the flag for (deterministic rollout)', + }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ + description: 'User role to check against allowedRoles (STUDENT | INSTRUCTOR | ADMIN)', + example: 'STUDENT', + }) + @IsOptional() + @IsString() + role?: string; +} diff --git a/src/modules/feature-flags/dto/update-feature-flag.dto.ts b/src/modules/feature-flags/dto/update-feature-flag.dto.ts new file mode 100644 index 0000000..6831816 --- /dev/null +++ b/src/modules/feature-flags/dto/update-feature-flag.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateFeatureFlagDto } from './create-feature-flag.dto'; + +/** + * All fields from CreateFeatureFlagDto become optional. + * The `key` field is intentionally omitted — keys are immutable after creation. + */ +export class UpdateFeatureFlagDto extends PartialType(CreateFeatureFlagDto) {} diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..88f46f3 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -0,0 +1,155 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Roles, RolesGuard } from '../../common/guards/roles.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { FeatureFlagsService } from './feature-flags.service'; +import { CreateFeatureFlagDto } from './dto/create-feature-flag.dto'; +import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto'; +import { EvaluateFeatureFlagDto } from './dto/evaluate-feature-flag.dto'; + +@ApiTags('feature-flags') +@Controller('feature-flags') +export class FeatureFlagsController { + constructor(private readonly featureFlagsService: FeatureFlagsService) {} + + // ---------------------------------------------------------- + // ADMIN — CRUD + // ---------------------------------------------------------- + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create a new feature flag (admin)' }) + create(@Body() dto: CreateFeatureFlagDto) { + return this.featureFlagsService.create(dto); + } + + @Get() + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List all feature flags (admin)' }) + findAll() { + return this.featureFlagsService.findAll(); + } + + @Get(':key') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get a single feature flag by key (admin)' }) + @ApiParam({ name: 'key', description: 'Feature flag key, e.g. new_payment_flow' }) + findOne(@Param('key') key: string) { + return this.featureFlagsService.findOne(key); + } + + @Patch(':key') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update a feature flag (admin)' }) + @ApiParam({ name: 'key', description: 'Feature flag key' }) + update(@Param('key') key: string, @Body() dto: UpdateFeatureFlagDto) { + return this.featureFlagsService.update(key, dto); + } + + @Delete(':key') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Delete a feature flag (admin)' }) + @ApiParam({ name: 'key', description: 'Feature flag key' }) + remove(@Param('key') key: string) { + return this.featureFlagsService.remove(key); + } + + // ---------------------------------------------------------- + // ADMIN — TOGGLE SHORTCUTS + // ---------------------------------------------------------- + + @Post(':key/enable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Enable a feature flag (admin shortcut)' }) + @ApiParam({ name: 'key', description: 'Feature flag key' }) + enable(@Param('key') key: string) { + return this.featureFlagsService.enable(key); + } + + @Post(':key/disable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Disable a feature flag (admin shortcut)' }) + @ApiParam({ name: 'key', description: 'Feature flag key' }) + disable(@Param('key') key: string) { + return this.featureFlagsService.disable(key); + } + + // ---------------------------------------------------------- + // EVALUATION — available to authenticated users + // ---------------------------------------------------------- + + @Get(':key/evaluate') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Evaluate a single flag for the calling user (or a specified context)', + }) + @ApiParam({ name: 'key', description: 'Feature flag key' }) + evaluate( + @Param('key') key: string, + @CurrentUser('id') userId: string, + @CurrentUser('role') role: string, + @Query() query: EvaluateFeatureFlagDto, + ) { + return this.featureFlagsService.evaluate(key, { + userId: query.userId ?? userId, + role: query.role ?? role, + }); + } + + @Get('evaluate/all') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Bulk-evaluate all flags for the calling user — use for SDK bootstrapping', + }) + evaluateAll( + @CurrentUser('id') userId: string, + @CurrentUser('role') role: string, + @Query() query: EvaluateFeatureFlagDto, + ) { + return this.featureFlagsService.evaluateAll({ + userId: query.userId ?? userId, + role: query.role ?? role, + }); + } +} diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..e52eea1 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FeatureFlagsService } from './feature-flags.service'; +import { FeatureFlagsController } from './feature-flags.controller'; + +@Module({ + controllers: [FeatureFlagsController], + providers: [FeatureFlagsService], + exports: [FeatureFlagsService], // export so other modules can call isEnabled() +}) +export class FeatureFlagsModule {} diff --git a/src/modules/feature-flags/feature-flags.service.ts b/src/modules/feature-flags/feature-flags.service.ts new file mode 100644 index 0000000..1016f7e --- /dev/null +++ b/src/modules/feature-flags/feature-flags.service.ts @@ -0,0 +1,237 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { CreateFeatureFlagDto } from './dto/create-feature-flag.dto'; +import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto'; +import { Prisma } from '@prisma/client'; + +/** + * FeatureFlagsService + * + * Provides: + * - CRUD management for feature flags (admin only) + * - Flag evaluation with deterministic rollout, role filtering, and user allow-list + * - Bulk flag evaluation for SDK-style client bootstrapping + * + * Rollout algorithm: + * A flag is enabled for a user when ALL of the following are true: + * 1. flag.enabled === true + * 2. Role check passes (allowedRoles empty OR user role is in the list) + * 3. User is in the allow-list OR falls within the rollout bucket + * - Bucket = fnv1a(userId + key) % 100 < rolloutPercent + * - If no userId, bucket defaults to 0 (always in if rolloutPercent > 0) + */ +@Injectable() +export class FeatureFlagsService { + private readonly logger = new Logger(FeatureFlagsService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ---------------------------------------------------------- + // CRUD + // ---------------------------------------------------------- + + async create(dto: CreateFeatureFlagDto) { + const existing = await this.prisma.featureFlag.findUnique({ + where: { key: dto.key }, + }); + if (existing) { + throw new ConflictException(`Feature flag "${dto.key}" already exists`); + } + + const flag = await this.prisma.featureFlag.create({ + data: { + key: dto.key, + description: dto.description ?? null, + enabled: dto.enabled ?? false, + rolloutPercent: dto.rolloutPercent ?? 100, + allowedRoles: dto.allowedRoles ?? [], + allowedUserIds: dto.allowedUserIds ?? [], + metadata: (dto.metadata as Prisma.InputJsonValue) ?? Prisma.JsonNull, + }, + }); + + this.logger.log(`Feature flag created: "${flag.key}" (enabled=${flag.enabled})`); + return flag; + } + + async findAll() { + return this.prisma.featureFlag.findMany({ + orderBy: { key: 'asc' }, + }); + } + + async findOne(key: string) { + const flag = await this.prisma.featureFlag.findUnique({ where: { key } }); + if (!flag) { + throw new NotFoundException(`Feature flag "${key}" not found`); + } + return flag; + } + + async update(key: string, dto: UpdateFeatureFlagDto) { + await this.findOne(key); // ensure it exists + + const flag = await this.prisma.featureFlag.update({ + where: { key }, + data: { + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.enabled !== undefined && { enabled: dto.enabled }), + ...(dto.rolloutPercent !== undefined && { rolloutPercent: dto.rolloutPercent }), + ...(dto.allowedRoles !== undefined && { allowedRoles: dto.allowedRoles }), + ...(dto.allowedUserIds !== undefined && { allowedUserIds: dto.allowedUserIds }), + ...(dto.metadata !== undefined && { + metadata: dto.metadata as Prisma.InputJsonValue, + }), + }, + }); + + this.logger.log( + `Feature flag updated: "${flag.key}" (enabled=${flag.enabled}, rollout=${flag.rolloutPercent}%)`, + ); + return flag; + } + + async remove(key: string) { + await this.findOne(key); // ensure it exists + await this.prisma.featureFlag.delete({ where: { key } }); + this.logger.log(`Feature flag deleted: "${key}"`); + return { message: `Feature flag "${key}" deleted` }; + } + + // ---------------------------------------------------------- + // TOGGLE SHORTCUTS + // ---------------------------------------------------------- + + async enable(key: string) { + return this.update(key, { enabled: true }); + } + + async disable(key: string) { + return this.update(key, { enabled: false }); + } + + // ---------------------------------------------------------- + // EVALUATION + // ---------------------------------------------------------- + + /** + * Evaluate a single flag for a specific user context. + * Returns { key, enabled: boolean, reason } + */ + async evaluate( + key: string, + context: { userId?: string; role?: string }, + ): Promise<{ key: string; enabled: boolean; reason: string }> { + const flag = await this.findOne(key); + return this.evaluateFlag(flag, context); + } + + /** + * Bulk-evaluate all enabled flags for a user context. + * Useful for SDK bootstrapping (returns a key→boolean map). + */ + async evaluateAll(context: { + userId?: string; + role?: string; + }): Promise> { + const flags = await this.prisma.featureFlag.findMany({ + orderBy: { key: 'asc' }, + }); + + const result: Record = {}; + for (const flag of flags) { + const { enabled } = this.evaluateFlag(flag, context); + result[flag.key] = enabled; + } + return result; + } + + /** + * Check whether a flag is enabled for a user without throwing (safe for guards/interceptors). + * Returns false if the flag does not exist. + */ + async isEnabled( + key: string, + context: { userId?: string; role?: string } = {}, + ): Promise { + try { + const { enabled } = await this.evaluate(key, context); + return enabled; + } catch { + return false; + } + } + + // ---------------------------------------------------------- + // INTERNAL — deterministic rollout + // ---------------------------------------------------------- + + private evaluateFlag( + flag: { + key: string; + enabled: boolean; + rolloutPercent: number; + allowedRoles: string[]; + allowedUserIds: string[]; + }, + context: { userId?: string; role?: string }, + ): { key: string; enabled: boolean; reason: string } { + if (!flag.enabled) { + return { key: flag.key, enabled: false, reason: 'flag_disabled' }; + } + + // Role check + if (flag.allowedRoles.length > 0) { + if (!context.role || !flag.allowedRoles.includes(context.role)) { + return { + key: flag.key, + enabled: false, + reason: `role_not_allowed (role=${context.role ?? 'none'})`, + }; + } + } + + // Explicit allow-list + if (context.userId && flag.allowedUserIds.includes(context.userId)) { + return { key: flag.key, enabled: true, reason: 'explicit_allow_list' }; + } + + // Rollout bucket — deterministic per user+flag key + if (flag.rolloutPercent >= 100) { + return { key: flag.key, enabled: true, reason: 'full_rollout' }; + } + + if (flag.rolloutPercent <= 0) { + return { key: flag.key, enabled: false, reason: 'zero_rollout' }; + } + + const bucket = this.rolloutBucket(context.userId, flag.key); + const inBucket = bucket < flag.rolloutPercent; + return { + key: flag.key, + enabled: inBucket, + reason: inBucket + ? `in_rollout_bucket (${bucket}/${flag.rolloutPercent})` + : `outside_rollout_bucket (${bucket}/${flag.rolloutPercent})`, + }; + } + + /** + * FNV-1a 32-bit hash → value in [0, 100). + * Deterministic: same userId+key always maps to the same bucket. + */ + private rolloutBucket(userId: string | undefined, key: string): number { + const input = `${userId ?? 'anonymous'}:${key}`; + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; // keep as unsigned 32-bit + } + return hash % 100; + } +}