diff --git a/prisma/migrations/20260522230006_add_feature_flag_whitelist/migration.sql b/prisma/migrations/20260522230006_add_feature_flag_whitelist/migration.sql new file mode 100644 index 0000000..51d93e3 --- /dev/null +++ b/prisma/migrations/20260522230006_add_feature_flag_whitelist/migration.sql @@ -0,0 +1 @@ +ALTER TABLE FeatureFlag ADD COLUMN whitelist TEXT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0fb19e6..fc9c8b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,6 +860,7 @@ model FeatureFlag { enabled Boolean @default(false) description String? @db.VarChar(500) rolloutPct Int @default(100) + whitelist String? @db.Text updatedBy String? @db.VarChar(100) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/config/config.controller.ts b/src/modules/config/config.controller.ts index a752a3c..abf98d5 100644 --- a/src/modules/config/config.controller.ts +++ b/src/modules/config/config.controller.ts @@ -49,8 +49,13 @@ export class AppConfigController { @Post('flags/:name') @AdminRoles('SUPER_ADMIN' as AdminRole) - async toggleFlag(@Param('name') name: string, @Body() d: { enabled: boolean }, @Req() req: any) { - await this.flags.setEnabled(name, d.enabled, req.adminUser?.email); + async toggleFlag(@Param('name') name: string, @Body() d: { enabled?: boolean; whitelist?: string }, @Req() req: any) { + if (typeof d.enabled === 'boolean') { + await this.flags.setEnabled(name, d.enabled, req.adminUser?.email); + } + if (d.whitelist !== undefined) { + await this.flags.setWhitelist(name, d.whitelist); + } return { success: true }; } } diff --git a/src/modules/config/feature-flag.service.ts b/src/modules/config/feature-flag.service.ts index 214cda7..5571886 100644 --- a/src/modules/config/feature-flag.service.ts +++ b/src/modules/config/feature-flag.service.ts @@ -12,7 +12,8 @@ export class FeatureFlagService { private readonly redis: RedisService, ) {} - async isEnabled(name: string): Promise { + /** Check if flag is enabled, with optional user-level whitelist */ + async isEnabled(name: string, userId?: string): Promise { try { const cached = await this.redis.get(FF_PREFIX + name); if (cached !== null) return cached === '1'; @@ -20,6 +21,12 @@ export class FeatureFlagService { const flag = await this.prisma.featureFlag.findUnique({ where: { name } }); const enabled = flag?.enabled ?? false; + + // If user whitelist is set, only enabled for whitelisted users + if (enabled && flag?.whitelist) { + const whitelist = (flag.whitelist as string).split(',').map(s => s.trim()); + if (!userId || !whitelist.includes(userId)) return false; + } try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {} return enabled; } @@ -38,5 +45,10 @@ export class FeatureFlagService { try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {} } + async setWhitelist(name: string, whitelist: string): Promise { + await this.prisma.featureFlag.update({ where: { name }, data: { whitelist } }); + try { await this.redis.del(FF_PREFIX + name); } catch {} + } + async getAll() { return this.prisma.featureFlag.findMany({ orderBy: { name: 'asc' } }) } }