fix resolve
This commit is contained in:
parent
76d940d73b
commit
856531da5d
@ -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: [
|
||||
|
||||
@ -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}` };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -43,18 +43,6 @@ import {
|
||||
|
||||
const REFRESH_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Escape user-provided text before interpolating it into HTML emails
|
||||
*/
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
*
|
||||
@ -262,12 +250,13 @@ export class AuthController {
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Audit log: record failed login attempts (the attempted email is the
|
||||
// only identity we have — the credentials did not match a valid user)
|
||||
// only identity we have — the credentials did not match a valid user,
|
||||
// so user and organization are recorded as null)
|
||||
await this.auditService.logFailure(
|
||||
AuditAction.USER_LOGIN,
|
||||
'unknown',
|
||||
null,
|
||||
dto.email,
|
||||
'unknown',
|
||||
null,
|
||||
error?.message || 'Invalid credentials',
|
||||
{
|
||||
resourceType: 'user',
|
||||
@ -404,53 +393,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}`);
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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') {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
20
apps/backend/src/domain/services/admin-continuity.spec.ts
Normal file
20
apps/backend/src/domain/services/admin-continuity.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
apps/backend/src/domain/services/admin-continuity.ts
Normal file
43
apps/backend/src/domain/services/admin-continuity.ts
Normal 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;
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
97
apps/backend/src/infrastructure/email/email.adapter.spec.ts
Normal file
97
apps/backend/src/infrastructure/email/email.adapter.spec.ts
Normal 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>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@ -40,6 +66,9 @@ function htmlToPlainText(html: string): string {
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/→/g, '→')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@ -147,10 +176,22 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Expéditeur : nom affiché selon le type d'email, adresse validée SMTP_FROM. */
|
||||
private sender(kind: SenderKind): string {
|
||||
const address = this.configService.get<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 });
|
||||
}
|
||||
}
|
||||
|
||||
317
apps/backend/src/infrastructure/email/templates/email-layout.ts
Normal file
317
apps/backend/src/infrastructure/email/templates/email-layout.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Texte multiligne saisi par un tiers : echappe, retours a la ligne conserves. */
|
||||
export function escMultiline(value: unknown): string {
|
||||
return esc(value).replace(/\r?\n/g, '<br />');
|
||||
}
|
||||
|
||||
let validationLevel: 'soft' | 'strict' = 'soft';
|
||||
|
||||
/**
|
||||
* En test, `strict` fait echouer le rendu a la moindre erreur MJML. En
|
||||
* production, un attribut mal forme ne doit pas empecher un email de partir.
|
||||
*/
|
||||
export function setEmailValidationLevel(level: 'soft' | 'strict'): void {
|
||||
validationLevel = level;
|
||||
}
|
||||
|
||||
export interface EmailLayout {
|
||||
/** <title> : repris par les lecteurs d'ecran, aligne sur le sujet. */
|
||||
title: string;
|
||||
/** Preheader affiche apres le sujet dans la boite de reception (< 90 car.). */
|
||||
preview: string;
|
||||
/** Surtitre court : situe l'email en un coup d'oeil. */
|
||||
eyebrow?: string;
|
||||
heading: string;
|
||||
/** Blocs produits par les fonctions ci-dessous. */
|
||||
blocks: string[];
|
||||
/** Pourquoi la personne recoit cet email. */
|
||||
footerNote?: string;
|
||||
logoUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
const section = (content: string, padding = '0') =>
|
||||
`<mj-section padding="${padding}"><mj-column>${content}</mj-column></mj-section>`;
|
||||
|
||||
const label = (text: string, align: 'left' | 'center' | 'right' = 'left', padding = '0 0 6px 0') =>
|
||||
`<mj-text align="${align}" font-size="11px" line-height="16px" font-weight="700" letter-spacing="1.2px" text-transform="uppercase" color="${C.muted}" padding="${padding}">${esc(text)}</mj-text>`;
|
||||
|
||||
/** Compile le gabarit complet en HTML pret a l'envoi. */
|
||||
export function renderEmail(layout: EmailLayout): string {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const mjml = `
|
||||
<mjml lang="fr" dir="ltr">
|
||||
<mj-head>
|
||||
<mj-title>${esc(layout.title)}</mj-title>
|
||||
<mj-preview>${esc(layout.preview)}</mj-preview>
|
||||
<mj-raw>
|
||||
<meta name="color-scheme" content="light only" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
</mj-raw>
|
||||
<mj-font name="Manrope" href="https://fonts.googleapis.com/css2?family=Manrope:wght@700;800&display=swap" />
|
||||
<mj-font name="Montserrat" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" />
|
||||
<mj-attributes>
|
||||
<mj-all font-family="${BODY_FONT}" />
|
||||
<mj-section padding="0" />
|
||||
<mj-column padding="0" />
|
||||
<mj-text color="${C.text}" font-size="15px" line-height="24px" padding="0 0 16px 0" />
|
||||
<mj-button background-color="${C.navy}" color="#FFFFFF" font-size="15px" font-weight="600" line-height="20px" border-radius="8px" inner-padding="14px 28px" padding="8px 0 20px 0" align="left" />
|
||||
<mj-divider border-color="${C.border}" border-width="1px" padding="8px 0 24px 0" />
|
||||
<mj-table color="${C.text}" font-size="14px" line-height="20px" padding="0" />
|
||||
</mj-attributes>
|
||||
<mj-style>
|
||||
a { color: ${C.teal}; }
|
||||
@media only screen and (max-width: 480px) {
|
||||
.xp-card > table > tbody > tr > td { padding: 28px 20px 20px 20px !important; }
|
||||
.xp-button-full table { width: 100% !important; }
|
||||
/* Boutons empiles : les retraits prevus pour la version cote a cote les decalaient. */
|
||||
.xp-pair > table > tbody > tr > td { padding: 0 0 10px 0 !important; }
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="${C.page}" width="600px">
|
||||
<mj-section padding="32px 16px 20px 16px">
|
||||
<mj-column>
|
||||
<mj-image src="${esc(layout.logoUrl)}" alt="Xpeditis" width="64px" align="center" padding="0" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-wrapper css-class="xp-card" background-color="${C.card}" border-top="4px solid ${C.turquoise}" border-radius="12px" padding="36px 40px 20px 40px">
|
||||
${layout.eyebrow ? section(`<mj-text font-size="12px" line-height="16px" font-weight="700" letter-spacing="1.4px" text-transform="uppercase" color="${C.teal}" padding="0 0 10px 0">${esc(layout.eyebrow)}</mj-text>`) : ''}
|
||||
${section(`<mj-text padding="0 0 16px 0"><h1 style="margin:0;font-family:${HEADING_FONT};font-size:26px;line-height:34px;font-weight:800;color:${C.navy};">${esc(layout.heading)}</h1></mj-text>`)}
|
||||
${layout.blocks.join('\n ')}
|
||||
</mj-wrapper>
|
||||
|
||||
<mj-section padding="24px 24px 40px 24px">
|
||||
<mj-column>
|
||||
${layout.footerNote ? `<mj-text align="center" font-size="12px" line-height="18px" color="${C.muted}" padding="0 0 12px 0">${esc(layout.footerNote)}</mj-text>` : ''}
|
||||
<mj-text align="center" font-size="12px" line-height="20px" color="${C.muted}" padding="0">
|
||||
<strong style="color:${C.navy};">Xpeditis</strong> · Réservation de fret maritime en ligne<br />
|
||||
<a href="${esc(layout.appUrl)}" style="color:${C.muted};text-decoration:underline;">Accéder à la plateforme</a>
|
||||
·
|
||||
<a href="mailto:support@xpeditis.com" style="color:${C.muted};text-decoration:underline;">support@xpeditis.com</a><br />
|
||||
© ${year} Xpeditis. Tous droits réservés.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const { html } = mjml2html(mjml, { validationLevel });
|
||||
|
||||
// Doublon volontaire de lang/dir sur le contenu du <body> (voir en-tete).
|
||||
return html
|
||||
.replace(/<body([^>]*)>/, '<body$1><div lang="fr" dir="ltr">')
|
||||
.replace(/<\/body>/, '</div></body>');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Blocs */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/** Paragraphe. `safeHtml` doit deja etre echappe (utiliser `esc`). */
|
||||
export function paragraph(
|
||||
safeHtml: string,
|
||||
options: { muted?: boolean; small?: boolean; align?: 'left' | 'center' } = {}
|
||||
): string {
|
||||
const attributes = [
|
||||
options.muted ? `color="${C.muted}"` : '',
|
||||
options.small ? 'font-size="13px" line-height="20px"' : '',
|
||||
options.align ? `align="${options.align}"` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return section(`<mj-text ${attributes}>${safeHtml}</mj-text>`);
|
||||
}
|
||||
|
||||
/** Intertitre (h2). */
|
||||
export function heading2(title: string): string {
|
||||
return section(
|
||||
`<mj-text padding="8px 0 10px 0"><h2 style="margin:0;font-family:${HEADING_FONT};font-size:16px;line-height:24px;font-weight:700;color:${C.navy};">${esc(title)}</h2></mj-text>`
|
||||
);
|
||||
}
|
||||
|
||||
export type ButtonVariant = 'primary' | 'success' | 'dangerOutline';
|
||||
|
||||
export interface ButtonSpec {
|
||||
label: string;
|
||||
href: string;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
const BUTTON_STYLES: Record<ButtonVariant, string> = {
|
||||
primary: `background-color="${C.navy}" color="#FFFFFF"`,
|
||||
success: `background-color="${C.green}" color="#FFFFFF"`,
|
||||
dangerOutline: `background-color="#FFFFFF" color="${C.red}" border="2px solid ${C.red}"`,
|
||||
};
|
||||
|
||||
/** Bouton d'action principal. Libelle explicite : jamais « cliquez ici ». */
|
||||
export function button(label: string, href: string, variant: ButtonVariant = 'primary'): string {
|
||||
return section(
|
||||
`<mj-button href="${esc(href)}" ${BUTTON_STYLES[variant]} css-class="xp-button-full">${esc(label)}</mj-button>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Deux boutons cote a cote, empiles sur mobile. */
|
||||
export function buttonPair(first: ButtonSpec, second: ButtonSpec): string {
|
||||
const cell = (spec: ButtonSpec, padding: string) =>
|
||||
`<mj-column width="50%" padding="${padding}" css-class="xp-pair"><mj-button href="${esc(spec.href)}" ${BUTTON_STYLES[spec.variant ?? 'primary']} width="100%" padding="0" align="center">${esc(spec.label)}</mj-button></mj-column>`;
|
||||
return `<mj-section padding="8px 0 20px 0">${cell(first, '0 6px 10px 0')}${cell(second, '0 0 10px 6px')}</mj-section>`;
|
||||
}
|
||||
|
||||
export interface DetailRow {
|
||||
label: string;
|
||||
/** Valeur deja echappee (peut contenir un lien ou un <br />). */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Tableau libelle / valeur dans un panneau gris clair. */
|
||||
export function details(rows: DetailRow[]): string {
|
||||
const body = rows
|
||||
.map((row, index) => {
|
||||
const border = index < rows.length - 1 ? `border-bottom:1px solid ${C.border};` : '';
|
||||
return (
|
||||
`<tr>` +
|
||||
`<td style="padding:11px 0;${border}color:${C.muted};font-size:13px;line-height:20px;vertical-align:top;width:44%;">${esc(row.label)}</td>` +
|
||||
`<td style="padding:11px 0 11px 12px;${border}color:${C.navy};font-size:14px;line-height:20px;font-weight:600;text-align:right;vertical-align:top;">${row.value}</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<mj-section padding="4px 0 20px 0"><mj-column background-color="${C.panel}" border="1px solid ${C.border}" border-radius="10px" padding="4px 20px"><mj-table>${body}</mj-table></mj-column></mj-section>`;
|
||||
}
|
||||
|
||||
/** Trajet « depart → arrivee », lisible d'un coup d'oeil, conserve sur mobile. */
|
||||
export function route(origin: string, destination: string): string {
|
||||
const port = (value: string, align: 'left' | 'right') =>
|
||||
`<mj-text align="${align}" font-family="${HEADING_FONT}" font-size="24px" line-height="30px" font-weight="800" color="${C.navy}" padding="0">${esc(value)}</mj-text>`;
|
||||
return (
|
||||
`<mj-section padding="4px 0 16px 0"><mj-group>` +
|
||||
`<mj-column width="42%" vertical-align="middle">${label('Départ')}${port(origin, 'left')}</mj-column>` +
|
||||
`<mj-column width="16%" vertical-align="middle"><mj-text align="center" font-size="22px" line-height="30px" color="${C.turquoise}" padding="20px 0 0 0"><span aria-hidden="true">→</span></mj-text></mj-column>` +
|
||||
`<mj-column width="42%" vertical-align="middle">${label('Arrivée', 'right')}${port(destination, 'right')}</mj-column>` +
|
||||
`</mj-group></mj-section>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Code a recopier (mot de passe temporaire, acces aux documents). */
|
||||
export function codeBox(title: string, code: string, hint?: string): string {
|
||||
return (
|
||||
`<mj-section padding="4px 0 20px 0"><mj-column background-color="${C.panel}" border="1px dashed ${C.borderStrong}" border-radius="10px" padding="18px 20px">` +
|
||||
label(title, 'center') +
|
||||
`<mj-text align="center" font-family="${MONO_FONT}" font-size="24px" line-height="32px" font-weight="700" letter-spacing="3px" color="${C.navy}" padding="0">${esc(code)}</mj-text>` +
|
||||
(hint
|
||||
? `<mj-text align="center" font-size="12px" line-height="18px" color="${C.muted}" padding="8px 0 0 0">${esc(hint)}</mj-text>`
|
||||
: '') +
|
||||
`</mj-column></mj-section>`
|
||||
);
|
||||
}
|
||||
|
||||
export type CalloutTone = 'info' | 'warning' | 'success';
|
||||
|
||||
const CALLOUT_TONES: Record<CalloutTone, { background: string; border: string; title: string }> = {
|
||||
info: { background: C.infoBg, border: C.turquoise, title: C.infoText },
|
||||
warning: { background: C.warningBg, border: C.warningBorder, title: C.warningText },
|
||||
success: { background: C.successBg, border: C.green, title: C.green },
|
||||
};
|
||||
|
||||
/** Encadre d'information. `safeHtml` doit deja etre echappe. */
|
||||
export function callout(tone: CalloutTone, title: string | undefined, safeHtml: string): string {
|
||||
const colors = CALLOUT_TONES[tone];
|
||||
return (
|
||||
`<mj-section padding="4px 0 20px 0"><mj-column background-color="${colors.background}" border-left="4px solid ${colors.border}" border-radius="8px" padding="14px 18px">` +
|
||||
(title
|
||||
? `<mj-text font-size="14px" line-height="20px" font-weight="700" color="${colors.title}" padding="0 0 4px 0">${esc(title)}</mj-text>`
|
||||
: '') +
|
||||
`<mj-text font-size="14px" line-height="22px" color="${C.text}" padding="0">${safeHtml}</mj-text>` +
|
||||
`</mj-column></mj-section>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Texte cite (message d'un client) dans un panneau neutre. */
|
||||
export function quote(safeHtml: string): string {
|
||||
return `<mj-section padding="0 0 20px 0"><mj-column background-color="${C.panel}" border="1px solid ${C.border}" border-radius="10px" padding="16px 20px"><mj-text font-size="14px" line-height="22px" color="${C.text}" padding="0">${safeHtml}</mj-text></mj-column></mj-section>`;
|
||||
}
|
||||
|
||||
/** Liste a puces. Elements deja echappes. */
|
||||
export function bulletList(safeItems: string[]): string {
|
||||
const items = safeItems.map(item => `<li style="margin:0 0 8px 0;">${item}</li>`).join('');
|
||||
return section(`<mj-text><ul style="margin:0;padding:0 0 0 20px;">${items}</ul></mj-text>`);
|
||||
}
|
||||
|
||||
/** Etapes numerotees. Elements deja echappes. */
|
||||
export function steps(safeItems: string[]): string {
|
||||
const items = safeItems.map(item => `<li style="margin:0 0 10px 0;">${item}</li>`).join('');
|
||||
return section(`<mj-text><ol style="margin:0;padding:0 0 0 22px;">${items}</ol></mj-text>`);
|
||||
}
|
||||
|
||||
/** Lien de secours sous un bouton : certains clients bloquent les boutons. */
|
||||
export function linkFallback(url: string): string {
|
||||
return paragraph(
|
||||
`Le bouton ne s’affiche pas ? Copiez ce lien dans votre navigateur :<br /><a href="${esc(url)}" style="color:${C.teal};word-break:break-all;">${esc(url)}</a>`,
|
||||
{ muted: true, small: true }
|
||||
);
|
||||
}
|
||||
|
||||
export function divider(): string {
|
||||
return section('<mj-divider />');
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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()`);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
apps/frontend/public/assets/email/xpeditis-logo.png
Normal file
BIN
apps/frontend/public/assets/email/xpeditis-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@ -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)}>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -52,6 +52,7 @@ export {
|
||||
createCsvBooking,
|
||||
getCsvBooking,
|
||||
listCsvBookings,
|
||||
listOrganizationCsvBookings,
|
||||
getCsvBookingStats,
|
||||
cancelCsvBooking,
|
||||
deleteCsvBooking,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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é. |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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 \
|
||||
|
||||
Loading…
Reference in New Issue
Block a user