xpeditis2.0/apps/backend/src/application/guards/feature-flag.guard.ts

98 lines
2.9 KiB
TypeScript

import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
/**
* Feature Flag Guard
*
* Checks if the user's subscription plan includes the required features.
* Uses current subscription data so stale token claims cannot preserve revoked rights.
*
* Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
* @RequiresFeature('dashboard')
*/
@Injectable()
export class FeatureFlagGuard implements CanActivate {
private readonly logger = new Logger(FeatureFlagGuard.name);
constructor(
private readonly reflector: Reflector,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required features from @RequiresFeature() decorator
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
REQUIRED_FEATURES_KEY,
[context.getHandler(), context.getClass()]
);
// If no features are required, allow access
if (!requiredFeatures || requiredFeatures.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.organizationId) {
return false;
}
// ADMIN users have full access to all features — no plan check needed
if (user.role === 'ADMIN') {
return true;
}
// Always resolve current rights, including after suspension or downgrade.
try {
const subscription = await this.subscriptionRepository.findByOrganizationId(
user.organizationId
);
if (!subscription) {
// No subscription means Bronze (free) plan — no premium features
this.throwFeatureRequired(requiredFeatures);
}
const missingFeatures = requiredFeatures.filter(
feature => !subscription!.hasFeature(feature)
);
if (missingFeatures.length > 0) {
this.throwFeatureRequired(requiredFeatures);
}
return true;
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to check subscription features: ${error}`);
// On DB error, deny access to premium features rather than 500
this.throwFeatureRequired(requiredFeatures);
}
}
private throwFeatureRequired(features: PlanFeature[]): never {
const featureNames = features.join(', ');
throw new ForbiddenException(
`Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.`
);
}
}