Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,300 changes: 1,308 additions & 992 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { AdminModule } from './admin/admin.module';
import { ProfilerModule } from './profiler/profiler.module';
import { ProfilerInterceptor } from './profiler/profiler.interceptor';
import { HealthModule } from './health/health.module';
import { FeatureFlagsModule } from './feature-flags/feature-flags.module';

// In-memory storage for development (no Redis needed)
class ThrottlerMemoryStorage {
Expand Down Expand Up @@ -320,6 +321,7 @@ async function createThrottlerStorage(
AdminModule,
ProfilerModule,
HealthModule,
FeatureFlagsModule,
],
controllers: [AppController],
providers: [
Expand Down
19 changes: 4 additions & 15 deletions src/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,7 @@ export class DisputeController {
@ApiResponse({ status: 201, description: 'Dispute created' })
@ApiResponse({ status: 400, description: 'Bad request' })
async create(@Body() dto: CreateDisputeDto) {
return this.disputeService.createDispute(
dto.claimId,
dto.trigger,
dto.originalConfidence,
dto.initiatorId,
dto.metadata,
);
return this.disputeService.createDispute(dto);
}

@Patch(':id/start-review')
Expand All @@ -50,20 +44,15 @@ export class DisputeController {
@ApiResponse({ status: 200, description: 'Dispute resolved' })
@ApiResponse({ status: 404, description: 'Dispute not found' })
async resolve(@Param('id') id: string, @Body() dto: ResolveDisputeDto) {
return this.disputeService.resolveDispute(
id,
dto.outcome,
dto.finalConfidence,
dto.metadata,
);
return this.disputeService.resolveDispute({ disputeId: id, outcome: dto.outcome, finalConfidence: dto.finalConfidence, metadata: dto.metadata });
}

@Patch(':id/reject')
@ApiOperation({ summary: 'Reject a dispute as spam/invalid' })
@ApiResponse({ status: 200, description: 'Dispute rejected' })
@ApiResponse({ status: 404, description: 'Dispute not found' })
async reject(@Param('id') id: string, @Body() dto: RejectDisputeDto) {
return this.disputeService.rejectDispute(id, dto.reason);
return this.disputeService.rejectDispute({ disputeId: id, reason: dto.reason, rejectedBy: dto.rejectedBy });
}

@Get('claim/:claimId')
Expand All @@ -88,6 +77,6 @@ export class DisputeController {
@Query('status') status?: DisputeStatus,
@Query('trigger') trigger?: DisputeTrigger,
) {
return this.disputeService.findAll(status, trigger);
return this.disputeService.findAll({ status, trigger });
}
}
50 changes: 49 additions & 1 deletion src/feature-flags/configuration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RedisService } from '../redis/redis.service';
import { ConfigurationValue } from './entities/configuration-value.entity';
import { ConfigurationHistory } from './entities/configuration-history.entity';
import { ConfigurationHistoryEntry } from './feature-flags.types';
import { FeatureFlagsMetricsService } from './metrics/feature-flag.metrics';

const CACHE_TTL_SECONDS = 60;

Expand All @@ -14,7 +16,10 @@ export class ConfigurationService {
constructor(
@InjectRepository(ConfigurationValue)
private readonly configRepo: Repository<ConfigurationValue>,
@InjectRepository(ConfigurationHistory)
private readonly historyRepo: Repository<ConfigurationHistory>,
private readonly redisService: RedisService,
private readonly metrics: FeatureFlagsMetricsService,
) {}

async get<T = unknown>(key: string, environment?: string): Promise<T | null> {
Expand Down Expand Up @@ -72,7 +77,17 @@ export class ConfigurationService {
createdBy,
changeReason,
});
await this.historyRepo.insert({
configurationId: existing.id,
key,
value: value as unknown,
environment: env,
version: nextVersion,
createdBy,
changeReason,
});
await this.invalidateCache(key, env);
this.metrics.incrementConfigChanges(env, 'config');
return this.configRepo.findOneOrFail({ where: { id: existing.id } });
}

Expand All @@ -85,7 +100,17 @@ export class ConfigurationService {
changeReason,
});
const saved = await this.configRepo.save(record);
await this.historyRepo.insert({
configurationId: saved.id,
key,
value: value as unknown,
environment: env,
version: 1,
createdBy,
changeReason,
});
await this.invalidateCache(key, env);
this.metrics.incrementConfigChanges(env, 'config');
return saved;
}

Expand Down Expand Up @@ -115,7 +140,7 @@ export class ConfigurationService {
limit = 50,
): Promise<ConfigurationHistoryEntry[]> {
const env = environment ?? this.getDefaultEnvironment();
const records = await this.configRepo.find({
const records = await this.historyRepo.find({
where: { key, environment: env },
order: { createdAt: 'DESC' },
take: limit,
Expand All @@ -131,6 +156,29 @@ export class ConfigurationService {
}));
}

async rollback(id: string, targetVersion: number, rolledBackBy?: string): Promise<ConfigurationValue> {
const current = await this.findOne(id);
if (targetVersion >= current.version || targetVersion < 1) {
throw new Error('Invalid rollback target version');
}

const historyRecord = await this.historyRepo.findOne({
where: { configurationId: id, version: targetVersion },
});

if (!historyRecord) {
throw new NotFoundException(`History record for version ${targetVersion} not found`);
}

return this.set(
current.key,
historyRecord.value,
current.environment,
rolledBackBy,
`Rolled back to version ${targetVersion}`,
);
}

private async invalidateCache(
key: string,
environment: string,
Expand Down
38 changes: 38 additions & 0 deletions src/feature-flags/entities/configuration-history.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';

@Entity('configuration_history')
@Index(['configurationId', 'version'], { unique: true })
export class ConfigurationHistory {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
configurationId: string;

@Column()
key: string;

@Column({ type: 'json' })
value: unknown;

@Column()
environment: string;

@Column()
version: number;

@Column({ nullable: true })
createdBy: string;

@Column({ type: 'text', nullable: true })
changeReason: string;

@CreateDateColumn()
createdAt: Date;
}
9 changes: 8 additions & 1 deletion src/feature-flags/entities/feature-flag.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type FeatureFlagType =
| 'user'
| 'role'
| 'environment'
| 'time';
| 'time'
| 'kill-switch';

@Entity('feature_flags')
@Index(['key', 'environment'], { unique: true })
Expand Down Expand Up @@ -42,6 +43,12 @@ export class FeatureFlag {
@Column({ type: 'text', nullable: true })
description: string;

@Column({ type: 'boolean', default: false })
isPermanent: boolean;

@Column({ type: 'json', nullable: true })
metadata: Record<string, unknown>;

@Column({ nullable: true })
expiresAt: Date;

Expand Down
81 changes: 75 additions & 6 deletions src/feature-flags/feature-flags.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/guards/admin.guard';
import { AdminOnly } from '../auth/decorators/admin-only.decorator';
import { FeatureFlagsService } from './feature-flags.service';
import { ConfigurationService } from './configuration.service';
import { AuditTrailService } from '../audit/services/audit-trail.service';
Expand Down Expand Up @@ -72,7 +74,8 @@ export class FeatureFlagsController {
}

@Post('feature-flags')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a feature flag' })
async createFlag(
Expand All @@ -96,7 +99,8 @@ export class FeatureFlagsController {
}

@Patch('feature-flags/:id')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a feature flag' })
async updateFlag(
Expand All @@ -120,7 +124,8 @@ export class FeatureFlagsController {
}

@Post('feature-flags/:id/toggle')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Toggle a feature flag' })
async toggleFlag(
Expand All @@ -144,7 +149,8 @@ export class FeatureFlagsController {
}

@Post('feature-flags/:id/rollback')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Rollback a feature flag' })
async rollbackFlag(
Expand Down Expand Up @@ -184,7 +190,8 @@ export class FeatureFlagsController {
}

@Post('configuration')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Set a configuration value' })
async setConfig(
Expand Down Expand Up @@ -221,7 +228,8 @@ export class FeatureFlagsController {
}

@Delete('configuration/:id')
@UseGuards(JwtAuthGuard)
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a configuration value' })
async deleteConfig(
Expand All @@ -241,6 +249,67 @@ export class FeatureFlagsController {
});
}

@Get('configuration/:key/history')
@ApiOperation({ summary: 'Get configuration history' })
getHistory(
@Param('key') key: string,
@Query('environment') environment?: string,
) {
return this.configService.getHistory(key, environment);
}

@Post('configuration/:id/rollback')
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Rollback a configuration value' })
async rollbackConfig(
@Param('id') id: string,
@Body('targetVersion') targetVersion: number,
@Request() req: RequestWithUser,
): Promise<ConfigurationValue> {
const userId = this.userIdFrom(req);
const config = await this.configService.rollback(id, targetVersion, userId);
await this.auditTrailService.log({
actionType: AuditActionType.CONFIG_ROLLED_BACK,
entityType: AuditEntityType.CONFIGURATION,
entityId: config.id,
userId,
description: `Rolled back configuration ${config.key} to version ${targetVersion}`,
afterState: { version: config.version } as Record<string, any>,
});
return config;
}

@Post('configuration/promote')
@AdminOnly()
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Promote a configuration to another environment' })
async promoteConfig(
@Body() input: { key: string; sourceEnvironment: string; targetEnvironment: string },
@Request() req: RequestWithUser,
): Promise<ConfigurationValue> {
const userId = this.userIdFrom(req);
const sourceValue = await this.configService.getRequired(input.key, input.sourceEnvironment);
const saved = await this.configService.set(
input.key,
sourceValue,
input.targetEnvironment,
userId,
`Promoted from ${input.sourceEnvironment}`,
);
await this.auditTrailService.log({
actionType: AuditActionType.CONFIG_UPDATED,
entityType: AuditEntityType.CONFIGURATION,
entityId: saved.id,
userId,
description: `Promoted configuration ${saved.key} from ${input.sourceEnvironment} to ${input.targetEnvironment}`,
afterState: { value: saved.value, environment: input.targetEnvironment },
});
return saved;
}

private userIdFrom(req: RequestWithUser): string | undefined {
return req.user?.userId ?? req.user?.id;
}
Expand Down
16 changes: 13 additions & 3 deletions src/feature-flags/feature-flags.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ import { ConfigurationService } from './configuration.service';
import { FeatureFlagsController } from './feature-flags.controller';
import { FeatureFlag } from './entities/feature-flag.entity';
import { ConfigurationValue } from './entities/configuration-value.entity';
import { ConfigurationHistory } from './entities/configuration-history.entity';
import { FeatureFlagsMetricsService } from './metrics/feature-flag.metrics';

@Module({
imports: [
TypeOrmModule.forFeature([FeatureFlag, ConfigurationValue]),
TypeOrmModule.forFeature([
FeatureFlag,
ConfigurationValue,
ConfigurationHistory,
]),
RedisModule,
],
providers: [FeatureFlagsService, ConfigurationService],
providers: [
FeatureFlagsService,
ConfigurationService,
FeatureFlagsMetricsService,
],
controllers: [FeatureFlagsController],
exports: [FeatureFlagsService, ConfigurationService],
exports: [FeatureFlagsService, ConfigurationService, FeatureFlagsMetricsService],
})
export class FeatureFlagsModule {}
Loading
Loading