Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m32s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m21s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m45s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 7m40s
CD Preprod / Build Log Exporter (push) Successful in 31s
CD Preprod / Build Frontend (push) Successful in 29m57s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s

This commit is contained in:
David 2026-09-14 21:12:25 +02:00
commit 10bc0cf898
59 changed files with 3195 additions and 1270 deletions

View File

@ -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: [

View File

@ -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<UserListResponseDto> {
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<void> {
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: `<p>Email de test envoyé depuis le panel admin par <strong>${user.email}</strong>.</p><p>Si vous lisez ceci, la configuration SMTP fonctionne correctement.</p>`,
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}` };

View File

@ -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;

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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 = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #10183A; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: #34CCCD; margin: 0; font-size: 20px;">Nouveau message de contact</h1>
</div>
<div style="background: #f9f9f9; padding: 24px; border: 1px solid #e0e0e0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${firstName} ${lastName}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${email}" style="color: #34CCCD;">${email}</a></td>
</tr>
${company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${company}</td></tr>` : ''}
${phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${phone}</td></tr>` : ''}
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
</tr>
</table>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${message}</p>
</div>
</div>
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
<p style="color: #999; font-size: 12px; margin: 0;">Xpeditis — Formulaire de contact</p>
</div>
</div>
`;
// 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}`);

View File

@ -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<CsvBookingResponseDto> {
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
);
}
/**

View File

@ -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);
}

View File

@ -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);
});

View File

@ -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<UserResponseDto> {
): Promise<UserResponseDto & { invitationEmailSent: boolean }> {
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<boolean> {
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<void> {
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') {

View File

@ -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',

View File

@ -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();

View File

@ -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);

View File

@ -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 {}

View File

@ -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();
});
});

View File

@ -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<void> {
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<void> {
const target = await this.userRepository.findById(userId);
if (target) {
await this.assertKeepsAnActiveAdmin(target, null);
}
}
}

View File

@ -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;

View File

@ -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);
});

View File

@ -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<typeof booking>[],
members: ReturnType<typeof member>[]
) {
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);
});
});

View File

@ -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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
// 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<CsvBookingResponseDto> {
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<CsvBookingListResponseDto> {
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,

View File

@ -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);
});
});

View File

@ -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<GDPRErasureReport> {
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;
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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<void>;
/**
* Alert admins that a declared bank transfer awaits validation
*/
sendBankTransferToValidate(to: string[], data: BankTransferToValidateEmail): Promise<void>;
/**
* Send the SMTP diagnostic email from the admin panel
*/
sendSmtpTest(to: string, requestedBy: string): Promise<void>;
/**
* Send an email
*/

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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');

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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<string, string> = {
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 () => '<p>invitation</p>'),
renderCsvBookingRequest: jest.fn(async () => '<p>demande</p>'),
renderUserInvitation: jest.fn(async () => '<p>compte</p>'),
renderPasswordResetEmail: jest.fn(async () => '<p>reset</p>'),
};
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" <noreply@xpeditis.com>');
});
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" <noreply@xpeditis.com>');
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: '<p>t</p>' });
const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from);
expect(froms).toEqual([
'"Équipe Xpeditis" <contact@mondomaine.fr>',
'"Xpeditis Sécurité" <contact@mondomaine.fr>',
'"Xpeditis" <contact@mondomaine.fr>',
]);
});
});

View File

@ -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é" <security@xpeditis.com>',
BOOKINGS: '"Xpeditis Bookings" <bookings@xpeditis.com>',
TEAM: '"Équipe Xpeditis" <team@xpeditis.com>',
CARRIERS: '"Xpeditis Transporteurs" <carriers@xpeditis.com>',
NOREPLY: '"Xpeditis" <noreply@xpeditis.com>',
/**
* 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 <head> 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(/<head[\s\S]*?<\/head>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<div[^>]*display:\s*none[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)')
.replace(/<li[^>]*>/gi, '\n• ')
.replace(/<\/(p|div|h[1-6]|tr|table|ul|ol)>/gi, '\n')
.replace(/<\/td>/gi, ' ')
.replace(/<a[^>]*href="(?!mailto:)([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
@ -40,6 +66,9 @@ function htmlToPlainText(html: string): string {
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#8594;/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<string>('SMTP_FROM', DEFAULT_FROM_ADDRESS);
return `"${SENDER_NAMES[kind]}" <${address}>`;
}
private get appUrl(): string {
return String(this.configService.get<string>('APP_URL', 'http://localhost:3000')).replace(
/\/+$/,
''
);
}
async send(options: EmailOptions): Promise<void> {
try {
const from =
options.from ?? this.configService.get<string>('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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const loginUrl = `${baseUrl}/carrier/login`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9f9f9; }
.credentials { background: white; padding: 20px; margin: 20px 0; border-left: 4px solid #0066cc; }
.button { display: inline-block; padding: 12px 30px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚢 Bienvenue sur Xpeditis</h1>
</div>
<div class="content">
<h2>Votre compte transporteur a été créé</h2>
<p>Bonjour <strong>${carrierName}</strong>,</p>
<p>Un compte transporteur a été automatiquement créé pour vous sur la plateforme Xpeditis.</p>
<div class="credentials">
<h3>Vos identifiants de connexion :</h3>
<p><strong>Email :</strong> ${email}</p>
<p><strong>Mot de passe temporaire :</strong> <code style="background: #f0f0f0; padding: 5px 10px; border-radius: 3px;">${temporaryPassword}</code></p>
</div>
<p><strong>⚠️ Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire dès votre première connexion.</p>
<div style="text-align: center;">
<a href="${loginUrl}" class="button">Se connecter maintenant</a>
</div>
<h3>Prochaines étapes :</h3>
<ol>
<li>Connectez-vous avec vos identifiants</li>
<li>Changez votre mot de passe</li>
<li>Complétez votre profil transporteur</li>
<li>Consultez vos demandes de réservation</li>
</ol>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>
`;
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<void> {
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const loginUrl = `${baseUrl}/carrier/login`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9f9f9; }
.credentials { background: white; padding: 20px; margin: 20px 0; border-left: 4px solid #ff9900; }
.button { display: inline-block; padding: 12px 30px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 5px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔑 Réinitialisation de mot de passe</h1>
</div>
<div class="content">
<h2>Votre mot de passe a été réinitialisé</h2>
<p>Bonjour <strong>${carrierName}</strong>,</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe Xpeditis.</p>
<div class="credentials">
<h3>Votre nouveau mot de passe temporaire :</h3>
<p><code style="background: #f0f0f0; padding: 10px 15px; border-radius: 3px; font-size: 16px; display: inline-block;">${temporaryPassword}</code></p>
</div>
<div class="warning">
<p><strong>⚠️ Sécurité :</strong></p>
<ul style="margin: 10px 0;">
<li>Ce mot de passe est temporaire et doit être changé immédiatement</li>
<li>Ne partagez jamais vos identifiants avec qui que ce soit</li>
<li>Si vous n'avez pas demandé cette réinitialisation, contactez-nous immédiatement</li>
</ul>
</div>
<div style="text-align: center;">
<a href="${loginUrl}" class="button">Se connecter et changer le mot de passe</a>
</div>
<p style="margin-top: 30px;">Si vous rencontrez des difficultés, n'hésitez pas à contacter notre équipe support.</p>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>
`;
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<void> {
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
? `
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
</div>
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;">⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
</div>
`
: '';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #0284c7; margin: 0 10px; }
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
.summary-row:last-child { border-bottom: none; }
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Documents disponibles</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
<div class="route">
${data.origin} <span class="route-arrow">→</span> ${data.destination}
</div>
<div class="summary">
<div class="summary-row">
<span style="color: #64748b;">Volume</span>
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
</div>
<div class="summary-row">
<span style="color: #64748b;">Poids</span>
<span style="font-weight: 500;">${data.weightKG} kg</span>
</div>
</div>
<div style="text-align: center;">
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
</div>
${passwordSection}
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
</div>
<div class="footer">
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
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<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #f59e0b; margin: 0 10px; }
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nouveaux documents ajoutes</h1>
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
<div class="route">
${data.origin} <span class="route-arrow">→</span> ${data.destination}
</div>
<div class="highlight">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
</p>
<p style="margin: 5px 0 0 0; color: #a16207;">
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
</p>
</div>
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
</div>
<div class="footer">
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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 });
}
}

View File

@ -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 <html> ET sur le contenu du <body> : plusieurs clients
* suppriment les attributs de <html> ;
* - un <title> 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 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&amp;display=swap" />
<mj-font name="Montserrat" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&amp;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">&#8594;</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 />');
}

View File

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -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';
}

View File

@ -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',

View File

@ -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<void> {
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;

View File

@ -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<string> {
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<boolean> {
if (!passwordHash) {
return false;
}
try {
return await argon2.verify(passwordHash, SEED_PASSWORD);
} catch {
return false;
}
}
async function findSeedAccounts(queryRunner: QueryRunner): Promise<SeedAccountRow[]> {
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<void> {
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<void> {

View File

@ -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<void> {
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<void> {
// 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`);
}
}

View File

@ -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<void> {
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 = '<adresse>';"
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
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()`);
}
}

View File

@ -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);
});

View File

@ -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')}
</span>
</button>
) : 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.
<button
onClick={() => {
const id = openMenuId;
setOpenMenuId(null);
setMenuPosition(null);
if (id) handleResendCarrierEmail(id);
}}
className="w-full px-4 py-3 text-left hover:bg-neutral-50 flex items-center space-x-3 border-b border-border"
>
<svg
className="w-5 h-5 text-brand-navy"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span className="text-sm font-medium text-brand-navy">
{t('resendCarrierEmail')}
</span>
</button>
) : null;
})()}
<button

View File

@ -9,6 +9,7 @@ import { getAllOrganizations } from '@/lib/api/admin';
import type { UserRole } from '@/types/api';
import { PageHeader } from '@/components/ui/PageHeader';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/lib/context/auth-context';
interface User {
id: string;
@ -31,6 +32,8 @@ export default function AdminUsersPage() {
const { toast } = useToast();
const t = useTranslations('dashboard.admin.users');
const tCommon = useTranslations('common');
const { user: currentUser } = useAuth();
const currentUserId = currentUser?.id ?? currentUser?.sub;
const [users, setUsers] = useState<User[]>([]);
const [showPassword, setShowPassword] = useState(false);
@ -84,10 +87,17 @@ export default function AdminUsersPage() {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createUser(formData);
const created = (await createUser(formData)) as { invitationEmailSent?: boolean };
await fetchData();
setShowCreateModal(false);
resetForm();
// Le compte est cree dans tous les cas ; l'administrateur doit savoir si
// la personne a bien recu ses acces.
if (created?.invitationEmailSent === false) {
toast.error(t('createEmailFailed', { email: formData.email }));
} else {
toast.success(t('createSuccess', { email: formData.email }));
}
} catch (err: any) {
toast.error(err.message || t('createError'));
}
@ -151,6 +161,9 @@ export default function AdminUsersPage() {
};
const openDeleteConfirm = (user: User) => {
// Un administrateur ne se supprime pas lui-meme depuis cette page (l'API le
// refuse aussi) : il passe par « Supprimer mon compte ».
if (user.id === currentUserId) return;
setSelectedUser(user);
setShowDeleteConfirm(true);
};
@ -264,12 +277,15 @@ export default function AdminUsersPage() {
>
{t('edit')}
</button>
{/* Pas de suppression de son propre compte depuis l'administration */}
{user.id !== currentUserId && (
<button
onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900"
>
{t('delete')}
</button>
)}
</td>
</tr>
))}

View File

@ -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={
<>
<BookingStatusBadge status={booking.status} />
{unpaid && (
{unpaid && isOwner && (
<Button asChild>
<Link href={`/dashboard/booking/${booking.id}/pay`}>
<CreditCard />
@ -113,7 +118,11 @@ export default function BookingDetailPage() {
{unpaid && (
<Callout variant="warning" className="mb-4">
{tList('detail.quoteNotice')}
{isOwner
? tList('detail.quoteNotice')
: tList('detail.ownerOnly', {
name: booking.createdByName ?? tList('createdBy.deleted'),
})}
</Callout>
)}

View File

@ -128,7 +128,10 @@ export default function UsersManagementPage() {
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.inviteError'));
// Le client API leve une ApiError dont le message est deja celui du
// serveur (licences epuisees, invitation deja active...). `err.response`
// n'existe pas ici : le vrai motif etait toujours masque.
setError(err?.message || t('messages.inviteError'));
setTimeout(() => setError(''), 5000);
},
});

View File

@ -528,7 +528,7 @@
},
"bookingsList": {
"title": "Bookings",
"description": "Manage and track your shipments",
"description": "Manage and track your company's shipments",
"new": "New Booking",
"limit": {
"title": "Reservation limit reached",
@ -544,6 +544,7 @@
"exportFilename": "bookings",
"export": {
"id": "ID",
"createdBy": "Created by",
"pallets": "Pallets",
"weight": "Weight (kg)",
"volume": "Volume (CBM)",
@ -595,7 +596,16 @@
"date": "Date",
"quoteNumber": "Quote No.",
"bookingNumber": "Booking No.",
"actions": "Actions"
"actions": "Actions",
"createdBy": "Created by"
},
"userFilter": {
"label": "Created by",
"all": "All users"
},
"createdBy": {
"you": "you",
"deleted": "Deleted user"
},
"actions": {
"view": "View",
@ -611,6 +621,8 @@
},
"detail": {
"title": "Booking",
"createdBy": "Created by",
"ownerOnly": "Only the author of this quote ({name}) can edit or pay it.",
"route": "Route",
"date": "Creation date",
"cargo": "Cargo",
@ -650,7 +662,7 @@
},
"quotesList": {
"title": "Quote history",
"description": "Your requests not yet committed with a carrier",
"description": "Your company's requests not yet committed with a carrier",
"new": "New Booking",
"limit": {
"title": "Reservation limit reached",
@ -666,6 +678,7 @@
"exportFilename": "quotes",
"export": {
"id": "ID",
"createdBy": "Created by",
"pallets": "Pallets",
"weight": "Weight (kg)",
"volume": "Volume (CBM)",
@ -717,7 +730,16 @@
"date": "Date",
"quoteNumber": "Quote No.",
"bookingNumber": "Booking No.",
"actions": "Actions"
"actions": "Actions",
"createdBy": "Created by"
},
"userFilter": {
"label": "Created by",
"all": "All users"
},
"createdBy": {
"you": "you",
"deleted": "Deleted user"
},
"actions": {
"view": "View",
@ -733,6 +755,8 @@
},
"detail": {
"title": "Quote",
"createdBy": "Created by",
"ownerOnly": "Only the author of this quote ({name}) can edit or pay it.",
"route": "Route",
"date": "Creation date",
"cargo": "Cargo",
@ -1219,6 +1243,11 @@
"loadError": "Unable to load bookings",
"deleteError": "Error while deleting",
"validateError": "Error while validating bank transfer",
"validateSuccess": "Transfer validated: the request was sent to the carrier",
"carrierEmailFailed": "Transfer validated, but the email to the carrier could not be sent. Use “Resend email to carrier”.",
"resendCarrierEmail": "Resend email to carrier",
"resendSuccess": "Email resent to the carrier",
"resendError": "The email to the carrier could not be resent",
"confirmDelete": "Permanently delete this booking?",
"confirmValidate": "Confirm receipt of bank transfer and activate this booking?",
"title": "Booking management",
@ -1501,6 +1530,8 @@
"loading": "Loading users...",
"loadError": "Failed to load data",
"createError": "Failed to create user",
"createSuccess": "Account created: an access email was sent to {email}",
"createEmailFailed": "Account created, but the access email to {email} could not be sent. Check the SMTP configuration (Admin settings → Test email).",
"updateError": "Failed to update user",
"deleteError": "Failed to delete user",
"title": "User Management",

View File

@ -528,7 +528,7 @@
},
"bookingsList": {
"title": "Réservations",
"description": "Gérez et suivez vos envois",
"description": "Gérez et suivez les envois de votre entreprise",
"new": "Nouvelle Réservation",
"limit": {
"title": "Limite de réservations atteinte",
@ -544,6 +544,7 @@
"exportFilename": "reservations",
"export": {
"id": "ID",
"createdBy": "Créé par",
"pallets": "Palettes",
"weight": "Poids (kg)",
"volume": "Volume (CBM)",
@ -595,7 +596,16 @@
"date": "Date",
"quoteNumber": "N° Devis",
"bookingNumber": "N° Booking",
"actions": "Actions"
"actions": "Actions",
"createdBy": "Créé par"
},
"userFilter": {
"label": "Créé par",
"all": "Tous les utilisateurs"
},
"createdBy": {
"you": "vous",
"deleted": "Utilisateur supprimé"
},
"actions": {
"view": "Voir",
@ -611,6 +621,8 @@
},
"detail": {
"title": "Réservation",
"createdBy": "Créé par",
"ownerOnly": "Seul l’auteur de ce devis ({name}) peut le modifier ou le payer.",
"route": "Route",
"date": "Date de création",
"cargo": "Marchandise",
@ -650,7 +662,7 @@
},
"quotesList": {
"title": "Historique des devis",
"description": "Vos demandes non engagées chez un transporteur",
"description": "Les demandes de votre entreprise non engagées chez un transporteur",
"new": "Nouvelle Réservation",
"limit": {
"title": "Limite de réservations atteinte",
@ -666,6 +678,7 @@
"exportFilename": "devis",
"export": {
"id": "ID",
"createdBy": "Créé par",
"pallets": "Palettes",
"weight": "Poids (kg)",
"volume": "Volume (CBM)",
@ -717,7 +730,16 @@
"date": "Date",
"quoteNumber": "N° Devis",
"bookingNumber": "N° Booking",
"actions": "Actions"
"actions": "Actions",
"createdBy": "Créé par"
},
"userFilter": {
"label": "Créé par",
"all": "Tous les utilisateurs"
},
"createdBy": {
"you": "vous",
"deleted": "Utilisateur supprimé"
},
"actions": {
"view": "Voir",
@ -733,6 +755,8 @@
},
"detail": {
"title": "Devis",
"createdBy": "Créé par",
"ownerOnly": "Seul l’auteur de ce devis ({name}) peut le modifier ou le payer.",
"route": "Route",
"date": "Date de création",
"cargo": "Marchandise",
@ -1219,6 +1243,11 @@
"loadError": "Impossible de charger les réservations",
"deleteError": "Erreur lors de la suppression",
"validateError": "Erreur lors de la validation du virement",
"validateSuccess": "Virement validé : la demande a été envoyée au transporteur",
"carrierEmailFailed": "Virement validé, mais l'email au transporteur n'a pas pu être envoyé. Utilisez « Renvoyer l'email au transporteur ».",
"resendCarrierEmail": "Renvoyer l'email au transporteur",
"resendSuccess": "Email renvoyé au transporteur",
"resendError": "L'email au transporteur n'a pas pu être renvoyé",
"confirmDelete": "Supprimer définitivement cette réservation ?",
"confirmValidate": "Confirmer la réception du virement et activer ce booking ?",
"title": "Gestion des réservations",
@ -1501,6 +1530,8 @@
"loading": "Chargement des utilisateurs...",
"loadError": "Impossible de charger les données",
"createError": "Échec de la création de l'utilisateur",
"createSuccess": "Compte créé : un email d'accès a été envoyé à {email}",
"createEmailFailed": "Compte créé, mais l'email d'accès à {email} n'a pas pu être envoyé. Vérifiez la configuration SMTP (Paramètres admin → Test email).",
"updateError": "Échec de la mise à jour de l'utilisateur",
"deleteError": "Échec de la suppression de l'utilisateur",
"title": "Gestion des utilisateurs",

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -26,6 +26,13 @@ interface BookingDetailDialogProps {
buildEditUrl: (booking: any) => string;
/** Namespace de la vue appelante, pour que le titre suive l'onglet. */
namespace?: string;
/**
* La liste montre les lignes de toute l'entreprise, mais seul l'auteur d'un
* devis peut le modifier ou le payer (regle appliquee aussi par l'API).
*/
canManage?: boolean;
/** Auteur de la reservation, deja formate par la liste. */
createdByLabel?: string;
}
export function BookingDetailDialog({
@ -33,6 +40,8 @@ export function BookingDetailDialog({
onOpenChange,
buildEditUrl,
namespace = 'dashboard.bookingsList',
canManage = true,
createdByLabel,
}: BookingDetailDialogProps) {
const t = useTranslations(namespace as any);
const locale = useLocale();
@ -81,6 +90,8 @@ export function BookingDetailDialog({
{longDate(booking.createdAt || booking.requestedAt)}
</Field>
{createdByLabel && <Field label={t('detail.createdBy')}>{createdByLabel}</Field>}
<Field label={t('detail.cargo')}>
{booking.palletCount
? t('units.palletsCount', { count: booking.palletCount })
@ -104,7 +115,11 @@ export function BookingDetailDialog({
</dl>
{booking.status === 'QUOTE' && (
<Callout variant="warning">{t('detail.quoteNotice')}</Callout>
<Callout variant="warning">
{canManage
? t('detail.quoteNotice')
: t('detail.ownerOnly', { name: createdByLabel ?? '' })}
</Callout>
)}
</div>
@ -113,7 +128,7 @@ export function BookingDetailDialog({
{t('detail.close')}
</Button>
{booking.status === 'QUOTE' && (
{booking.status === 'QUOTE' && canManage && (
<>
<Button asChild variant="outline">
<Link href={buildEditUrl(booking)} onClick={() => onOpenChange(false)}>

View File

@ -18,7 +18,8 @@ import {
type LucideIcon,
} from 'lucide-react';
import { deleteCsvBooking, listCsvBookings } from '@/lib/api';
import { deleteCsvBooking, listOrganizationCsvBookings } from '@/lib/api';
import { useAuth } from '@/lib/context/auth-context';
import { Link } from '@/i18n/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { Button } from '@/components/ui/button';
@ -79,6 +80,10 @@ export interface BookingListViewProps {
* Les deux onglets (« Reservations » et « Historique des devis ») partagent la
* meme table, le meme export et les memes filtres : seuls les statuts couverts
* et les actions disponibles les distinguent.
*
* La liste couvre toute l'entreprise, quel que soit l'auteur : chaque ligne
* indique qui l'a creee, et un filtre permet de n'afficher qu'une personne.
* Modifier, payer ou supprimer un devis reste reserve a son auteur.
*/
export function BookingListView({
namespace,
@ -104,8 +109,12 @@ export function BookingListView({
const searchTerm = url.get('q') ?? '';
const searchType = (url.get('by') as SearchType) ?? 'route';
const statusFilter = url.get('status') ?? '';
const userFilter = url.get('user') ?? '';
const page = url.getNumber('page', 1);
const { user } = useAuth();
const currentUserId = user?.id ?? user?.sub;
const [showTransferBanner, setShowTransferBanner] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
@ -121,8 +130,10 @@ export function BookingListView({
error: csvError,
refetch,
} = useQuery({
queryKey: ['csv-bookings'],
queryFn: () => listCsvBookings({ page: 1, limit: 1000 }),
// Sous-cle de ['csv-bookings'] : les invalidations existantes (paiement,
// suppression...) rafraichissent aussi cette liste.
queryKey: ['csv-bookings', 'organization'],
queryFn: () => listOrganizationCsvBookings({ page: 1, limit: 1000 }),
});
const quota = useReservationQuota();
@ -182,6 +193,34 @@ export function BookingListView({
[t, statuses.join(',')]
);
// Auteur d'une ligne : son nom, marque « vous » pour ses propres lignes, et
// un libelle neutre pour un compte supprime (anonymise).
const creatorLabel = (b: any): string => {
const name = b.createdByName || t('createdBy.deleted');
return b.userId && b.userId === currentUserId ? `${name} (${t('createdBy.you')})` : name;
};
// Le filtre ne propose que les personnes ayant au moins une ligne dans cet
// onglet : un collegue sans devis n'y donnerait qu'une liste vide. L'utilisateur
// connecte vient en premier.
const userOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const b of (csvData?.bookings || []) as any[]) {
if (statuses.includes(b.status) && b.userId && !byId.has(b.userId)) {
byId.set(b.userId, creatorLabel(b));
}
}
// Array.from plutot que [...map] : la cible TypeScript du front n'itere pas
// les Map sans downlevelIteration.
const people = Array.from(byId, ([value, label]) => ({ value, label })).sort((a, b) => {
if (a.value === currentUserId) return -1;
if (b.value === currentUserId) return 1;
return a.label.localeCompare(b.label, locale);
});
return [{ value: 'all', label: t('userFilter.all') }, ...people];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [csvData, currentUserId, locale, t, statuses.join(',')]);
const searchTypeOptions: { value: SearchType; label: string }[] = [
{ value: 'route', label: t('searchType.route') },
{ value: 'pallets', label: t('searchType.pallets') },
@ -204,6 +243,10 @@ export function BookingListView({
filtered = filtered.filter((b: any) => b.status === statusFilter);
}
if (userFilter) {
filtered = filtered.filter((b: any) => b.userId === userFilter);
}
const term = searchTerm.trim().toLowerCase();
if (term) {
filtered = filtered.filter((booking: any) => {
@ -239,7 +282,7 @@ export function BookingListView({
return filtered;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [csvData, statusFilter, searchTerm, searchType, dateLocale, statuses.join(',')]);
}, [csvData, statusFilter, userFilter, searchTerm, searchType, dateLocale, statuses.join(',')]);
const totalBookings = filteredBookings.length;
const pageCount = Math.max(1, Math.ceil(totalBookings / ITEMS_PER_PAGE));
@ -312,6 +355,15 @@ export function BookingListView({
);
},
},
{
id: 'createdBy',
header: t('columns.createdBy'),
enableSorting: false,
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.createdBy') },
cell: ({ row }) => (
<span className="block truncate text-neutral-700">{creatorLabel(row.original)}</span>
),
},
{
id: 'status',
header: t('columns.status'),
@ -351,7 +403,9 @@ export function BookingListView({
{t('actions.view')}
</DropdownMenuItem>
{quoteActions && b.status === 'QUOTE' && (
{/* Actions reservees a l'auteur du devis : l'API refuse de toute
facon qu'un collegue modifie, paie ou supprime son devis. */}
{quoteActions && b.status === 'QUOTE' && b.userId === currentUserId && (
<>
<DropdownMenuItem asChild>
<Link href={buildEditUrl(b)}>
@ -384,7 +438,7 @@ export function BookingListView({
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, dateLocale, quoteActions]
[t, dateLocale, quoteActions, currentUserId]
);
const activeFilters: ActiveFilter[] = [];
@ -396,6 +450,14 @@ export function BookingListView({
onRemove: () => url.set('status', undefined),
});
}
if (userFilter) {
activeFilters.push({
key: 'user',
label: t('userFilter.label'),
value: userOptions.find(o => o.value === userFilter)?.label ?? t('createdBy.deleted'),
onRemove: () => url.set('user', undefined),
});
}
if (searchTerm) {
activeFilters.push({
key: 'q',
@ -440,6 +502,11 @@ export function BookingListView({
{ key: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') },
{
key: 'createdByName',
label: t('export.createdBy'),
format: v => v || t('createdBy.deleted'),
},
{ key: 'status', label: t('export.status'), format: v => statusLabel(v) },
{
key: 'createdAt',
@ -521,6 +588,22 @@ export function BookingListView({
))}
</SelectContent>
</Select>
<Select
value={userFilter || 'all'}
onValueChange={value => url.set('user', value === 'all' ? undefined : value)}
>
<SelectTrigger className="w-full md:w-56" aria-label={t('userFilter.label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{userOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
}
>
@ -549,7 +632,10 @@ export function BookingListView({
empty={{
icon: emptyIcon,
title: t('empty.title'),
description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'),
description:
searchTerm || statusFilter || userFilter
? t('empty.hasFilters')
: t('empty.noBookings'),
action: newReservationAction,
}}
caption={t('title')}
@ -579,6 +665,8 @@ export function BookingListView({
onOpenChange={open => !open && setSelectedBooking(null)}
buildEditUrl={buildEditUrl}
namespace={namespace}
canManage={!!selectedBooking && selectedBooking.userId === currentUserId}
createdByLabel={selectedBooking ? creatorLabel(selectedBooking) : undefined}
/>
</>
);

View File

@ -277,25 +277,26 @@ function PortField({
{label}
</p>
<motion.p
// Le code port est la donnee, pas le nom de ville : chiffres tabulaires
// et interlettrage large, comme sur un connaissement.
className="font-heading text-lg font-extrabold tracking-[0.06em] text-brand-navy"
// Le nom du port d'abord, en gras : c'est ce que le lecteur reconnait.
// `truncate` : un nom long (« Alexandrie ») ne doit pas pousser la ligne.
className="truncate font-heading text-lg font-extrabold text-brand-navy"
initial={{ opacity: 0 }}
animate={{ opacity: filled ? 1 : 0.15 }}
transition={{ duration: 0.4, ease: EASE }}
>
{filled ? code : '·····'}
{filled ? city : '·····'}
</motion.p>
<motion.p
className="truncate text-[11px] text-neutral-500"
// Le code UN/LOCODE en dessous, en graisse normale.
className="truncate text-[11px] font-normal tracking-[0.06em] text-neutral-500"
initial={{ opacity: 0 }}
animate={{ opacity: filled ? 1 : 0 }}
// Le decalage ne vaut qu'a la saisie, ou la ville arrive apres son code.
// A l'effacement il desynchronisait les deux lignes : le code repassait
// en pointilles alors que la ville restait pleinement lisible.
// Le decalage ne vaut qu'a la saisie, ou le code arrive apres le nom.
// A l'effacement il desynchronisait les deux lignes : le nom repassait
// en pointilles alors que le code restait pleinement lisible.
transition={{ duration: 0.4, ease: EASE, delay: filled ? 0.1 : 0 }}
>
{city}
{code}
</motion.p>
</div>
);

View File

@ -141,8 +141,23 @@ export async function getAdminBooking(id: string): Promise<BookingResponse> {
* Confirms receipt of wire transfer and activates the booking
* Requires: ADMIN role
*/
export async function validateBankTransfer(bookingId: string): Promise<BookingResponse> {
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
export async function validateBankTransfer(
bookingId: string
): Promise<BookingResponse & { carrierEmailSent?: boolean }> {
return post<BookingResponse & { carrierEmailSent?: boolean }>(
`/api/v1/admin/bookings/${bookingId}/validate-transfer`,
{}
);
}
/**
* Resend the booking request email to the carrier (admin only)
* POST /api/v1/admin/bookings/:id/resend-carrier-email
* Useful when the email failed at transfer validation.
* Requires: ADMIN role
*/
export async function resendCarrierEmail(bookingId: string): Promise<void> {
return post<void>(`/api/v1/admin/bookings/${bookingId}/resend-carrier-email`, {});
}
/**

View File

@ -39,6 +39,14 @@ export interface UpdateBookingStatusRequest {
export interface CsvBookingResponse {
id: string;
bookingId: string;
/** Auteur de la reservation. */
userId: string;
organizationId?: string;
/**
* Nom de l'auteur (liste de l'entreprise et detail). `null` si son compte a
* ete supprime.
*/
createdByName?: string | null;
/** Référence affichée, ex. « XPD-2026-R9KE8U ». */
bookingNumber?: string;
carrierName: string;
@ -269,6 +277,24 @@ export async function listCsvBookings(params?: {
return get<CsvBookingListResponse>(`/api/v1/csv-bookings${queryString ? `?${queryString}` : ''}`);
}
/**
* Reservations et devis de toute l'entreprise, avec le nom de leur auteur.
* GET /api/v1/csv-bookings/organization/all?page=1&limit=20
*/
export async function listOrganizationCsvBookings(params?: {
page?: number;
limit?: number;
}): Promise<CsvBookingListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const queryString = queryParams.toString();
return get<CsvBookingListResponse>(
`/api/v1/csv-bookings/organization/all${queryString ? `?${queryString}` : ''}`
);
}
/**
* Get CSV booking statistics for current user
* GET /api/v1/csv-bookings/stats

View File

@ -52,6 +52,7 @@ export {
createCsvBooking,
getCsvBooking,
listCsvBookings,
listOrganizationCsvBookings,
getCsvBookingStats,
cancelCsvBooking,
deleteCsvBooking,

View File

@ -147,6 +147,10 @@ services:
- "traefik.http.middlewares.xpeditis-api-redirect.redirectscheme.permanent=true"
environment:
NODE_ENV: production
# Environnement de deploiement lu par les migrations. NODE_ENV=production
# ne suffit pas a distinguer preprod et prod : sans APP_ENV=preprod, les
# comptes de test (admin@xpeditis.com...) seraient neutralises ici.
APP_ENV: preprod
PORT: "4000"
API_PREFIX: api/v1
LOG_FORMAT: json

View File

@ -64,7 +64,7 @@ applicatif reste entièrement reconstructible sans toucher aux données.
| `scripts/02-setup-k3s-server.sh` | Installe k3s durci (secrets chiffrés au repos, audit API, Traefik configuré). |
| `scripts/03-install-cluster-addons.sh` | cert-manager, namespaces, accès registre, ClusterIssuer. |
| `scripts/secrets-apply.sh` | Déchiffre SOPS → applique sur le cluster, sans passer par le disque. |
| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration (`admin@xpeditis.com` / `Password123!`) sur une base migrée avant l'ajout de la garde `NODE_ENV`. Le cas nominal est traité par les migrations. |
| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration qui acceptent encore `Password123!` (hash d'origine intact) sur une base migrée avant l'ajout de la garde `APP_ENV`. Un compte dont le mot de passe a été changé est conservé. Le cas nominal est traité par les migrations. |
| `scripts/deploy.sh` | Déploiement complet : migrations → images → attente → tests → retour arrière. |
| `scripts/ssh-deploy-wrapper.sh` | Restreint la clé SSH de la CI à quatre commandes. Une clé volée ne donne pas un shell. |
| `scripts/deploy-monitoring.sh` | Pile d'observabilité. |

View File

@ -15,6 +15,9 @@ metadata:
app.kubernetes.io/part-of: xpeditis
data:
NODE_ENV: "production"
# Lu par les migrations (deployment-environment.ts) : bloque les comptes de
# test et neutralise ceux qui accepteraient encore leur mot de passe public.
APP_ENV: "production"
PORT: "4000"
API_PREFIX: "api/v1"
# Force la sortie pino en JSON : c'est ce que Promtail sait decouper en

View File

@ -8,15 +8,15 @@
# Le traitement est desormais fait par les migrations, donc automatiquement et
# sans risque d'oubli :
#
# 1730000000007-SeedTestUsers ne s'execute plus si
# NODE_ENV=production
# 1730000000007-SeedTestUsers ne s'execute plus en
# production (APP_ENV)
# 1756000000000-NeutralizeSeedAccountsInProduction filet de securite
# 1756000000001-BootstrapAdminFromEnv cree VOTRE administrateur
#
# Ce script reste utile dans trois situations :
#
# - une base de production migree AVANT l'ajout de la garde NODE_ENV ;
# - un deploiement ou NODE_ENV n'etait pas correctement positionne (le journal
# - une base de production migree AVANT l'ajout de la garde APP_ENV ;
# - un deploiement ou APP_ENV n'etait pas correctement positionne (le journal
# du Job de migration affiche alors "Seeded test users successfully") ;
# - une verification manuelle : il affiche l'etat des comptes sans rien
# changer s'il n'y a rien a changer.
@ -36,6 +36,17 @@
# Sur une base de production, cela donne un acces complet a la plateforme a
# quiconque a lu le depot.
#
# SEULS les comptes dont le hash est encore celui de SeedTestUsers sont traites :
# un compte dont le mot de passe a ete change est un vrai compte, et le
# neutraliser verrouillerait l'administrateur reellement utilise. (Un compte
# remis a Password123! via "mot de passe oublie" aurait un autre sel : le test
# de connexion de preflight-check.sh le detecte.)
#
# Si le compte de demonstration est le SEUL administrateur actif, PostgreSQL
# refuse la neutralisation (declencheur trg_users_keep_active_admin, SQLSTATE
# XP001) et la transaction est annulee : creez ou promouvez d'abord votre
# administrateur, puis relancez ce script.
#
# Ce script ne SUPPRIME pas les lignes (des cles etrangeres peuvent y pointer,
# et une suppression en cascade dans audit_logs serait pire). Il :
# 1. renomme les adresses vers un domaine invalide -- ce qui libere au passage
@ -58,16 +69,19 @@ psql_run() {
exec -T -u postgres postgres psql -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" "$@"
}
# Hash Argon2id de "Password123!" pose par la migration SeedTestUsers.
SEED_HASH='$argon2id$v=19$m=65536,t=3,p=4$Uj+yeQiaqgBFqyTJ5FX3Cw$wpRCYORyFwjQFSuO3gpmzh10gx9wjYFOCvVZ8TVaP8Q'
echo ">>> Etat avant intervention"
psql_run -c "
SELECT email, role, is_active
FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
"
psql_run -v seed_hash="$SEED_HASH" <<'SQL'
SELECT email, role, is_active, password_hash = :'seed_hash' AS mot_de_passe_public
FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
SQL
echo
echo ">>> Neutralisation"
psql_run <<'SQL'
psql_run -v seed_hash="$SEED_HASH" <<'SQL'
BEGIN;
-- Mot de passe remplace par une valeur aleatoire : le format reste un hash
@ -81,7 +95,8 @@ SET
|| '$' || md5(random()::text) || md5(clock_timestamp()::text),
is_active = false,
updated_at = NOW()
WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com');
WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com')
AND password_hash = :'seed_hash';
COMMIT;
SQL
@ -95,16 +110,18 @@ psql_run -c "
"
echo
echo ">>> Controle : aucun compte de demonstration ne doit subsister"
RESTE=$(psql_run -tAc "
SELECT count(*) FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
")
echo ">>> Controle : aucun compte ne doit garder le mot de passe public"
RESTE=$(psql_run -tA -v seed_hash="$SEED_HASH" <<'SQL'
SELECT count(*) FROM users
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com')
AND password_hash = :'seed_hash';
SQL
)
if [[ "$RESTE" != "0" ]]; then
echo "ECHEC : ${RESTE} compte(s) de demonstration encore actifs." >&2
echo "ECHEC : ${RESTE} compte(s) de demonstration acceptent encore Password123!." >&2
exit 1
fi
echo "OK : aucun compte de demonstration actif."
echo "OK : aucun compte de demonstration n'accepte le mot de passe public."
echo
echo ">>> Organisations de demonstration presentes (a examiner, non modifiees)"

View File

@ -128,11 +128,11 @@ section "4. Comptes de demonstration"
# La migration 1730000000007-SeedTestUsers creait admin@xpeditis.com avec le mot
# de passe "Password123!", ecrit en clair dans le depot. Elle ne s'execute plus
# quand NODE_ENV=production, et 1756000000000 neutralise ces comptes s'ils
# existent malgre tout.
# en production (APP_ENV, a defaut NODE_ENV), et 1756000000000 neutralise ces
# comptes s'ils acceptent encore ce mot de passe.
#
# Ce controle verifie le RESULTAT en conditions reelles, pas l'intention : c'est
# le seul moyen de detecter un NODE_ENV mal positionne ou une base restauree
# le seul moyen de detecter un APP_ENV mal positionne ou une base restauree
# depuis une sauvegarde anterieure. Le controle le plus important de la liste.
for compte in admin manager user; do
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \