# Plan d'implémentation : Portail Transporteur avec Authentification ## 📋 Vue d'ensemble Ce document détaille l'implémentation complète d'un portail transporteur B2B avec : - ✅ Création automatique de compte au premier clic sur le lien email - 📊 Dashboard avec statistiques et historique - 📄 Gestion et téléchargement des documents par booking - 🔐 Authentification sécurisée JWT - 👤 Gestion du profil transporteur --- ## 🏗️ Architecture Technique ### Stack Technologique - **Backend** : NestJS + TypeORM + PostgreSQL - **Frontend** : Next.js 14 + TanStack Query + Zustand - **Auth** : JWT (même système que les utilisateurs clients) - **Storage** : MinIO/S3 pour les documents - **Email** : Nodemailer pour les notifications ### Modèle de données ``` ┌─────────────────┐ ┌──────────────────┐ │ User │ │ Organization │ │ (Carrier) │────────▶│ (Carrier Org) │ └─────────────────┘ └──────────────────┘ │ │ │ │ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ │ CsvBooking │────────▶│ BookingDocument │ │ │ │ │ └─────────────────┘ └──────────────────┘ │ ▼ ┌─────────────────┐ │CarrierActivity │ (historique actions) └─────────────────┘ ``` --- ## 📅 PHASE 1 : Base de données et Migrations ### Étape 1.1 : Créer la table `carrier_profiles` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-CreateCarrierProfiles.ts` ```typescript export class CreateCarrierProfiles1234567890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE carrier_profiles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, -- Informations professionnelles company_name VARCHAR(255) NOT NULL, company_registration VARCHAR(100), vat_number VARCHAR(50), -- Contact phone VARCHAR(50), website VARCHAR(255), -- Adresse street_address TEXT, city VARCHAR(100), postal_code VARCHAR(20), country VARCHAR(2), -- ISO code -- Statistiques total_bookings_accepted INT DEFAULT 0, total_bookings_rejected INT DEFAULT 0, acceptance_rate DECIMAL(5,2) DEFAULT 0.00, total_revenue_usd DECIMAL(15,2) DEFAULT 0.00, total_revenue_eur DECIMAL(15,2) DEFAULT 0.00, -- Préférences preferred_currency VARCHAR(3) DEFAULT 'USD', notification_email VARCHAR(255), auto_accept_enabled BOOLEAN DEFAULT false, -- Métadonnées is_verified BOOLEAN DEFAULT false, is_active BOOLEAN DEFAULT true, last_login_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_carrier_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_carrier_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ); CREATE INDEX idx_carrier_profiles_user_id ON carrier_profiles(user_id); CREATE INDEX idx_carrier_profiles_org_id ON carrier_profiles(organization_id); CREATE INDEX idx_carrier_profiles_company_name ON carrier_profiles(company_name); `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE IF EXISTS carrier_profiles CASCADE`); } } ``` **Commande** : ```bash cd apps/backend npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateCarrierProfiles npm run migration:run ``` --- ### Étape 1.2 : Créer la table `carrier_activities` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-CreateCarrierActivities.ts` ```typescript export class CreateCarrierActivities1234567891 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TYPE carrier_activity_type AS ENUM ( 'BOOKING_ACCEPTED', 'BOOKING_REJECTED', 'DOCUMENT_DOWNLOADED', 'PROFILE_UPDATED', 'LOGIN', 'PASSWORD_CHANGED' ); CREATE TABLE carrier_activities ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), carrier_id UUID NOT NULL REFERENCES carrier_profiles(id) ON DELETE CASCADE, booking_id UUID REFERENCES csv_bookings(id) ON DELETE SET NULL, activity_type carrier_activity_type NOT NULL, description TEXT, metadata JSONB, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_activity_carrier FOREIGN KEY (carrier_id) REFERENCES carrier_profiles(id), CONSTRAINT fk_activity_booking FOREIGN KEY (booking_id) REFERENCES csv_bookings(id) ); CREATE INDEX idx_carrier_activities_carrier_id ON carrier_activities(carrier_id); CREATE INDEX idx_carrier_activities_booking_id ON carrier_activities(booking_id); CREATE INDEX idx_carrier_activities_type ON carrier_activities(activity_type); CREATE INDEX idx_carrier_activities_created_at ON carrier_activities(created_at DESC); `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE IF EXISTS carrier_activities CASCADE`); await queryRunner.query(`DROP TYPE IF EXISTS carrier_activity_type`); } } ``` **Commande** : ```bash npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateCarrierActivities npm run migration:run ``` --- ### Étape 1.3 : Modifier la table `csv_bookings` Ajouter les colonnes nécessaires pour lier les bookings aux transporteurs. **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-AddCarrierToCsvBookings.ts` ```typescript export class AddCarrierToCsvBookings1234567892 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE csv_bookings ADD COLUMN carrier_id UUID REFERENCES carrier_profiles(id) ON DELETE SET NULL, ADD COLUMN carrier_viewed_at TIMESTAMP, ADD COLUMN carrier_accepted_at TIMESTAMP, ADD COLUMN carrier_rejected_at TIMESTAMP, ADD COLUMN carrier_rejection_reason TEXT, ADD COLUMN carrier_notes TEXT; CREATE INDEX idx_csv_bookings_carrier_id ON csv_bookings(carrier_id); `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE csv_bookings DROP COLUMN IF EXISTS carrier_id, DROP COLUMN IF EXISTS carrier_viewed_at, DROP COLUMN IF EXISTS carrier_accepted_at, DROP COLUMN IF EXISTS carrier_rejected_at, DROP COLUMN IF EXISTS carrier_rejection_reason, DROP COLUMN IF EXISTS carrier_notes; `); } } ``` **Commande** : ```bash npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/AddCarrierToCsvBookings npm run migration:run ``` --- ### Étape 1.4 : Modifier la table `organizations` Ajouter un flag pour identifier les organisations transporteurs. **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-AddCarrierFlagToOrganizations.ts` ```typescript export class AddCarrierFlagToOrganizations1234567893 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE organizations ADD COLUMN is_carrier BOOLEAN DEFAULT false, ADD COLUMN carrier_type VARCHAR(50); -- 'LCL', 'FCL', 'BOTH', 'NVOCC', etc. CREATE INDEX idx_organizations_is_carrier ON organizations(is_carrier); `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE organizations DROP COLUMN IF EXISTS is_carrier, DROP COLUMN IF EXISTS carrier_type; `); } } ``` **Commande** : ```bash npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/AddCarrierFlagToOrganizations npm run migration:run ``` --- ## 📅 PHASE 2 : Backend - Entités et Domain ### Étape 2.1 : Créer l'entité ORM `CarrierProfile` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, OneToMany, } from 'typeorm'; import { UserOrmEntity } from './user.orm-entity'; import { OrganizationOrmEntity } from './organization.orm-entity'; import { CsvBookingOrmEntity } from './csv-booking.orm-entity'; import { CarrierActivityOrmEntity } from './carrier-activity.orm-entity'; @Entity('carrier_profiles') export class CarrierProfileOrmEntity { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: UserOrmEntity; @Column({ name: 'organization_id', type: 'uuid' }) organizationId: string; @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'organization_id' }) organization: OrganizationOrmEntity; @Column({ name: 'company_name', length: 255 }) companyName: string; @Column({ name: 'company_registration', length: 100, nullable: true }) companyRegistration: string | null; @Column({ name: 'vat_number', length: 50, nullable: true }) vatNumber: string | null; @Column({ length: 50, nullable: true }) phone: string | null; @Column({ length: 255, nullable: true }) website: string | null; @Column({ name: 'street_address', type: 'text', nullable: true }) streetAddress: string | null; @Column({ length: 100, nullable: true }) city: string | null; @Column({ name: 'postal_code', length: 20, nullable: true }) postalCode: string | null; @Column({ length: 2, nullable: true }) country: string | null; @Column({ name: 'total_bookings_accepted', type: 'int', default: 0 }) totalBookingsAccepted: number; @Column({ name: 'total_bookings_rejected', type: 'int', default: 0 }) totalBookingsRejected: number; @Column({ name: 'acceptance_rate', type: 'decimal', precision: 5, scale: 2, default: 0 }) acceptanceRate: number; @Column({ name: 'total_revenue_usd', type: 'decimal', precision: 15, scale: 2, default: 0 }) totalRevenueUsd: number; @Column({ name: 'total_revenue_eur', type: 'decimal', precision: 15, scale: 2, default: 0 }) totalRevenueEur: number; @Column({ name: 'preferred_currency', length: 3, default: 'USD' }) preferredCurrency: string; @Column({ name: 'notification_email', length: 255, nullable: true }) notificationEmail: string | null; @Column({ name: 'auto_accept_enabled', default: false }) autoAcceptEnabled: boolean; @Column({ name: 'is_verified', default: false }) isVerified: boolean; @Column({ name: 'is_active', default: true }) isActive: boolean; @Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) lastLoginAt: Date | null; @OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrier) bookings: CsvBookingOrmEntity[]; @OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrier) activities: CarrierActivityOrmEntity[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` --- ### Étape 2.2 : Créer l'entité ORM `CarrierActivity` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { CarrierProfileOrmEntity } from './carrier-profile.orm-entity'; import { CsvBookingOrmEntity } from './csv-booking.orm-entity'; export enum CarrierActivityType { BOOKING_ACCEPTED = 'BOOKING_ACCEPTED', BOOKING_REJECTED = 'BOOKING_REJECTED', DOCUMENT_DOWNLOADED = 'DOCUMENT_DOWNLOADED', PROFILE_UPDATED = 'PROFILE_UPDATED', LOGIN = 'LOGIN', PASSWORD_CHANGED = 'PASSWORD_CHANGED', } @Entity('carrier_activities') export class CarrierActivityOrmEntity { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'carrier_id', type: 'uuid' }) carrierId: string; @ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'carrier_id' }) carrier: CarrierProfileOrmEntity; @Column({ name: 'booking_id', type: 'uuid', nullable: true }) bookingId: string | null; @ManyToOne(() => CsvBookingOrmEntity, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'booking_id' }) booking: CsvBookingOrmEntity | null; @Column({ name: 'activity_type', type: 'enum', enum: CarrierActivityType, }) activityType: CarrierActivityType; @Column({ type: 'text', nullable: true }) description: string | null; @Column({ type: 'jsonb', nullable: true }) metadata: Record | null; @Column({ name: 'ip_address', length: 45, nullable: true }) ipAddress: string | null; @Column({ name: 'user_agent', type: 'text', nullable: true }) userAgent: string | null; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` --- ### Étape 2.3 : Mettre à jour l'entité `CsvBooking` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts` Ajouter les relations avec le transporteur : ```typescript // Ajouter ces imports import { CarrierProfileOrmEntity } from './carrier-profile.orm-entity'; // Dans la classe CsvBookingOrmEntity, ajouter : @Column({ name: 'carrier_id', type: 'uuid', nullable: true }) carrierId: string | null; @ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, { onDelete: 'SET NULL', }) @JoinColumn({ name: 'carrier_id' }) carrier: CarrierProfileOrmEntity | null; @Column({ name: 'carrier_viewed_at', type: 'timestamp', nullable: true }) carrierViewedAt: Date | null; @Column({ name: 'carrier_accepted_at', type: 'timestamp', nullable: true }) carrierAcceptedAt: Date | null; @Column({ name: 'carrier_rejected_at', type: 'timestamp', nullable: true }) carrierRejectedAt: Date | null; @Column({ name: 'carrier_rejection_reason', type: 'text', nullable: true }) carrierRejectionReason: string | null; @Column({ name: 'carrier_notes', type: 'text', nullable: true }) carrierNotes: string | null; ``` --- ## 📅 PHASE 3 : Backend - Services et Repositories ### Étape 3.1 : Créer le Repository `CarrierProfileRepository` **Fichier** : `apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts` ```typescript import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CarrierProfileOrmEntity } from '../entities/carrier-profile.orm-entity'; @Injectable() export class CarrierProfileRepository { private readonly logger = new Logger(CarrierProfileRepository.name); constructor( @InjectRepository(CarrierProfileOrmEntity) private readonly repository: Repository ) {} async findById(id: string): Promise { return await this.repository.findOne({ where: { id }, relations: ['user', 'organization'], }); } async findByUserId(userId: string): Promise { return await this.repository.findOne({ where: { userId }, relations: ['user', 'organization'], }); } async findByEmail(email: string): Promise { return await this.repository.findOne({ where: { user: { email } }, relations: ['user', 'organization'], }); } async create(data: Partial): Promise { const profile = this.repository.create(data); return await this.repository.save(profile); } async update(id: string, data: Partial): Promise { await this.repository.update(id, data); return await this.findById(id); } async updateStatistics( id: string, stats: { totalBookingsAccepted?: number; totalBookingsRejected?: number; acceptanceRate?: number; totalRevenueUsd?: number; totalRevenueEur?: number; } ): Promise { await this.repository.update(id, stats); } async updateLastLogin(id: string): Promise { await this.repository.update(id, { lastLoginAt: new Date() }); } } ``` --- ### Étape 3.2 : Créer le Service `CarrierAuthService` **Fichier** : `apps/backend/src/application/services/carrier-auth.service.ts` ```typescript import { Injectable, Logger, UnauthorizedException, ConflictException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; import { UserRepository } from '@infrastructure/persistence/typeorm/repositories/user.repository'; import { OrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/organization.repository'; import * as argon2 from 'argon2'; import { randomBytes } from 'crypto'; @Injectable() export class CarrierAuthService { private readonly logger = new Logger(CarrierAuthService.name); constructor( private readonly carrierProfileRepository: CarrierProfileRepository, private readonly userRepository: UserRepository, private readonly organizationRepository: OrganizationRepository, private readonly jwtService: JwtService ) {} /** * Créer automatiquement un compte transporteur lors du premier clic sur accept/reject */ async createCarrierAccountIfNotExists( carrierEmail: string, carrierName: string ): Promise<{ carrierId: string; userId: string; isNewAccount: boolean; temporaryPassword?: string; }> { // Vérifier si le transporteur existe déjà const existingCarrier = await this.carrierProfileRepository.findByEmail(carrierEmail); if (existingCarrier) { this.logger.log(`Carrier already exists: ${carrierEmail}`); return { carrierId: existingCarrier.id, userId: existingCarrier.userId, isNewAccount: false, }; } // Créer une nouvelle organisation pour le transporteur const organization = await this.organizationRepository.create({ name: carrierName, isCarrier: true, carrierType: 'LCL', // Par défaut }); // Générer un mot de passe temporaire const temporaryPassword = this.generateTemporaryPassword(); const hashedPassword = await argon2.hash(temporaryPassword); // Créer le compte utilisateur const user = await this.userRepository.create({ email: carrierEmail, password: hashedPassword, firstName: carrierName.split(' ')[0] || 'Carrier', lastName: carrierName.split(' ').slice(1).join(' ') || 'Account', role: 'CARRIER', // Nouveau rôle organizationId: organization.id, isEmailVerified: true, // Auto-vérifié car créé via email }); // Créer le profil transporteur const carrierProfile = await this.carrierProfileRepository.create({ userId: user.id, organizationId: organization.id, companyName: carrierName, notificationEmail: carrierEmail, }); this.logger.log(`Created new carrier account: ${carrierEmail}`); return { carrierId: carrierProfile.id, userId: user.id, isNewAccount: true, temporaryPassword, }; } /** * Générer un token JWT pour auto-login */ async generateAutoLoginToken(userId: string, carrierId: string): Promise { const payload = { sub: userId, carrierId, type: 'carrier', autoLogin: true, }; return this.jwtService.sign(payload, { expiresIn: '1h' }); } /** * Login classique pour les transporteurs */ async login(email: string, password: string): Promise<{ accessToken: string; refreshToken: string; carrier: any; }> { const carrier = await this.carrierProfileRepository.findByEmail(email); if (!carrier || !carrier.user) { throw new UnauthorizedException('Invalid credentials'); } const isPasswordValid = await argon2.verify(carrier.user.password, password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid credentials'); } // Mettre à jour last login await this.carrierProfileRepository.updateLastLogin(carrier.id); const payload = { sub: carrier.userId, email: carrier.user.email, carrierId: carrier.id, organizationId: carrier.organizationId, role: 'CARRIER', }; const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); return { accessToken, refreshToken, carrier: { id: carrier.id, companyName: carrier.companyName, email: carrier.user.email, }, }; } private generateTemporaryPassword(): string { return randomBytes(16).toString('hex').slice(0, 12); } } ``` --- ### Étape 3.3 : Créer le Service `CarrierDashboardService` **Fichier** : `apps/backend/src/application/services/carrier-dashboard.service.ts` ```typescript import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common'; import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; import { CsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository'; import { DocumentService } from './document.service'; export interface CarrierDashboardStats { totalBookings: number; pendingBookings: number; acceptedBookings: number; rejectedBookings: number; acceptanceRate: number; totalRevenue: { usd: number; eur: number; }; recentActivities: any[]; } export interface CarrierBookingListItem { id: string; bookingNumber: string; origin: string; destination: string; status: string; priceUsd: number; priceEur: number; primaryCurrency: string; createdAt: Date; carrierViewedAt: Date | null; documentsCount: number; } @Injectable() export class CarrierDashboardService { private readonly logger = new Logger(CarrierDashboardService.name); constructor( private readonly carrierProfileRepository: CarrierProfileRepository, private readonly csvBookingRepository: CsvBookingRepository, private readonly carrierActivityRepository: CarrierActivityRepository, private readonly documentService: DocumentService ) {} /** * Obtenir les statistiques du transporteur */ async getCarrierStats(carrierId: string): Promise { const carrier = await this.carrierProfileRepository.findById(carrierId); if (!carrier) { throw new NotFoundException('Carrier not found'); } // Compter les bookings par statut const bookings = await this.csvBookingRepository.findByCarrierId(carrierId); const pendingCount = bookings.filter((b) => b.status === 'PENDING').length; const acceptedCount = bookings.filter((b) => b.status === 'ACCEPTED').length; const rejectedCount = bookings.filter((b) => b.status === 'REJECTED').length; // Récupérer les activités récentes const recentActivities = await this.carrierActivityRepository.findByCarrierId( carrierId, 10 ); return { totalBookings: bookings.length, pendingBookings: pendingCount, acceptedBookings: acceptedCount, rejectedBookings: rejectedCount, acceptanceRate: carrier.acceptanceRate, totalRevenue: { usd: carrier.totalRevenueUsd, eur: carrier.totalRevenueEur, }, recentActivities: recentActivities.map((activity) => ({ id: activity.id, type: activity.activityType, description: activity.description, createdAt: activity.createdAt, bookingId: activity.bookingId, })), }; } /** * Obtenir la liste des bookings du transporteur */ async getCarrierBookings( carrierId: string, page: number = 1, limit: number = 10, status?: string ): Promise<{ data: CarrierBookingListItem[]; total: number; page: number; limit: number; }> { const carrier = await this.carrierProfileRepository.findById(carrierId); if (!carrier) { throw new NotFoundException('Carrier not found'); } const bookings = await this.csvBookingRepository.findByCarrierIdPaginated( carrierId, page, limit, status ); const data = await Promise.all( bookings.data.map(async (booking) => { const documentsCount = await this.documentService.countDocumentsByBooking(booking.id); return { id: booking.id, bookingNumber: booking.bookingNumber, origin: booking.origin, destination: booking.destination, status: booking.status, priceUsd: booking.priceUsd, priceEur: booking.priceEur, primaryCurrency: booking.primaryCurrency, createdAt: booking.createdAt, carrierViewedAt: booking.carrierViewedAt, documentsCount, }; }) ); return { data, total: bookings.total, page, limit, }; } /** * Obtenir les détails d'un booking avec documents */ async getBookingDetails(carrierId: string, bookingId: string): Promise { const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException('Booking not found'); } // Vérifier que le booking appartient bien au transporteur if (booking.carrierId !== carrierId) { throw new ForbiddenException('Access denied to this booking'); } // Récupérer les documents const documents = await this.documentService.getBookingDocuments(bookingId); // Marquer comme vu si pas encore fait if (!booking.carrierViewedAt) { await this.csvBookingRepository.update(bookingId, { carrierViewedAt: new Date(), }); } return { ...booking, documents, }; } /** * Télécharger un document */ async downloadDocument( carrierId: string, bookingId: string, documentId: string ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> { // Vérifier l'accès const booking = await this.csvBookingRepository.findById(bookingId); if (!booking || booking.carrierId !== carrierId) { throw new ForbiddenException('Access denied to this document'); } // Enregistrer l'activité await this.carrierActivityRepository.create({ carrierId, bookingId, activityType: 'DOCUMENT_DOWNLOADED', description: `Downloaded document ${documentId}`, metadata: { documentId }, }); // Télécharger depuis S3/MinIO return await this.documentService.downloadDocument(documentId); } } ``` --- ## 📅 PHASE 4 : Backend - Controllers ### Étape 4.1 : Créer le Controller `CarrierAuthController` **Fichier** : `apps/backend/src/application/controllers/carrier-auth.controller.ts` ```typescript import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Request, Get, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { CarrierAuthService } from '../services/carrier-auth.service'; import { Public } from '../decorators/public.decorator'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; class CarrierLoginDto { email: string; password: string; } @ApiTags('Carrier Auth') @Controller('carrier-auth') export class CarrierAuthController { constructor(private readonly carrierAuthService: CarrierAuthService) {} @Public() @Post('login') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Carrier login' }) @ApiResponse({ status: 200, description: 'Login successful' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) async login(@Body() dto: CarrierLoginDto) { return await this.carrierAuthService.login(dto.email, dto.password); } @UseGuards(JwtAuthGuard) @Get('me') @ApiBearerAuth() @ApiOperation({ summary: 'Get current carrier profile' }) @ApiResponse({ status: 200, description: 'Profile retrieved' }) async getProfile(@Request() req: any) { // Le profil sera renvoyé par le guard return req.user; } } ``` --- ### Étape 4.2 : Créer le Controller `CarrierDashboardController` **Fichier** : `apps/backend/src/application/controllers/carrier-dashboard.controller.ts` ```typescript import { Controller, Get, Param, Query, UseGuards, Request, Res, StreamableFile, ParseIntPipe, DefaultValuePipe, } from '@nestjs/common'; import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CarrierDashboardService } from '../services/carrier-dashboard.service'; @ApiTags('Carrier Dashboard') @Controller('carrier-dashboard') @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class CarrierDashboardController { constructor(private readonly carrierDashboardService: CarrierDashboardService) {} @Get('stats') @ApiOperation({ summary: 'Get carrier statistics' }) @ApiResponse({ status: 200, description: 'Statistics retrieved' }) async getStats(@Request() req: any) { const carrierId = req.user.carrierId; return await this.carrierDashboardService.getCarrierStats(carrierId); } @Get('bookings') @ApiOperation({ summary: 'Get carrier bookings list' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'status', required: false, type: String }) @ApiResponse({ status: 200, description: 'Bookings retrieved' }) async getBookings( @Request() req: any, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query('status') status?: string ) { const carrierId = req.user.carrierId; return await this.carrierDashboardService.getCarrierBookings(carrierId, page, limit, status); } @Get('bookings/:id') @ApiOperation({ summary: 'Get booking details with documents' }) @ApiParam({ name: 'id', description: 'Booking ID' }) @ApiResponse({ status: 200, description: 'Booking details retrieved' }) @ApiResponse({ status: 404, description: 'Booking not found' }) @ApiResponse({ status: 403, description: 'Access denied' }) async getBookingDetails(@Request() req: any, @Param('id') bookingId: string) { const carrierId = req.user.carrierId; return await this.carrierDashboardService.getBookingDetails(carrierId, bookingId); } @Get('bookings/:bookingId/documents/:documentId/download') @ApiOperation({ summary: 'Download booking document' }) @ApiParam({ name: 'bookingId', description: 'Booking ID' }) @ApiParam({ name: 'documentId', description: 'Document ID' }) @ApiResponse({ status: 200, description: 'Document downloaded' }) @ApiResponse({ status: 403, description: 'Access denied' }) @ApiResponse({ status: 404, description: 'Document not found' }) async downloadDocument( @Request() req: any, @Param('bookingId') bookingId: string, @Param('documentId') documentId: string, @Res({ passthrough: true }) res: Response ): Promise { const carrierId = req.user.carrierId; const { buffer, fileName, mimeType } = await this.carrierDashboardService.downloadDocument( carrierId, bookingId, documentId ); res.set({ 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${fileName}"`, }); return new StreamableFile(buffer); } } ``` --- ### Étape 4.3 : Modifier le Controller `CsvBookingsController` Mettre à jour les méthodes `acceptBooking` et `rejectBooking` pour créer le compte transporteur. **Fichier** : `apps/backend/src/application/controllers/csv-bookings.controller.ts` ```typescript // Dans la méthode acceptBooking (ligne 258), REMPLACER par : async acceptBooking(@Param('token') token: string, @Res() res: Response): Promise { // 1. Accepter le booking const booking = await this.csvBookingService.acceptBooking(token); // 2. Créer le compte transporteur si nécessaire const { carrierId, userId, isNewAccount, temporaryPassword } = await this.carrierAuthService.createCarrierAccountIfNotExists( booking.carrierEmail, booking.carrierName ); // 3. Lier le booking au transporteur await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); // 4. Générer un token auto-login const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); // 5. Rediriger vers la page de confirmation avec auto-login const frontendUrl = process.env.APP_URL || 'http://localhost:3000'; res.redirect( HttpStatus.FOUND, `${frontendUrl}/carrier/confirmed?token=${autoLoginToken}&action=accepted&bookingId=${booking.id}&new=${isNewAccount}` ); } // Dans la méthode rejectBooking (ligne 297), REMPLACER par : async rejectBooking( @Param('token') token: string, @Query('reason') reason: string, @Res() res: Response ): Promise { // 1. Rejeter le booking const booking = await this.csvBookingService.rejectBooking(token, reason); // 2. Créer le compte transporteur si nécessaire const { carrierId, userId, isNewAccount, temporaryPassword } = await this.carrierAuthService.createCarrierAccountIfNotExists( booking.carrierEmail, booking.carrierName ); // 3. Lier le booking au transporteur await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); // 4. Générer un token auto-login const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); // 5. Rediriger vers la page de confirmation avec auto-login const frontendUrl = process.env.APP_URL || 'http://localhost:3000'; res.redirect( HttpStatus.FOUND, `${frontendUrl}/carrier/confirmed?token=${autoLoginToken}&action=rejected&bookingId=${booking.id}&new=${isNewAccount}` ); } ``` --- ## 📅 PHASE 5 : Frontend - Pages d'authentification ### Étape 5.1 : Créer la page de confirmation avec auto-login **Fichier** : `apps/frontend/app/carrier/confirmed/page.tsx` ```typescript 'use client'; import { useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; export default function CarrierConfirmedPage() { const searchParams = useSearchParams(); const router = useRouter(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const token = searchParams.get('token'); const action = searchParams.get('action'); const bookingId = searchParams.get('bookingId'); const isNewAccount = searchParams.get('new') === 'true'; useEffect(() => { const autoLogin = async () => { if (!token) { setError('Token manquant'); setLoading(false); return; } try { // Stocker le token JWT localStorage.setItem('carrier_access_token', token); // Rediriger vers le dashboard après 3 secondes setTimeout(() => { router.push(`/carrier/dashboard/bookings/${bookingId}`); }, 3000); setLoading(false); } catch (err) { setError('Erreur lors de la connexion automatique'); setLoading(false); } }; autoLogin(); }, [token, bookingId, router]); if (loading) { return (

Connexion en cours...

); } if (error) { return (

Erreur

{error}

); } const isAccepted = action === 'accepted'; return (
{/* Success Icon */} {isAccepted ? ( ) : ( )} {/* Title */}

{isAccepted ? '✅ Demande acceptée avec succès' : '❌ Demande refusée'}

{/* New Account Message */} {isNewAccount && (

🎉 Bienvenue sur Xpeditis !

Un compte transporteur a été créé automatiquement pour vous. Vous recevrez un email avec vos identifiants de connexion.

)} {/* Confirmation Message */}

{isAccepted ? 'Votre acceptation a été enregistrée. Le client va être notifié automatiquement par email.' : 'Votre refus a été enregistré. Le client va être notifié automatiquement.'}

{/* Redirection Notice */}

Redirection vers votre tableau de bord dans quelques secondes...

{/* Next Steps */}

📋 Prochaines étapes

{isAccepted ? (
  • 1. Le client va vous contacter directement par email
  • 2. Envoyez-lui le numéro de réservation (booking number)
  • 3. Organisez l'enlèvement de la marchandise
  • 4. Suivez l'expédition depuis votre tableau de bord
) : (
  • 1. Le client sera notifié de votre refus
  • 2. Il pourra rechercher une alternative
)}
{/* Manual Link */}
); } ``` --- ### Étape 5.2 : Créer la page de login transporteur **Fichier** : `apps/frontend/app/carrier/login/page.tsx` ```typescript 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Ship, Mail, Lock, Loader2 } from 'lucide-react'; export default function CarrierLoginPage() { const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); try { const response = await fetch('http://localhost:4000/api/v1/carrier-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new Error('Identifiants invalides'); } const data = await response.json(); // Stocker le token localStorage.setItem('carrier_access_token', data.accessToken); localStorage.setItem('carrier_refresh_token', data.refreshToken); // Rediriger vers le dashboard router.push('/carrier/dashboard'); } catch (err: any) { setError(err.message || 'Erreur de connexion'); } finally { setLoading(false); } }; return (
{/* Header */}

Portail Transporteur

Connectez-vous à votre espace Xpeditis

{/* Error Message */} {error && (

{error}

)} {/* Login Form */}
{/* Email */}
setEmail(e.target.value)} required className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="votre@email.com" />
{/* Password */}
setPassword(e.target.value)} required className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="••••••••" />
{/* Submit Button */}
{/* Footer Links */}

Vous n'avez pas encore de compte ?
Un compte sera créé automatiquement lors de votre première acceptation de demande.

); } ``` --- ## 📅 PHASE 6 : Frontend - Dashboard Transporteur ### Étape 6.1 : Créer le layout du dashboard **Fichier** : `apps/frontend/app/carrier/dashboard/layout.tsx` ```typescript 'use client'; import { useEffect, useState } from 'react'; import { useRouter, usePathname } from 'next/navigation'; import Link from 'next/link'; import { Ship, LayoutDashboard, FileText, BarChart3, User, LogOut, Menu, X, } from 'lucide-react'; export default function CarrierDashboardLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [carrierName, setCarrierName] = useState('Transporteur'); useEffect(() => { // Vérifier l'authentification const token = localStorage.getItem('carrier_access_token'); if (!token) { router.push('/carrier/login'); } }, [router]); const handleLogout = () => { localStorage.removeItem('carrier_access_token'); localStorage.removeItem('carrier_refresh_token'); router.push('/carrier/login'); }; const menuItems = [ { name: 'Tableau de bord', href: '/carrier/dashboard', icon: LayoutDashboard, }, { name: 'Réservations', href: '/carrier/dashboard/bookings', icon: FileText, }, { name: 'Statistiques', href: '/carrier/dashboard/stats', icon: BarChart3, }, { name: 'Mon profil', href: '/carrier/dashboard/profile', icon: User, }, ]; return (
{/* Mobile Sidebar Toggle */}
{/* Sidebar */} {/* Main Content */}
{children}
{/* Mobile Overlay */} {isSidebarOpen && (
setIsSidebarOpen(false)} /> )}
); } ``` --- ### Étape 6.2 : Créer la page dashboard principal **Fichier** : `apps/frontend/app/carrier/dashboard/page.tsx` ```typescript 'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, CheckCircle, XCircle, Clock, TrendingUp, DollarSign, Euro, Activity, } from 'lucide-react'; interface DashboardStats { totalBookings: number; pendingBookings: number; acceptedBookings: number; rejectedBookings: number; acceptanceRate: number; totalRevenue: { usd: number; eur: number; }; recentActivities: any[]; } export default function CarrierDashboardPage() { const router = useRouter(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchStats(); }, []); const fetchStats = async () => { try { const token = localStorage.getItem('carrier_access_token'); const response = await fetch('http://localhost:4000/api/v1/carrier-dashboard/stats', { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) throw new Error('Failed to fetch stats'); const data = await response.json(); setStats(data); } catch (error) { console.error('Error fetching stats:', error); } finally { setLoading(false); } }; if (loading) { return (

Chargement...

); } if (!stats) { return
Erreur de chargement des statistiques
; } const statCards = [ { title: 'Total Réservations', value: stats.totalBookings, icon: FileText, color: 'blue', }, { title: 'En attente', value: stats.pendingBookings, icon: Clock, color: 'yellow', }, { title: 'Acceptées', value: stats.acceptedBookings, icon: CheckCircle, color: 'green', }, { title: 'Refusées', value: stats.rejectedBookings, icon: XCircle, color: 'red', }, ]; return (
{/* Header */}

Tableau de bord

Vue d'ensemble de votre activité

{/* Stats Cards */}
{statCards.map((card) => { const Icon = card.icon; return (

{card.title}

{card.value}

); })}
{/* Revenue & Acceptance Rate */}
{/* Revenue */}

Revenus totaux

USD
${stats.totalRevenue.usd.toLocaleString()}
EUR
€{stats.totalRevenue.eur.toLocaleString()}
{/* Acceptance Rate */}

Taux d'acceptation

{stats.acceptanceRate.toFixed(1)}%

{stats.acceptedBookings} acceptées / {stats.totalBookings} total

{/* Recent Activities */}

Activité récente

{stats.recentActivities.length > 0 ? (
{stats.recentActivities.map((activity) => (

{activity.description}

{new Date(activity.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit', })}

{activity.type}
))}
) : (

Aucune activité récente

)}
); } ``` --- ## 📅 PHASE 7 : Tests et Documentation ### Étape 7.1 : Tests unitaires Créer des tests pour les services principaux. **Fichiers à créer** : - `apps/backend/src/application/services/carrier-auth.service.spec.ts` - `apps/backend/src/application/services/carrier-dashboard.service.spec.ts` ### Étape 7.2 : Tests d'intégration **Fichier** : `apps/backend/test/carrier-portal.e2e-spec.ts` ### Étape 7.3 : Documentation **Fichier** : `apps/backend/docs/CARRIER_PORTAL_API.md` --- ## 📋 Checklist finale ### Backend - [ ] Migrations de base de données exécutées - [ ] Entités ORM créées et testées - [ ] Repositories implémentés - [ ] Services métier créés - [ ] Controllers avec Swagger documentation - [ ] Guards et authentification JWT - [ ] Gestion des documents (upload/download) - [ ] Emails de notification ### Frontend - [ ] Page de confirmation avec auto-login - [ ] Page de login transporteur - [ ] Layout dashboard avec sidebar - [ ] Page dashboard principal avec stats - [ ] Page liste des réservations - [ ] Page détails d'une réservation - [ ] Téléchargement de documents - [ ] Page de profil transporteur ### Tests - [ ] Tests unitaires backend - [ ] Tests d'intégration - [ ] Tests E2E - [ ] Tests de charge ### Déploiement - [ ] Variables d'environnement configurées - [ ] Build frontend production - [ ] Build backend production - [ ] Documentation API complète --- ## 🚀 Commandes de déploiement ```bash # Backend cd apps/backend npm run build npm run migration:run npm run start:prod # Frontend cd apps/frontend npm run build npm start ``` --- ## 📊 Estimation du temps | Phase | Estimation | Priorité | |-------|-----------|----------| | Phase 1 : BDD | 2 heures | ⭐⭐⭐ | | Phase 2 : Entités | 3 heures | ⭐⭐⭐ | | Phase 3 : Services | 5 heures | ⭐⭐⭐ | | Phase 4 : Controllers | 3 heures | ⭐⭐⭐ | | Phase 5 : Frontend Auth | 4 heures | ⭐⭐⭐ | | Phase 6 : Frontend Dashboard | 8 heures | ⭐⭐⭐ | | Phase 7 : Tests | 4 heures | ⭐⭐ | **Total estimé : 29 heures** (environ 1 semaine de développement) --- ## 🎯 Prochaines améliorations (Phase 2) 1. **Notifications temps réel** : WebSocket pour les nouvelles demandes 2. **Analytics avancés** : Graphiques de performance mensuelle 3. **API mobile** : Application mobile pour les transporteurs 4. **Multi-langue** : Support FR/EN/ES 5. **Système de rating** : Noter les clients après chaque transport 6. **Intégration calendrier** : Synchroniser les réservations avec Google Calendar 7. **Chat intégré** : Messagerie client ↔ transporteur --- **Document créé le** : 2025-12-03 **Auteur** : Claude Code **Version** : 1.0