import { Controller, Get, Post, Patch, Delete, Param, Body, Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, NotFoundException, BadRequestException, ParseUUIDPipe, UseGuards, UseInterceptors, UploadedFile, Inject, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { v4 as uuidv4 } from 'uuid'; import * as path from 'path'; import sharp from 'sharp'; import { ApiTags, ApiOperation, ApiResponse, ApiNotFoundResponse, ApiParam, ApiQuery, ApiConsumes, 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'; // Blog imports import { BlogService } from '../services/blog.service'; import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto'; import { BlogPost } from '@domain/entities/blog-post.entity'; import type { BlogPostCategory } from '@domain/entities/blog-post.entity'; // Storage imports import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; const ALLOWED_IMAGE_MIMETYPES = [ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', ]; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB /** * 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, private readonly blogService: BlogService, @Inject(STORAGE_PORT) private readonly storage: StoragePort ) {} // ==================== 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), commissionRate: booking.commissionRate, commissionAmountEur: booking.commissionAmountEur, freightTotal: booking.freightTotal, freightCurrency: booking.freightCurrency, fobTotal: booking.fobTotal, fobCurrency: booking.fobCurrency, }; } // ==================== 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, }; } /** * Delete a document from a CSV booking (admin only) * Bypasses ownership and status restrictions */ @Delete('bookings/:bookingId/documents/:documentId') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete document from CSV booking (Admin only)', description: 'Remove a document from a booking, bypassing ownership and status restrictions.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Document deleted successfully', }) async deleteDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('documentId', ParseUUIDPipe) documentId: string, @CurrentUser() user: UserPayload ): Promise<{ success: boolean; message: string }> { this.logger.log( `[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}` ); const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`); } const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document ${documentId} not found`); } const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId }, }); if (ormBooking) { ormBooking.documents = updatedDocuments.map(doc => ({ id: doc.id, type: doc.type, fileName: doc.fileName, filePath: doc.filePath, mimeType: doc.mimeType, size: doc.size, uploadedAt: doc.uploadedAt, })); await this.csvBookingRepository['repository'].save(ormBooking); } this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`); return { success: true, message: 'Document deleted successfully' }; } // ==================== BLOG ENDPOINTS ==================== @Post('blog/images') @UseInterceptors( FileInterceptor('image', { storage: memoryStorage(), limits: { fileSize: MAX_IMAGE_SIZE }, fileFilter: (_req, file, cb) => { if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) { cb(null, true); } else { cb( new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'), false ); } }, }) ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' }) @ApiResponse({ status: 201, schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } }, }) async uploadBlogImage( @UploadedFile() file: Express.Multer.File, @CurrentUser() user: UserPayload ): Promise<{ url: string; filename: string }> { if (!file) throw new BadRequestException('No image file provided'); this.logger.log(`[ADMIN: ${user.email}] Uploading blog image: ${file.originalname}`); const ext = path.extname(file.originalname).toLowerCase(); const sanitizedName = path .basename(file.originalname, ext) .replace(/[^a-z0-9]/gi, '-') .toLowerCase(); const filename = `${uuidv4()}-${sanitizedName}${ext}`; const key = `blog-images/${filename}`; await this.storage.upload({ bucket: BLOG_IMAGES_BUCKET, key, body: file.buffer, contentType: file.mimetype, }); this.logger.log(`[ADMIN] Blog image uploaded: ${key}`); return { url: `/api/v1/blog/images/${filename}`, filename }; } @Post('blog/cover-images') @UseInterceptors( FileInterceptor('image', { storage: memoryStorage(), limits: { fileSize: MAX_IMAGE_SIZE }, fileFilter: (_req, file, cb) => { // SVG cannot be raster-cropped; restrict to raster formats. if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) { cb(null, true); } else { cb(new BadRequestException('Only JPG, PNG, WebP or GIF images are allowed'), false); } }, }) ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload and auto-crop a blog cover image (Admin only)', description: 'Resizes and crops the image to the public 16:9 card ratio (1280x720) so covers are never distorted or cut off on the public site.', }) @ApiResponse({ status: 201, schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } }, }) async uploadBlogCoverImage( @UploadedFile() file: Express.Multer.File, @CurrentUser() user: UserPayload ): Promise<{ url: string; filename: string }> { if (!file) throw new BadRequestException('No image file provided'); this.logger.log(`[ADMIN: ${user.email}] Uploading blog cover image: ${file.originalname}`); // Crop to the public blog card ratio (16:9). "attention" focuses on the most // salient region so important content is preserved. let processed: Buffer; try { processed = await sharp(file.buffer) .resize(1280, 720, { fit: 'cover', position: sharp.strategy.attention }) .webp({ quality: 82 }) .toBuffer(); } catch (err: any) { this.logger.error(`Failed to process cover image: ${err?.message}`); throw new BadRequestException("Impossible de traiter l'image"); } const filename = `${uuidv4()}-cover.webp`; const key = `blog-images/${filename}`; await this.storage.upload({ bucket: BLOG_IMAGES_BUCKET, key, body: processed, contentType: 'image/webp', }); this.logger.log(`[ADMIN] Blog cover image uploaded: ${key}`); return { url: `/api/v1/blog/images/${filename}`, filename }; } @Get('blog') @ApiOperation({ summary: 'List all blog posts (Admin only)' }) @ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'category', required: false }) @ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'trashed', required: false, type: Boolean }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'offset', required: false, type: Number }) async listBlogPosts( @Query('status') status?: any, @Query('category') category?: BlogPostCategory, @Query('search') search?: string, @Query('trashed') trashed?: string, @Query('limit') limit = 50, @Query('offset') offset = 0, @CurrentUser() user?: UserPayload ) { this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`); const { posts, total } = await this.blogService.listAllPosts({ status, category, search, trashed: trashed === 'true', limit: Number(limit), offset: Number(offset), }); return { posts: posts.map(this.mapBlogPostToDto), total }; } @Post('blog') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Create a blog post (Admin only)' }) async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`); const post = await this.blogService.createPost(dto); return this.mapBlogPostToDto(post); } @Patch('blog/:id') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Update a blog post (Admin only)' }) async updateBlogPost( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto, @CurrentUser() user: UserPayload ) { this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`); const post = await this.blogService.updatePost(id, dto); return this.mapBlogPostToDto(post); } @Delete('blog/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a blog post to the trash (Admin only)' }) async deleteBlogPost( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Trashing blog post: ${id}`); await this.blogService.deletePost(id); } @Post('blog/:id/restore') @ApiOperation({ summary: 'Restore a blog post from the trash (Admin only)' }) async restoreBlogPost(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { this.logger.log(`[ADMIN: ${user.email}] Restoring blog post: ${id}`); const post = await this.blogService.restorePost(id); return this.mapBlogPostToDto(post); } @Delete('blog/:id/permanent') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Permanently delete a blog post (Admin only)' }) async permanentlyDeleteBlogPost( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[ADMIN: ${user.email}] Permanently deleting blog post: ${id}`); await this.blogService.permanentlyDeletePost(id); } @Post('blog/:id/duplicate') @ApiOperation({ summary: 'Duplicate a blog post as a new draft (Admin only)' }) async duplicateBlogPost( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ) { this.logger.log(`[ADMIN: ${user.email}] Duplicating blog post: ${id}`); const post = await this.blogService.duplicatePost(id); return this.mapBlogPostToDto(post); } private mapBlogPostToDto(post: BlogPost) { return { id: post.id, title: post.title, slug: post.slug, excerpt: post.excerpt, content: post.content, coverImageUrl: post.coverImageUrl, category: post.category, tags: post.tags, authorName: post.authorName, status: post.status, isFeatured: post.isFeatured, publishedAt: post.publishedAt, metaTitle: post.metaTitle, metaDescription: post.metaDescription, primaryKeyword: post.primaryKeyword, secondaryKeywords: post.secondaryKeywords, aiSummary: post.aiSummary, faq: post.faq, keyTakeaways: post.keyTakeaways, aiEntities: post.aiEntities, createdAt: post.createdAt, updatedAt: post.updatedAt, }; } }