diff --git a/.github/workflows/cd-main.yml b/.github/workflows/cd-main.yml index f8f5236..5633e39 100644 --- a/.github/workflows/cd-main.yml +++ b/.github/workflows/cd-main.yml @@ -10,7 +10,7 @@ name: CD Production # If someone merges to main without going through preprod, # this step fails and the deployment is blocked. # -# Flow: quality-gate → verify-image → promote → deploy → smoke-tests → notify +# Flow: quality-gate → verify-image → promote → deploy → notify # # Secrets required: # REGISTRY_TOKEN — Scaleway registry (read/write) @@ -231,47 +231,11 @@ jobs: kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s echo "Rollback complete. Previous version is live." - # ── 5. Smoke Tests ─────────────────────────────────────────────────── - # kubectl rollout status already verified pod readiness. - # These smoke tests validate the full network path: - # Cloudflare → Hetzner LB → Traefik → pod. - smoke-tests: - name: Smoke Tests - runs-on: ubuntu-latest - needs: deploy - steps: - - name: Wait for LB propagation - run: sleep 30 - - - name: Health — Backend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi - sleep 15 - done - echo "CRITICAL: Backend unreachable after 12 attempts." - exit 1 - - - name: Health — Frontend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PROD_FRONTEND_URL }}" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi - sleep 15 - done - echo "CRITICAL: Frontend unreachable after 12 attempts." - exit 1 - # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest - needs: [verify-image, smoke-tests] + needs: [verify-image, deploy] if: success() steps: - run: | @@ -292,7 +256,7 @@ jobs: notify-failure: name: Notify Failure runs-on: ubuntu-latest - needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy, smoke-tests] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy] if: failure() steps: - run: | diff --git a/.github/workflows/cd-preprod.yml b/.github/workflows/cd-preprod.yml index 830f19c..9da16bf 100644 --- a/.github/workflows/cd-preprod.yml +++ b/.github/workflows/cd-preprod.yml @@ -1,7 +1,7 @@ name: CD Preprod # Full pipeline triggered on every push to preprod. -# Flow: lint → unit tests → integration tests → docker build → deploy → smoke tests → notify +# Flow: lint → unit tests → integration tests → docker build → deploy → notify # # Secrets required: # REGISTRY_TOKEN — Scaleway registry (read/write) @@ -217,60 +217,68 @@ jobs: NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }} + build-log-exporter: + name: Build Log Exporter + runs-on: ubuntu-latest + needs: integration-tests + outputs: + sha: ${{ steps.sha.outputs.short }} + steps: + - uses: actions/checkout@v4 + - name: Short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./apps/log-exporter + file: ./apps/log-exporter/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod + ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + # ── 5. Deploy via Portainer ────────────────────────────────────────── deploy: name: Deploy to Preprod runs-on: ubuntu-latest - needs: [build-backend, build-frontend] + needs: [build-backend, build-frontend, build-log-exporter] environment: preprod steps: - name: Deploy backend run: | - curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}") + echo "Portainer response: HTTP $HTTP_CODE" + if [[ "$HTTP_CODE" != "2"* ]]; then + echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE" + exit 1 + fi echo "Backend webhook triggered." - name: Wait for backend startup run: sleep 20 - name: Deploy frontend run: | - curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}") + echo "Portainer response: HTTP $HTTP_CODE" + if [[ "$HTTP_CODE" != "2"* ]]; then + echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE" + exit 1 + fi echo "Frontend webhook triggered." - # ── 6. Smoke Tests ─────────────────────────────────────────────────── - smoke-tests: - name: Smoke Tests - runs-on: ubuntu-latest - needs: deploy - steps: - - name: Wait for services - run: sleep 40 - - name: Health — Backend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi - sleep 15 - done - echo "Backend unreachable after 12 attempts." - exit 1 - - name: Health — Frontend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PREPROD_FRONTEND_URL }}" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi - sleep 15 - done - echo "Frontend unreachable after 12 attempts." - exit 1 - # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest - needs: [build-backend, build-frontend, smoke-tests] + needs: [build-backend, build-frontend, deploy] if: success() steps: - run: | @@ -290,7 +298,7 @@ jobs: notify-failure: name: Notify Failure runs-on: ubuntu-latest - needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy, smoke-tests] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy] if: failure() steps: - run: | diff --git a/.gitignore b/.gitignore index d8748a3..d74b04b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ lerna-debug.log* # Docker docker-compose.override.yml +stack-portainer.yaml +tmp.stack-portainer.yaml # Uploads uploads/ diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 7e7ada3..0ac03c6 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; import { AdminModule } from './application/admin/admin.module'; +import { LogsModule } from './application/logs/logs.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { CacheModule } from './infrastructure/cache/cache.module'; @@ -67,6 +68,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), + LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'), }), }), @@ -147,6 +149,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; AdminModule, SubscriptionsModule, ApiKeysModule, + LogsModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 96498fe..d5c0d18 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -311,12 +311,12 @@ export class AuthService { * Generate access and refresh tokens */ private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { - // ADMIN users always get ENTERPRISE plan with no expiration - let plan = 'FREE'; + // ADMIN users always get PLATINIUM plan with no expiration + let plan = 'BRONZE'; let planFeatures: string[] = []; if (user.role === UserRole.ADMIN) { - plan = 'ENTERPRISE'; + plan = 'PLATINIUM'; planFeatures = [ 'dashboard', 'wiki', diff --git a/apps/backend/src/application/controllers/health.controller.ts b/apps/backend/src/application/controllers/health.controller.ts index 1952eec..67991b5 100644 --- a/apps/backend/src/application/controllers/health.controller.ts +++ b/apps/backend/src/application/controllers/health.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Public } from '../decorators/public.decorator'; +@Public() @ApiTags('health') @Controller('health') export class HealthController { diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts index db046a7..5302528 100644 --- a/apps/backend/src/application/dto/subscription.dto.ts +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -11,10 +11,10 @@ import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator'; * Subscription plan types */ export enum SubscriptionPlanDto { - FREE = 'FREE', - STARTER = 'STARTER', - PRO = 'PRO', - ENTERPRISE = 'ENTERPRISE', + BRONZE = 'BRONZE', + SILVER = 'SILVER', + GOLD = 'GOLD', + PLATINIUM = 'PLATINIUM', } /** @@ -44,7 +44,7 @@ export enum BillingIntervalDto { */ export class CreateCheckoutSessionDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'The subscription plan to purchase', enum: SubscriptionPlanDto, }) @@ -188,7 +188,7 @@ export class LicenseResponseDto { */ export class PlanDetailsDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Plan identifier', enum: SubscriptionPlanDto, }) @@ -274,7 +274,7 @@ export class SubscriptionResponseDto { organizationId: string; @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Current subscription plan', enum: SubscriptionPlanDto, }) diff --git a/apps/backend/src/application/logs/logs.controller.ts b/apps/backend/src/application/logs/logs.controller.ts new file mode 100644 index 0000000..1926c1e --- /dev/null +++ b/apps/backend/src/application/logs/logs.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Query, + Res, + UseGuards, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; + +@Controller('logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class LogsController { + private readonly logExporterUrl: string; + + constructor(private readonly configService: ConfigService) { + this.logExporterUrl = this.configService.get( + 'LOG_EXPORTER_URL', + 'http://xpeditis-log-exporter:3200', + ); + } + + /** + * GET /api/v1/logs/services + * Proxy → log-exporter /api/logs/services + */ + @Get('services') + async getServices() { + try { + const res = await fetch(`${this.logExporterUrl}/api/logs/services`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`log-exporter error: ${res.status}`); + return res.json(); + } catch (err: any) { + throw new HttpException( + { error: err.message }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + /** + * GET /api/v1/logs/export + * Proxy → log-exporter /api/logs/export (JSON or CSV) + */ + @Get('export') + async exportLogs( + @Query('service') service: string, + @Query('level') level: string, + @Query('search') search: string, + @Query('start') start: string, + @Query('end') end: string, + @Query('limit') limit: string, + @Query('format') format: string = 'json', + @Res() res: Response, + ) { + try { + const params = new URLSearchParams(); + if (service) params.set('service', service); + if (level) params.set('level', level); + if (search) params.set('search', search); + if (start) params.set('start', start); + if (end) params.set('end', end); + if (limit) params.set('limit', limit); + params.set('format', format); + + const upstream = await fetch( + `${this.logExporterUrl}/api/logs/export?${params}`, + { signal: AbortSignal.timeout(30000) }, + ); + + if (!upstream.ok) { + const body = await upstream.json().catch(() => ({})); + throw new HttpException(body, upstream.status); + } + + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + if (['content-type', 'content-disposition'].includes(key.toLowerCase())) { + res.setHeader(key, value); + } + }); + + const buffer = await upstream.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (err: any) { + if (err instanceof HttpException) throw err; + throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY); + } + } +} diff --git a/apps/backend/src/application/logs/logs.module.ts b/apps/backend/src/application/logs/logs.module.ts new file mode 100644 index 0000000..ca12157 --- /dev/null +++ b/apps/backend/src/application/logs/logs.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { LogsController } from './logs.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [LogsController], +}) +export class LogsModule {} diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 47e4841..255c0e3 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -182,8 +182,8 @@ export class SubscriptionService { } // Cannot checkout for FREE plan - if (dto.plan === SubscriptionPlanDto.FREE) { - throw new BadRequestException('Cannot create checkout session for Free plan'); + if (dto.plan === SubscriptionPlanDto.BRONZE) { + throw new BadRequestException('Cannot create checkout session for Bronze plan'); } const subscription = await this.getOrCreateSubscription(organizationId); diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts index 4e93f08..190c521 100644 --- a/apps/backend/src/domain/entities/subscription.entity.spec.ts +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -21,12 +21,12 @@ describe('Subscription Entity', () => { }; describe('create', () => { - it('should create a subscription with default FREE plan', () => { + it('should create a subscription with default BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.id).toBe('sub-123'); expect(subscription.organizationId).toBe('org-123'); - expect(subscription.plan.value).toBe('FREE'); + expect(subscription.plan.value).toBe('BRONZE'); expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.cancelAtPeriodEnd).toBe(false); }); @@ -35,10 +35,10 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.plan.value).toBe('STARTER'); + expect(subscription.plan.value).toBe('SILVER'); }); it('should create a subscription with Stripe IDs', () => { @@ -59,7 +59,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'PRO', + plan: 'GOLD', status: 'ACTIVE', stripeCustomerId: 'cus_123', stripeSubscriptionId: 'sub_stripe_123', @@ -71,57 +71,57 @@ describe('Subscription Entity', () => { }); expect(subscription.id).toBe('sub-123'); - expect(subscription.plan.value).toBe('PRO'); + expect(subscription.plan.value).toBe('GOLD'); expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.cancelAtPeriodEnd).toBe(true); }); }); describe('maxLicenses', () => { - it('should return correct limits for FREE plan', () => { + it('should return correct limits for BRONZE plan', () => { const subscription = createValidSubscription(); - expect(subscription.maxLicenses).toBe(2); + expect(subscription.maxLicenses).toBe(1); }); - it('should return correct limits for STARTER plan', () => { + it('should return correct limits for SILVER plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); expect(subscription.maxLicenses).toBe(5); }); - it('should return correct limits for PRO plan', () => { + it('should return correct limits for GOLD plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.pro(), + plan: SubscriptionPlan.gold(), }); expect(subscription.maxLicenses).toBe(20); }); - it('should return -1 for ENTERPRISE plan (unlimited)', () => { + it('should return -1 for PLATINIUM plan (unlimited)', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.maxLicenses).toBe(-1); }); }); describe('isUnlimited', () => { - it('should return false for FREE plan', () => { + it('should return false for BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.isUnlimited()).toBe(false); }); - it('should return true for ENTERPRISE plan', () => { + it('should return true for PLATINIUM plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.isUnlimited()).toBe(true); }); @@ -137,7 +137,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'TRIALING', stripeCustomerId: null, stripeSubscriptionId: null, @@ -154,7 +154,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -170,21 +170,20 @@ describe('Subscription Entity', () => { describe('canAllocateLicenses', () => { it('should return true when licenses are available', () => { - const subscription = createValidSubscription(); + const subscription = createValidSubscription(); // BRONZE = 1 license expect(subscription.canAllocateLicenses(0, 1)).toBe(true); - expect(subscription.canAllocateLicenses(1, 1)).toBe(true); }); it('should return false when no licenses available', () => { const subscription = createValidSubscription(); - expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses + expect(subscription.canAllocateLicenses(1, 1)).toBe(false); // BRONZE has 1 license max }); - it('should always return true for ENTERPRISE plan', () => { + it('should always return true for PLATINIUM plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.canAllocateLicenses(1000, 100)).toBe(true); }); @@ -193,7 +192,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -208,23 +207,23 @@ describe('Subscription Entity', () => { }); describe('canUpgradeTo', () => { - it('should allow upgrade from FREE to STARTER', () => { + it('should allow upgrade from BRONZE to SILVER', () => { const subscription = createValidSubscription(); - expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + expect(subscription.canUpgradeTo(SubscriptionPlan.silver())).toBe(true); }); - it('should allow upgrade from FREE to PRO', () => { + it('should allow upgrade from BRONZE to GOLD', () => { const subscription = createValidSubscription(); - expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + expect(subscription.canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); it('should not allow downgrade via canUpgradeTo', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false); + expect(subscription.canUpgradeTo(SubscriptionPlan.bronze())).toBe(false); }); }); @@ -233,34 +232,34 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true); }); it('should prevent downgrade when user count exceeds new plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false); }); }); describe('updatePlan', () => { it('should update to new plan when valid', () => { const subscription = createValidSubscription(); - const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1); + const updated = subscription.updatePlan(SubscriptionPlan.silver(), 1); - expect(updated.plan.value).toBe('STARTER'); + expect(updated.plan.value).toBe('SILVER'); }); it('should throw when subscription is not active', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -271,7 +270,7 @@ describe('Subscription Entity', () => { updatedAt: new Date(), }); - expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( + expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 0)).toThrow( SubscriptionNotActiveException ); }); @@ -280,10 +279,10 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.pro(), + plan: SubscriptionPlan.gold(), }); - expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( + expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 10)).toThrow( InvalidSubscriptionDowngradeException ); }); @@ -341,7 +340,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'STARTER', + plan: 'SILVER', status: 'ACTIVE', stripeCustomerId: 'cus_123', stripeSubscriptionId: 'sub_123', @@ -368,17 +367,17 @@ describe('Subscription Entity', () => { }); describe('isFree and isPaid', () => { - it('should return true for isFree when FREE plan', () => { + it('should return true for isFree when BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.isFree()).toBe(true); expect(subscription.isPaid()).toBe(false); }); - it('should return true for isPaid when STARTER plan', () => { + it('should return true for isPaid when SILVER plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); expect(subscription.isFree()).toBe(false); expect(subscription.isPaid()).toBe(true); @@ -397,7 +396,7 @@ describe('Subscription Entity', () => { expect(obj.id).toBe('sub-123'); expect(obj.organizationId).toBe('org-123'); - expect(obj.plan).toBe('FREE'); + expect(obj.plan).toBe('BRONZE'); expect(obj.status).toBe('ACTIVE'); expect(obj.stripeCustomerId).toBe('cus_123'); }); diff --git a/apps/backend/src/domain/value-objects/plan-feature.vo.ts b/apps/backend/src/domain/value-objects/plan-feature.vo.ts index b13a165..ee6bd91 100644 --- a/apps/backend/src/domain/value-objects/plan-feature.vo.ts +++ b/apps/backend/src/domain/value-objects/plan-feature.vo.ts @@ -24,13 +24,13 @@ export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [ 'dedicated_kam', ]; -export type SubscriptionPlanTypeForFeatures = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export const PLAN_FEATURES: Record = { - FREE: [], - STARTER: ['dashboard', 'wiki', 'user_management', 'csv_export'], - PRO: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], - ENTERPRISE: [ + BRONZE: [], + SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'], + GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], + PLATINIUM: [ 'dashboard', 'wiki', 'user_management', diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts index 81564a3..ddbcaa2 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -8,31 +8,56 @@ import { SubscriptionPlan } from './subscription-plan.vo'; describe('SubscriptionPlan Value Object', () => { describe('static factory methods', () => { - it('should create FREE plan', () => { + it('should create BRONZE plan via bronze()', () => { + const plan = SubscriptionPlan.bronze(); + expect(plan.value).toBe('BRONZE'); + }); + + it('should create SILVER plan via silver()', () => { + const plan = SubscriptionPlan.silver(); + expect(plan.value).toBe('SILVER'); + }); + + it('should create GOLD plan via gold()', () => { + const plan = SubscriptionPlan.gold(); + expect(plan.value).toBe('GOLD'); + }); + + it('should create PLATINIUM plan via platinium()', () => { + const plan = SubscriptionPlan.platinium(); + expect(plan.value).toBe('PLATINIUM'); + }); + + it('should create BRONZE plan via free() alias', () => { const plan = SubscriptionPlan.free(); - expect(plan.value).toBe('FREE'); + expect(plan.value).toBe('BRONZE'); }); - it('should create STARTER plan', () => { + it('should create SILVER plan via starter() alias', () => { const plan = SubscriptionPlan.starter(); - expect(plan.value).toBe('STARTER'); + expect(plan.value).toBe('SILVER'); }); - it('should create PRO plan', () => { + it('should create GOLD plan via pro() alias', () => { const plan = SubscriptionPlan.pro(); - expect(plan.value).toBe('PRO'); + expect(plan.value).toBe('GOLD'); }); - it('should create ENTERPRISE plan', () => { + it('should create PLATINIUM plan via enterprise() alias', () => { const plan = SubscriptionPlan.enterprise(); - expect(plan.value).toBe('ENTERPRISE'); + expect(plan.value).toBe('PLATINIUM'); }); }); describe('create', () => { - it('should create plan from valid type', () => { - const plan = SubscriptionPlan.create('STARTER'); - expect(plan.value).toBe('STARTER'); + it('should create plan from valid type SILVER', () => { + const plan = SubscriptionPlan.create('SILVER'); + expect(plan.value).toBe('SILVER'); + }); + + it('should create plan from valid type BRONZE', () => { + const plan = SubscriptionPlan.create('BRONZE'); + expect(plan.value).toBe('BRONZE'); }); it('should throw for invalid plan type', () => { @@ -41,9 +66,29 @@ describe('SubscriptionPlan Value Object', () => { }); describe('fromString', () => { - it('should create plan from lowercase string', () => { + it('should create SILVER from lowercase "silver"', () => { + const plan = SubscriptionPlan.fromString('silver'); + expect(plan.value).toBe('SILVER'); + }); + + it('should map legacy "starter" to SILVER', () => { const plan = SubscriptionPlan.fromString('starter'); - expect(plan.value).toBe('STARTER'); + expect(plan.value).toBe('SILVER'); + }); + + it('should map legacy "free" to BRONZE', () => { + const plan = SubscriptionPlan.fromString('free'); + expect(plan.value).toBe('BRONZE'); + }); + + it('should map legacy "pro" to GOLD', () => { + const plan = SubscriptionPlan.fromString('pro'); + expect(plan.value).toBe('GOLD'); + }); + + it('should map legacy "enterprise" to PLATINIUM', () => { + const plan = SubscriptionPlan.fromString('enterprise'); + expect(plan.value).toBe('PLATINIUM'); }); it('should throw for invalid string', () => { @@ -52,146 +97,150 @@ describe('SubscriptionPlan Value Object', () => { }); describe('maxLicenses', () => { - it('should return 2 for FREE plan', () => { - const plan = SubscriptionPlan.free(); - expect(plan.maxLicenses).toBe(2); + it('should return 1 for BRONZE plan', () => { + const plan = SubscriptionPlan.bronze(); + expect(plan.maxLicenses).toBe(1); }); - it('should return 5 for STARTER plan', () => { - const plan = SubscriptionPlan.starter(); + it('should return 5 for SILVER plan', () => { + const plan = SubscriptionPlan.silver(); expect(plan.maxLicenses).toBe(5); }); - it('should return 20 for PRO plan', () => { - const plan = SubscriptionPlan.pro(); + it('should return 20 for GOLD plan', () => { + const plan = SubscriptionPlan.gold(); expect(plan.maxLicenses).toBe(20); }); - it('should return -1 (unlimited) for ENTERPRISE plan', () => { - const plan = SubscriptionPlan.enterprise(); + it('should return -1 (unlimited) for PLATINIUM plan', () => { + const plan = SubscriptionPlan.platinium(); expect(plan.maxLicenses).toBe(-1); }); }); describe('isUnlimited', () => { - it('should return false for FREE plan', () => { - expect(SubscriptionPlan.free().isUnlimited()).toBe(false); + it('should return false for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isUnlimited()).toBe(false); }); - it('should return false for STARTER plan', () => { - expect(SubscriptionPlan.starter().isUnlimited()).toBe(false); + it('should return false for SILVER plan', () => { + expect(SubscriptionPlan.silver().isUnlimited()).toBe(false); }); - it('should return false for PRO plan', () => { - expect(SubscriptionPlan.pro().isUnlimited()).toBe(false); + it('should return false for GOLD plan', () => { + expect(SubscriptionPlan.gold().isUnlimited()).toBe(false); }); - it('should return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true); + it('should return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().isUnlimited()).toBe(true); }); }); describe('isPaid', () => { - it('should return false for FREE plan', () => { - expect(SubscriptionPlan.free().isPaid()).toBe(false); + it('should return false for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isPaid()).toBe(false); }); - it('should return true for STARTER plan', () => { - expect(SubscriptionPlan.starter().isPaid()).toBe(true); + it('should return true for SILVER plan', () => { + expect(SubscriptionPlan.silver().isPaid()).toBe(true); }); - it('should return true for PRO plan', () => { - expect(SubscriptionPlan.pro().isPaid()).toBe(true); + it('should return true for GOLD plan', () => { + expect(SubscriptionPlan.gold().isPaid()).toBe(true); }); - it('should return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().isPaid()).toBe(true); + it('should return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().isPaid()).toBe(true); }); }); describe('isFree', () => { - it('should return true for FREE plan', () => { - expect(SubscriptionPlan.free().isFree()).toBe(true); + it('should return true for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isFree()).toBe(true); }); - it('should return false for STARTER plan', () => { - expect(SubscriptionPlan.starter().isFree()).toBe(false); + it('should return false for SILVER plan', () => { + expect(SubscriptionPlan.silver().isFree()).toBe(false); }); }); describe('canAccommodateUsers', () => { - it('should return true for FREE plan with 2 users', () => { - expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true); + it('should return true for BRONZE plan with 1 user', () => { + expect(SubscriptionPlan.bronze().canAccommodateUsers(1)).toBe(true); }); - it('should return false for FREE plan with 3 users', () => { - expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false); + it('should return false for BRONZE plan with 2 users', () => { + expect(SubscriptionPlan.bronze().canAccommodateUsers(2)).toBe(false); }); - it('should return true for STARTER plan with 5 users', () => { - expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true); + it('should return true for SILVER plan with 5 users', () => { + expect(SubscriptionPlan.silver().canAccommodateUsers(5)).toBe(true); }); - it('should always return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true); + it('should always return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().canAccommodateUsers(1000)).toBe(true); }); }); describe('canUpgradeTo', () => { - it('should allow upgrade from FREE to STARTER', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + it('should allow upgrade from BRONZE to SILVER', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.silver())).toBe(true); }); - it('should allow upgrade from FREE to PRO', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + it('should allow upgrade from BRONZE to GOLD', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); - it('should allow upgrade from FREE to ENTERPRISE', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true); + it('should allow upgrade from BRONZE to PLATINIUM', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.platinium())).toBe(true); }); - it('should allow upgrade from STARTER to PRO', () => { - expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + it('should allow upgrade from SILVER to GOLD', () => { + expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); - it('should not allow downgrade from STARTER to FREE', () => { - expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false); + it('should not allow downgrade from SILVER to BRONZE', () => { + expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.bronze())).toBe(false); }); it('should not allow same plan upgrade', () => { - expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false); + expect(SubscriptionPlan.gold().canUpgradeTo(SubscriptionPlan.gold())).toBe(false); }); }); describe('canDowngradeTo', () => { - it('should allow downgrade from STARTER to FREE when users fit', () => { - expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + it('should allow downgrade from SILVER to BRONZE when users fit', () => { + expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true); }); - it('should not allow downgrade from STARTER to FREE when users exceed', () => { - expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + it('should not allow downgrade from SILVER to BRONZE when users exceed', () => { + expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false); }); it('should not allow upgrade via canDowngradeTo', () => { - expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false); + expect(SubscriptionPlan.bronze().canDowngradeTo(SubscriptionPlan.silver(), 1)).toBe(false); }); }); describe('plan details', () => { - it('should return correct name for FREE plan', () => { - expect(SubscriptionPlan.free().name).toBe('Free'); + it('should return correct name for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().name).toBe('Bronze'); }); - it('should return correct prices for STARTER plan', () => { - const plan = SubscriptionPlan.starter(); - expect(plan.monthlyPriceEur).toBe(49); - expect(plan.yearlyPriceEur).toBe(470); + it('should return correct name for SILVER plan', () => { + expect(SubscriptionPlan.silver().name).toBe('Silver'); }); - it('should return features for PRO plan', () => { - const plan = SubscriptionPlan.pro(); - expect(plan.features).toContain('Up to 20 users'); - expect(plan.features).toContain('API access'); + it('should return correct prices for SILVER plan', () => { + const plan = SubscriptionPlan.silver(); + expect(plan.monthlyPriceEur).toBe(249); + expect(plan.yearlyPriceEur).toBe(2739); + }); + + it('should return features for GOLD plan', () => { + const plan = SubscriptionPlan.gold(); + expect(plan.features).toContain("Jusqu'à 20 utilisateurs"); + expect(plan.features).toContain('Intégration API'); }); }); @@ -200,24 +249,24 @@ describe('SubscriptionPlan Value Object', () => { const plans = SubscriptionPlan.getAllPlans(); expect(plans).toHaveLength(4); - expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']); + expect(plans.map(p => p.value)).toEqual(['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']); }); }); describe('equals', () => { it('should return true for same plan', () => { - expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true); + expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.bronze())).toBe(true); }); it('should return false for different plans', () => { - expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false); + expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.silver())).toBe(false); }); }); describe('toString', () => { it('should return plan value as string', () => { - expect(SubscriptionPlan.free().toString()).toBe('FREE'); - expect(SubscriptionPlan.starter().toString()).toBe('STARTER'); + expect(SubscriptionPlan.bronze().toString()).toBe('BRONZE'); + expect(SubscriptionPlan.silver().toString()).toBe('SILVER'); }); }); }); diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index 4e138bb..5ffa990 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -5,25 +5,24 @@ * Each plan has a maximum number of licenses, shipment limits, commission rates, * feature flags, and support levels. * - * Plans: FREE (0EUR/mo), STARTER (49EUR/mo), PRO (249EUR/mo), ENTERPRISE (custom) + * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) */ import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; -export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam'; export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium'; /** - * Legacy plan name mapping for backward compatibility with DB values. - * DB stores BRONZE/SILVER/GOLD/PLATINIUM (from migration); map them to canonical names. + * Legacy plan name mapping for backward compatibility during migration. */ const LEGACY_PLAN_MAPPING: Record = { - BRONZE: 'FREE', - SILVER: 'STARTER', - GOLD: 'PRO', - PLATINIUM: 'ENTERPRISE', + FREE: 'BRONZE', + STARTER: 'SILVER', + PRO: 'GOLD', + ENTERPRISE: 'PLATINIUM', }; interface PlanDetails { @@ -40,58 +39,58 @@ interface PlanDetails { } const PLAN_DETAILS: Record = { - FREE: { - name: 'Free', - maxLicenses: 2, + BRONZE: { + name: 'Bronze', + maxLicenses: 1, monthlyPriceEur: 0, yearlyPriceEur: 0, maxShipmentsPerYear: 12, commissionRatePercent: 5, statusBadge: 'none', supportLevel: 'none', - planFeatures: PLAN_FEATURES.FREE, - features: ['Up to 2 users', '12 shipments per year', 'Basic rate search'], + planFeatures: PLAN_FEATURES.BRONZE, + features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], }, - STARTER: { - name: 'Starter', + SILVER: { + name: 'Silver', maxLicenses: 5, - monthlyPriceEur: 49, - yearlyPriceEur: 470, + monthlyPriceEur: 249, + yearlyPriceEur: 2739, maxShipmentsPerYear: -1, commissionRatePercent: 3, statusBadge: 'silver', supportLevel: 'email', - planFeatures: PLAN_FEATURES.STARTER, + planFeatures: PLAN_FEATURES.SILVER, features: [ - 'Up to 5 users', - 'Unlimited shipments', - 'Dashboard', - 'Maritime Wiki', - 'User management', - 'CSV import', - 'Email support', + "Jusqu'à 5 utilisateurs", + 'Expéditions illimitées', + 'Tableau de bord', + 'Wiki Maritime', + 'Gestion des utilisateurs', + 'Import CSV', + 'Support par email', ], }, - PRO: { - name: 'Pro', + GOLD: { + name: 'Gold', maxLicenses: 20, - monthlyPriceEur: 249, - yearlyPriceEur: 2739, + monthlyPriceEur: 899, + yearlyPriceEur: 9889, maxShipmentsPerYear: -1, commissionRatePercent: 2, statusBadge: 'gold', supportLevel: 'direct', - planFeatures: PLAN_FEATURES.PRO, + planFeatures: PLAN_FEATURES.GOLD, features: [ - 'Up to 20 users', - 'Unlimited shipments', - 'All Starter features', - 'API access', - 'Direct commercial support', + "Jusqu'à 20 utilisateurs", + 'Expéditions illimitées', + 'Toutes les fonctionnalités Silver', + 'Intégration API', + 'Assistance commerciale directe', ], }, - ENTERPRISE: { - name: 'Enterprise', + PLATINIUM: { + name: 'Platinium', maxLicenses: -1, // unlimited monthlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing @@ -99,13 +98,13 @@ const PLAN_DETAILS: Record = { commissionRatePercent: 1, statusBadge: 'platinium', supportLevel: 'dedicated_kam', - planFeatures: PLAN_FEATURES.ENTERPRISE, + planFeatures: PLAN_FEATURES.PLATINIUM, features: [ - 'Unlimited users', - 'All Pro features', - 'Dedicated Key Account Manager', - 'Custom interface', - 'Framework rate contracts', + 'Utilisateurs illimités', + 'Toutes les fonctionnalités Gold', + 'Key Account Manager dédié', + 'Interface personnalisable', + 'Contrats tarifaires cadre', ], }, }; @@ -122,18 +121,18 @@ export class SubscriptionPlan { /** * Create from string with legacy name support. - * Accepts both old DB names (BRONZE/SILVER/GOLD/PLATINIUM) and canonical names (FREE/STARTER/PRO/ENTERPRISE). + * Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names. */ static fromString(value: string): SubscriptionPlan { const upperValue = value.toUpperCase(); - // Check legacy mapping first (DB values BRONZE/SILVER/GOLD/PLATINIUM) + // Check legacy mapping first const mapped = LEGACY_PLAN_MAPPING[upperValue]; if (mapped) { return new SubscriptionPlan(mapped); } - // Try direct match (canonical names) + // Try direct match if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { return new SubscriptionPlan(upperValue as SubscriptionPlanType); } @@ -142,41 +141,41 @@ export class SubscriptionPlan { } // Named factories - static free(): SubscriptionPlan { - return new SubscriptionPlan('FREE'); - } - - static starter(): SubscriptionPlan { - return new SubscriptionPlan('STARTER'); - } - - static pro(): SubscriptionPlan { - return new SubscriptionPlan('PRO'); - } - - static enterprise(): SubscriptionPlan { - return new SubscriptionPlan('ENTERPRISE'); - } - - // Legacy aliases (kept for backward compatibility) static bronze(): SubscriptionPlan { - return SubscriptionPlan.free(); + return new SubscriptionPlan('BRONZE'); } static silver(): SubscriptionPlan { - return SubscriptionPlan.starter(); + return new SubscriptionPlan('SILVER'); } static gold(): SubscriptionPlan { - return SubscriptionPlan.pro(); + return new SubscriptionPlan('GOLD'); } static platinium(): SubscriptionPlan { - return SubscriptionPlan.enterprise(); + return new SubscriptionPlan('PLATINIUM'); + } + + // Legacy aliases + static free(): SubscriptionPlan { + return SubscriptionPlan.bronze(); + } + + static starter(): SubscriptionPlan { + return SubscriptionPlan.silver(); + } + + static pro(): SubscriptionPlan { + return SubscriptionPlan.gold(); + } + + static enterprise(): SubscriptionPlan { + return SubscriptionPlan.platinium(); } static getAllPlans(): SubscriptionPlan[] { - return (['FREE', 'STARTER', 'PRO', 'ENTERPRISE'] as SubscriptionPlanType[]).map( + return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map( p => new SubscriptionPlan(p) ); } @@ -226,75 +225,48 @@ export class SubscriptionPlan { return PLAN_DETAILS[this.plan].planFeatures; } - /** - * Check if this plan includes a specific feature - */ hasFeature(feature: PlanFeature): boolean { return this.planFeatures.includes(feature); } - /** - * Returns true if this plan has unlimited licenses - */ isUnlimited(): boolean { return this.maxLicenses === -1; } - /** - * Returns true if this plan has unlimited shipments - */ hasUnlimitedShipments(): boolean { return this.maxShipmentsPerYear === -1; } - /** - * Returns true if this is a paid plan - */ isPaid(): boolean { - return this.plan !== 'FREE'; + return this.plan !== 'BRONZE'; } - /** - * Returns true if this is the free plan - */ isFree(): boolean { - return this.plan === 'FREE'; + return this.plan === 'BRONZE'; } - /** - * Returns true if this plan has custom pricing (Enterprise) - */ isCustomPricing(): boolean { - return this.plan === 'ENTERPRISE'; + return this.plan === 'PLATINIUM'; } - /** - * Check if a given number of users can be accommodated by this plan - */ canAccommodateUsers(userCount: number): boolean { if (this.isUnlimited()) return true; return userCount <= this.maxLicenses; } - /** - * Check if upgrade to target plan is allowed - */ canUpgradeTo(targetPlan: SubscriptionPlan): boolean { - const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); return targetIndex > currentIndex; } - /** - * Check if downgrade to target plan is allowed given current user count - */ canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { - const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); - if (targetIndex >= currentIndex) return false; // Not a downgrade + if (targetIndex >= currentIndex) return false; return targetPlan.canAccommodateUsers(currentUserCount); } diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts index 1a4a092..4cd3665 100644 --- a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -51,22 +51,22 @@ export class StripeAdapter implements StripePort { const platiniumMonthly = this.configService.get('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); const platiniumYearly = this.configService.get('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); - if (silverMonthly) this.priceIdMap.set(silverMonthly, 'STARTER'); - if (silverYearly) this.priceIdMap.set(silverYearly, 'STARTER'); - if (goldMonthly) this.priceIdMap.set(goldMonthly, 'PRO'); - if (goldYearly) this.priceIdMap.set(goldYearly, 'PRO'); - if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'ENTERPRISE'); - if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'ENTERPRISE'); + if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER'); + if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER'); + if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD'); + if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD'); + if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM'); + if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM'); - this.planPriceMap.set('STARTER', { + this.planPriceMap.set('SILVER', { monthly: silverMonthly || '', yearly: silverYearly || '', }); - this.planPriceMap.set('PRO', { + this.planPriceMap.set('GOLD', { monthly: goldMonthly || '', yearly: goldYearly || '', }); - this.planPriceMap.set('ENTERPRISE', { + this.planPriceMap.set('PLATINIUM', { monthly: platiniumMonthly || '', yearly: platiniumYearly || '', }); diff --git a/apps/frontend/app/dashboard/admin/logs/page.tsx b/apps/frontend/app/dashboard/admin/logs/page.tsx index edf04ed..b40fe25 100644 --- a/apps/frontend/app/dashboard/admin/logs/page.tsx +++ b/apps/frontend/app/dashboard/admin/logs/page.tsx @@ -11,9 +11,9 @@ import { Bug, Server, } from 'lucide-react'; +import { get, download } from '@/lib/api/client'; -const LOG_EXPORTER_URL = - process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200'; +const LOGS_PREFIX = '/api/v1/logs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -125,8 +125,7 @@ export default function AdminLogsPage() { // Load available services useEffect(() => { - fetch(`${LOG_EXPORTER_URL}/api/logs/services`) - .then(r => r.json()) + get<{ services: string[] }>(`${LOGS_PREFIX}/services`) .then(d => setServices(d.services || [])) .catch(() => {}); }, []); @@ -150,14 +149,9 @@ export default function AdminLogsPage() { setLoading(true); setError(null); try { - const res = await fetch( - `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`, + const data = await get( + `${LOGS_PREFIX}/export?${buildQueryString('json')}`, ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - const data: LogsResponse = await res.json(); setLogs(data.logs || []); setTotal(data.total || 0); } catch (err: any) { @@ -174,19 +168,11 @@ export default function AdminLogsPage() { const handleExport = async (format: 'json' | 'csv') => { setExportLoading(true); try { - const res = await fetch( - `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`, + const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; + await download( + `${LOGS_PREFIX}/export?${buildQueryString(format)}`, + filename, ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); } catch (err: any) { setError(err.message); } finally { @@ -384,8 +370,7 @@ export default function AdminLogsPage() { Impossible de contacter le log-exporter : {error}
- Vérifiez que le container log-exporter est démarré sur{' '} - {LOG_EXPORTER_URL} + Vérifiez que le backend et le log-exporter sont démarrés. diff --git a/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json index 96e624f..c930170 100644 --- a/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json +++ b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json @@ -1,32 +1,37 @@ { - "title": "Xpeditis — Logs & Monitoring", - "uid": "xpeditis-logs", - "description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs", - "tags": ["xpeditis", "logs", "backend", "frontend"], - "timezone": "browser", + "__inputs": [ + { + "name": "DS_LOKI", + "label": "Loki", + "description": "Loki datasource", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.0.0" }, + { "type": "datasource", "id": "loki", "name": "Loki", "version": "1.0.0" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" }, + { "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" }, + { "type": "panel", "id": "logs", "name": "Logs", "version": "" } + ], + "title": "Xpeditis — Logs & KPIs", + "uid": "xpeditis-logs-kpis", + "description": "Logs applicatifs, KPIs HTTP, temps de réponse et erreurs — Backend & Frontend", + "tags": ["xpeditis", "logs", "monitoring", "backend"], + "timezone": "Europe/Paris", "refresh": "30s", - "schemaVersion": 38, + "schemaVersion": 39, "time": { "from": "now-1h", "to": "now" }, "timepicker": {}, - "fiscalYearStartMonth": 0, "graphTooltip": 1, "editable": true, "version": 1, - "weekStart": "", "links": [], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { "type": "grafana", "uid": "-- Grafana --" }, - "enable": true, - "hide": true, - "iconColor": "rgba(0,211,255,1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, + "annotations": { "list": [] }, "templating": { "list": [ @@ -34,119 +39,99 @@ "name": "service", "label": "Service", "type": "query", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "query": "label_values(service)", "refresh": 2, - "sort": 1, "includeAll": true, "allValue": ".+", - "multi": false, - "hide": 0, + "multi": true, "current": {}, - "options": [] + "hide": 0, + "sort": 1 }, { "name": "level", "label": "Niveau", - "type": "custom", - "query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug", - "includeAll": false, - "multi": false, + "type": "query", + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "query": "label_values(level)", + "refresh": 2, + "includeAll": true, + "allValue": ".+", + "multi": true, + "current": {}, "hide": 0, - "current": { "text": "All", "value": ".+" }, - "options": [ - { "text": "All", "value": ".+", "selected": true }, - { "text": "error", "value": "error", "selected": false }, - { "text": "fatal", "value": "fatal", "selected": false }, - { "text": "warn", "value": "warn", "selected": false }, - { "text": "info", "value": "info", "selected": false }, - { "text": "debug", "value": "debug", "selected": false } - ] - }, - { - "name": "search", - "label": "Recherche", - "type": "textbox", - "query": "", - "hide": 0, - "current": { "text": "", "value": "" }, - "options": [{ "selected": true, "text": "", "value": "" }] + "sort": 1 } ] }, "panels": [ - { - "id": 100, - "type": "row", - "title": "Vue d'ensemble", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } - }, - { "id": 1, - "title": "Total logs", + "title": "Requêtes totales", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "#10183A" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "#10183A", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=~\"$service\"} [$__range]))", - "legendFormat": "Total", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum(count_over_time({service=~\"$service\"} | json | req_method != \"\" [$__range]))", + "legendFormat": "Requêtes", + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 2, - "title": "Erreurs & Fatal", + "title": "Erreurs (error + fatal)", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "red" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))", "legendFormat": "Erreurs", - "instant": true + "instant": true, + "range": false, + "refId": "A" } ] }, @@ -155,342 +140,342 @@ "id": 3, "title": "Warnings", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "orange" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))", "legendFormat": "Warnings", - "instant": true + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 4, - "title": "Info", + "title": "Taux d'erreur", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "fixedColor": "blue", "mode": "fixed" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, - "mappings": [] + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.01 }, + { "color": "red", "value": 0.05 } + ] + }, + "color": { "mode": "thresholds" } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))", - "legendFormat": "Info", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum(rate({service=~\"$service\", level=~\"error|fatal\"} [$__interval])) / sum(rate({service=~\"$service\"} [$__interval]))", + "legendFormat": "Taux d'erreur", + "instant": false, + "range": true, + "refId": "A" } ] }, { "id": 5, - "title": "Requêtes HTTP 5xx", - "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Trafic par service (req/s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "orientation": "auto", - "textMode": "auto", - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center" + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, - "mappings": [] + "unit": "reqps", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "gradientMode": "opacity", + "spanNulls": false + } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))", - "legendFormat": "5xx", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(service) (rate({service=~\"$service\"} | json | req_method != \"\" [$__interval]))", + "legendFormat": "{{service}}", + "instant": false, + "range": true, + "refId": "A" } ] }, { "id": 6, - "title": "Temps réponse moyen (ms)", - "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Erreurs & Warnings dans le temps", + "type": "timeseries", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "orientation": "auto", - "textMode": "auto", - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center" + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "unit": "ms", - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }, - "mappings": [] + "unit": "short", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 15, + "gradientMode": "opacity" + } }, - "overrides": [] + "overrides": [ + { + "matcher": { "id": "byName", "options": "error" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] + }, + { + "matcher": { "id": "byName", "options": "fatal" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] + }, + { + "matcher": { "id": "byName", "options": "warn" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] + } + ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))", - "legendFormat": "Avg", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(level) (rate({service=~\"$service\", level=~\"error|fatal|warn\"} [$__interval]))", + "legendFormat": "{{level}}", + "instant": false, + "range": true, + "refId": "A" } ] }, - { - "id": 200, - "type": "row", - "title": "Volume des logs", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 } - }, - { "id": 7, - "title": "Volume par niveau", + "title": "Temps de réponse Backend", "type": "timeseries", - "gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 12, "w": 16, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { + "unit": "ms", "color": { "mode": "palette-classic" }, "custom": { - "drawStyle": "bars", - "fillOpacity": 80, - "stacking": { "group": "A", "mode": "normal" }, - "lineWidth": 1, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false + "lineWidth": 2, + "fillOpacity": 8, + "gradientMode": "opacity" }, - "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 500 }, + { "color": "red", "value": 1000 } + ] + } }, "overrides": [ - { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] } + { + "matcher": { "id": "byName", "options": "Pire cas (1% des requêtes)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] + }, + { + "matcher": { "id": "byName", "options": "Lent (5% des requêtes)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] + }, + { + "matcher": { "id": "byName", "options": "Temps médian (requête typique)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] + } ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))", - "legendFormat": "{{level}}" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.50, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Temps médian (requête typique)", + "instant": false, + "range": true, + "refId": "A" + }, + { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.95, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Lent (5% des requêtes)", + "instant": false, + "range": true, + "refId": "B" + }, + { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.99, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Pire cas (1% des requêtes)", + "instant": false, + "range": true, + "refId": "C" } ] }, { "id": 8, - "title": "Volume par service", - "type": "timeseries", - "gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Répartition par niveau de log", + "type": "piechart", + "gridPos": { "x": 16, "y": 12, "w": 8, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "pieType": "donut", + "tooltip": { "mode": "single" }, + "legend": { "displayMode": "list", "placement": "bottom", "values": ["percent"] } }, "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "stacking": { "group": "A", "mode": "normal" }, - "lineWidth": 1, - "showPoints": "never", - "spanNulls": false - }, - "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } - }, - "overrides": [] + "defaults": { "unit": "short", "color": { "mode": "palette-classic" } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }, + { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] }, + { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] }, + { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] }, + { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] } + ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))", - "legendFormat": "{{service}}" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(level) (count_over_time({service=~\"$service\", level=~\"$level\"} [$__range]))", + "legendFormat": "{{level}}", + "instant": true, + "range": false, + "refId": "A" } ] }, - { - "id": 300, - "type": "row", - "title": "HTTP — Backend", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 } - }, - { "id": 9, - "title": "Taux d'erreur HTTP", - "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Codes HTTP (5m)", + "type": "bargauge", + "gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "displayMode": "gradient", + "valueMode": "color", + "showUnfilled": true, + "minVizWidth": 10, + "minVizHeight": 10 }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "line", - "fillOpacity": 20, - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 1 } + ] + } }, - "overrides": [ - { "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] } - ] + "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))", - "legendFormat": "5xx" - }, - { - "refId": "B", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))", - "legendFormat": "4xx" - }, - { - "refId": "C", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))", - "legendFormat": "2xx" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(status_code) (count_over_time({service=\"backend\"} | json | res_statusCode != \"\" | label_format status_code=\"{{res_statusCode}}\" [$__range]))", + "legendFormat": "HTTP {{status_code}}", + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 10, - "title": "Temps de réponse (ms)", - "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Top erreurs par contexte NestJS", + "type": "bargauge", + "gridPos": { "x": 12, "y": 20, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "displayMode": "gradient", + "showUnfilled": true }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "ms", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] } + "unit": "short", + "color": { "mode": "fixed", "fixedColor": "red" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", - "legendFormat": "Moy" - }, - { - "refId": "B", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", - "legendFormat": "Max" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "topk(10, sum by(context) (count_over_time({service=\"backend\", level=~\"error|fatal\"} | json | context != \"\" [$__range]) ))", + "legendFormat": "{{context}}", + "instant": true, + "range": false, + "refId": "A" } ] }, - { - "id": 400, - "type": "row", - "title": "Logs — Flux en direct", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } - }, - { "id": 11, - "title": "Backend — Logs", + "title": "Logs — Backend", "type": "logs", - "gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 28, "w": 24, "h": 12 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "dedupStrategy": "none", "enableLogDetails": true, @@ -503,24 +488,27 @@ }, "targets": [ { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "{service=\"backend\", level=~\"$level\"}", + "legendFormat": "", + "instant": false, + "range": true, "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" + "maxLines": 500 } ] }, { "id": 12, - "title": "Frontend — Logs", + "title": "Logs — Frontend", "type": "logs", - "gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 40, "w": 24, "h": 10 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "dedupStrategy": "none", "enableLogDetails": true, - "prettifyLogMessage": true, + "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, @@ -529,105 +517,13 @@ }, "targets": [ { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "{service=\"frontend\"}", + "legendFormat": "", + "instant": false, + "range": true, "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" - } - ] - }, - - { - "id": 500, - "type": "row", - "title": "Tous les logs filtrés", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 } - }, - - { - "id": 13, - "title": "Flux filtré — $service / $level", - "description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé", - "type": "logs", - "gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" - } - ] - }, - - { - "id": 600, - "type": "row", - "title": "Erreurs & Exceptions", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 } - }, - - { - "id": 14, - "title": "Erreurs — Backend", - "type": "logs", - "gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "signature", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"backend\", level=~\"error|fatal\"}", - "legendFormat": "" - } - ] - }, - - { - "id": 15, - "title": "Erreurs — Frontend", - "type": "logs", - "gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "signature", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"frontend\", level=~\"error|fatal\"}", - "legendFormat": "" + "maxLines": 200 } ] } diff --git a/infra/logging/grafana/provisioning/datasources/loki.yml b/infra/logging/grafana/provisioning/datasources/loki.yml index b3102e9..3d48bde 100644 --- a/infra/logging/grafana/provisioning/datasources/loki.yml +++ b/infra/logging/grafana/provisioning/datasources/loki.yml @@ -5,7 +5,7 @@ datasources: uid: loki-xpeditis type: loki access: proxy - url: http://loki:3100 + url: http://xpeditis-loki:3100 isDefault: true version: 1 editable: false diff --git a/infra/logging/promtail/promtail-config.yml b/infra/logging/promtail/promtail-config.yml index 453e222..df36f4a 100644 --- a/infra/logging/promtail/promtail-config.yml +++ b/infra/logging/promtail/promtail-config.yml @@ -1,53 +1,43 @@ server: http_listen_port: 9080 - grpc_listen_port: 0 log_level: warn positions: filename: /tmp/positions.yaml clients: - - url: http://loki:3100/loki/api/v1/push + - url: http://xpeditis-loki:3100/loki/api/v1/push batchwait: 1s batchsize: 1048576 timeout: 10s scrape_configs: - # ─── Docker container log collection (Mac-compatible via Docker socket API) ─ - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s filters: - # Only collect containers with label: logging=promtail - # Add this label to backend and frontend in docker-compose.dev.yml - name: label values: ['logging=promtail'] relabel_configs: - # Use docker-compose service name as the "service" label - - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + - source_labels: ['__meta_docker_container_label_logging_service'] target_label: service - # Keep container name for context - source_labels: ['__meta_docker_container_name'] regex: '/?(.*)' replacement: '${1}' target_label: container - # Log stream (stdout / stderr) - source_labels: ['__meta_docker_container_log_stream'] target_label: stream pipeline_stages: - # Drop entries older than 15 min to avoid replaying full container log history - drop: older_than: 15m drop_counter_reason: entry_too_old - # Drop noisy health-check / ping lines - drop: expression: 'GET /(health|metrics|minio/health)' - # Try to parse JSON (NestJS/pino output) - json: expressions: level: level @@ -55,12 +45,10 @@ scrape_configs: context: context reqId: reqId - # Promote parsed fields as Loki labels - labels: level: context: - # Map pino numeric levels to strings - template: source: level template: >-