diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index e834f3b..e8af64f 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -35,6 +35,9 @@ import { BlogModule } from '../blog/blog.module'; // Storage import { StorageModule } from '@infrastructure/storage/storage.module'; +// User deletion (GDPR erasure) +import { GDPRModule } from '../gdpr/gdpr.module'; + @Module({ imports: [ TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), @@ -43,6 +46,7 @@ import { StorageModule } from '@infrastructure/storage/storage.module'; EmailModule, BlogModule, StorageModule, + GDPRModule, ], controllers: [AdminController], providers: [ diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 939da63..5f888ef 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -75,6 +75,11 @@ import type { BlogPostCategory } from '@domain/entities/blog-post.entity'; // Storage imports import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; +// User deletion +import { UserDeletionService } from '../services/user-deletion.service'; +import { AdminContinuityService } from '../services/admin-continuity.service'; +import { isAnonymisedEmail } from '@domain/services/data-retention'; + const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; const ALLOWED_IMAGE_MIMETYPES = [ 'image/jpeg', @@ -111,7 +116,9 @@ export class AdminController { private readonly siretVerificationPort: SiretVerificationPort, @Inject(EMAIL_PORT) private readonly emailPort: EmailPort, private readonly blogService: BlogService, - @Inject(STORAGE_PORT) private readonly storage: StoragePort + @Inject(STORAGE_PORT) private readonly storage: StoragePort, + private readonly userDeletionService: UserDeletionService, + private readonly adminContinuity: AdminContinuityService ) {} // ==================== USERS ENDPOINTS ==================== @@ -143,7 +150,9 @@ export class AdminController { async getAllUsers(@CurrentUser() user: UserPayload): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching ALL users from database`); - let users = await this.userRepository.findAll(); + // Erased accounts stay in the table (their bookings reference them) but are + // no longer users: they must not come back in the list after a deletion. + let users = (await this.userRepository.findAll()).filter(u => !isAnonymisedEmail(u.email)); // Security: Non-admin users (MANAGER and below) cannot see ADMIN users if (user.role !== 'ADMIN') { @@ -237,6 +246,12 @@ export class AdminController { throw new BadRequestException('You cannot change your own role'); } + // At least one active admin must remain: refuse demoting or deactivating the last one + await this.adminContinuity.assertKeepsAnActiveAdmin(foundUser, { + role: dto.role ?? foundUser.role, + isActive: dto.isActive ?? foundUser.isActive, + }); + // Apply updates if (dto.firstName) { foundUser.updateFirstName(dto.firstName); @@ -268,7 +283,10 @@ export class AdminController { @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete user (Admin only)', - description: 'Permanently delete a user from the database', + description: + 'Erase a user (any role, including another ADMIN): personal data is deleted or anonymised, ' + + 'the account is disabled and its license revoked. Bookings are kept. ' + + 'An admin cannot delete their own account here, nor the last active admin.', }) @ApiParam({ name: 'id', @@ -287,12 +305,7 @@ export class AdminController { ): Promise { this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`); - const foundUser = await this.userRepository.findById(id); - if (!foundUser) { - throw new NotFoundException(`User ${id} not found`); - } - - await this.userRepository.deleteById(id); + await this.userDeletionService.deleteByAdmin(id, user); this.logger.log(`[ADMIN] User deleted successfully: ${id}`); } @@ -789,12 +802,8 @@ export class AdminController { this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`); try { - await this.emailPort.send({ - to: body.to, - subject: '[Xpeditis] Test SMTP', - html: `

Email de test envoyé depuis le panel admin par ${user.email}.

Si vous lisez ceci, la configuration SMTP fonctionne correctement.

`, - text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`, - }); + // Même gabarit que les vrais emails : le test valide aussi leur affichage. + await this.emailPort.sendSmtpTest(body.to, user.email); this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`); return { success: true, message: `Email envoyé avec succès à ${body.to}` }; diff --git a/apps/backend/src/application/controllers/audit.controller.ts b/apps/backend/src/application/controllers/audit.controller.ts index 12589cf..d51c928 100644 --- a/apps/backend/src/application/controllers/audit.controller.ts +++ b/apps/backend/src/application/controllers/audit.controller.ts @@ -25,9 +25,9 @@ class AuditLogResponseDto { id: string; action: string; status: string; - userId: string; + userId: string | null; userEmail: string; - organizationId: string; + organizationId: string | null; resourceType?: string; resourceId?: string; resourceName?: string; diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index f6290d5..39476d7 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -43,18 +43,6 @@ import { const REFRESH_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -/** - * Escape user-provided text before interpolating it into HTML emails - */ -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - /** * Authentication Controller * @@ -262,12 +250,13 @@ export class AuthController { }; } catch (error: any) { // Audit log: record failed login attempts (the attempted email is the - // only identity we have — the credentials did not match a valid user) + // only identity we have — the credentials did not match a valid user, + // so user and organization are recorded as null) await this.auditService.logFailure( AuditAction.USER_LOGIN, - 'unknown', + null, dto.email, - 'unknown', + null, error?.message || 'Invalid credentials', { resourceType: 'user', @@ -413,53 +402,16 @@ export class AuthController { other: 'Autre', }; - const subjectLabel = escapeHtml(subjectLabels[dto.subject] || dto.subject); - const firstName = escapeHtml(dto.firstName); - const lastName = escapeHtml(dto.lastName); - const email = escapeHtml(dto.email); - const company = dto.company ? escapeHtml(dto.company) : undefined; - const phone = dto.phone ? escapeHtml(dto.phone) : undefined; - const message = escapeHtml(dto.message); - - const html = ` -
-
-

Nouveau message de contact

-
-
- - - - - - - - - - ${company ? `` : ''} - ${phone ? `` : ''} - - - - -
Nom${firstName} ${lastName}
Email${email}
Entreprise${company}
Téléphone${phone}
Sujet${subjectLabel}
-
-

Message :

-

${message}

-
-
-
-

Xpeditis — Formulaire de contact

-
-
- `; - + // Le gabarit échappe lui-même chaque champ saisi par le visiteur. try { - await this.emailService.send({ - to: 'contact@xpeditis.com', - replyTo: dto.email, - subject: `[Contact] ${subjectLabels[dto.subject] || dto.subject} — ${dto.firstName} ${dto.lastName}`, - html, + await this.emailService.sendContactMessage('contact@xpeditis.com', { + firstName: dto.firstName, + lastName: dto.lastName, + email: dto.email, + company: dto.company || undefined, + phone: dto.phone || undefined, + subjectLabel: subjectLabels[dto.subject] || dto.subject, + message: dto.message, }); } catch (error) { this.logger.error(`Failed to send contact email: ${error}`); diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 8f93c6f..15359de 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -316,7 +316,8 @@ export class CsvBookingsController { @ApiOperation({ summary: 'Get organization bookings', description: - "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + "Retrieve all bookings and quotes of the user's organization, whoever created them, " + + 'with the name of their creator. Available to every member of the organization.', }) @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) @@ -547,7 +548,9 @@ export class CsvBookingsController { @ApiBearerAuth() @ApiOperation({ summary: 'Get booking by ID', - description: 'Retrieve a specific CSV booking by its ID. Only accessible by the booking owner.', + description: + 'Retrieve a specific CSV booking by its ID. Readable by any member of the booking organization ' + + 'and by the assigned carrier; changes remain reserved to the booking owner.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ @@ -560,7 +563,12 @@ export class CsvBookingsController { async getBooking(@Param('id') id: string, @Request() req: any): Promise { const userId = req.user.id; const carrierId = req.user.carrierId; // May be undefined if not a carrier - return await this.csvBookingService.getBookingById(id, userId, carrierId); + return await this.csvBookingService.getBookingById( + id, + userId, + carrierId, + req.user.organizationId + ); } /** diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index 3b23bcd..a96cf2e 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -25,6 +25,7 @@ import { GDPRService, GDPRDataExport, GDPRErasureReport } from '../services/gdpr import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto'; import { DeleteAccountDto } from '../dto/delete-account.dto'; import { RetentionService, RetentionReport } from '../services/retention.service'; +import { AdminContinuityService } from '../services/admin-continuity.service'; import { RETENTION_RULES } from '@domain/services/data-retention'; @ApiTags('GDPR') @@ -34,7 +35,8 @@ import { RETENTION_RULES } from '@domain/services/data-retention'; export class GDPRController { constructor( private readonly gdprService: GDPRService, - private readonly retentionService: RetentionService + private readonly retentionService: RetentionService, + private readonly adminContinuity: AdminContinuityService ) {} /** Export de portabilité au format JSON (art. 20). */ @@ -96,6 +98,10 @@ export class GDPRController { }); } + // Le dernier administrateur actif ne peut pas effacer son propre compte : + // la plateforme resterait sans personne pour l'administrer. + await this.adminContinuity.assertCanErase(user.id); + return this.gdprService.deleteUserData(user.id, body.reason); } diff --git a/apps/backend/src/application/controllers/users.controller.create.spec.ts b/apps/backend/src/application/controllers/users.controller.create.spec.ts new file mode 100644 index 0000000..b2f5b5a --- /dev/null +++ b/apps/backend/src/application/controllers/users.controller.create.spec.ts @@ -0,0 +1,96 @@ +import { Logger } from '@nestjs/common'; +import { UsersController } from './users.controller'; + +/** + * Creation d'un compte depuis le panel admin : l'email d'acces n'etait jamais + * envoye (TODO), et le mot de passe temporaire etait ecrit dans les journaux. + */ + +const ADMIN = { + id: 'admin-1', + email: 'admin@xpeditis.com', + role: 'ADMIN', + organizationId: 'org-1', +} as never; + +function buildController(options: { emailFails?: boolean } = {}) { + const userRepository = { + findByEmail: jest.fn(async () => null), + save: jest.fn(async (user: unknown) => user), + findById: jest.fn(async () => ({ firstName: 'Paul', lastName: 'Martin' })), + }; + const emailPort = { + sendUserInvitation: jest.fn(async () => { + if (options.emailFails) throw new Error('550 sender not valid'); + }), + }; + const organizationRepository = { findById: jest.fn(async () => ({ name: 'Acme' })) }; + const unused = {} as never; + + const controller = new UsersController( + userRepository as never, + unused, + unused, + unused, + emailPort as never, + organizationRepository as never + ); + return { controller, emailPort }; +} + +const dto = { + email: 'marie@acme.test', + firstName: 'Marie', + lastName: 'Dupont', + role: 'USER', + organizationId: '550e8400-e29b-41d4-a716-446655440000', + password: 'Temp-Password-42', +} as never; + +describe('UsersController.createUser — email d’acces', () => { + let logs: string[]; + + beforeEach(() => { + logs = []; + const capture = (message: unknown) => { + logs.push(String(message)); + }; + jest.spyOn(Logger.prototype, 'log').mockImplementation(capture); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(capture); + jest.spyOn(Logger.prototype, 'error').mockImplementation(capture); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("envoie l'email d'acces au compte cree", async () => { + const { controller, emailPort } = buildController(); + + const result = await controller.createUser(dto, ADMIN); + + expect(emailPort.sendUserInvitation).toHaveBeenCalledWith( + 'marie@acme.test', + 'Acme', + 'Paul Martin', + 'Temp-Password-42' + ); + expect(result.invitationEmailSent).toBe(true); + }, 20000); + + it("n'ecrit jamais le mot de passe temporaire dans les journaux", async () => { + const { controller } = buildController(); + + await controller.createUser(dto, ADMIN); + + expect(logs.join('\n')).not.toContain('Temp-Password-42'); + }, 20000); + + it("cree le compte meme si l'email echoue, et le signale", async () => { + const { controller } = buildController({ emailFails: true }); + + const result = await controller.createUser(dto, ADMIN); + + expect(result.email).toBe('marie@acme.test'); + expect(result.invitationEmailSent).toBe(false); + expect(logs.join('\n')).not.toContain('Temp-Password-42'); + }, 20000); +}); diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 8483b6a..447ffe7 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -52,6 +52,14 @@ import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; import { SubscriptionService } from '../services/subscription.service'; +import { UserDeletionService } from '../services/user-deletion.service'; +import { AdminContinuityService } from '../services/admin-continuity.service'; +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; +import { isAnonymisedEmail } from '@domain/services/data-retention'; /** * Users Controller @@ -74,7 +82,12 @@ export class UsersController { constructor( @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, - private readonly subscriptionService: SubscriptionService + private readonly subscriptionService: SubscriptionService, + private readonly userDeletionService: UserDeletionService, + private readonly adminContinuity: AdminContinuityService, + @Inject(EMAIL_PORT) private readonly emailPort: EmailPort, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository ) {} /** @@ -111,7 +124,7 @@ export class UsersController { async createUser( @Body() dto: CreateUserDto, @CurrentUser() user: UserPayload - ): Promise { + ): Promise { this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`); // Authorization: Only ADMIN can assign ADMIN role @@ -160,12 +173,45 @@ export class UsersController { this.logger.log(`User created successfully: ${savedUser.id}`); - // TODO: Send invitation email with temporary password - this.logger.warn( - `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` - ); + // L'email d'accès n'etait jamais envoye (TODO), et le mot de passe + // temporaire etait ecrit en clair dans les journaux. Il part desormais par + // email, et n'apparait plus nulle part ailleurs. + const invitationEmailSent = await this.sendAccountCreatedEmail(savedUser, tempPassword, user); - return UserMapper.toDto(savedUser); + return { ...UserMapper.toDto(savedUser), invitationEmailSent }; + } + + /** + * Envoie au nouveau compte ses identifiants et le lien de connexion. + * Un echec n'annule pas la creation : il est signale a l'administrateur. + */ + private async sendAccountCreatedEmail( + newUser: User, + tempPassword: string, + creator: UserPayload + ): Promise { + try { + const [organization, creatorAccount] = await Promise.all([ + this.organizationRepository.findById(newUser.organizationId), + this.userRepository.findById(creator.id), + ]); + const inviterName = creatorAccount + ? `${creatorAccount.firstName} ${creatorAccount.lastName}`.trim() + : creator.email; + + await this.emailPort.sendUserInvitation( + newUser.email, + organization?.name ?? 'Xpeditis', + inviterName || creator.email, + tempPassword + ); + this.logger.log(`Access email sent to new user ${newUser.email}`); + return true; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`User ${newUser.email} created but the access email failed: ${message}`); + return false; + } } /** @@ -263,6 +309,13 @@ export class UsersController { throw new ForbiddenException('You can only update users in your own organization'); } + // At least one active admin must remain. Checked before any write — the + // license would otherwise be revoked for a change that is then refused. + await this.adminContinuity.assertKeepsAnActiveAdmin(user, { + role: dto.role ?? user.role, + isActive: dto.isActive ?? user.isActive, + }); + // Update fields if (dto.firstName) { user.updateFirstName(dto.firstName); @@ -313,7 +366,10 @@ export class UsersController { @Roles('admin') @ApiOperation({ summary: 'Delete user', - description: 'Deactivate a user account. Admin only.', + description: + 'Erase a user (any role, including another ADMIN): personal data is deleted or anonymised, ' + + 'the account is disabled and its license revoked. Bookings are kept. Admin only; ' + + 'an admin cannot delete their own account here, nor the last active admin.', }) @ApiParam({ name: 'id', @@ -336,17 +392,8 @@ export class UsersController { ): Promise { this.logger.log(`[Admin: ${currentUser.email}] Deleting user: ${id}`); - const user = await this.userRepository.findById(id); - if (!user) { - throw new NotFoundException(`User ${id} not found`); - } - - // Revoke license before deleting user - await this.subscriptionService.revokeLicense(id); - this.logger.log(`License revoked for user being deleted: ${id}`); - - // Permanently delete user from database - await this.userRepository.deleteById(id); + // GDPR erasure (bookings kept) + license revocation — see UserDeletionService + await this.userDeletionService.deleteByAdmin(id, currentUser); this.logger.log(`User deleted successfully: ${id}`); } @@ -398,7 +445,11 @@ export class UsersController { this.logger.log( `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}` ); - let users = await this.userRepository.findByOrganization(currentUser.organizationId); + // Erased accounts stay in the table (their bookings reference them) but are + // no longer users: hide them from the list. + let users = (await this.userRepository.findByOrganization(currentUser.organizationId)).filter( + u => !isAnonymisedEmail(u.email) + ); // Security: Non-admin users cannot see ADMIN users if (currentUser.role !== 'ADMIN') { diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index e55f26c..b7c057e 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -451,6 +451,23 @@ export class CsvBookingResponseDto { }) organizationId: string; + @ApiPropertyOptional({ + description: + 'Full name of the user who created the booking (organization listing). ' + + 'Null when that account has been deleted.', + example: 'Marie Dupont', + nullable: true, + }) + createdByName?: string | null; + + @ApiPropertyOptional({ + description: + 'Bank transfer validation only: whether the booking request email reached the SMTP relay. ' + + 'When false, the booking is active but the admin should resend the carrier email.', + example: true, + }) + carrierEmailSent?: boolean; + @ApiProperty({ description: 'Carrier/Company name', example: 'SSC Consolidation', diff --git a/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts b/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts index b158501..df1ec5c 100644 --- a/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts +++ b/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts @@ -75,6 +75,38 @@ describe('UnhandledExceptionFilter', () => { expect(JSON.stringify(payload)).not.toContain('stack'); }); + it('turns the last-active-admin trigger refusal into an explained 409', () => { + // Deux admins qui se suppriment l'un l'autre au meme instant : le controle + // applicatif passe des deux cotes, le declencheur PostgreSQL refuse le second. + const { host, status, body } = hostFor({}, '/api/v1/admin/users/abc'); + const triggerError = Object.assign( + new Error('Au moins un administrateur actif doit subsister'), + { + code: 'XP001', + } + ); + + filter.catch(triggerError, host); + + expect(status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(body()).toMatchObject({ + code: 'last_active_admin', + message: 'translated:error.LAST_ACTIVE_ADMIN', + }); + expect(body().reference).toBeUndefined(); + }); + + it('recognises the trigger refusal through driverError as well', () => { + const { host, status } = hostFor(); + + filter.catch( + Object.assign(new Error('query failed'), { driverError: { code: 'XP001' } }), + host + ); + + expect(status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + }); + it('gives each incident its own reference', () => { const first = hostFor(); const second = hostFor(); diff --git a/apps/backend/src/application/filters/unhandled-exception.filter.ts b/apps/backend/src/application/filters/unhandled-exception.filter.ts index 16c0aa7..e626fdd 100644 --- a/apps/backend/src/application/filters/unhandled-exception.filter.ts +++ b/apps/backend/src/application/filters/unhandled-exception.filter.ts @@ -10,6 +10,10 @@ import { randomUUID } from 'crypto'; import { Request, Response } from 'express'; import { I18nContext, I18nService } from 'nestjs-i18n'; import { DEFAULT_LOCALE, Locale, isLocale } from '@domain/value-objects/locale.vo'; +import { + LAST_ACTIVE_ADMIN_CODE, + LAST_ACTIVE_ADMIN_SQLSTATE, +} from '@domain/services/admin-continuity'; /** * Dernier recours avant la reponse HTTP. @@ -51,6 +55,22 @@ export class UnhandledExceptionFilter implements ExceptionFilter { } const lang = resolveLocale(request); + + // Le declencheur PostgreSQL a refuse de retirer le dernier administrateur + // actif (cas concurrent que le controle applicatif ne peut pas voir) : c'est + // un conflit explicable, pas une panne. + if (isLastActiveAdminViolation(exception)) { + response.status(HttpStatus.CONFLICT).json({ + statusCode: HttpStatus.CONFLICT, + error: 'Conflict', + code: LAST_ACTIVE_ADMIN_CODE, + message: this.translate('error.LAST_ACTIVE_ADMIN', lang), + timestamp: new Date().toISOString(), + path: request.url, + }); + return; + } + const unavailable = isDependencyUnavailable(exception); const status = unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.INTERNAL_SERVER_ERROR; const key = unavailable ? 'error.SERVICE_UNAVAILABLE' : 'error.UNEXPECTED_ERROR'; @@ -81,6 +101,17 @@ export class UnhandledExceptionFilter implements ExceptionFilter { } } +/** + * L'erreur est-elle le refus du declencheur `trg_users_keep_active_admin` ? + * TypeORM recopie le code du pilote sur QueryFailedError ; `driverError` est + * verifie aussi, au cas ou cette recopie changerait. + */ +export function isLastActiveAdminViolation(exception: unknown): boolean { + if (!(exception instanceof Error)) return false; + const { code, driverError } = exception as { code?: string; driverError?: { code?: string } }; + return code === LAST_ACTIVE_ADMIN_SQLSTATE || driverError?.code === LAST_ACTIVE_ADMIN_SQLSTATE; +} + const describe = (exception: unknown): string => exception instanceof Error ? `${exception.name}: ${exception.message}` : String(exception); diff --git a/apps/backend/src/application/gdpr/gdpr.module.ts b/apps/backend/src/application/gdpr/gdpr.module.ts index 690dd19..e111e5a 100644 --- a/apps/backend/src/application/gdpr/gdpr.module.ts +++ b/apps/backend/src/application/gdpr/gdpr.module.ts @@ -10,8 +10,13 @@ import { AuditModule } from '../audit/audit.module'; import { GDPRController } from '../controllers/gdpr.controller'; import { GDPRService } from '../services/gdpr.service'; import { RetentionService } from '../services/retention.service'; +import { UserDeletionService } from '../services/user-deletion.service'; +import { AdminContinuityService } from '../services/admin-continuity.service'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; @Module({ imports: [ @@ -23,9 +28,19 @@ import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm // Les demandes de droits sont journalisees : l'article 5.2 impose de // pouvoir demontrer qu'elles ont ete traitees. AuditModule, + // Suppression par un administrateur : la licence est revoquee apres + // l'effacement. + SubscriptionsModule, ], controllers: [GDPRController], - providers: [GDPRService, RetentionService], - exports: [GDPRService, RetentionService], + providers: [ + GDPRService, + RetentionService, + UserDeletionService, + // Au moins un administrateur actif : partage avec Admin et Users. + AdminContinuityService, + { provide: USER_REPOSITORY, useClass: TypeOrmUserRepository }, + ], + exports: [GDPRService, RetentionService, UserDeletionService, AdminContinuityService], }) export class GDPRModule {} diff --git a/apps/backend/src/application/services/admin-continuity.service.spec.ts b/apps/backend/src/application/services/admin-continuity.service.spec.ts new file mode 100644 index 0000000..7f3adeb --- /dev/null +++ b/apps/backend/src/application/services/admin-continuity.service.spec.ts @@ -0,0 +1,56 @@ +import { ConflictException } from '@nestjs/common'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { User, UserRole } from '@domain/entities/user.entity'; +import { AdminContinuityService } from './admin-continuity.service'; + +const user = (id: string, role: UserRole, isActive = true) => + ({ id, role, isActive, email: `${id}@xpeditis.com` }) as unknown as User; + +function serviceWith(users: User[]) { + const userRepository = { + findAllActive: jest.fn(async () => users.filter(u => u.isActive)), + findById: jest.fn(async (id: string) => users.find(u => u.id === id) ?? null), + } as unknown as UserRepository; + return new AdminContinuityService(userRepository); +} + +describe('AdminContinuityService', () => { + it('refuse de retirer le dernier admin actif, quelle que soit la maniere', async () => { + const onlyAdmin = user('a1', UserRole.ADMIN); + const service = serviceWith([onlyAdmin, user('u1', UserRole.USER)]); + + await expect(service.assertKeepsAnActiveAdmin(onlyAdmin, null)).rejects.toBeInstanceOf( + ConflictException + ); + await expect( + service.assertKeepsAnActiveAdmin(onlyAdmin, { role: UserRole.MANAGER, isActive: true }) + ).rejects.toBeInstanceOf(ConflictException); + await expect( + service.assertKeepsAnActiveAdmin(onlyAdmin, { role: UserRole.ADMIN, isActive: false }) + ).rejects.toBeInstanceOf(ConflictException); + await expect(service.assertCanErase('a1')).rejects.toBeInstanceOf(ConflictException); + }); + + it("autorise l'operation tant qu'un autre admin actif subsiste", async () => { + const first = user('a1', UserRole.ADMIN); + const service = serviceWith([first, user('a2', UserRole.ADMIN)]); + + await expect(service.assertKeepsAnActiveAdmin(first, null)).resolves.toBeUndefined(); + }); + + it('ne compte pas un admin desactive comme remplacant', async () => { + const first = user('a1', UserRole.ADMIN); + const service = serviceWith([first, user('a2', UserRole.ADMIN, false)]); + + await expect(service.assertKeepsAnActiveAdmin(first, null)).rejects.toBeInstanceOf( + ConflictException + ); + }); + + it("ne controle rien pour un compte qui n'est pas admin actif", async () => { + const service = serviceWith([user('u1', UserRole.USER)]); + + await expect(service.assertCanErase('u1')).resolves.toBeUndefined(); + await expect(service.assertCanErase('inconnu')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/backend/src/application/services/admin-continuity.service.ts b/apps/backend/src/application/services/admin-continuity.service.ts new file mode 100644 index 0000000..7f7e526 --- /dev/null +++ b/apps/backend/src/application/services/admin-continuity.service.ts @@ -0,0 +1,61 @@ +/** + * Refuse, avant toute écriture, une opération qui laisserait la plateforme sans + * administrateur actif (suppression, rétrogradation, désactivation, + * auto-effacement RGPD). + * + * Ce contrôle donne un message clair ; il ne suffit pas seul : deux requêtes + * simultanées peuvent chacune voir l'autre administrateur encore actif. Le + * déclencheur PostgreSQL `trg_users_keep_active_admin` ferme ce cas — voir + * domain/services/admin-continuity.ts. + */ + +import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { User, UserRole } from '@domain/entities/user.entity'; +import { + AdminState, + LAST_ACTIVE_ADMIN_CODE, + removesActiveAdmin, +} from '@domain/services/admin-continuity'; + +export function lastActiveAdminException(): ConflictException { + return new ConflictException({ + statusCode: 409, + error: 'Conflict', + code: LAST_ACTIVE_ADMIN_CODE, + message: + 'Impossible : la plateforme doit garder au moins un administrateur actif. ' + + 'Promouvez ou réactivez d’abord un autre administrateur.', + }); +} + +@Injectable() +export class AdminContinuityService { + constructor(@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository) {} + + /** + * @param target compte modifié, dans son état actuel + * @param after état visé, ou `null` si le compte est effacé + */ + async assertKeepsAnActiveAdmin(target: User, after: AdminState | null): Promise { + if (!removesActiveAdmin({ role: target.role, isActive: target.isActive }, after)) { + return; + } + + const otherActiveAdmins = (await this.userRepository.findAllActive()).filter( + u => u.role === UserRole.ADMIN && u.id !== target.id + ); + + if (otherActiveAdmins.length === 0) { + throw lastActiveAdminException(); + } + } + + /** Variante pour un effacement dont on ne connaît que l'identifiant. */ + async assertCanErase(userId: string): Promise { + const target = await this.userRepository.findById(userId); + if (target) { + await this.assertKeepsAnActiveAdmin(target, null); + } + } +} diff --git a/apps/backend/src/application/services/audit.service.ts b/apps/backend/src/application/services/audit.service.ts index 059ef71..6ac89b5 100644 --- a/apps/backend/src/application/services/audit.service.ts +++ b/apps/backend/src/application/services/audit.service.ts @@ -17,9 +17,9 @@ import { export interface LogAuditInput { action: AuditAction; status: AuditStatus; - userId: string; + userId: string | null; userEmail: string; - organizationId: string; + organizationId: string | null; resourceType?: string; resourceId?: string; resourceName?: string; @@ -89,12 +89,15 @@ export class AuditService { /** * Log failed action + * + * userId / organizationId are null when no user could be identified + * (e.g. a failed login) — never a placeholder string, the columns are uuid. */ async logFailure( action: AuditAction, - userId: string, + userId: string | null, userEmail: string, - organizationId: string, + organizationId: string | null, errorMessage: string, options?: { resourceType?: string; diff --git a/apps/backend/src/application/services/csv-booking.bank-transfer.spec.ts b/apps/backend/src/application/services/csv-booking.bank-transfer.spec.ts new file mode 100644 index 0000000..34ed71d --- /dev/null +++ b/apps/backend/src/application/services/csv-booking.bank-transfer.spec.ts @@ -0,0 +1,104 @@ +import { Logger } from '@nestjs/common'; +import { CsvBookingService } from './csv-booking.service'; +import { CsvBookingStatus } from '@domain/entities/csv-booking.entity'; + +/** + * Validation d'un virement par un administrateur : le booking passe en PENDING + * et la demande part chez le transporteur. Un echec de cet email etait avale + * sans que l'administrateur le sache. + */ + +function pendingTransferBooking() { + const booking = { + id: 'b-1', + bookingNumber: 'XPD-2026-AAAAAA', + userId: 'u-1', + organizationId: 'org-1', + carrierName: 'SSC Consolidation', + carrierEmail: 'booking@ssc.test', + origin: { getValue: () => 'FRLEH' }, + destination: { getValue: () => 'EGEDK' }, + volumeCBM: 2.4, + weightKG: 850, + palletCount: 2, + priceUSD: 200, + priceEUR: 180, + primaryCurrency: 'EUR', + transitDays: 11, + containerType: 'LCL', + status: CsvBookingStatus.PENDING_BANK_TRANSFER as CsvBookingStatus, + documents: [], + confirmationToken: 'token', + requestedAt: new Date('2026-09-01T10:00:00Z'), + notes: undefined, + getRouteDescription: () => 'FRLEH → EGEDK', + isExpired: () => false, + getPriceInCurrency: () => 180, + options: {}, + markBankTransferValidated() { + booking.status = CsvBookingStatus.PENDING; + }, + }; + return booking; +} + +function buildService(options: { emailFails: boolean }) { + const booking = pendingTransferBooking(); + const csvBookingRepository = { + findById: jest.fn(async () => booking), + update: jest.fn(async (b: unknown) => b), + repository: { + findOne: jest.fn(async () => ({ bookingNumber: booking.bookingNumber, passwordHash: null })), + save: jest.fn(async (b: unknown) => b), + }, + }; + const emailAdapter = { + sendCsvBookingRequest: jest.fn(async () => { + if (options.emailFails) throw new Error('550 sender not valid'); + }), + }; + const notificationRepository = { save: jest.fn(async () => undefined) }; + const unused = {} as never; + + const service = new CsvBookingService( + csvBookingRepository as never, + notificationRepository as never, + emailAdapter as never, + unused, + unused, + unused, + unused + ); + return { service, booking, emailAdapter }; +} + +describe('CsvBookingService.validateBankTransfer', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('active le booking et envoie la demande au transporteur', async () => { + const { service, booking, emailAdapter } = buildService({ emailFails: false }); + + const result = await service.validateBankTransfer('b-1'); + + expect(booking.status).toBe(CsvBookingStatus.PENDING); + expect(emailAdapter.sendCsvBookingRequest).toHaveBeenCalledWith( + 'booking@ssc.test', + expect.objectContaining({ bookingNumber: 'XPD-2026-AAAAAA', confirmationToken: 'token' }) + ); + expect(result.carrierEmailSent).toBe(true); + }, 20000); + + it("signale l'echec de l'email au lieu de l'avaler, sans annuler la validation", async () => { + const { service, booking } = buildService({ emailFails: true }); + + const result = await service.validateBankTransfer('b-1'); + + expect(booking.status).toBe(CsvBookingStatus.PENDING); + expect(result.carrierEmailSent).toBe(false); + }, 20000); +}); diff --git a/apps/backend/src/application/services/csv-booking.organization.spec.ts b/apps/backend/src/application/services/csv-booking.organization.spec.ts new file mode 100644 index 0000000..605eee3 --- /dev/null +++ b/apps/backend/src/application/services/csv-booking.organization.spec.ts @@ -0,0 +1,118 @@ +import { NotFoundException } from '@nestjs/common'; +import { CsvBookingService } from './csv-booking.service'; +import { anonymisedEmail } from '@domain/services/data-retention'; + +/** + * Reservations et devis partages au sein d'une entreprise : chaque membre voit + * les lignes de ses collegues, avec le nom de leur auteur. + */ + +const ORG = 'org-1'; + +const booking = (id: string, userId: string, organizationId = ORG) => ({ + id, + bookingNumber: `XPD-2026-${id.toUpperCase()}`, + userId, + organizationId, + carrierName: 'SSC Consolidation', + carrierEmail: 'booking@ssc.test', + origin: { getValue: () => 'FRLEH' }, + destination: { getValue: () => 'EGEDK' }, + volumeCBM: 2.4, + weightKG: 850, + palletCount: 2, + priceUSD: 200, + priceEUR: 180, + primaryCurrency: 'EUR', + transitDays: 11, + containerType: 'LCL', + status: 'QUOTE', + documents: [], + confirmationToken: 'token', + requestedAt: new Date('2026-09-01T10:00:00Z'), + getRouteDescription: () => 'FRLEH → EGEDK', + isExpired: () => false, + getPriceInCurrency: () => 180, + options: {}, +}); + +const member = (id: string, firstName: string, lastName: string, email = `${id}@acme.test`) => ({ + id, + email, + firstName, + lastName, +}); + +function buildService( + bookings: ReturnType[], + members: ReturnType[] +) { + const csvBookingRepository = { + findByOrganizationId: jest.fn(async (orgId: string) => + bookings.filter(b => b.organizationId === orgId) + ), + findById: jest.fn(async (id: string) => bookings.find(b => b.id === id) ?? null), + repository: { findOne: jest.fn(async () => null) }, + }; + const userRepository = { + findByOrganization: jest.fn(async () => members), + findById: jest.fn(async (id: string) => members.find(m => m.id === id) ?? null), + }; + const unused = {} as never; + + const service = new CsvBookingService( + csvBookingRepository as never, + unused, + unused, + unused, + unused, + unused, + userRepository as never + ); + return { service, userRepository }; +} + +describe('CsvBookingService — reservations de l’entreprise', () => { + it('liste les reservations de tous les membres, avec le nom de leur auteur', async () => { + const { service, userRepository } = buildService( + [booking('a', 'marie'), booking('b', 'paul'), booking('c', 'erased'), booking('d', 'gone')], + [ + member('marie', 'Marie', 'Dupont'), + member('paul', '', '', 'paul@acme.test'), + member('erased', 'anonymised', 'anonymised', anonymisedEmail('erased')), + ] + ); + + const result = await service.getOrganizationBookings(ORG, 1, 100); + + expect(result.total).toBe(4); + expect(result.bookings.map(b => [b.userId, b.createdByName])).toEqual([ + ['marie', 'Marie Dupont'], + // Sans nom renseigne, l'adresse sert de libelle. + ['paul', 'paul@acme.test'], + // Compte efface : aucune identite ne ressort. + ['erased', null], + ['gone', null], + ]); + // Une seule requete pour les membres, pas une par reservation. + expect(userRepository.findByOrganization).toHaveBeenCalledTimes(1); + }); + + it('laisse un collegue de la meme entreprise consulter une reservation', async () => { + const { service } = buildService([booking('a', 'marie')], [member('marie', 'Marie', 'Dupont')]); + + const dto = await service.getBookingById('a', 'paul', undefined, ORG); + + expect(dto.id).toBe('a'); + expect(dto.createdByName).toBe('Marie Dupont'); + }); + + it("refuse la consultation a un utilisateur d'une autre entreprise", async () => { + const { service } = buildService([booking('a', 'marie')], [member('marie', 'Marie', 'Dupont')]); + + await expect(service.getBookingById('a', 'intrus', undefined, 'org-2')).rejects.toBeInstanceOf( + NotFoundException + ); + await expect(service.getBookingById('a', 'intrus')).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index c452338..908ef60 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -18,6 +18,7 @@ import { } from '@domain/ports/out/notification.repository'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { isAnonymisedEmail } from '@domain/services/data-retention'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { @@ -479,38 +480,13 @@ export class CsvBookingService { ) : 'N/A'; - await this.emailAdapter.send({ - to: adminEmails, - subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`, - html: ` -
-

Nouveau virement à valider

-

Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :

- - - - - - - - - - - - - - - - - -
Numéro de booking${bookingNumber}
Transporteur${booking.carrierName}
Trajet${booking.getRouteDescription()}
Montant commission${commissionAmount}
-

Rendez-vous dans la console d'administration pour valider ce virement et activer le booking.

- - Voir les bookings en attente - -
- `, + // Gabarit commun ; le lien pointe vers /admin/bookings (l'ancien + // /dashboard/admin/bookings n'existe pas). + await this.emailAdapter.sendBankTransferToValidate(adminEmails, { + bookingNumber, + carrierName: booking.carrierName, + routeDescription: booking.getRouteDescription(), + commissionAmount, }); this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); } @@ -608,10 +584,13 @@ export class CsvBookingService { where: { id: bookingId }, }); const bookingNumber = ormBooking?.bookingNumber; - const documentPassword = await this.syncDocumentPassword(booking.id); - // Send email to carrier + // Send email to carrier. The transfer stays validated even if the email + // fails, but the admin is told (carrierEmailSent) so they can resend it — + // the failure used to be silent. + let carrierEmailSent = false; try { + const documentPassword = await this.syncDocumentPassword(booking.id); await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { bookingId: booking.id, bookingNumber: bookingNumber || '', @@ -633,11 +612,16 @@ export class CsvBookingService { confirmationToken: booking.confirmationToken, notes: booking.notes, }); + carrierEmailSent = true; this.logger.log( `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` ); } catch (error: any) { - this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); + this.logger.error( + `Bank transfer validated for booking ${bookingId} but the carrier email to ` + + `${booking.carrierEmail} failed: ${error?.message}`, + error?.stack + ); } // In-app notification for the user @@ -657,17 +641,19 @@ export class CsvBookingService { this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); } - return this.toResponseDto(updatedBooking); + return { ...this.toResponseDto(updatedBooking), carrierEmailSent }; } /** * Get booking by ID - * Accessible by: booking owner OR assigned carrier + * Readable by: booking owner, any member of the booking's organization, or + * the assigned carrier. Changes (pay, edit, cancel, documents) stay owner-only. */ async getBookingById( id: string, userId: string, - carrierId?: string + carrierId?: string, + organizationId?: string ): Promise { const booking = await this.csvBookingRepository.findById(id); @@ -680,15 +666,33 @@ export class CsvBookingService { where: { id }, }); - // Verify user owns this booking OR is the assigned carrier + // Verify user owns this booking, belongs to its organization, OR is the assigned carrier const isOwner = booking.userId === userId; + const isSameOrganization = !!organizationId && booking.organizationId === organizationId; const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId; - if (!isOwner && !isAssignedCarrier) { + if (!isOwner && !isSameOrganization && !isAssignedCarrier) { throw new NotFoundException(`Booking with ID ${id} not found`); } - return this.toResponseDto(booking); + return { + ...this.toResponseDto(booking), + createdByName: this.creatorName(await this.userRepository.findById(booking.userId)), + }; + } + + /** + * Display name of a booking's creator, or null when the account no longer + * exists or has been erased (its identity is anonymised). + */ + private creatorName( + user: { email: string; firstName?: string; lastName?: string } | null | undefined + ): string | null { + if (!user || isAnonymisedEmail(user.email)) { + return null; + } + const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); + return fullName || user.email; } /** @@ -1202,14 +1206,20 @@ export class CsvBookingService { } /** - * Get bookings for an organization (paginated) + * Get bookings for an organization (paginated), whoever created them, each + * with the name of its creator so the list can show and filter by author. */ async getOrganizationBookings( organizationId: string, page: number = 1, limit: number = 10 ): Promise { - const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + // One query for the members instead of one lookup per booking. + const [bookings, members] = await Promise.all([ + this.csvBookingRepository.findByOrganizationId(organizationId), + this.userRepository.findByOrganization(organizationId), + ]); + const creatorNames = new Map(members.map(member => [member.id, this.creatorName(member)])); // Simple pagination (in-memory) const start = (page - 1) * limit; @@ -1217,7 +1227,10 @@ export class CsvBookingService { const paginatedBookings = bookings.slice(start, end); return { - bookings: paginatedBookings.map(b => this.toResponseDto(b)), + bookings: paginatedBookings.map(b => ({ + ...this.toResponseDto(b), + createdByName: creatorNames.get(b.userId) ?? null, + })), total: bookings.length, page, limit, diff --git a/apps/backend/src/application/services/user-deletion.service.spec.ts b/apps/backend/src/application/services/user-deletion.service.spec.ts new file mode 100644 index 0000000..ca4d6a0 --- /dev/null +++ b/apps/backend/src/application/services/user-deletion.service.spec.ts @@ -0,0 +1,107 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { User, UserRole } from '@domain/entities/user.entity'; +import { anonymisedEmail } from '@domain/services/data-retention'; +import { UserDeletionService } from './user-deletion.service'; +import { GDPRService } from './gdpr.service'; +import { SubscriptionService } from './subscription.service'; +import { AdminContinuityService } from './admin-continuity.service'; + +const ACTOR = { id: 'admin-actor', email: 'actor@xpeditis.com' }; + +const buildUser = ( + overrides: Partial<{ id: string; email: string; role: UserRole; isActive: boolean }> +) => + ({ + id: 'target', + email: 'target@xpeditis.com', + role: UserRole.USER, + isActive: true, + ...overrides, + }) as unknown as User; + +function buildService(users: User[]) { + const userRepository = { + findById: jest.fn(async (id: string) => users.find(u => u.id === id) ?? null), + findAllActive: jest.fn(async () => users.filter(u => u.isActive)), + } as unknown as UserRepository; + + const report = { userId: 'target', erasedAt: 'now', deleted: {}, anonymised: {} }; + const gdprService = { + deleteUserData: jest.fn(async () => report), + } as unknown as GDPRService; + + const subscriptionService = { + revokeLicense: jest.fn(async () => undefined), + } as unknown as SubscriptionService; + + // Vrai service : c'est la regle reelle qui est exercee. + const adminContinuity = new AdminContinuityService(userRepository); + + const service = new UserDeletionService( + userRepository, + gdprService, + subscriptionService, + adminContinuity + ); + return { service, gdprService, subscriptionService, report }; +} + +describe('UserDeletionService', () => { + it('permet a un admin de supprimer un autre admin, sans DELETE destructif', async () => { + const users = [ + buildUser({ id: ACTOR.id, email: ACTOR.email, role: UserRole.ADMIN }), + buildUser({ id: 'target', role: UserRole.ADMIN }), + ]; + const { service, gdprService, subscriptionService, report } = buildService(users); + + await expect(service.deleteByAdmin('target', ACTOR)).resolves.toBe(report); + + expect(gdprService.deleteUserData).toHaveBeenCalledWith( + 'target', + expect.stringContaining(ACTOR.id) + ); + expect(subscriptionService.revokeLicense).toHaveBeenCalledWith('target'); + }); + + it("refuse qu'un admin supprime son propre compte", async () => { + const users = [buildUser({ id: ACTOR.id, role: UserRole.ADMIN })]; + const { service, gdprService } = buildService(users); + + await expect(service.deleteByAdmin(ACTOR.id, ACTOR)).rejects.toBeInstanceOf( + BadRequestException + ); + expect(gdprService.deleteUserData).not.toHaveBeenCalled(); + }); + + it('refuse de supprimer le dernier administrateur actif', async () => { + // L'acteur n'est plus actif en base (jeton encore valide) : la cible est le + // seul ADMIN actif restant. + const users = [ + buildUser({ id: ACTOR.id, role: UserRole.ADMIN, isActive: false }), + buildUser({ id: 'target', role: UserRole.ADMIN }), + ]; + const { service, gdprService } = buildService(users); + + await expect(service.deleteByAdmin('target', ACTOR)).rejects.toBeInstanceOf(ConflictException); + expect(gdprService.deleteUserData).not.toHaveBeenCalled(); + }); + + it('renvoie 404 pour un compte inconnu ou deja efface', async () => { + const users = [buildUser({ id: 'erased', email: anonymisedEmail('erased') })]; + const { service, gdprService } = buildService(users); + + await expect(service.deleteByAdmin('missing', ACTOR)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.deleteByAdmin('erased', ACTOR)).rejects.toBeInstanceOf(NotFoundException); + expect(gdprService.deleteUserData).not.toHaveBeenCalled(); + }); + + it('ne fait pas echouer la suppression si la revocation de licence echoue', async () => { + const users = [buildUser({ id: 'target' })]; + const { service, subscriptionService, report } = buildService(users); + (subscriptionService.revokeLicense as jest.Mock).mockRejectedValueOnce(new Error('db down')); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(service.deleteByAdmin('target', ACTOR)).resolves.toBe(report); + }); +}); diff --git a/apps/backend/src/application/services/user-deletion.service.ts b/apps/backend/src/application/services/user-deletion.service.ts new file mode 100644 index 0000000..872c84c --- /dev/null +++ b/apps/backend/src/application/services/user-deletion.service.ts @@ -0,0 +1,81 @@ +/** + * Suppression d'un utilisateur par un administrateur. + * + * Les deux endpoints (`DELETE /admin/users/:id`, `DELETE /users/:id`) + * exécutaient un `DELETE FROM users`. Deux conséquences : + * - `csv_rate_configs.uploaded_by` référence `users` sans `ON DELETE` : dès + * que la personne avait importé une grille, PostgreSQL refusait et l'API + * répondait 500 ; + * - sinon, les clés `ON DELETE CASCADE` (`csv_bookings`, `licenses`, + * `api_keys`…) emportaient ses réservations, donc des pièces comptables. + * + * On applique donc l'effacement RGPD (`GDPRService.deleteUserData`) : données + * personnelles supprimées ou anonymisées, compte désactivé, réservations + * conservées — le tout en transaction et journalisé. + * + * Garde-fous : + * - un administrateur ne supprime pas son propre compte ici (il passe par + * « Supprimer mon compte », qui exige une confirmation) ; + * - le dernier administrateur actif ne peut pas être supprimé : la + * plateforme n'aurait plus personne pour la gérer (AdminContinuityService, + * doublé d'un déclencheur PostgreSQL pour les suppressions simultanées). + */ + +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { isAnonymisedEmail } from '@domain/services/data-retention'; +import { GDPRService, GDPRErasureReport } from './gdpr.service'; +import { SubscriptionService } from './subscription.service'; +import { AdminContinuityService } from './admin-continuity.service'; + +/** Administrateur à l'origine de la suppression. */ +export interface DeletionActor { + id: string; + email: string; +} + +@Injectable() +export class UserDeletionService { + private readonly logger = new Logger(UserDeletionService.name); + + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + private readonly gdprService: GDPRService, + private readonly subscriptionService: SubscriptionService, + private readonly adminContinuity: AdminContinuityService + ) {} + + async deleteByAdmin(targetId: string, actor: DeletionActor): Promise { + if (targetId === actor.id) { + throw new BadRequestException( + 'Vous ne pouvez pas supprimer votre propre compte depuis l’administration. ' + + 'Utilisez « Supprimer mon compte » dans vos paramètres.' + ); + } + + const target = await this.userRepository.findById(targetId); + // Un compte déjà effacé n'est plus un utilisateur. + if (!target || isAnonymisedEmail(target.email)) { + throw new NotFoundException(`User ${targetId} not found`); + } + + await this.adminContinuity.assertKeepsAnActiveAdmin(target, null); + + const report = await this.gdprService.deleteUserData( + targetId, + `Suppression par un administrateur (${actor.id})` + ); + + // Après l'effacement : si la révocation échoue, le compte est bel et bien + // effacé, et répondre 500 ferait croire le contraire. + try { + await this.subscriptionService.revokeLicense(targetId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`User ${targetId} erased but license revocation failed: ${message}`); + } + + this.logger.warn(`User ${targetId} (${target.role}) erased by admin ${actor.email}`); + return report; + } +} diff --git a/apps/backend/src/application/users/users.module.ts b/apps/backend/src/application/users/users.module.ts index 1603268..37b727c 100644 --- a/apps/backend/src/application/users/users.module.ts +++ b/apps/backend/src/application/users/users.module.ts @@ -8,9 +8,20 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { FeatureFlagGuard } from '../guards/feature-flag.guard'; +import { GDPRModule } from '../gdpr/gdpr.module'; +import { EmailModule } from '../../infrastructure/email/email.module'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; @Module({ - imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule], + imports: [ + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity]), + SubscriptionsModule, + GDPRModule, + // Email d'accès envoyé au compte créé par un administrateur + EmailModule, + ], controllers: [UsersController], providers: [ FeatureFlagGuard, @@ -18,6 +29,10 @@ import { FeatureFlagGuard } from '../guards/feature-flag.guard'; provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, }, + { + provide: ORGANIZATION_REPOSITORY, + useClass: TypeOrmOrganizationRepository, + }, ], exports: [ USER_REPOSITORY, // optional, export if other modules need it diff --git a/apps/backend/src/domain/entities/audit-log.entity.ts b/apps/backend/src/domain/entities/audit-log.entity.ts index a5831bb..f9ca2d3 100644 --- a/apps/backend/src/domain/entities/audit-log.entity.ts +++ b/apps/backend/src/domain/entities/audit-log.entity.ts @@ -67,9 +67,9 @@ export interface AuditLogProps { id: string; action: AuditAction; status: AuditStatus; - userId: string; + userId: string | null; // null when no user could be identified (e.g. failed login) userEmail: string; - organizationId: string; + organizationId: string | null; resourceType?: string; // e.g., 'booking', 'user', 'document' resourceId?: string; resourceName?: string; @@ -117,7 +117,7 @@ export class AuditLog { return this.props.status; } - get userId(): string { + get userId(): string | null { return this.props.userId; } @@ -125,7 +125,7 @@ export class AuditLog { return this.props.userEmail; } - get organizationId(): string { + get organizationId(): string | null { return this.props.organizationId; } diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index 596293b..47aad65 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -25,7 +25,43 @@ export interface EmailOptions { attachments?: EmailAttachment[]; } +/** Message du formulaire de contact, adressé à l'équipe. */ +export interface ContactMessageEmail { + firstName: string; + lastName: string; + email: string; + company?: string; + phone?: string; + /** Libellé lisible du sujet choisi. */ + subjectLabel: string; + message: string; +} + +/** Virement déclaré par un client, à valider par un administrateur. */ +export interface BankTransferToValidateEmail { + bookingNumber: string; + carrierName: string; + routeDescription: string; + /** Montant déjà formaté (ex. « 150,00 € »). */ + commissionAmount: string; +} + export interface EmailPort { + /** + * Send the contact form message to the team (reply-to = sender) + */ + sendContactMessage(to: string, data: ContactMessageEmail): Promise; + + /** + * Alert admins that a declared bank transfer awaits validation + */ + sendBankTransferToValidate(to: string[], data: BankTransferToValidateEmail): Promise; + + /** + * Send the SMTP diagnostic email from the admin panel + */ + sendSmtpTest(to: string, requestedBy: string): Promise; + /** * Send an email */ diff --git a/apps/backend/src/domain/services/admin-continuity.spec.ts b/apps/backend/src/domain/services/admin-continuity.spec.ts new file mode 100644 index 0000000..02b5ace --- /dev/null +++ b/apps/backend/src/domain/services/admin-continuity.spec.ts @@ -0,0 +1,20 @@ +import { removesActiveAdmin } from './admin-continuity'; + +const activeAdmin = { role: 'ADMIN', isActive: true }; + +describe('removesActiveAdmin', () => { + it("detecte l'effacement, la retrogradation et la desactivation d'un admin actif", () => { + expect(removesActiveAdmin(activeAdmin, null)).toBe(true); + expect(removesActiveAdmin(activeAdmin, { role: 'MANAGER', isActive: true })).toBe(true); + expect(removesActiveAdmin(activeAdmin, { role: 'ADMIN', isActive: false })).toBe(true); + }); + + it("laisse passer ce qui conserve l'admin actif", () => { + expect(removesActiveAdmin(activeAdmin, { role: 'ADMIN', isActive: true })).toBe(false); + }); + + it('ignore les comptes qui ne sont pas des admins actifs', () => { + expect(removesActiveAdmin({ role: 'USER', isActive: true }, null)).toBe(false); + expect(removesActiveAdmin({ role: 'ADMIN', isActive: false }, null)).toBe(false); + }); +}); diff --git a/apps/backend/src/domain/services/admin-continuity.ts b/apps/backend/src/domain/services/admin-continuity.ts new file mode 100644 index 0000000..ef30bf1 --- /dev/null +++ b/apps/backend/src/domain/services/admin-continuity.ts @@ -0,0 +1,43 @@ +/** + * Continuité de l'administration : la plateforme garde toujours au moins un + * administrateur actif. + * + * Sans administrateur, plus personne ne peut valider une organisation, gérer + * les comptes ou rétablir un accès : la seule issue est une intervention SQL + * sur la base de production. + * + * La règle est appliquée à deux niveaux : + * - dans l'application (AdminContinuityService), pour répondre un message + * clair avant toute écriture ; + * - dans PostgreSQL (déclencheur `trg_users_keep_active_admin`, migration + * 1790000000002), seul endroit capable de l'imposer à deux requêtes + * simultanées — deux administrateurs qui se suppriment l'un l'autre au même + * instant — et à toute écriture qui ne passerait pas par l'application. + */ + +/** Code métier renvoyé au client quand l'opération retirerait le dernier admin. */ +export const LAST_ACTIVE_ADMIN_CODE = 'last_active_admin'; + +/** SQLSTATE levé par le déclencheur PostgreSQL pour la même violation. */ +export const LAST_ACTIVE_ADMIN_SQLSTATE = 'XP001'; + +const ADMIN_ROLE = 'ADMIN'; + +export interface AdminState { + role: string; + isActive: boolean; +} + +/** + * L'opération fait-elle perdre à ce compte son statut d'administrateur actif ? + * + * @param before état actuel du compte + * @param after état visé, ou `null` si le compte est effacé + */ +export function removesActiveAdmin(before: AdminState, after: AdminState | null): boolean { + const wasActiveAdmin = before.role === ADMIN_ROLE && before.isActive; + if (!wasActiveAdmin) { + return false; + } + return after === null || after.role !== ADMIN_ROLE || !after.isActive; +} diff --git a/apps/backend/src/domain/services/data-retention.ts b/apps/backend/src/domain/services/data-retention.ts index fb0bc97..7ebe6b2 100644 --- a/apps/backend/src/domain/services/data-retention.ts +++ b/apps/backend/src/domain/services/data-retention.ts @@ -80,7 +80,7 @@ export const RETENTION_RULES: readonly RetentionRule[] = [ timestampColumn: 'created_at', onErasure: 'delete', months: 12, - basis: "Confort de service, sans valeur probante : rien ne justifie de les conserver.", + basis: 'Confort de service, sans valeur probante : rien ne justifie de les conserver.', }, { table: 'trade_conversations', @@ -155,3 +155,7 @@ export const ANONYMISED = 'anonymised'; * permet aucun rattachement — l'identifiant technique existait déjà en base. */ export const anonymisedEmail = (userId: string): string => `${ANONYMISED}+${userId}@invalid.local`; + +/** Le compte portant cette adresse a-t-il été effacé ? */ +export const isAnonymisedEmail = (email: string): boolean => + email.startsWith(`${ANONYMISED}+`) && email.endsWith('@invalid.local'); diff --git a/apps/backend/src/i18n/en/error.json b/apps/backend/src/i18n/en/error.json index a88b031..d22ce70 100644 --- a/apps/backend/src/i18n/en/error.json +++ b/apps/backend/src/i18n/en/error.json @@ -20,6 +20,7 @@ "RATE_QUOTE_EXPIRED": "Rate quote has expired", "CARRIER_NOT_FOUND": "Carrier not found", "NO_LICENSES_AVAILABLE": "No licenses available for this organization", + "LAST_ACTIVE_ADMIN": "Not possible: the platform must keep at least one active administrator. Promote or reactivate another administrator first.", "SERVICE_UNAVAILABLE": "The service is temporarily unavailable. Try again in a moment; if the problem persists, contact support@xpeditis.com.", "UNEXPECTED_ERROR": "Something went wrong on our side. Try again, and if it happens again, send the reference below to support@xpeditis.com." } diff --git a/apps/backend/src/i18n/fr/error.json b/apps/backend/src/i18n/fr/error.json index d9eec85..643c45e 100644 --- a/apps/backend/src/i18n/fr/error.json +++ b/apps/backend/src/i18n/fr/error.json @@ -20,6 +20,7 @@ "RATE_QUOTE_EXPIRED": "La cotation a expiré", "CARRIER_NOT_FOUND": "Transporteur introuvable", "NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation", + "LAST_ACTIVE_ADMIN": "Impossible : la plateforme doit garder au moins un administrateur actif. Promouvez ou réactivez d’abord un autre administrateur.", "SERVICE_UNAVAILABLE": "Service momentanément indisponible. Réessayez dans quelques instants ; si le problème persiste, contactez support@xpeditis.com.", "UNEXPECTED_ERROR": "Une erreur inattendue s'est produite de notre côté. Réessayez, et si cela se reproduit, transmettez la référence ci-dessous à support@xpeditis.com." } diff --git a/apps/backend/src/infrastructure/email/email.adapter.spec.ts b/apps/backend/src/infrastructure/email/email.adapter.spec.ts new file mode 100644 index 0000000..617cf61 --- /dev/null +++ b/apps/backend/src/infrastructure/email/email.adapter.spec.ts @@ -0,0 +1,97 @@ +import { Logger } from '@nestjs/common'; +import { EmailAdapter } from './email.adapter'; + +/** + * Tous les emails doivent partir de l'adresse SMTP_FROM, la seule validee chez + * le relais SMTP (Brevo). Les invitations et les demandes aux transporteurs + * partaient d'adresses codees en dur et etaient refusees. + */ + +function buildAdapter(smtpFrom = 'noreply@xpeditis.com') { + const settings: Record = { + SMTP_FROM: smtpFrom, + APP_URL: 'https://app.preprod.xpeditis.com', + }; + const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) }; + const templates = { + renderInvitationWithToken: jest.fn(async () => '

invitation

'), + renderCsvBookingRequest: jest.fn(async () => '

demande

'), + renderUserInvitation: jest.fn(async () => '

compte

'), + renderPasswordResetEmail: jest.fn(async () => '

reset

'), + }; + const adapter = new EmailAdapter(config as never, templates as never); + + // Parametre type : sans lui, Jest infere un appel sans argument et + // `mock.calls[0][0]` ne compile pas. + const sendMail = jest.fn(async (_mail: { from: string; to: string }) => ({ + messageId: 'm-1', + accepted: ['x'], + rejected: [], + })); + (adapter as unknown as { transporter: { sendMail: typeof sendMail } }).transporter = { sendMail }; + + return { adapter, sendMail }; +} + +const sentFrom = (sendMail: jest.Mock) => (sendMail.mock.calls[0][0] as { from: string }).from; + +describe('EmailAdapter — expediteur', () => { + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + it("envoie l'invitation depuis SMTP_FROM, avec le nom de l'equipe", async () => { + const { adapter, sendMail } = buildAdapter(); + + await adapter.sendInvitationWithToken( + 'nouveau@client.test', + 'Marie', + 'Dupont', + 'Acme', + 'Paul Martin', + 'https://app/register?token=t', + new Date('2026-09-21T10:00:00Z') + ); + + expect(sentFrom(sendMail)).toBe('"Équipe Xpeditis" '); + }); + + it('envoie la demande au transporteur depuis SMTP_FROM', async () => { + const { adapter, sendMail } = buildAdapter(); + + await adapter.sendCsvBookingRequest('booking@ssc.test', { + bookingId: 'b-1', + bookingNumber: 'XPD-2026-AAAAAA', + origin: 'FRLEH', + destination: 'EGEDK', + volumeCBM: 2.4, + weightKG: 850, + palletCount: 2, + priceUSD: 200, + priceEUR: 180, + primaryCurrency: 'EUR', + transitDays: 11, + containerType: 'LCL', + documents: [], + confirmationToken: 'token', + }); + + expect(sentFrom(sendMail)).toBe('"Xpeditis Bookings" '); + expect((sendMail.mock.calls[0][0] as { to: string }).to).toBe('booking@ssc.test'); + }); + + it("suit l'adresse configuree, pour tous les types d'email", async () => { + const { adapter, sendMail } = buildAdapter('contact@mondomaine.fr'); + + await adapter.sendUserInvitation('a@b.test', 'Acme', 'Paul', 'Temp-1234'); + await adapter.sendPasswordResetEmail('a@b.test', 'token'); + await adapter.send({ to: 'a@b.test', subject: 'Test', html: '

t

' }); + + const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from); + expect(froms).toEqual([ + '"Équipe Xpeditis" ', + '"Xpeditis Sécurité" ', + '"Xpeditis" ', + ]); + }); +}); diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 5102d0f..66b5079 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -1,38 +1,64 @@ /** * Email Adapter * - * Implements EmailPort using nodemailer + * Implements EmailPort using nodemailer. Le contenu et la mise en page des + * emails vivent dans `templates/` : l'adaptateur ne fait que les assembler + * (destinataire, expediteur, sujet) et les envoyer. */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; import * as https from 'https'; -import { EmailPort, EmailOptions } from '@domain/ports/out/email.port'; +import { + BankTransferToValidateEmail, + ContactMessageEmail, + EmailPort, + EmailOptions, +} from '@domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; -// Display names included → moins susceptibles d'être marqués spam -const EMAIL_SENDERS = { - SECURITY: '"Xpeditis Sécurité" ', - BOOKINGS: '"Xpeditis Bookings" ', - TEAM: '"Équipe Xpeditis" ', - CARRIERS: '"Xpeditis Transporteurs" ', - NOREPLY: '"Xpeditis" ', +/** + * Noms d'expéditeur affichés (un nom lisible est moins souvent classé en spam). + * + * L'ADRESSE, elle, est toujours SMTP_FROM. Chaque type d'email partait d'une + * adresse codée en dur (team@, bookings@, security@, carriers@xpeditis.com) : + * un relais SMTP comme Brevo refuse tout expéditeur non validé chez lui. Seuls + * les emails envoyés depuis SMTP_FROM (test SMTP, alerte virement aux admins) + * partaient donc — invitations et demandes aux transporteurs étaient rejetées. + */ +const SENDER_NAMES = { + SECURITY: 'Xpeditis Sécurité', + BOOKINGS: 'Xpeditis Bookings', + TEAM: 'Équipe Xpeditis', + CARRIERS: 'Xpeditis Transporteurs', + NOREPLY: 'Xpeditis', } as const; +type SenderKind = keyof typeof SENDER_NAMES; + +const DEFAULT_FROM_ADDRESS = 'noreply@xpeditis.com'; + /** - * Génère une version plain text à partir du HTML pour améliorer la délivrabilité. - * Les emails sans version texte sont pénalisés par les filtres anti-spam. + * Version texte générée à partir du HTML (les emails sans version texte sont + * pénalisés par les filtres anti-spam, et elle sert aux clients texte). + * + * Le HTML compilé par MJML contient un chargé de styles, des + * commentaires conditionnels Outlook et un preheader masqué : ils sont retirés + * avant d'extraire le texte, sinon la version texte commençait par du CSS. */ function htmlToPlainText(html: string): string { return html + .replace(//gi, '') + .replace(//g, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*display:\s*none[^>]*>[\s\S]*?<\/div>/gi, '') .replace(//gi, '\n') - .replace(/<\/p>/gi, '\n\n') - .replace(/<\/div>/gi, '\n') - .replace(/<\/h[1-6]>/gi, '\n\n') - .replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)') + .replace(/]*>/gi, '\n• ') + .replace(/<\/(p|div|h[1-6]|tr|table|ul|ol)>/gi, '\n') + .replace(/<\/td>/gi, ' ') + .replace(/]*href="(?!mailto:)([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') @@ -40,6 +66,9 @@ function htmlToPlainText(html: string): string { .replace(/ /g, ' ') .replace(/"/g, '"') .replace(/'/g, "'") + .replace(/→/g, '→') + .replace(/[ \t]+/g, ' ') + .replace(/ *\n */g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } @@ -147,10 +176,22 @@ export class EmailAdapter implements EmailPort, OnModuleInit { }); } + /** Expéditeur : nom affiché selon le type d'email, adresse validée SMTP_FROM. */ + private sender(kind: SenderKind): string { + const address = this.configService.get('SMTP_FROM', DEFAULT_FROM_ADDRESS); + return `"${SENDER_NAMES[kind]}" <${address}>`; + } + + private get appUrl(): string { + return String(this.configService.get('APP_URL', 'http://localhost:3000')).replace( + /\/+$/, + '' + ); + } + async send(options: EmailOptions): Promise { try { - const from = - options.from ?? this.configService.get('SMTP_FROM', EMAIL_SENDERS.NOREPLY); + const from = options.from ?? this.sender('NOREPLY'); // Génère automatiquement la version plain text si absente (améliore le score anti-spam) const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined); @@ -176,60 +217,30 @@ export class EmailAdapter implements EmailPort, OnModuleInit { } } - async sendBookingConfirmation( - email: string, - bookingNumber: string, - bookingDetails: any, - pdfAttachment?: Buffer - ): Promise { - const html = await this.emailTemplates.renderBookingConfirmation({ - bookingNumber, - bookingDetails, - }); - - const attachments = pdfAttachment - ? [ - { - filename: `booking-${bookingNumber}.pdf`, - content: pdfAttachment, - contentType: 'application/pdf', - }, - ] - : undefined; - - await this.send({ - to: email, - from: EMAIL_SENDERS.BOOKINGS, - subject: `Booking Confirmation - ${bookingNumber}`, - html, - attachments, - }); - } + /* ---------------------------------------------------------------------- */ + /* Compte et sécurité */ + /* ---------------------------------------------------------------------- */ async sendVerificationEmail(email: string, token: string): Promise { - const verifyUrl = `${this.configService.get('APP_URL')}/verify-email?token=${token}`; - const html = await this.emailTemplates.renderVerificationEmail({ - verifyUrl, - }); + const verifyUrl = `${this.appUrl}/verify-email?token=${token}`; + const html = await this.emailTemplates.renderVerificationEmail({ verifyUrl }); await this.send({ to: email, - from: EMAIL_SENDERS.SECURITY, - subject: 'Verify your email - Xpeditis', + from: this.sender('SECURITY'), + subject: 'Confirmez votre adresse email — Xpeditis', html, }); } async sendPasswordResetEmail(email: string, token: string): Promise { - const resetUrl = `${this.configService.get('APP_URL')}/reset-password?token=${token}`; - const html = await this.emailTemplates.renderPasswordResetEmail({ - resetUrl, - }); + const resetUrl = `${this.appUrl}/reset-password?token=${token}`; + const html = await this.emailTemplates.renderPasswordResetEmail({ resetUrl }); await this.send({ to: email, - from: EMAIL_SENDERS.SECURITY, - subject: 'Reset your password - Xpeditis', + from: this.sender('SECURITY'), + subject: 'Réinitialisez votre mot de passe Xpeditis', html, }); } @@ -237,13 +248,13 @@ export class EmailAdapter implements EmailPort, OnModuleInit { async sendWelcomeEmail(email: string, firstName: string): Promise { const html = await this.emailTemplates.renderWelcomeEmail({ firstName, - dashboardUrl: `${this.configService.get('APP_URL')}/dashboard`, + dashboardUrl: `${this.appUrl}/dashboard`, }); await this.send({ to: email, - from: EMAIL_SENDERS.NOREPLY, - subject: 'Welcome to Xpeditis', + from: this.sender('NOREPLY'), + subject: `Bienvenue sur Xpeditis, ${firstName}`, html, }); } @@ -254,18 +265,18 @@ export class EmailAdapter implements EmailPort, OnModuleInit { inviterName: string, tempPassword: string ): Promise { - const loginUrl = `${this.configService.get('APP_URL')}/login`; const html = await this.emailTemplates.renderUserInvitation({ organizationName, inviterName, tempPassword, - loginUrl, + loginUrl: `${this.appUrl}/login`, + email, }); await this.send({ to: email, - from: EMAIL_SENDERS.TEAM, - subject: `You've been invited to join ${organizationName} on Xpeditis`, + from: this.sender('TEAM'), + subject: `Votre accès à ${organizationName} sur Xpeditis`, html, }); } @@ -280,8 +291,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit { expiresAt: Date ): Promise { try { - this.logger.log(`[sendInvitationWithToken] Starting email generation for ${email}`); - const expiresAtFormatted = expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', @@ -290,7 +299,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit { minute: '2-digit', }); - this.logger.log(`[sendInvitationWithToken] Rendering template...`); const html = await this.emailTemplates.renderInvitationWithToken({ firstName, lastName, @@ -300,38 +308,59 @@ export class EmailAdapter implements EmailPort, OnModuleInit { expiresAt: expiresAtFormatted, }); - this.logger.log(`[sendInvitationWithToken] Template rendered, sending email to ${email}...`); - this.logger.log(`[sendInvitationWithToken] HTML size: ${html.length} bytes`); - await this.send({ to: email, - from: EMAIL_SENDERS.TEAM, - subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, + from: this.sender('TEAM'), + subject: `${inviterName} vous invite à rejoindre ${organizationName} sur Xpeditis`, html, }); this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const errorCode = (error as any).code; - const errorResponse = (error as any).response; - const errorResponseCode = (error as any).responseCode; - const errorCommand = (error as any).command; - - this.logger.error(`[sendInvitationWithToken] ERROR MESSAGE: ${errorMessage}`); - this.logger.error(`[sendInvitationWithToken] ERROR CODE: ${errorCode}`); - this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE: ${errorResponse}`); - this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE CODE: ${errorResponseCode}`); - this.logger.error(`[sendInvitationWithToken] ERROR COMMAND: ${errorCommand}`); - - if (error instanceof Error && error.stack) { - this.logger.error(`[sendInvitationWithToken] STACK: ${error.stack.substring(0, 500)}`); - } - + this.logger.error( + `[sendInvitationWithToken] ${errorMessage} | code: ${(error as any)?.code} | response: ${(error as any)?.response}` + ); throw error; } } + /* ---------------------------------------------------------------------- */ + /* Réservations */ + /* ---------------------------------------------------------------------- */ + + async sendBookingConfirmation( + email: string, + bookingNumber: string, + bookingDetails: any, + pdfAttachment?: Buffer + ): Promise { + const html = await this.emailTemplates.renderBookingConfirmation({ + bookingNumber, + bookingDetails, + // Le lien du bouton n'etait jamais fourni : il pointait nulle part. + dashboardUrl: `${this.appUrl}/dashboard/bookings`, + }); + + const attachments = pdfAttachment + ? [ + { + filename: `booking-${bookingNumber}.pdf`, + content: pdfAttachment, + contentType: 'application/pdf', + }, + ] + : undefined; + + await this.send({ + to: email, + from: this.sender('BOOKINGS'), + subject: `Réservation confirmée — ${bookingNumber}`, + html, + attachments, + }); + } + async sendCsvBookingRequest( carrierEmail: string, bookingData: { @@ -356,11 +385,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit { notes?: string; } ): Promise { - // Use APP_URL (frontend) for accept/reject links - // The frontend pages will call the backend API at /accept/:token and /reject/:token - const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const acceptUrl = `${frontendUrl}/carrier/accept/${bookingData.confirmationToken}`; - const rejectUrl = `${frontendUrl}/carrier/reject/${bookingData.confirmationToken}`; + // Les pages du frontend appellent ensuite l'API /accept/:token et /reject/:token. + const acceptUrl = `${this.appUrl}/carrier/accept/${bookingData.confirmationToken}`; + const rejectUrl = `${this.appUrl}/carrier/reject/${bookingData.confirmationToken}`; const html = await this.emailTemplates.renderCsvBookingRequest({ ...bookingData, @@ -370,8 +397,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit { await this.send({ to: carrierEmail, - from: EMAIL_SENDERS.BOOKINGS, - subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`, + from: this.sender('BOOKINGS'), + subject: `Nouvelle demande de réservation${bookingData.bookingNumber ? ` ${bookingData.bookingNumber}` : ''} — ${bookingData.origin} → ${bookingData.destination}`, html, }); @@ -380,160 +407,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit { ); } - /** - * Send carrier account creation email with temporary password - */ - async sendCarrierAccountCreated( - email: string, - carrierName: string, - temporaryPassword: string - ): Promise { - const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const loginUrl = `${baseUrl}/carrier/login`; - - const html = ` - - - - - - - -
-
-

🚢 Bienvenue sur Xpeditis

-
-
-

Votre compte transporteur a été créé

-

Bonjour ${carrierName},

-

Un compte transporteur a été automatiquement créé pour vous sur la plateforme Xpeditis.

- -
-

Vos identifiants de connexion :

-

Email : ${email}

-

Mot de passe temporaire : ${temporaryPassword}

-
- -

⚠️ Important : Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire dès votre première connexion.

- - - -

Prochaines étapes :

-
    -
  1. Connectez-vous avec vos identifiants
  2. -
  3. Changez votre mot de passe
  4. -
  5. Complétez votre profil transporteur
  6. -
  7. Consultez vos demandes de réservation
  8. -
-
- -
- - - `; - - await this.send({ - to: email, - from: EMAIL_SENDERS.CARRIERS, - subject: '🚢 Votre compte transporteur Xpeditis a été créé', - html, - }); - - this.logger.log(`Carrier account creation email sent to ${email}`); - } - - /** - * Send carrier password reset email with temporary password - */ - async sendCarrierPasswordReset( - email: string, - carrierName: string, - temporaryPassword: string - ): Promise { - const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const loginUrl = `${baseUrl}/carrier/login`; - - const html = ` - - - - - - - -
-
-

🔑 Réinitialisation de mot de passe

-
-
-

Votre mot de passe a été réinitialisé

-

Bonjour ${carrierName},

-

Vous avez demandé la réinitialisation de votre mot de passe Xpeditis.

- -
-

Votre nouveau mot de passe temporaire :

-

${temporaryPassword}

-
- -
-

⚠️ Sécurité :

-
    -
  • Ce mot de passe est temporaire et doit être changé immédiatement
  • -
  • Ne partagez jamais vos identifiants avec qui que ce soit
  • -
  • Si vous n'avez pas demandé cette réinitialisation, contactez-nous immédiatement
  • -
-
- - - -

Si vous rencontrez des difficultés, n'hésitez pas à contacter notre équipe support.

-
- -
- - - `; - - await this.send({ - to: email, - from: EMAIL_SENDERS.SECURITY, - subject: '🔑 Réinitialisation de votre mot de passe Xpeditis', - html, - }); - - this.logger.log(`Carrier password reset email sent to ${email}`); - } - - /** - * Send document access email to carrier after booking acceptance - */ async sendDocumentAccessEmail( carrierEmail: string, data: { @@ -549,103 +422,22 @@ export class EmailAdapter implements EmailPort, OnModuleInit { confirmationToken: string; } ): Promise { - const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; - - // Password section HTML - only show if password is set - const passwordSection = data.documentPassword - ? ` -
-

🔐 Mot de passe d'accès aux documents

-

Pour accéder aux documents, vous aurez besoin du mot de passe suivant :

-
- ${data.documentPassword} -
-

⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.

-
- ` - : ''; - - const html = ` - - - - - - - - -
-
-

Documents disponibles

-

Votre reservation a ete acceptee

- ${data.bookingNumber ? `

N° ${data.bookingNumber}

` : ''} -
-
-

Bonjour ${data.carrierName},

-

Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.

- -
- ${data.origin} → ${data.destination} -
- -
-
- Volume - ${data.volumeCBM} CBM -
-
- Poids - ${data.weightKG} kg -
-
- -
- ${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''} -
- - ${passwordSection} - - Acceder aux documents - -

Ce lien est permanent. Vous pouvez y acceder a tout moment.

-
- -
- - - `; + const html = await this.emailTemplates.renderDocumentAccess({ + ...data, + documentsUrl: `${this.appUrl}/carrier/documents/${data.confirmationToken}`, + }); + const reference = data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase(); await this.send({ to: carrierEmail, - from: EMAIL_SENDERS.BOOKINGS, - subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`, + from: this.sender('BOOKINGS'), + subject: `Documents disponibles — réservation ${reference} (${data.origin} → ${data.destination})`, html, }); this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`); } - /** - * Send notification to carrier when new documents are added - */ async sendNewDocumentsNotification( carrierEmail: string, data: { @@ -658,65 +450,16 @@ export class EmailAdapter implements EmailPort, OnModuleInit { confirmationToken: string; } ): Promise { - const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; - - const html = ` - - - - - - - - -
-
-

Nouveaux documents ajoutes

-
-
-

Bonjour ${data.carrierName},

-

De nouveaux documents ont ete ajoutes a votre reservation.

- -
- ${data.origin} → ${data.destination} -
- -
-

- +${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''} -

-

- Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''} -

-
- - Voir les documents -
- -
- - - `; + const html = await this.emailTemplates.renderNewDocuments({ + ...data, + documentsUrl: `${this.appUrl}/carrier/documents/${data.confirmationToken}`, + }); + const count = data.newDocumentsCount; await this.send({ to: carrierEmail, - from: EMAIL_SENDERS.BOOKINGS, - subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`, + from: this.sender('BOOKINGS'), + subject: `${count} ${count > 1 ? 'nouveaux documents' : 'nouveau document'} — réservation ${data.origin} → ${data.destination}`, html, }); @@ -724,4 +467,90 @@ export class EmailAdapter implements EmailPort, OnModuleInit { `New documents notification sent to ${carrierEmail} for booking ${data.bookingId}` ); } + + /* ---------------------------------------------------------------------- */ + /* Espace transporteur */ + /* ---------------------------------------------------------------------- */ + + async sendCarrierAccountCreated( + email: string, + carrierName: string, + temporaryPassword: string + ): Promise { + // Pas de page /carrier/login : les transporteurs se connectent par /login. + const html = await this.emailTemplates.renderCarrierAccountCreated({ + carrierName, + email, + temporaryPassword, + loginUrl: `${this.appUrl}/login`, + }); + + await this.send({ + to: email, + from: this.sender('CARRIERS'), + subject: 'Votre compte transporteur Xpeditis est prêt', + html, + }); + + this.logger.log(`Carrier account creation email sent to ${email}`); + } + + async sendCarrierPasswordReset( + email: string, + carrierName: string, + temporaryPassword: string + ): Promise { + const html = await this.emailTemplates.renderCarrierPasswordReset({ + carrierName, + temporaryPassword, + loginUrl: `${this.appUrl}/login`, + }); + + await this.send({ + to: email, + from: this.sender('SECURITY'), + subject: 'Votre mot de passe transporteur Xpeditis a été réinitialisé', + html, + }); + + this.logger.log(`Carrier password reset email sent to ${email}`); + } + + /* ---------------------------------------------------------------------- */ + /* Emails internes */ + /* ---------------------------------------------------------------------- */ + + async sendContactMessage(to: string, data: ContactMessageEmail): Promise { + const html = await this.emailTemplates.renderContactMessage(data); + + await this.send({ + to, + // Répondre au message écrit directement à la personne qui l'a envoyé. + replyTo: data.email, + subject: `[Contact] ${data.subjectLabel} — ${data.firstName} ${data.lastName}`, + html, + }); + } + + async sendBankTransferToValidate(to: string[], data: BankTransferToValidateEmail): Promise { + const html = await this.emailTemplates.renderBankTransferToValidate({ + ...data, + adminUrl: `${this.appUrl}/admin/bookings`, + }); + + await this.send({ + to, + subject: `Virement à valider — ${data.bookingNumber}`, + html, + }); + } + + async sendSmtpTest(to: string, requestedBy: string): Promise { + const html = await this.emailTemplates.renderSmtpTest({ + requestedBy, + sentAt: new Date().toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' }), + }); + + await this.send({ to, subject: 'Test SMTP Xpeditis', html }); + } } diff --git a/apps/backend/src/infrastructure/email/templates/email-layout.ts b/apps/backend/src/infrastructure/email/templates/email-layout.ts new file mode 100644 index 0000000..1f996e4 --- /dev/null +++ b/apps/backend/src/infrastructure/email/templates/email-layout.ts @@ -0,0 +1,317 @@ +/** + * Gabarit commun des emails Xpeditis. + * + * Chaque email reprend la meme structure : logo, carte blanche liseree de + * turquoise, surtitre, titre, contenu, pied de page. Les regles suivies + * viennent des skills « email-best-practices » (Resend) et « mjml » : + * + * - `lang`/`dir` sur ET sur le contenu du : plusieurs clients + * suppriment les attributs de ; + * - un et un preheader propres a chaque email ; + * - un seul <h1>, et du texte a 4,5:1 de contraste minimum ; + * - uniquement des composants MJML, compiles en tableaux : pas de flex, de + * grid ni de degrade, que Gmail et Outlook ignorent (les anciens boutons + * « Accepter / Refuser » s'effondraient pour cette raison) ; + * - toute donnee dynamique echappee : noms, notes et messages sont saisis + * par des tiers. + */ + +import mjml2html from 'mjml'; + +export const EMAIL_COLORS = { + navy: '#10183A', + turquoise: '#34CCCD', + /** Turquoise assombri : lisible en texte sur blanc (5,1:1). */ + teal: '#0B7A7B', + green: '#067224', + red: '#B42318', + page: '#EEF1F5', + card: '#FFFFFF', + panel: '#F6F8FB', + border: '#E3E8EF', + borderStrong: '#C9D3DF', + text: '#3B4256', + /** Texte secondaire, 5:1 sur blanc. */ + muted: '#667085', + infoBg: '#EEFBFB', + infoText: '#0B5657', + warningBg: '#FFF7E6', + warningBorder: '#F5B546', + warningText: '#8A4B00', + successBg: '#ECF7EF', +} as const; + +const C = EMAIL_COLORS; + +const HEADING_FONT = "Manrope, 'Segoe UI', Helvetica, Arial, sans-serif"; +const BODY_FONT = "Montserrat, 'Segoe UI', Helvetica, Arial, sans-serif"; +const MONO_FONT = "'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace"; + +/** Echappe une valeur pour l'inserer dans du HTML ou dans un attribut. */ +export function esc(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Texte multiligne saisi par un tiers : echappe, retours a la ligne conserves. */ +export function escMultiline(value: unknown): string { + return esc(value).replace(/\r?\n/g, '<br />'); +} + +let validationLevel: 'soft' | 'strict' = 'soft'; + +/** + * En test, `strict` fait echouer le rendu a la moindre erreur MJML. En + * production, un attribut mal forme ne doit pas empecher un email de partir. + */ +export function setEmailValidationLevel(level: 'soft' | 'strict'): void { + validationLevel = level; +} + +export interface EmailLayout { + /** <title> : repris par les lecteurs d'ecran, aligne sur le sujet. */ + title: string; + /** Preheader affiche apres le sujet dans la boite de reception (< 90 car.). */ + preview: string; + /** Surtitre court : situe l'email en un coup d'oeil. */ + eyebrow?: string; + heading: string; + /** Blocs produits par les fonctions ci-dessous. */ + blocks: string[]; + /** Pourquoi la personne recoit cet email. */ + footerNote?: string; + logoUrl: string; + appUrl: string; +} + +const section = (content: string, padding = '0') => + `<mj-section padding="${padding}"><mj-column>${content}</mj-column></mj-section>`; + +const label = (text: string, align: 'left' | 'center' | 'right' = 'left', padding = '0 0 6px 0') => + `<mj-text align="${align}" font-size="11px" line-height="16px" font-weight="700" letter-spacing="1.2px" text-transform="uppercase" color="${C.muted}" padding="${padding}">${esc(text)}</mj-text>`; + +/** Compile le gabarit complet en HTML pret a l'envoi. */ +export function renderEmail(layout: EmailLayout): string { + const year = new Date().getFullYear(); + + const mjml = ` +<mjml lang="fr" dir="ltr"> + <mj-head> + <mj-title>${esc(layout.title)}</mj-title> + <mj-preview>${esc(layout.preview)}</mj-preview> + <mj-raw> + <meta name="color-scheme" content="light only" /> + <meta name="supported-color-schemes" content="light" /> + </mj-raw> + <mj-font name="Manrope" href="https://fonts.googleapis.com/css2?family=Manrope:wght@700;800&display=swap" /> + <mj-font name="Montserrat" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" /> + <mj-attributes> + <mj-all font-family="${BODY_FONT}" /> + <mj-section padding="0" /> + <mj-column padding="0" /> + <mj-text color="${C.text}" font-size="15px" line-height="24px" padding="0 0 16px 0" /> + <mj-button background-color="${C.navy}" color="#FFFFFF" font-size="15px" font-weight="600" line-height="20px" border-radius="8px" inner-padding="14px 28px" padding="8px 0 20px 0" align="left" /> + <mj-divider border-color="${C.border}" border-width="1px" padding="8px 0 24px 0" /> + <mj-table color="${C.text}" font-size="14px" line-height="20px" padding="0" /> + </mj-attributes> + <mj-style> + a { color: ${C.teal}; } + @media only screen and (max-width: 480px) { + .xp-card > table > tbody > tr > td { padding: 28px 20px 20px 20px !important; } + .xp-button-full table { width: 100% !important; } + /* Boutons empiles : les retraits prevus pour la version cote a cote les decalaient. */ + .xp-pair > table > tbody > tr > td { padding: 0 0 10px 0 !important; } + } + </mj-style> + </mj-head> + <mj-body background-color="${C.page}" width="600px"> + <mj-section padding="32px 16px 20px 16px"> + <mj-column> + <mj-image src="${esc(layout.logoUrl)}" alt="Xpeditis" width="64px" align="center" padding="0" /> + </mj-column> + </mj-section> + + <mj-wrapper css-class="xp-card" background-color="${C.card}" border-top="4px solid ${C.turquoise}" border-radius="12px" padding="36px 40px 20px 40px"> + ${layout.eyebrow ? section(`<mj-text font-size="12px" line-height="16px" font-weight="700" letter-spacing="1.4px" text-transform="uppercase" color="${C.teal}" padding="0 0 10px 0">${esc(layout.eyebrow)}</mj-text>`) : ''} + ${section(`<mj-text padding="0 0 16px 0"><h1 style="margin:0;font-family:${HEADING_FONT};font-size:26px;line-height:34px;font-weight:800;color:${C.navy};">${esc(layout.heading)}</h1></mj-text>`)} + ${layout.blocks.join('\n ')} + </mj-wrapper> + + <mj-section padding="24px 24px 40px 24px"> + <mj-column> + ${layout.footerNote ? `<mj-text align="center" font-size="12px" line-height="18px" color="${C.muted}" padding="0 0 12px 0">${esc(layout.footerNote)}</mj-text>` : ''} + <mj-text align="center" font-size="12px" line-height="20px" color="${C.muted}" padding="0"> + <strong style="color:${C.navy};">Xpeditis</strong> · Réservation de fret maritime en ligne<br /> + <a href="${esc(layout.appUrl)}" style="color:${C.muted};text-decoration:underline;">Accéder à la plateforme</a> + · + <a href="mailto:support@xpeditis.com" style="color:${C.muted};text-decoration:underline;">support@xpeditis.com</a><br /> + © ${year} Xpeditis. Tous droits réservés. + </mj-text> + </mj-column> + </mj-section> + </mj-body> +</mjml>`; + + const { html } = mjml2html(mjml, { validationLevel }); + + // Doublon volontaire de lang/dir sur le contenu du <body> (voir en-tete). + return html + .replace(/<body([^>]*)>/, '<body$1><div lang="fr" dir="ltr">') + .replace(/<\/body>/, '</div></body>'); +} + +/* ------------------------------------------------------------------------ */ +/* Blocs */ +/* ------------------------------------------------------------------------ */ + +/** Paragraphe. `safeHtml` doit deja etre echappe (utiliser `esc`). */ +export function paragraph( + safeHtml: string, + options: { muted?: boolean; small?: boolean; align?: 'left' | 'center' } = {} +): string { + const attributes = [ + options.muted ? `color="${C.muted}"` : '', + options.small ? 'font-size="13px" line-height="20px"' : '', + options.align ? `align="${options.align}"` : '', + ] + .filter(Boolean) + .join(' '); + return section(`<mj-text ${attributes}>${safeHtml}</mj-text>`); +} + +/** Intertitre (h2). */ +export function heading2(title: string): string { + return section( + `<mj-text padding="8px 0 10px 0"><h2 style="margin:0;font-family:${HEADING_FONT};font-size:16px;line-height:24px;font-weight:700;color:${C.navy};">${esc(title)}</h2></mj-text>` + ); +} + +export type ButtonVariant = 'primary' | 'success' | 'dangerOutline'; + +export interface ButtonSpec { + label: string; + href: string; + variant?: ButtonVariant; +} + +const BUTTON_STYLES: Record<ButtonVariant, string> = { + primary: `background-color="${C.navy}" color="#FFFFFF"`, + success: `background-color="${C.green}" color="#FFFFFF"`, + dangerOutline: `background-color="#FFFFFF" color="${C.red}" border="2px solid ${C.red}"`, +}; + +/** Bouton d'action principal. Libelle explicite : jamais « cliquez ici ». */ +export function button(label: string, href: string, variant: ButtonVariant = 'primary'): string { + return section( + `<mj-button href="${esc(href)}" ${BUTTON_STYLES[variant]} css-class="xp-button-full">${esc(label)}</mj-button>` + ); +} + +/** Deux boutons cote a cote, empiles sur mobile. */ +export function buttonPair(first: ButtonSpec, second: ButtonSpec): string { + const cell = (spec: ButtonSpec, padding: string) => + `<mj-column width="50%" padding="${padding}" css-class="xp-pair"><mj-button href="${esc(spec.href)}" ${BUTTON_STYLES[spec.variant ?? 'primary']} width="100%" padding="0" align="center">${esc(spec.label)}</mj-button></mj-column>`; + return `<mj-section padding="8px 0 20px 0">${cell(first, '0 6px 10px 0')}${cell(second, '0 0 10px 6px')}</mj-section>`; +} + +export interface DetailRow { + label: string; + /** Valeur deja echappee (peut contenir un lien ou un <br />). */ + value: string; +} + +/** Tableau libelle / valeur dans un panneau gris clair. */ +export function details(rows: DetailRow[]): string { + const body = rows + .map((row, index) => { + const border = index < rows.length - 1 ? `border-bottom:1px solid ${C.border};` : ''; + return ( + `<tr>` + + `<td style="padding:11px 0;${border}color:${C.muted};font-size:13px;line-height:20px;vertical-align:top;width:44%;">${esc(row.label)}</td>` + + `<td style="padding:11px 0 11px 12px;${border}color:${C.navy};font-size:14px;line-height:20px;font-weight:600;text-align:right;vertical-align:top;">${row.value}</td>` + + `</tr>` + ); + }) + .join(''); + return `<mj-section padding="4px 0 20px 0"><mj-column background-color="${C.panel}" border="1px solid ${C.border}" border-radius="10px" padding="4px 20px"><mj-table>${body}</mj-table></mj-column></mj-section>`; +} + +/** Trajet « depart → arrivee », lisible d'un coup d'oeil, conserve sur mobile. */ +export function route(origin: string, destination: string): string { + const port = (value: string, align: 'left' | 'right') => + `<mj-text align="${align}" font-family="${HEADING_FONT}" font-size="24px" line-height="30px" font-weight="800" color="${C.navy}" padding="0">${esc(value)}</mj-text>`; + return ( + `<mj-section padding="4px 0 16px 0"><mj-group>` + + `<mj-column width="42%" vertical-align="middle">${label('Départ')}${port(origin, 'left')}</mj-column>` + + `<mj-column width="16%" vertical-align="middle"><mj-text align="center" font-size="22px" line-height="30px" color="${C.turquoise}" padding="20px 0 0 0"><span aria-hidden="true">→</span></mj-text></mj-column>` + + `<mj-column width="42%" vertical-align="middle">${label('Arrivée', 'right')}${port(destination, 'right')}</mj-column>` + + `</mj-group></mj-section>` + ); +} + +/** Code a recopier (mot de passe temporaire, acces aux documents). */ +export function codeBox(title: string, code: string, hint?: string): string { + return ( + `<mj-section padding="4px 0 20px 0"><mj-column background-color="${C.panel}" border="1px dashed ${C.borderStrong}" border-radius="10px" padding="18px 20px">` + + label(title, 'center') + + `<mj-text align="center" font-family="${MONO_FONT}" font-size="24px" line-height="32px" font-weight="700" letter-spacing="3px" color="${C.navy}" padding="0">${esc(code)}</mj-text>` + + (hint + ? `<mj-text align="center" font-size="12px" line-height="18px" color="${C.muted}" padding="8px 0 0 0">${esc(hint)}</mj-text>` + : '') + + `</mj-column></mj-section>` + ); +} + +export type CalloutTone = 'info' | 'warning' | 'success'; + +const CALLOUT_TONES: Record<CalloutTone, { background: string; border: string; title: string }> = { + info: { background: C.infoBg, border: C.turquoise, title: C.infoText }, + warning: { background: C.warningBg, border: C.warningBorder, title: C.warningText }, + success: { background: C.successBg, border: C.green, title: C.green }, +}; + +/** Encadre d'information. `safeHtml` doit deja etre echappe. */ +export function callout(tone: CalloutTone, title: string | undefined, safeHtml: string): string { + const colors = CALLOUT_TONES[tone]; + return ( + `<mj-section padding="4px 0 20px 0"><mj-column background-color="${colors.background}" border-left="4px solid ${colors.border}" border-radius="8px" padding="14px 18px">` + + (title + ? `<mj-text font-size="14px" line-height="20px" font-weight="700" color="${colors.title}" padding="0 0 4px 0">${esc(title)}</mj-text>` + : '') + + `<mj-text font-size="14px" line-height="22px" color="${C.text}" padding="0">${safeHtml}</mj-text>` + + `</mj-column></mj-section>` + ); +} + +/** Texte cite (message d'un client) dans un panneau neutre. */ +export function quote(safeHtml: string): string { + return `<mj-section padding="0 0 20px 0"><mj-column background-color="${C.panel}" border="1px solid ${C.border}" border-radius="10px" padding="16px 20px"><mj-text font-size="14px" line-height="22px" color="${C.text}" padding="0">${safeHtml}</mj-text></mj-column></mj-section>`; +} + +/** Liste a puces. Elements deja echappes. */ +export function bulletList(safeItems: string[]): string { + const items = safeItems.map(item => `<li style="margin:0 0 8px 0;">${item}</li>`).join(''); + return section(`<mj-text><ul style="margin:0;padding:0 0 0 20px;">${items}</ul></mj-text>`); +} + +/** Etapes numerotees. Elements deja echappes. */ +export function steps(safeItems: string[]): string { + const items = safeItems.map(item => `<li style="margin:0 0 10px 0;">${item}</li>`).join(''); + return section(`<mj-text><ol style="margin:0;padding:0 0 0 22px;">${items}</ol></mj-text>`); +} + +/** Lien de secours sous un bouton : certains clients bloquent les boutons. */ +export function linkFallback(url: string): string { + return paragraph( + `Le bouton ne s’affiche pas ? Copiez ce lien dans votre navigateur :<br /><a href="${esc(url)}" style="color:${C.teal};word-break:break-all;">${esc(url)}</a>`, + { muted: true, small: true } + ); +} + +export function divider(): string { + return section('<mj-divider />'); +} diff --git a/apps/backend/src/infrastructure/email/templates/email-templates.spec.ts b/apps/backend/src/infrastructure/email/templates/email-templates.spec.ts new file mode 100644 index 0000000..2cd8492 --- /dev/null +++ b/apps/backend/src/infrastructure/email/templates/email-templates.spec.ts @@ -0,0 +1,238 @@ +import { EmailTemplates } from './email-templates'; +import { setEmailValidationLevel } from './email-layout'; + +/** + * Controle mecanique des 14 emails, d'apres la checklist d'accessibilite du + * skill « email-best-practices » (Resend) et les regles MJML : compilation + * sans erreur, langue declaree deux fois, titre, texte alternatif du logo, + * aucune donnee brute injectee, et un poids sous le seuil de coupure de Gmail. + */ + +const APP_URL = 'https://app.preprod.xpeditis.com'; +const settings: Record<string, string> = { APP_URL }; +const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) }; +const templates = new EmailTemplates(config as never); + +/** Injecte dans chaque champ libre : ne doit jamais ressortir tel quel. */ +const XSS = '<script>alert("x")</script>'; + +const bookingRequest = { + bookingId: '0f6c8a52-3d0e-4b7e-9a51-2b7c1d9e4f10', + bookingNumber: 'XPD-2026-R9KE8U', + documentPassword: 'K7PQ2MXA', + origin: 'FRLEH', + destination: 'EGEDK', + volumeCBM: 2.4, + weightKG: 850, + palletCount: 2, + priceUSD: 196, + priceEUR: 180, + primaryCurrency: 'EUR', + transitDays: 11, + containerType: 'LCL', + documents: [ + { type: 'COMMERCIAL_INVOICE', fileName: 'facture-2026-031.pdf' }, + { type: 'PACKING_LIST', fileName: `colisage ${XSS}.pdf` }, + ], + notes: `Livraison le matin.\n${XSS}`, + acceptUrl: `${APP_URL}/carrier/accept/token-123`, + rejectUrl: `${APP_URL}/carrier/reject/token-123`, +}; + +const cases: Array<[string, () => Promise<string>, string[]]> = [ + [ + 'verification', + () => templates.renderVerificationEmail({ verifyUrl: `${APP_URL}/verify-email?token=v1` }), + [`${APP_URL}/verify-email?token=v1`], + ], + [ + 'reinitialisation du mot de passe', + () => templates.renderPasswordResetEmail({ resetUrl: `${APP_URL}/reset-password?token=r1` }), + [`${APP_URL}/reset-password?token=r1`, '1 heure'], + ], + [ + 'bienvenue', + () => templates.renderWelcomeEmail({ firstName: XSS, dashboardUrl: `${APP_URL}/dashboard` }), + [`${APP_URL}/dashboard`], + ], + [ + 'compte cree par un administrateur', + () => + templates.renderUserInvitation({ + organizationName: 'Acme Logistique', + inviterName: XSS, + tempPassword: 'Temp-42-Xyz', + loginUrl: `${APP_URL}/login`, + email: 'marie@acme.test', + }), + ['Temp-42-Xyz', 'marie@acme.test', `${APP_URL}/login`], + ], + [ + 'invitation par lien', + () => + templates.renderInvitationWithToken({ + firstName: 'Marie', + lastName: 'Dupont', + organizationName: 'Acme Logistique', + inviterName: 'Paul Martin', + invitationLink: `${APP_URL}/register?token=i1`, + expiresAt: '21 septembre 2026 à 10:00', + }), + [`${APP_URL}/register?token=i1`, '21 septembre 2026'], + ], + [ + 'confirmation de reservation', + () => + templates.renderBookingConfirmation({ + bookingNumber: 'WCM-2026-000042', + bookingDetails: { + origin: 'Le Havre', + destination: 'Alexandrie', + carrier: 'SSC Consolidation', + etd: new Date('2026-10-02T00:00:00Z'), + eta: new Date('2026-10-13T00:00:00Z'), + }, + dashboardUrl: `${APP_URL}/dashboard/bookings`, + }), + ['WCM-2026-000042', `${APP_URL}/dashboard/bookings`], + ], + [ + 'demande de reservation au transporteur', + () => templates.renderCsvBookingRequest(bookingRequest), + [bookingRequest.acceptUrl, bookingRequest.rejectUrl, 'K7PQ2MXA', 'Facture commerciale'], + ], + [ + 'documents disponibles', + () => + templates.renderDocumentAccess({ + carrierName: XSS, + bookingId: bookingRequest.bookingId, + bookingNumber: bookingRequest.bookingNumber, + documentPassword: 'K7PQ2MXA', + origin: 'FRLEH', + destination: 'EGEDK', + volumeCBM: 2.4, + weightKG: 850, + documentCount: 3, + documentsUrl: `${APP_URL}/carrier/documents/token-123`, + }), + [`${APP_URL}/carrier/documents/token-123`, '3 documents'], + ], + [ + 'nouveaux documents', + () => + templates.renderNewDocuments({ + carrierName: 'SSC Consolidation', + bookingId: bookingRequest.bookingId, + origin: 'FRLEH', + destination: 'EGEDK', + newDocumentsCount: 2, + totalDocumentsCount: 5, + documentsUrl: `${APP_URL}/carrier/documents/token-123`, + }), + ['2 nouveaux documents', '5 documents'], + ], + [ + 'compte transporteur cree', + () => + templates.renderCarrierAccountCreated({ + carrierName: XSS, + email: 'booking@ssc.test', + temporaryPassword: 'Carrier-Temp-1', + loginUrl: `${APP_URL}/login`, + }), + ['Carrier-Temp-1', 'booking@ssc.test'], + ], + [ + 'mot de passe transporteur reinitialise', + () => + templates.renderCarrierPasswordReset({ + carrierName: 'SSC Consolidation', + temporaryPassword: 'Carrier-Temp-2', + loginUrl: `${APP_URL}/login`, + }), + ['Carrier-Temp-2'], + ], + [ + 'formulaire de contact', + () => + templates.renderContactMessage({ + firstName: 'Marie', + lastName: XSS, + email: 'marie@acme.test', + company: 'Acme Logistique', + phone: '+33 6 12 34 56 78', + subjectLabel: 'Demande de démonstration', + message: `Bonjour,\nNous expédions 40 conteneurs par mois.\n${XSS}`, + }), + ['mailto:marie@acme.test', 'Acme Logistique'], + ], + [ + 'virement a valider', + () => + templates.renderBankTransferToValidate({ + bookingNumber: 'XPD-2026-R9KE8U', + carrierName: 'SSC Consolidation', + routeDescription: 'FRLEH → EGEDK', + commissionAmount: '150,00 €', + adminUrl: `${APP_URL}/admin/bookings`, + }), + [`${APP_URL}/admin/bookings`, '150,00 €'], + ], + [ + 'test SMTP', + () => templates.renderSmtpTest({ requestedBy: 'admin@xpeditis.com', sentAt: '14 sept. 2026' }), + ['admin@xpeditis.com'], + ], +]; + +describe('Emails Xpeditis — gabarit commun', () => { + beforeAll(() => setEmailValidationLevel('strict')); + afterAll(() => setEmailValidationLevel('soft')); + + describe.each(cases)('%s', (_name, render, expectedContent) => { + let html: string; + + beforeAll(async () => { + // En mode strict, toute erreur MJML fait echouer ce rendu. + html = await render(); + }); + + it('compile en un document HTML complet', () => { + expect(html).toMatch(/^<!doctype html>/i); + }); + + it('declare la langue sur <html> et sur le contenu du <body>', () => { + expect(html).toMatch(/<html[^>]*lang="fr"[^>]*dir="ltr"/); + expect(html).toMatch(/<body[^>]*><div lang="fr" dir="ltr">/); + }); + + it('a un <title> propre et un logo avec texte alternatif', () => { + expect(html).toMatch(/<title>[^<]{8,}<\/title>/); + expect(html).toContain('alt="Xpeditis"'); + expect(html).toContain(`${APP_URL}/assets/email/xpeditis-logo.png`); + }); + + it('contient un seul titre de niveau 1', () => { + expect(html.match(/<h1[\s>]/g)).toHaveLength(1); + }); + + it('affiche les informations attendues', () => { + for (const content of expectedContent) { + expect(html).toContain(content); + } + }); + + it("n'injecte jamais une donnee saisie sans l'echapper", () => { + expect(html).not.toContain('<script>'); + }); + + it('ne laisse aucun marqueur de gabarit ni valeur manquante', () => { + expect(html).not.toMatch(/\{\{|\}\}|undefined|NaN|\[object Object\]/); + }); + + it('reste sous le seuil de coupure de Gmail (102 Ko)', () => { + expect(Buffer.byteLength(html, 'utf8')).toBeLessThan(102 * 1024); + }); + }); +}); diff --git a/apps/backend/src/infrastructure/email/templates/email-templates.ts b/apps/backend/src/infrastructure/email/templates/email-templates.ts index 7fe0d94..04abde5 100644 --- a/apps/backend/src/infrastructure/email/templates/email-templates.ts +++ b/apps/backend/src/infrastructure/email/templates/email-templates.ts @@ -1,264 +1,268 @@ /** * Email Templates Service * - * Renders email templates using MJML and Handlebars + * Contenu des emails Xpeditis. La mise en page commune (logo, carte, pied de + * page, accessibilite) vit dans `email-layout.ts` : chaque methode ne decrit + * que le message. + * + * Principes de redaction (skill « email-best-practices ») : un titre qui dit + * ce qui se passe, l'action principale visible sans defiler, les details + * ensuite, puis les informations secondaires ; des boutons au libelle + * explicite ; une mention qui explique pourquoi la personne recoit l'email. */ import { Injectable } from '@nestjs/common'; -import mjml2html from 'mjml'; -import Handlebars from 'handlebars'; +import { ConfigService } from '@nestjs/config'; +import { + EMAIL_COLORS, + EmailLayout, + bulletList, + button, + buttonPair, + callout, + codeBox, + details, + DetailRow, + esc, + escMultiline, + heading2, + linkFallback, + paragraph, + quote, + renderEmail, + route, + steps, +} from './email-layout'; + +const LOCALE = 'fr-FR'; + +function formatNumber(value: number, maximumFractionDigits = 2): string { + return new Intl.NumberFormat(LOCALE, { maximumFractionDigits }).format(Number(value) || 0); +} + +function formatMoney(value: number, currency: string): string { + try { + return new Intl.NumberFormat(LOCALE, { style: 'currency', currency }).format( + Number(value) || 0 + ); + } catch { + return `${formatNumber(value)} ${currency}`; + } +} + +function formatDate(value: unknown): string { + const date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value ?? ''); + return date.toLocaleDateString(LOCALE, { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function plural(count: number, singular: string, pluralForm: string): string { + return `${formatNumber(count, 0)} ${count > 1 ? pluralForm : singular}`; +} + +const DOCUMENT_LABELS: Record<string, string> = { + BILL_OF_LADING: 'Connaissement', + PACKING_LIST: 'Liste de colisage', + COMMERCIAL_INVOICE: 'Facture commerciale', + CERTIFICATE_OF_ORIGIN: 'Certificat d’origine', + OTHER: 'Autre document', +}; + +function documentLabel(type: string): string { + if (DOCUMENT_LABELS[type]) return DOCUMENT_LABELS[type]; + const words = String(type).replace(/[_-]+/g, ' ').toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} @Injectable() export class EmailTemplates { - /** - * Render booking confirmation email - */ - async renderBookingConfirmation(data: { - bookingNumber: string; - bookingDetails: any; - }): Promise<string> { - const mjmlTemplate = ` - <mjml> - <mj-head> - <mj-attributes> - <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" /> - <mj-text font-size="14px" color="#333333" line-height="1.6" /> - </mj-attributes> - </mj-head> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="24px" font-weight="bold" color="#0066cc"> - Booking Confirmation - </mj-text> - <mj-divider border-color="#0066cc" /> - <mj-text font-size="16px"> - Your booking has been confirmed successfully! - </mj-text> - <mj-text> - <strong>Booking Number:</strong> {{bookingNumber}} - </mj-text> - <mj-text> - Thank you for using Xpeditis. Your booking confirmation is attached as a PDF. - </mj-text> - <mj-button background-color="#0066cc" href="{{dashboardUrl}}"> - View in Dashboard - </mj-button> - </mj-column> - </mj-section> - <mj-section background-color="#f4f4f4" padding="10px"> - <mj-column> - <mj-text font-size="12px" color="#666666" align="center"> - © 2025 Xpeditis. All rights reserved. - </mj-text> - </mj-column> - </mj-section> - </mj-body> - </mjml> - `; + constructor(private readonly configService: ConfigService) {} - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + private get appUrl(): string { + return String(this.configService.get<string>('APP_URL', 'http://localhost:3000')).replace( + /\/+$/, + '' + ); } - /** - * Render verification email - */ + /** Logo PNG : le SVG n'est pas affiche par Gmail ni Outlook. */ + private get logoUrl(): string { + return ( + this.configService.get<string>('EMAIL_LOGO_URL') || + `${this.appUrl}/assets/email/xpeditis-logo.png` + ); + } + + private render(layout: Omit<EmailLayout, 'appUrl' | 'logoUrl'>): string { + return renderEmail({ ...layout, appUrl: this.appUrl, logoUrl: this.logoUrl }); + } + + /* ---------------------------------------------------------------------- */ + /* Compte et securite */ + /* ---------------------------------------------------------------------- */ + async renderVerificationEmail(data: { verifyUrl: string }): Promise<string> { - const mjmlTemplate = ` - <mjml> - <mj-head> - <mj-attributes> - <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" /> - </mj-attributes> - </mj-head> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="24px" font-weight="bold" color="#0066cc"> - Verify Your Email - </mj-text> - <mj-divider border-color="#0066cc" /> - <mj-text> - Welcome to Xpeditis! Please verify your email address to get started. - </mj-text> - <mj-button background-color="#0066cc" href="{{verifyUrl}}"> - Verify Email Address - </mj-button> - <mj-text font-size="12px" color="#666666"> - If you didn't create an account, you can safely ignore this email. - </mj-text> - </mj-column> - </mj-section> - <mj-section background-color="#f4f4f4" padding="10px"> - <mj-column> - <mj-text font-size="12px" color="#666666" align="center"> - © 2025 Xpeditis. All rights reserved. - </mj-text> - </mj-column> - </mj-section> - </mj-body> - </mjml> - `; - - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + return this.render({ + title: 'Confirmez votre adresse email — Xpeditis', + preview: 'Un clic suffit pour activer votre compte Xpeditis.', + eyebrow: 'Activation du compte', + heading: 'Confirmez votre adresse email', + blocks: [ + paragraph( + 'Bienvenue sur Xpeditis. Pour activer votre compte et sécuriser vos accès, confirmez que cette adresse vous appartient.' + ), + button('Confirmer mon adresse email', data.verifyUrl), + linkFallback(data.verifyUrl), + ], + footerNote: + 'Vous n’avez pas créé de compte Xpeditis ? Ignorez cet email : aucun compte ne sera activé.', + }); } - /** - * Render password reset email - */ async renderPasswordResetEmail(data: { resetUrl: string }): Promise<string> { - const mjmlTemplate = ` - <mjml> - <mj-head> - <mj-attributes> - <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" /> - </mj-attributes> - </mj-head> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="24px" font-weight="bold" color="#0066cc"> - Reset Your Password - </mj-text> - <mj-divider border-color="#0066cc" /> - <mj-text> - You requested to reset your password. Click the button below to set a new password. - </mj-text> - <mj-button background-color="#0066cc" href="{{resetUrl}}"> - Reset Password - </mj-button> - <mj-text font-size="12px" color="#666666"> - This link will expire in 1 hour. If you didn't request this, please ignore this email. - </mj-text> - </mj-column> - </mj-section> - <mj-section background-color="#f4f4f4" padding="10px"> - <mj-column> - <mj-text font-size="12px" color="#666666" align="center"> - © 2025 Xpeditis. All rights reserved. - </mj-text> - </mj-column> - </mj-section> - </mj-body> - </mjml> - `; - - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + return this.render({ + title: 'Réinitialisez votre mot de passe Xpeditis', + preview: 'Ce lien est valable 1 heure et ne peut servir qu’une seule fois.', + eyebrow: 'Sécurité du compte', + heading: 'Réinitialisez votre mot de passe', + blocks: [ + paragraph( + 'Nous avons reçu une demande de réinitialisation du mot de passe de votre compte Xpeditis. Choisissez-en un nouveau avec le bouton ci-dessous.' + ), + button('Choisir un nouveau mot de passe', data.resetUrl), + callout( + 'info', + 'Lien valable 1 heure', + 'Pour votre sécurité, ce lien expire dans une heure et ne peut être utilisé qu’une seule fois.' + ), + linkFallback(data.resetUrl), + ], + footerNote: + 'Vous n’êtes pas à l’origine de cette demande ? Ignorez cet email : votre mot de passe reste inchangé. En cas de doute, écrivez à support@xpeditis.com.', + }); } - /** - * Render welcome email - */ async renderWelcomeEmail(data: { firstName: string; dashboardUrl: string }): Promise<string> { - const mjmlTemplate = ` - <mjml> - <mj-head> - <mj-attributes> - <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" /> - </mj-attributes> - </mj-head> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="24px" font-weight="bold" color="#0066cc"> - Welcome to Xpeditis, {{firstName}}! - </mj-text> - <mj-divider border-color="#0066cc" /> - <mj-text> - We're excited to have you on board. Xpeditis helps you search and book maritime freight with ease. - </mj-text> - <mj-text> - <strong>Get started:</strong> - </mj-text> - <mj-text> - • Search for shipping rates<br/> - • Compare carriers and prices<br/> - • Book containers online<br/> - • Track your shipments - </mj-text> - <mj-button background-color="#0066cc" href="{{dashboardUrl}}"> - Go to Dashboard - </mj-button> - </mj-column> - </mj-section> - <mj-section background-color="#f4f4f4" padding="10px"> - <mj-column> - <mj-text font-size="12px" color="#666666" align="center"> - © 2025 Xpeditis. All rights reserved. - </mj-text> - </mj-column> - </mj-section> - </mj-body> - </mjml> - `; - - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + return this.render({ + title: `Bienvenue sur Xpeditis, ${data.firstName}`, + preview: 'Comparez les tarifs, réservez et suivez vos envois maritimes au même endroit.', + eyebrow: 'Bienvenue', + heading: `Bienvenue sur Xpeditis, ${data.firstName}`, + blocks: [ + paragraph('Votre compte est prêt. Voici comment tirer le meilleur de la plateforme :'), + steps([ + '<strong>Recherchez un tarif</strong> : indiquez vos ports de départ et d’arrivée, puis comparez les offres des transporteurs.', + '<strong>Réservez en ligne</strong> : choisissez l’offre qui vous convient et transmettez vos documents.', + '<strong>Suivez vos envois</strong> : devis, réservations et documents sont réunis dans votre tableau de bord.', + ]), + button('Accéder à mon tableau de bord', data.dashboardUrl), + ], + footerNote: 'Vous recevez cet email car vous venez de créer un compte Xpeditis.', + }); } - /** - * Render user invitation email - */ + /** Compte cree par un administrateur, avec mot de passe temporaire. */ async renderUserInvitation(data: { organizationName: string; inviterName: string; tempPassword: string; loginUrl: string; + email?: string; }): Promise<string> { - const mjmlTemplate = ` - <mjml> - <mj-head> - <mj-attributes> - <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" /> - </mj-attributes> - </mj-head> - <mj-body background-color="#f4f4f4"> - <mj-section background-color="#ffffff" padding="20px"> - <mj-column> - <mj-text font-size="24px" font-weight="bold" color="#0066cc"> - You've Been Invited! - </mj-text> - <mj-divider border-color="#0066cc" /> - <mj-text> - {{inviterName}} has invited you to join <strong>{{organizationName}}</strong> on Xpeditis. - </mj-text> - <mj-text> - <strong>Your temporary password:</strong> {{tempPassword}} - </mj-text> - <mj-text font-size="12px" color="#ff6600"> - Please change your password after your first login. - </mj-text> - <mj-button background-color="#0066cc" href="{{loginUrl}}"> - Login Now - </mj-button> - </mj-column> - </mj-section> - <mj-section background-color="#f4f4f4" padding="10px"> - <mj-column> - <mj-text font-size="12px" color="#666666" align="center"> - © 2025 Xpeditis. All rights reserved. - </mj-text> - </mj-column> - </mj-section> - </mj-body> - </mjml> - `; - - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + return this.render({ + title: `Votre accès à ${data.organizationName} sur Xpeditis`, + preview: `${data.inviterName} vous a créé un compte Xpeditis. Vos identifiants sont à l’intérieur.`, + eyebrow: 'Nouveau compte', + heading: 'Votre compte Xpeditis est prêt', + blocks: [ + paragraph( + `${esc(data.inviterName)} vous a ouvert un accès à l’espace <strong>${esc(data.organizationName)}</strong> sur Xpeditis.` + ), + ...(data.email ? [details([{ label: 'Identifiant', value: esc(data.email) }])] : []), + codeBox( + 'Mot de passe temporaire', + data.tempPassword, + 'Saisissez-le tel quel, en respectant les majuscules.' + ), + callout( + 'warning', + 'Changez ce mot de passe', + 'Ce mot de passe est provisoire : remplacez-le dès votre première connexion, depuis les paramètres de votre compte.' + ), + button('Me connecter à Xpeditis', data.loginUrl), + ], + footerNote: + 'Vous recevez cet email car un administrateur vous a créé un compte sur la plateforme Xpeditis.', + }); } - /** - * Render CSV booking request email - */ + /** Invitation a rejoindre une organisation (lien d'inscription). */ + async renderInvitationWithToken(data: { + firstName: string; + lastName: string; + organizationName: string; + inviterName: string; + invitationLink: string; + expiresAt: string; + }): Promise<string> { + return this.render({ + title: `Invitation à rejoindre ${data.organizationName} sur Xpeditis`, + preview: `${data.inviterName} vous invite à rejoindre ${data.organizationName}. Invitation valable jusqu’au ${data.expiresAt}.`, + eyebrow: 'Invitation', + heading: `Rejoignez ${data.organizationName} sur Xpeditis`, + blocks: [ + paragraph(`Bonjour ${esc(data.firstName)},`), + paragraph( + `${esc(data.inviterName)} vous invite à rejoindre l’espace <strong>${esc(data.organizationName)}</strong> sur Xpeditis, la plateforme pour comparer les tarifs de fret maritime, réserver vos envois et suivre vos expéditions.` + ), + button('Créer mon compte', data.invitationLink), + callout( + 'warning', + 'Invitation à durée limitée', + `Cette invitation est valable jusqu’au <strong>${esc(data.expiresAt)}</strong>. Passé ce délai, demandez-en une nouvelle à ${esc(data.inviterName)}.` + ), + linkFallback(data.invitationLink), + ], + footerNote: + 'Vous n’attendiez pas cette invitation ? Ignorez simplement cet email : aucun compte ne sera créé.', + }); + } + + /* ---------------------------------------------------------------------- */ + /* Reservations */ + /* ---------------------------------------------------------------------- */ + + async renderBookingConfirmation(data: { + bookingNumber: string; + bookingDetails: any; + dashboardUrl?: string; + }): Promise<string> { + const booking = data.bookingDetails ?? {}; + const rows: DetailRow[] = [{ label: 'N° de réservation', value: esc(data.bookingNumber) }]; + if (booking.carrier) rows.push({ label: 'Transporteur', value: esc(booking.carrier) }); + if (booking.etd) rows.push({ label: 'Départ estimé', value: esc(formatDate(booking.etd)) }); + if (booking.eta) rows.push({ label: 'Arrivée estimée', value: esc(formatDate(booking.eta)) }); + + return this.render({ + title: `Réservation confirmée — ${data.bookingNumber}`, + preview: `Votre réservation ${data.bookingNumber} est confirmée. La confirmation PDF est jointe.`, + eyebrow: 'Réservation confirmée', + heading: 'Votre réservation est confirmée', + blocks: [ + paragraph( + 'Merci d’avoir réservé avec Xpeditis. Vous trouverez votre confirmation détaillée en pièce jointe, au format PDF.' + ), + ...(booking.origin && booking.destination + ? [route(String(booking.origin), String(booking.destination))] + : []), + details(rows), + button('Suivre ma réservation', data.dashboardUrl ?? `${this.appUrl}/dashboard/bookings`), + ], + footerNote: 'Vous recevez cet email suite à une réservation effectuée sur Xpeditis.', + }); + } + + /** Demande de reservation envoyee au transporteur. */ async renderCsvBookingRequest(data: { bookingId: string; bookingNumber?: string; @@ -281,413 +285,322 @@ export class EmailTemplates { acceptUrl: string; rejectUrl: string; }): Promise<string> { - // Register Handlebars helper for equality check - Handlebars.registerHelper('eq', function (a, b) { - return a === b; + const inEuros = data.primaryCurrency !== 'USD'; + const price = inEuros ? formatMoney(data.priceEUR, 'EUR') : formatMoney(data.priceUSD, 'USD'); + const secondaryAmount = inEuros ? data.priceUSD : data.priceEUR; + const secondary = secondaryAmount + ? inEuros + ? formatMoney(data.priceUSD, 'USD') + : formatMoney(data.priceEUR, 'EUR') + : ''; + + const rows: DetailRow[] = [ + ...(data.bookingNumber ? [{ label: 'N° de demande', value: esc(data.bookingNumber) }] : []), + { label: 'Volume', value: esc(`${formatNumber(data.volumeCBM)} CBM`) }, + { label: 'Poids', value: esc(`${formatNumber(data.weightKG)} kg`) }, + { label: 'Palettes', value: esc(formatNumber(data.palletCount, 0)) }, + { label: 'Type', value: esc(data.containerType) }, + { label: 'Temps de transit', value: esc(plural(data.transitDays, 'jour', 'jours')) }, + { + label: 'Prix proposé', + value: + esc(price) + + (secondary + ? `<br /><span style="font-weight:400;font-size:12px;color:${EMAIL_COLORS.muted};">≈ ${esc(secondary)}</span>` + : ''), + }, + ]; + + const documents = data.documents ?? []; + + return this.render({ + title: `Nouvelle demande de réservation${data.bookingNumber ? ` ${data.bookingNumber}` : ''} — ${data.origin} → ${data.destination}`, + preview: `${data.origin} → ${data.destination} · ${formatNumber(data.volumeCBM)} CBM · ${price}. Acceptez ou refusez en un clic.`, + eyebrow: 'Demande de réservation', + heading: 'Une nouvelle demande de réservation vous attend', + blocks: [ + paragraph('Bonjour,'), + paragraph( + 'Un client Xpeditis souhaite réserver un transport avec vous. Examinez les informations ci-dessous, puis acceptez ou refusez la demande.' + ), + route(data.origin, data.destination), + details(rows), + ...(data.documentPassword + ? [ + codeBox( + 'Mot de passe d’accès aux documents', + data.documentPassword, + 'Conservez-le : il vous sera demandé pour télécharger les documents.' + ), + ] + : []), + heading2('Documents fournis'), + documents.length + ? bulletList( + documents.map( + doc => `<strong>${esc(documentLabel(doc.type))}</strong> · ${esc(doc.fileName)}` + ) + ) + : paragraph('Aucun document n’a été joint à cette demande.', { muted: true }), + ...(data.notes ? [callout('info', 'Note du client', escMultiline(data.notes))] : []), + buttonPair( + { label: 'Accepter la demande', href: data.acceptUrl, variant: 'success' }, + { label: 'Refuser la demande', href: data.rejectUrl, variant: 'dangerOutline' } + ), + callout( + 'warning', + 'Réponse attendue sous 7 jours', + 'Sans réponse de votre part, la demande expire automatiquement au bout de 7 jours.' + ), + ], + footerNote: `Référence ${data.bookingId}. Vous recevez cet email car vous êtes le transporteur sollicité pour cette demande.`, }); - - const htmlTemplate = ` - <!DOCTYPE html> - <html lang="fr"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Nouvelle demande de réservation - - - -
- -
-

🚢 Nouvelle demande de réservation

-

Xpeditis

-
- - -
-

- Bonjour, -

-

- Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande. -

- - {{#if bookingNumber}} - -
-

Numéro de devis

-

{{bookingNumber}}

- {{#if documentPassword}} -
-

🔐 Mot de passe pour accéder aux documents

-

{{documentPassword}}

-

Conservez ce mot de passe, il vous sera demandé pour télécharger les documents

-
- {{/if}} -
- {{/if}} - - -
📋 Détails du transport
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Route{{origin}} → {{destination}}
Volume{{volumeCBM}} CBM
Poids{{weightKG}} kg
Palettes{{palletCount}}
Type de conteneur{{containerType}}
Transit{{transitDays}} jours
Prix - - {{#if (eq primaryCurrency "EUR")}} - {{priceEUR}} EUR - {{else}} - {{priceUSD}} USD - {{/if}} - -
- - {{#if (eq primaryCurrency "EUR")}} - (≈ {{priceUSD}} USD) - {{else}} - (≈ {{priceEUR}} EUR) - {{/if}} - -
- - -
-
📄 Documents fournis
-
    - {{#each documents}} -
  • {{this.type}}: {{this.fileName}}
  • - {{/each}} -
-
- - {{#if notes}} - -
-

📝 Notes du client

-

{{notes}}

-
- {{/if}} - - -
-

Veuillez confirmer votre décision :

- -
- - -
-

- ⚠️ Important
- Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. Merci de répondre dans les meilleurs délais. -

-
-
- - - -
- - - `; - - const template = Handlebars.compile(htmlTemplate); - return template(data); } - /** - * Render invitation email with registration link - */ - async renderInvitationWithToken(data: { + /** Documents disponibles apres acceptation par le transporteur. */ + async renderDocumentAccess(data: { + carrierName: string; + bookingId: string; + bookingNumber?: string; + documentPassword?: string; + origin: string; + destination: string; + volumeCBM: number; + weightKG: number; + documentCount: number; + documentsUrl: string; + }): Promise { + const reference = data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase(); + + return this.render({ + title: `Documents disponibles — réservation ${reference} (${data.origin} → ${data.destination})`, + preview: `${plural(data.documentCount, 'document disponible', 'documents disponibles')} pour la réservation ${reference}.`, + eyebrow: 'Réservation acceptée', + heading: 'Les documents de la réservation sont disponibles', + blocks: [ + paragraph(`Bonjour ${esc(data.carrierName)},`), + paragraph( + 'Merci d’avoir accepté cette demande. Les documents du client sont maintenant disponibles au téléchargement.' + ), + route(data.origin, data.destination), + details([ + { label: 'Réservation', value: esc(reference) }, + { label: 'Volume', value: esc(`${formatNumber(data.volumeCBM)} CBM`) }, + { label: 'Poids', value: esc(`${formatNumber(data.weightKG)} kg`) }, + { label: 'Documents', value: esc(plural(data.documentCount, 'document', 'documents')) }, + ]), + ...(data.documentPassword + ? [ + codeBox( + 'Mot de passe d’accès aux documents', + data.documentPassword, + 'Il vous sera demandé à chaque consultation des documents.' + ), + ] + : []), + button('Accéder aux documents', data.documentsUrl), + paragraph('Ce lien reste valable : vous pouvez revenir aux documents à tout moment.', { + muted: true, + small: true, + }), + ], + footerNote: `Référence ${reference}. Vous recevez cet email car vous avez accepté cette réservation sur Xpeditis.`, + }); + } + + /** Nouveaux documents ajoutes a une reservation deja acceptee. */ + async renderNewDocuments(data: { + carrierName: string; + bookingId: string; + origin: string; + destination: string; + newDocumentsCount: number; + totalDocumentsCount: number; + documentsUrl: string; + }): Promise { + const reference = data.bookingId.substring(0, 8).toUpperCase(); + const added = plural(data.newDocumentsCount, 'nouveau document', 'nouveaux documents'); + + return this.render({ + title: `${added} — réservation ${data.origin} → ${data.destination}`, + preview: `Le client a ajouté ${added} à la réservation ${reference}.`, + eyebrow: 'Mise à jour de réservation', + heading: + data.newDocumentsCount > 1 + ? 'De nouveaux documents ont été ajoutés' + : 'Un nouveau document a été ajouté', + blocks: [ + paragraph(`Bonjour ${esc(data.carrierName)},`), + paragraph('Le client a complété le dossier d’une réservation que vous avez acceptée.'), + route(data.origin, data.destination), + details([ + { label: 'Ajoutés', value: esc(`+ ${added}`) }, + { + label: 'Total du dossier', + value: esc(plural(data.totalDocumentsCount, 'document', 'documents')), + }, + ]), + button('Voir les documents', data.documentsUrl), + ], + footerNote: `Référence ${reference}. Vous recevez cet email car vous avez accepté cette réservation sur Xpeditis.`, + }); + } + + /* ---------------------------------------------------------------------- */ + /* Espace transporteur */ + /* ---------------------------------------------------------------------- */ + + async renderCarrierAccountCreated(data: { + carrierName: string; + email: string; + temporaryPassword: string; + loginUrl: string; + }): Promise { + return this.render({ + title: 'Votre compte transporteur Xpeditis est prêt', + preview: 'Vos identifiants de connexion et les prochaines étapes.', + eyebrow: 'Espace transporteur', + heading: 'Votre compte transporteur est prêt', + blocks: [ + paragraph(`Bonjour ${esc(data.carrierName)},`), + paragraph( + 'Un compte transporteur vient d’être créé pour vous sur Xpeditis. Il vous permet de consulter et de traiter les demandes de réservation qui vous sont adressées.' + ), + details([{ label: 'Identifiant', value: esc(data.email) }]), + codeBox('Mot de passe temporaire', data.temporaryPassword), + heading2('Prochaines étapes'), + steps([ + 'Connectez-vous avec vos identifiants.', + 'Remplacez votre mot de passe temporaire.', + 'Complétez votre profil transporteur.', + 'Consultez vos demandes de réservation.', + ]), + button('Me connecter', data.loginUrl), + ], + footerNote: + 'Vous recevez cet email car un compte transporteur a été créé à votre nom sur Xpeditis.', + }); + } + + async renderCarrierPasswordReset(data: { + carrierName: string; + temporaryPassword: string; + loginUrl: string; + }): Promise { + return this.render({ + title: 'Votre mot de passe transporteur Xpeditis a été réinitialisé', + preview: 'Votre nouveau mot de passe temporaire est à l’intérieur.', + eyebrow: 'Sécurité du compte', + heading: 'Votre mot de passe a été réinitialisé', + blocks: [ + paragraph(`Bonjour ${esc(data.carrierName)},`), + paragraph( + 'Suite à votre demande, nous avons réinitialisé le mot de passe de votre compte transporteur.' + ), + codeBox('Nouveau mot de passe temporaire', data.temporaryPassword), + callout( + 'warning', + 'Pour votre sécurité', + 'Changez ce mot de passe dès votre connexion et ne le communiquez à personne.' + ), + button('Me connecter et changer mon mot de passe', data.loginUrl), + ], + footerNote: + 'Vous n’êtes pas à l’origine de cette demande ? Écrivez immédiatement à support@xpeditis.com.', + }); + } + + /* ---------------------------------------------------------------------- */ + /* Emails internes */ + /* ---------------------------------------------------------------------- */ + + /** Message du formulaire de contact, adresse a l'equipe. */ + async renderContactMessage(data: { firstName: string; lastName: string; - organizationName: string; - inviterName: string; - invitationLink: string; - expiresAt: string; + email: string; + company?: string; + phone?: string; + subjectLabel: string; + message: string; }): Promise { - const mjmlTemplate = ` - - - - - - - - - - - 🚢 Bienvenue sur Xpeditis ! - - + const fullName = `${data.firstName} ${data.lastName}`.trim(); + const rows: DetailRow[] = [ + { label: 'Nom', value: esc(fullName) }, + { + label: 'Email', + value: `${esc(data.email)}`, + }, + ]; + if (data.company) rows.push({ label: 'Entreprise', value: esc(data.company) }); + if (data.phone) rows.push({ label: 'Téléphone', value: esc(data.phone) }); - - Bonjour {{firstName}} {{lastName}}, - + const excerpt = data.message.replace(/\s+/g, ' ').trim(); - - {{inviterName}} vous invite à rejoindre {{organizationName}} sur la plateforme Xpeditis. - + return this.render({ + title: `[Contact] ${data.subjectLabel} — ${fullName}`, + preview: excerpt.length > 85 ? `${excerpt.slice(0, 85)}…` : excerpt, + eyebrow: 'Formulaire de contact', + heading: data.subjectLabel, + blocks: [ + details(rows), + heading2('Message'), + quote(escMultiline(data.message)), + button(`Répondre à ${data.firstName}`, `mailto:${data.email}`), + ], + footerNote: + 'Message envoyé depuis le formulaire de contact du site Xpeditis. Répondre à cet email écrit directement à l’expéditeur.', + }); + } - - Xpeditis est la solution complète pour gérer vos expéditions maritimes en ligne. Recherchez des tarifs, réservez des containers et suivez vos envois en temps réel. - + /** Alerte aux administrateurs : un virement declare attend validation. */ + async renderBankTransferToValidate(data: { + bookingNumber: string; + carrierName: string; + routeDescription: string; + commissionAmount: string; + adminUrl: string; + }): Promise { + return this.render({ + title: `Virement à valider — ${data.bookingNumber}`, + preview: `Virement de ${data.commissionAmount} déclaré pour ${data.bookingNumber}. Vérifiez sa réception.`, + eyebrow: 'Action requise', + heading: 'Un virement est à valider', + blocks: [ + paragraph( + 'Un client a déclaré avoir effectué le virement des frais de réservation. Vérifiez sa réception, puis validez-le pour transmettre la demande au transporteur.' + ), + details([ + { label: 'N° de booking', value: esc(data.bookingNumber) }, + { label: 'Transporteur', value: esc(data.carrierName) }, + { label: 'Trajet', value: esc(data.routeDescription) }, + { label: 'Montant à recevoir', value: esc(data.commissionAmount) }, + ]), + button('Ouvrir les bookings à valider', data.adminUrl), + ], + footerNote: 'Vous recevez cet email car vous êtes administrateur de la plateforme Xpeditis.', + }); + } - - - - Créer mon compte - - - - - - Ou copiez ce lien dans votre navigateur: - - - {{invitationLink}} - - - - - - - - ⏱️ Cette invitation expire le {{expiresAt}} - - - Créez votre compte avant cette date pour rejoindre votre organisation. - - - - - - - - © 2025 Xpeditis. Tous droits réservés. - - - Si vous n'avez pas sollicité cette invitation, vous pouvez ignorer cet email. - - - - - - `; - - const { html } = mjml2html(mjmlTemplate); - const template = Handlebars.compile(html); - return template(data); + /** Email de diagnostic envoye depuis le panel admin. */ + async renderSmtpTest(data: { requestedBy: string; sentAt: string }): Promise { + return this.render({ + title: 'Test SMTP Xpeditis', + preview: 'La configuration d’envoi des emails fonctionne.', + eyebrow: 'Diagnostic', + heading: 'La configuration email fonctionne', + blocks: [ + callout( + 'success', + 'Envoi réussi', + 'Le serveur SMTP accepte les envois de la plateforme et le gabarit des emails s’affiche correctement.' + ), + details([ + { label: 'Demandé par', value: esc(data.requestedBy) }, + { label: 'Envoyé le', value: esc(data.sentAt) }, + ]), + ], + footerNote: 'Email de test déclenché depuis le panel d’administration Xpeditis.', + }); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.spec.ts new file mode 100644 index 0000000..bd77195 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.spec.ts @@ -0,0 +1,19 @@ +import { isProductionDeployment } from './deployment-environment'; + +describe('isProductionDeployment', () => { + it('traite la preprod comme non productive meme avec NODE_ENV=production', () => { + expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: 'preprod' })).toBe(false); + }); + + it('reconnait APP_ENV=production, quelle que soit la casse', () => { + expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: ' Production ' })).toBe(true); + expect(isProductionDeployment({ APP_ENV: 'prod' })).toBe(true); + }); + + it('retombe sur NODE_ENV quand APP_ENV est absent ou vide', () => { + expect(isProductionDeployment({ NODE_ENV: 'production' })).toBe(true); + expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: '' })).toBe(true); + expect(isProductionDeployment({ NODE_ENV: 'development' })).toBe(false); + expect(isProductionDeployment({})).toBe(false); + }); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.ts b/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.ts new file mode 100644 index 0000000..662e61c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.ts @@ -0,0 +1,24 @@ +/** + * Environnement de déploiement, tel que le voient les migrations. + * + * NODE_ENV ne suffit pas à le déterminer : c'est un réglage d'exécution Node + * (optimisations, cookies Secure…) et la preprod le positionne légitimement à + * "production". S'y fier seul a fait neutraliser les comptes de démonstration + * de la preprod, alors qu'ils devaient y rester utilisables. + * + * APP_ENV dit OÙ l'on déploie (development, preprod, production). Il prime dès + * qu'il est renseigné. En son absence, on retombe sur NODE_ENV : une production + * qui n'aurait pas encore défini APP_ENV reste ainsi protégée. + * + * Fichier volontairement hors du dossier migrations/ : TypeORM chargerait sinon + * tout module de ce dossier comme une migration. + */ +export function isProductionDeployment(env: NodeJS.ProcessEnv = process.env): boolean { + const appEnv = (env.APP_ENV ?? '').trim().toLowerCase(); + + if (appEnv) { + return appEnv === 'production' || appEnv === 'prod'; + } + + return env.NODE_ENV === 'production'; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts index 2c3fa7a..6b4e84a 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts @@ -21,9 +21,10 @@ export class AuditLogOrmEntity { }) status: string; - @Column('uuid') + // Null pour une action sans utilisateur identifie (connexion echouee) + @Column({ type: 'uuid', nullable: true }) @Index() - user_id: string; + user_id: string | null; @Column({ type: 'varchar', @@ -31,9 +32,9 @@ export class AuditLogOrmEntity { }) user_email: string; - @Column('uuid') + @Column({ type: 'uuid', nullable: true }) @Index() - organization_id: string; + organization_id: string | null; @Column({ type: 'varchar', diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts index fae4ef9..f491847 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts @@ -8,8 +8,11 @@ * --------------------------------- * Ce fichier contient un mot de passe en clair pour un compte ADMIN. Sur une * base de production, l'appliquer creerait un administrateur aux identifiants - * publics, connus de quiconque a lu le depot. La garde NODE_ENV ci-dessous - * l'en empeche. + * publics, connus de quiconque a lu le depot. La garde ci-dessous l'en empeche. + * + * La production est detectee par APP_ENV, a defaut NODE_ENV (voir + * deployment-environment.ts) : la preprod tourne avec NODE_ENV=production et + * doit declarer APP_ENV=preprod pour recevoir ses comptes de test. * * Le corps de la migration a ete modifie apres son ecriture initiale, ce qui * deroge a la regle "ne jamais modifier une migration appliquee". C'est sans @@ -25,12 +28,13 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed'; +import { isProductionDeployment } from '../deployment-environment'; export class SeedTestUsers1730000000007 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - if (process.env.NODE_ENV === 'production') { + if (isProductionDeployment()) { console.log( - 'SeedTestUsers ignore : NODE_ENV=production. ' + + 'SeedTestUsers ignore : environnement de production (APP_ENV/NODE_ENV). ' + 'Utilisez BOOTSTRAP_ADMIN_EMAIL pour creer le premier administrateur.' ); return; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts index 9e152af..0d1117e 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts @@ -11,24 +11,42 @@ * SeedTestUsers ne s'exécute désormais plus en production (garde ajoutée dans * cette même migration). Ce filet de sécurité couvre les cas restants : * - une base de production migrée avant l'ajout de la garde ; - * - un environnement où NODE_ENV n'était pas correctement positionné ; + * - un environnement mal identifié ; * - une restauration à partir d'une sauvegarde antérieure. * + * CE QUI EST NEUTRALISÉ — ET CE QUI NE L'EST PAS + * ---------------------------------------------- + * Le danger n'est pas l'adresse `admin@xpeditis.com`, c'est le mot de passe + * public. Seuls les comptes qui acceptent ENCORE `Password123!` sont touchés. + * Un compte dont le mot de passe a été changé est un vrai compte : il est + * conservé tel quel. Sans cette vérification, la migration verrouillait + * l'administrateur réellement utilisé, sans aucun moyen de s'y reconnecter. + * * Les lignes ne sont PAS supprimées : `audit_logs` et d'autres tables peuvent y * référer, et une suppression en cascade ferait plus de dégâts que de bien. - * Les comptes sont renommés (ce qui libère `admin@xpeditis.com` pour votre vrai - * compte), rendus impossibles à authentifier, et désactivés. + * Les comptes exposés sont renommés (ce qui libère `admin@xpeditis.com` pour + * votre vrai compte), rendus impossibles à authentifier, et désactivés. * * Idempotente : une seconde exécution ne trouve plus rien à faire. * - * En développement et en preprod, cette migration ne fait rien — les comptes de - * test restent utilisables. Pour l'y forcer malgré tout : + * ENVIRONNEMENT + * ------------- + * La production est détectée par APP_ENV (à défaut NODE_ENV), voir + * deployment-environment.ts. La preprod tourne avec NODE_ENV=production : elle + * doit donc déclarer APP_ENV=preprod pour conserver ses comptes de test. + * Pour forcer la neutralisation hors production : * FORCE_NEUTRALIZE_SEED_ACCOUNTS=true + * + * Le corps de cette migration a été corrigé après sa première écriture. Les + * bases où elle a déjà tourné ne la rejouent pas (TypeORM suit le nom de + * classe) ; seules les bases qui ne l'ont pas encore appliquée bénéficient de + * la correction. */ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as crypto from 'crypto'; import * as argon2 from 'argon2'; +import { isProductionDeployment } from '../deployment-environment'; /** Paramètres Argon2id du projet (cf. auth.service.ts). */ const ARGON2_OPTIONS = { @@ -40,6 +58,15 @@ const ARGON2_OPTIONS = { const SEED_ACCOUNTS = ['admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com']; +/** Mot de passe public de SeedTestUsers — celui qu'il faut rendre inutilisable. */ +const SEED_PASSWORD = 'Password123!'; + +interface SeedAccountRow { + id: string; + email: string; + password_hash: string | null; +} + /** * Produit un hash Argon2id valide d'un secret aléatoire immédiatement perdu. * @@ -51,29 +78,57 @@ async function unusablePasswordHash(): Promise { return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS); } +/** + * Le compte accepte-t-il encore le mot de passe public ? + * Un hash absent ou malformé ne permet aucune connexion : il n'expose rien. + */ +async function acceptsSeedPassword(passwordHash: string | null): Promise { + if (!passwordHash) { + return false; + } + try { + return await argon2.verify(passwordHash, SEED_PASSWORD); + } catch { + return false; + } +} + +async function findSeedAccounts(queryRunner: QueryRunner): Promise { + return queryRunner.query( + `SELECT "id", "email", "password_hash" FROM "users" WHERE "email" = ANY($1)`, + [SEED_ACCOUNTS] + ); +} + export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface { name = 'NeutralizeSeedAccountsInProduction1756000000000'; public async up(queryRunner: QueryRunner): Promise { - const isProduction = process.env.NODE_ENV === 'production'; const forced = process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS === 'true'; - if (!isProduction && !forced) { - console.log('[neutralisation] NODE_ENV != production : comptes de démonstration conservés.'); + if (!isProductionDeployment() && !forced) { + console.log( + '[neutralisation] Environnement non productif (APP_ENV/NODE_ENV) : ' + + 'comptes de démonstration conservés.' + ); return; } - const rows: Array<{ id: string; email: string }> = await queryRunner.query( - `SELECT "id", "email" FROM "users" WHERE "email" = ANY($1)`, - [SEED_ACCOUNTS] - ); + const rows = await findSeedAccounts(queryRunner); if (rows.length === 0) { console.log('[neutralisation] Aucun compte de démonstration présent.'); return; } + let neutralized = 0; + for (const row of rows) { + if (!(await acceptsSeedPassword(row.password_hash))) { + console.log(`[neutralisation] ${row.email} : mot de passe personnalisé, compte conservé.`); + continue; + } + // Le nouveau libellé respecte la contrainte chk_users_email // (LOWER(email) = email) : les UUID sont en minuscules. const disabledEmail = `seed-disabled-${String(row.id).slice(0, 8)}@invalid.local`; @@ -88,23 +143,30 @@ export class NeutralizeSeedAccountsInProduction1756000000000 implements Migratio [disabledEmail, await unusablePasswordHash(), row.id] ); + neutralized++; console.log(`[neutralisation] ${row.email} -> ${disabledEmail} (désactivé)`); } // Contrôle explicite : la migration échoue plutôt que de laisser croire // que le nettoyage a eu lieu. - const remaining: Array<{ n: number }> = await queryRunner.query( - `SELECT count(*)::int AS n FROM "users" WHERE "email" = ANY($1)`, - [SEED_ACCOUNTS] - ); + const remaining = await findSeedAccounts(queryRunner); + const exposed: string[] = []; + for (const row of remaining) { + if (await acceptsSeedPassword(row.password_hash)) { + exposed.push(row.email); + } + } - if (remaining[0].n > 0) { + if (exposed.length > 0) { throw new Error( - `Neutralisation incomplète : ${remaining[0].n} compte(s) de démonstration subsistent.` + `Neutralisation incomplète : ${exposed.join(', ')} accepte(nt) encore le mot de passe public.` ); } - console.log(`[neutralisation] ${rows.length} compte(s) neutralisé(s).`); + console.log( + `[neutralisation] ${neutralized} compte(s) neutralisé(s), ` + + `${rows.length - neutralized} conservé(s).` + ); } public async down(): Promise { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000001-AllowAnonymousAuditLogs.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000001-AllowAnonymousAuditLogs.ts new file mode 100644 index 0000000..62ba873 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000001-AllowAnonymousAuditLogs.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: autorise les entrees d'audit sans utilisateur identifie. + * + * Une tentative de connexion echouee n'a ni utilisateur ni organisation : le + * controleur passait la chaine "unknown", refusee par les colonnes uuid, et + * l'entree etait perdue. Or ce sont precisement ces echecs qu'un audit de + * securite doit conserver (force brute, compte verrouille). + * + * Les requetes filtrees par organisation (`organization_id = $1`) ne voient pas + * ces lignes : aucune organisation n'accede aux tentatives d'une autre. + */ +export class AllowAnonymousAuditLogs1790000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "audit_logs" ALTER COLUMN "user_id" DROP NOT NULL`); + await queryRunner.query( + `ALTER TABLE "audit_logs" ALTER COLUMN "organization_id" DROP NOT NULL` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Les contraintes NOT NULL ne peuvent pas etre retablies tant que des + // lignes anonymes existent : elles sont supprimees. + await queryRunner.query( + `DELETE FROM "audit_logs" WHERE "user_id" IS NULL OR "organization_id" IS NULL` + ); + await queryRunner.query(`ALTER TABLE "audit_logs" ALTER COLUMN "user_id" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "audit_logs" ALTER COLUMN "organization_id" SET NOT NULL`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000002-EnsureActiveAdminRemains.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000002-EnsureActiveAdminRemains.ts new file mode 100644 index 0000000..4b88341 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000002-EnsureActiveAdminRemains.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: la plateforme garde toujours au moins un administrateur actif. + * + * L'application refuse deja de supprimer, retrograder ou desactiver le dernier + * administrateur (AdminContinuityService). Ce controle ne couvre pas : + * - deux requetes simultanees : deux administrateurs qui se suppriment l'un + * l'autre au meme instant voient chacun l'autre encore actif, et la base se + * retrouve sans administrateur ; + * - toute ecriture hors application (script SQL, future fonctionnalite). + * + * Le declencheur ci-dessous impose la regle dans PostgreSQL. Il ne se + * declenche que lorsqu'une ligne PERD le statut d'administrateur actif, et + * prend alors un verrou consultatif de transaction : la seconde transaction + * concurrente attend la premiere, puis constate qu'il ne reste plus personne et + * echoue. La transaction entiere est annulee (effacement RGPD compris). + * + * Le SQLSTATE XP001 (LAST_ACTIVE_ADMIN_SQLSTATE, domain/services/ + * admin-continuity.ts) est traduit en 409 par UnhandledExceptionFilter. + * + * Promouvoir ou reactiver un administrateur n'est jamais bloque : une base deja + * sans administrateur actif reste reparable. + */ +export class EnsureActiveAdminRemains1790000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION ensure_active_admin_remains() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + -- Seule la perte du statut d'administrateur actif est concernee. + IF NOT (OLD.role = 'ADMIN' AND OLD.is_active) THEN + RETURN NULL; + END IF; + IF TG_OP = 'UPDATE' AND NEW.role = 'ADMIN' AND NEW.is_active THEN + RETURN NULL; + END IF; + + -- Serialise les retraits d'administrateurs concurrents : sans ce verrou, + -- deux transactions verraient chacune l'autre administrateur encore actif. + PERFORM pg_advisory_xact_lock(hashtext('xpeditis:last_active_admin')); + + IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'ADMIN' AND is_active) THEN + RAISE EXCEPTION 'Au moins un administrateur actif doit subsister' + USING ERRCODE = 'XP001', + HINT = 'Promouvez ou reactivez un autre administrateur avant cette operation.'; + END IF; + + RETURN NULL; + END; + $$ + `); + + // AFTER ... FOR EACH ROW : le controle voit l'effet complet de l'instruction + // (un UPDATE qui retrograderait tous les administrateurs d'un coup echoue). + await queryRunner.query(` + CREATE TRIGGER trg_users_keep_active_admin + AFTER UPDATE OF role, is_active OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION ensure_active_admin_remains() + `); + + const admins: Array<{ n: number }> = await queryRunner.query( + `SELECT count(*)::int AS n FROM users WHERE role = 'ADMIN' AND is_active` + ); + if (admins[0].n === 0) { + console.warn( + '[continuite admin] Aucun administrateur actif en base. Le declencheur est installe, ' + + 'mais il faut promouvoir un compte : UPDATE users SET role = ' + + "'ADMIN', is_active = true WHERE email = '';" + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_users_keep_active_admin ON users`); + await queryRunner.query(`DROP FUNCTION IF EXISTS ensure_active_admin_remains()`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/seed-accounts-neutralization.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/seed-accounts-neutralization.spec.ts new file mode 100644 index 0000000..b2084f6 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/seed-accounts-neutralization.spec.ts @@ -0,0 +1,116 @@ +/** + * Tests de la migration 1756000000000-NeutralizeSeedAccountsInProduction. + * + * Fichier placé hors de migrations/ : TypeORM y chargerait tout module comme + * une migration. + */ + +import * as argon2 from 'argon2'; +import { QueryRunner } from 'typeorm'; +import { NeutralizeSeedAccountsInProduction1756000000000 } from './migrations/1756000000000-NeutralizeSeedAccountsInProduction'; + +interface FakeUser { + id: string; + email: string; + password_hash: string; + is_active: boolean; +} + +// Coût réduit : seule la vérification du mot de passe compte ici. +const fastHash = (password: string) => + argon2.hash(password, { type: argon2.argon2id, memoryCost: 1024, timeCost: 1 }); + +function fakeQueryRunner(users: FakeUser[]): { runner: QueryRunner; query: jest.Mock } { + const query = jest.fn(async (sql: string, params: unknown[]) => { + if (sql.trimStart().startsWith('SELECT')) { + const emails = params[0] as string[]; + return users + .filter(u => emails.includes(u.email)) + .map(({ id, email, password_hash }) => ({ id, email, password_hash })); + } + if (sql.trimStart().startsWith('UPDATE')) { + const user = users.find(u => u.id === params[2]); + if (user) { + user.email = params[0] as string; + user.password_hash = params[1] as string; + user.is_active = false; + } + return []; + } + throw new Error(`Requete inattendue : ${sql}`); + }); + return { runner: { query } as unknown as QueryRunner, query }; +} + +describe('NeutralizeSeedAccountsInProduction1756000000000', () => { + const originalEnv = { ...process.env }; + const migration = new NeutralizeSeedAccountsInProduction1756000000000(); + + beforeEach(() => { + delete process.env.APP_ENV; + delete process.env.NODE_ENV; + delete process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS; + jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it('ne touche a rien en preprod, meme avec NODE_ENV=production', async () => { + process.env.NODE_ENV = 'production'; + process.env.APP_ENV = 'preprod'; + const { runner, query } = fakeQueryRunner([]); + + await migration.up(runner); + + expect(query).not.toHaveBeenCalled(); + }); + + it('en production, neutralise un compte au mot de passe public et conserve un compte personnalise', async () => { + process.env.NODE_ENV = 'production'; + const users: FakeUser[] = [ + { + id: 'c59ae389-da30-4533-be0c-fdfe6ac945de', + email: 'admin@xpeditis.com', + password_hash: await fastHash('Un-vrai-mot-de-passe-2026'), + is_active: true, + }, + { + id: '496ba881-c055-4b78-b0c0-6c048215253b', + email: 'manager@xpeditis.com', + password_hash: await fastHash('Password123!'), + is_active: true, + }, + ]; + const { runner } = fakeQueryRunner(users); + + await migration.up(runner); + + expect(users[0]).toMatchObject({ email: 'admin@xpeditis.com', is_active: true }); + expect(users[1]).toMatchObject({ + email: 'seed-disabled-496ba881@invalid.local', + is_active: false, + }); + await expect(argon2.verify(users[1].password_hash, 'Password123!')).resolves.toBe(false); + }, 30000); + + it('neutralise hors production quand FORCE_NEUTRALIZE_SEED_ACCOUNTS=true', async () => { + process.env.APP_ENV = 'preprod'; + process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS = 'true'; + const users: FakeUser[] = [ + { + id: '361b409d-a32b-4ff9-a61b-e927450c1daf', + email: 'user@xpeditis.com', + password_hash: await fastHash('Password123!'), + is_active: true, + }, + ]; + const { runner } = fakeQueryRunner(users); + + await migration.up(runner); + + expect(users[0].is_active).toBe(false); + }, 30000); +}); diff --git a/apps/frontend/app/[locale]/admin/bookings/page.tsx b/apps/frontend/app/[locale]/admin/bookings/page.tsx index 115b4a1..2222dbc 100644 --- a/apps/frontend/app/[locale]/admin/bookings/page.tsx +++ b/apps/frontend/app/[locale]/admin/bookings/page.tsx @@ -2,8 +2,14 @@ import { useState, useEffect } from 'react'; import { useTranslations, useLocale } from 'next-intl'; -import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin'; +import { + getAllBookings, + validateBankTransfer, + deleteAdminBooking, + resendCarrierEmail, +} from '@/lib/api/admin'; import { useConfirm } from '@/components/ui/use-confirm'; +import { useToast } from '@/components/ui/toast'; interface Booking { id: string; @@ -34,6 +40,7 @@ interface Booking { export default function AdminBookingsPage() { const confirm = useConfirm(); + const { toast } = useToast(); const t = useTranslations('dashboard.admin.bookings'); const locale = useLocale(); const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; @@ -71,8 +78,15 @@ export default function AdminBookingsPage() { if (!(await confirm({ title: t('confirmValidate') }))) return; setValidatingId(bookingId); try { - await validateBankTransfer(bookingId); + const result = (await validateBankTransfer(bookingId)) as { carrierEmailSent?: boolean }; await fetchBookings(); + // Le booking est active dans tous les cas ; l'echec de l'email au + // transporteur etait silencieux, il est maintenant signale. + if (result?.carrierEmailSent === false) { + toast.error(t('carrierEmailFailed')); + } else { + toast.success(t('validateSuccess')); + } } catch (err: any) { setError(err.message || t('validateError')); } finally { @@ -80,6 +94,15 @@ export default function AdminBookingsPage() { } }; + const handleResendCarrierEmail = async (bookingId: string) => { + try { + await resendCarrierEmail(bookingId); + toast.success(t('resendSuccess')); + } catch (err: any) { + toast.error(err?.message || t('resendError')); + } + }; + const fetchBookings = async () => { try { setLoading(true); @@ -446,6 +469,35 @@ export default function AdminBookingsPage() { {t('menu.validateTransfer')} + ) : booking?.status.toUpperCase() === 'PENDING' ? ( + // Booking actif : la demande a deja du partir chez le + // transporteur. Si l'email a echoue, l'admin peut le renvoyer. + ) : null; })()} - + {/* Pas de suppression de son propre compte depuis l'administration */} + {user.id !== currentUserId && ( + + )} ))} diff --git a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx index 1c20fbc..45155a8 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx @@ -6,6 +6,7 @@ import { useLocale, useTranslations } from 'next-intl'; import { ArrowLeft, ArrowRight, CreditCard, FileText, Package, Pencil } from 'lucide-react'; import { getCsvBooking } from '@/lib/api'; +import { useAuth } from '@/lib/context/auth-context'; import { Link } from '@/i18n/navigation'; import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; @@ -34,6 +35,7 @@ export default function BookingDetailPage() { const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; const params = useParams(); const bookingId = params.id as string; + const { user } = useAuth(); const { data: booking, @@ -85,6 +87,9 @@ export default function BookingDetailPage() { } const unpaid = booking.status === 'QUOTE'; + // Un collegue de l'entreprise peut consulter la reservation ; seul son auteur + // peut la payer (l'API le refuse aux autres). + const isOwner = booking.userId === (user?.id ?? user?.sub); const price = booking.priceEUR ? `${booking.priceEUR} €` : booking.priceUSD @@ -99,7 +104,7 @@ export default function BookingDetailPage() { actions={ <> - {unpaid && ( + {unpaid && isOwner && ( - {booking.status === 'QUOTE' && ( + {booking.status === 'QUOTE' && canManage && ( <>