import { Controller, Get, Post, Patch, Delete, Param, Body, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, NotFoundException, BadRequestException, ParseUUIDPipe, UseGuards, Inject, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiNotFoundResponse, ApiParam, ApiBearerAuth, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; // User imports import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserMapper } from '../mappers/user.mapper'; import { UpdateUserDto, UserResponseDto, UserListResponseDto } from '../dto/user.dto'; // Organization imports import { OrganizationRepository, ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { OrganizationMapper } from '../mappers/organization.mapper'; import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/organization.dto'; // CSV Booking imports import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { CsvBookingService } from '../services/csv-booking.service'; // SIRET verification imports import { SiretVerificationPort, SIRET_VERIFICATION_PORT, } from '@domain/ports/out/siret-verification.port'; // Email imports import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; /** * Admin Controller * * Dedicated controller for admin-only endpoints that provide access to ALL data * in the database without organization filtering. * * All endpoints require ADMIN role. */ @ApiTags('Admin') @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @ApiBearerAuth() export class AdminController { private readonly logger = new Logger(AdminController.name); constructor( @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, private readonly csvBookingRepository: TypeOrmCsvBookingRepository, private readonly csvBookingService: CsvBookingService, @Inject(SIRET_VERIFICATION_PORT) private readonly siretVerificationPort: SiretVerificationPort, @Inject(EMAIL_PORT) private readonly emailPort: EmailPort ) {} // ==================== USERS ENDPOINTS ==================== /** * Get ALL users from database (admin only) * * Returns all users regardless of status (active/inactive) or organization */ @Get('users') @ApiOperation({ summary: 'Get all users (Admin only)', description: 'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.', }) @ApiResponse({ status: HttpStatus.OK, description: 'All users retrieved successfully', type: UserListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) async getAllUsers(@CurrentUser() user: UserPayload): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching ALL users from database`); let users = await this.userRepository.findAll(); // Security: Non-admin users (MANAGER and below) cannot see ADMIN users if (user.role !== 'ADMIN') { users = users.filter(u => u.role !== 'ADMIN'); this.logger.log(`[SECURITY] Non-admin user ${user.email} - filtered out ADMIN users`); } const userDtos = UserMapper.toDtoArray(users); this.logger.log(`[ADMIN] Retrieved ${users.length} users from database`); return { users: userDtos, total: users.length, page: 1, pageSize: users.length, totalPages: 1, }; } /** * Get user by ID (admin only) */ @Get('users/:id') @ApiOperation({ summary: 'Get user by ID (Admin only)', description: 'Retrieve a specific user by ID', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'User retrieved successfully', type: UserResponseDto, }) @ApiNotFoundResponse({ description: 'User not found', }) async getUserById( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`); const foundUser = await this.userRepository.findById(id); if (!foundUser) { throw new NotFoundException(`User ${id} not found`); } return UserMapper.toDto(foundUser); } /** * Update user (admin only) */ @Patch('users/:id') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Update user (Admin only)', description: 'Update user details (any user, any organization)', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'User updated successfully', type: UserResponseDto, }) @ApiNotFoundResponse({ description: 'User not found', }) async updateUser( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`); const foundUser = await this.userRepository.findById(id); if (!foundUser) { throw new NotFoundException(`User ${id} not found`); } // Security: Prevent users from changing their own role if (dto.role && id === user.id) { this.logger.warn(`[SECURITY] User ${user.email} attempted to change their own role`); throw new BadRequestException('You cannot change your own role'); } // Apply updates if (dto.firstName) { foundUser.updateFirstName(dto.firstName); } if (dto.lastName) { foundUser.updateLastName(dto.lastName); } if (dto.role) { foundUser.updateRole(dto.role); } if (dto.isActive !== undefined) { if (dto.isActive) { foundUser.activate(); } else { foundUser.deactivate(); } } const updatedUser = await this.userRepository.update(foundUser); this.logger.log(`[ADMIN] User updated successfully: ${updatedUser.id}`); return UserMapper.toDto(updatedUser); } /** * Delete user (admin only) */ @Delete('users/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete user (Admin only)', description: 'Permanently delete a user from the database', }) @ApiParam({ name: 'id', description: 'User ID (UUID)', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'User deleted successfully', }) @ApiNotFoundResponse({ description: 'User not found', }) async deleteUser( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`); const foundUser = await this.userRepository.findById(id); if (!foundUser) { throw new NotFoundException(`User ${id} not found`); } await this.userRepository.deleteById(id); this.logger.log(`[ADMIN] User deleted successfully: ${id}`); } // ==================== ORGANIZATIONS ENDPOINTS ==================== /** * Get ALL organizations from database (admin only) * * Returns all organizations regardless of status (active/inactive) */ @Get('organizations') @ApiOperation({ summary: 'Get all organizations (Admin only)', description: 'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.', }) @ApiResponse({ status: HttpStatus.OK, description: 'All organizations retrieved successfully', type: OrganizationListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) async getAllOrganizations( @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`); const organizations = await this.organizationRepository.findAll(); const orgDtos = OrganizationMapper.toDtoArray(organizations); this.logger.log(`[ADMIN] Retrieved ${organizations.length} organizations from database`); return { organizations: orgDtos, total: organizations.length, page: 1, pageSize: organizations.length, totalPages: 1, }; } /** * Get organization by ID (admin only) */ @Get('organizations/:id') @ApiOperation({ summary: 'Get organization by ID (Admin only)', description: 'Retrieve a specific organization by ID', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'Organization retrieved successfully', type: OrganizationResponseDto, }) @ApiNotFoundResponse({ description: 'Organization not found', }) async getOrganizationById( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } return OrganizationMapper.toDto(organization); } /** * Verify SIRET number for an organization (admin only) * * Calls Pappers API to verify the SIRET, then marks the organization as verified. */ @Post('organizations/:id/verify-siret') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Verify organization SIRET (Admin only)', description: 'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'SIRET verification result', schema: { type: 'object', properties: { verified: { type: 'boolean' }, companyName: { type: 'string' }, address: { type: 'string' }, message: { type: 'string' }, }, }, }) @ApiNotFoundResponse({ description: 'Organization not found', }) async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } const siret = organization.siret; if (!siret) { throw new BadRequestException( 'Organization has no SIRET number. Please set a SIRET number before verification.' ); } const result = await this.siretVerificationPort.verify(siret); if (!result.valid) { this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`); return { verified: false, message: `Le numero SIRET ${siret} est invalide ou introuvable.`, }; } // Mark as verified and save organization.markSiretVerified(); await this.organizationRepository.update(organization); this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`); return { verified: true, companyName: result.companyName, address: result.address, message: `SIRET ${siret} verifie avec succes.`, }; } /** * Manually approve SIRET/SIREN for an organization (admin only) * * Marks the organization's SIRET as verified without calling the external API. */ @Post('organizations/:id/approve-siret') @ApiOperation({ summary: 'Approve SIRET/SIREN (Admin only)', description: 'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) @ApiResponse({ status: HttpStatus.OK, description: 'SIRET approved successfully', }) @ApiNotFoundResponse({ description: 'Organization not found' }) async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } if (!organization.siret && !organization.siren) { throw new BadRequestException( "L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation." ); } organization.markSiretVerified(); await this.organizationRepository.update(organization); this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`); return { approved: true, message: 'SIRET/SIREN approuve manuellement avec succes.', organizationId: id, organizationName: organization.name, }; } /** * Reject SIRET/SIREN for an organization (admin only) * * Resets the verification flag to false. */ @Post('organizations/:id/reject-siret') @ApiOperation({ summary: 'Reject SIRET/SIREN (Admin only)', description: 'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) @ApiResponse({ status: HttpStatus.OK, description: 'SIRET rejected successfully', }) @ApiNotFoundResponse({ description: 'Organization not found' }) async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } // Reset SIRET verification to false by updating the SIRET (which resets siretVerified) // If no SIRET, just update directly if (organization.siret) { organization.updateSiret(organization.siret); // This resets siretVerified to false } await this.organizationRepository.update(organization); this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`); return { rejected: true, message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.", organizationId: id, organizationName: organization.name, }; } // ==================== CSV BOOKINGS ENDPOINTS ==================== /** * Get ALL csv bookings from database (admin only) * * Returns all csv bookings from all organizations */ @Get('bookings') @ApiOperation({ summary: 'Get all CSV bookings (Admin only)', description: 'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.', }) @ApiResponse({ status: HttpStatus.OK, description: 'All CSV bookings retrieved successfully', }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) async getAllBookings(@CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Fetching ALL csv bookings from database`); const csvBookings = await this.csvBookingRepository.findAll(); const bookingDtos = csvBookings.map(booking => this.csvBookingToDto(booking)); this.logger.log(`[ADMIN] Retrieved ${csvBookings.length} csv bookings from database`); return { bookings: bookingDtos, total: csvBookings.length, page: 1, pageSize: csvBookings.length, totalPages: csvBookings.length > 0 ? 1 : 0, }; } /** * Get csv booking by ID (admin only) */ @Get('bookings/:id') @ApiOperation({ summary: 'Get CSV booking by ID (Admin only)', description: 'Retrieve a specific CSV booking by ID', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'CSV booking retrieved successfully', }) @ApiNotFoundResponse({ description: 'CSV booking not found', }) async getBookingById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`); const csvBooking = await this.csvBookingRepository.findById(id); if (!csvBooking) { throw new NotFoundException(`CSV booking ${id} not found`); } return this.csvBookingToDto(csvBooking); } /** * Update csv booking (admin only) */ @Patch('bookings/:id') @ApiOperation({ summary: 'Update CSV booking (Admin only)', description: 'Update CSV booking status or details', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'CSV booking updated successfully', }) @ApiNotFoundResponse({ description: 'CSV booking not found', }) async updateBooking( @Param('id', ParseUUIDPipe) id: string, @Body() updateDto: any, @CurrentUser() user: UserPayload ) { this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`); const csvBooking = await this.csvBookingRepository.findById(id); if (!csvBooking) { throw new NotFoundException(`CSV booking ${id} not found`); } // Apply updates to the domain entity // Note: This is a simplified version. You may want to add proper domain methods const updatedBooking = await this.csvBookingRepository.update(csvBooking); this.logger.log(`[ADMIN] CSV booking updated successfully: ${id}`); return this.csvBookingToDto(updatedBooking); } /** * Resend carrier email for a booking (admin only) * * Manually sends the booking request email to the carrier. * Useful when the automatic email failed (SMTP error) or for testing without Stripe. */ @Post('bookings/:id/resend-carrier-email') @ApiOperation({ summary: 'Resend carrier email (Admin only)', description: 'Manually resend the booking request email to the carrier. Works regardless of payment status.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ status: 200, description: 'Email sent to carrier' }) @ApiNotFoundResponse({ description: 'Booking not found' }) async resendCarrierEmail( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ) { this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`); await this.csvBookingService.resendCarrierEmail(id); return { success: true, message: 'Email sent to carrier' }; } /** * Validate bank transfer for a booking (admin only) * * Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier */ @Post('bookings/:id/validate-transfer') @ApiOperation({ summary: 'Validate bank transfer (Admin only)', description: 'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' }) @ApiNotFoundResponse({ description: 'Booking not found' }) async validateBankTransfer( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ) { this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`); return this.csvBookingService.validateBankTransfer(id); } /** * Delete csv booking (admin only) */ @Delete('bookings/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete CSV booking (Admin only)', description: 'Permanently delete a CSV booking from the database', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'CSV booking deleted successfully', }) @ApiNotFoundResponse({ description: 'CSV booking not found', }) async deleteBooking( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`); const csvBooking = await this.csvBookingRepository.findById(id); if (!csvBooking) { throw new NotFoundException(`CSV booking ${id} not found`); } await this.csvBookingRepository.delete(id); this.logger.log(`[ADMIN] CSV booking deleted successfully: ${id}`); } /** * Helper method to convert CSV booking domain entity to DTO */ private csvBookingToDto(booking: any) { const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; return { id: booking.id, bookingNumber: booking.bookingNumber || null, userId: booking.userId, organizationId: booking.organizationId, carrierName: booking.carrierName, carrierEmail: booking.carrierEmail, origin: booking.origin.getValue(), destination: booking.destination.getValue(), volumeCBM: booking.volumeCBM, weightKG: booking.weightKG, palletCount: booking.palletCount, priceUSD: booking.priceUSD, priceEUR: booking.priceEUR, primaryCurrency: booking.primaryCurrency, transitDays: booking.transitDays, containerType: booking.containerType, status: booking.status, documents: booking.documents || [], confirmationToken: booking.confirmationToken, requestedAt: booking.requestedAt, respondedAt: booking.respondedAt || null, notes: booking.notes, rejectionReason: booking.rejectionReason, routeDescription: booking.getRouteDescription(), isExpired: booking.isExpired(), price: booking.getPriceInCurrency(primaryCurrency), }; } // ==================== EMAIL TEST ENDPOINT ==================== /** * Send a test email to verify SMTP configuration (admin only) * * Returns the exact SMTP error in the response instead of only logging it. */ @Post('test-email') @ApiOperation({ summary: 'Send test email (Admin only)', description: 'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.', }) @ApiResponse({ status: 200, description: 'Email sent successfully' }) @ApiResponse({ status: 400, description: 'SMTP error — check the message field' }) async sendTestEmail( @Body() body: { to: string }, @CurrentUser() user: UserPayload ) { if (!body?.to) { throw new BadRequestException('Field "to" is required'); } this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`); try { await this.emailPort.send({ to: body.to, subject: '[Xpeditis] Test SMTP', html: `

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

Si vous lisez ceci, la configuration SMTP fonctionne correctement.

`, text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`, }); this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`); return { success: true, message: `Email envoyé avec succès à ${body.to}` }; } catch (error: any) { this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack); throw new BadRequestException( `Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` + `Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}` ); } } // ==================== DOCUMENTS ENDPOINTS ==================== /** * Get ALL documents from all organizations (admin only) * * Returns documents grouped by organization */ @Get('documents') @ApiOperation({ summary: 'Get all documents (Admin only)', description: 'Retrieve ALL documents from all organizations in the database.', }) @ApiResponse({ status: HttpStatus.OK, description: 'All documents retrieved successfully', }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) async getAllDocuments(@CurrentUser() user: UserPayload): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching ALL documents from database`); // Get all organizations const organizations = await this.organizationRepository.findAll(); // Extract documents from all organizations const allDocuments = organizations.flatMap(org => org.documents.map(doc => ({ ...doc, organizationId: org.id, organizationName: org.name, })) ); this.logger.log( `[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations` ); return { documents: allDocuments, total: allDocuments.length, organizationCount: organizations.length, }; } /** * Get documents for a specific organization (admin only) */ @Get('organizations/:id/documents') @ApiOperation({ summary: 'Get organization documents (Admin only)', description: 'Retrieve all documents for a specific organization', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'Organization documents retrieved successfully', }) @ApiNotFoundResponse({ description: 'Organization not found', }) async getOrganizationDocuments( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } return { organizationId: organization.id, organizationName: organization.name, documents: organization.documents, total: organization.documents.length, }; } }