import { Controller, Get, Post, Patch, Param, Body, Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, NotFoundException, BadRequestException, ParseUUIDPipe, ParseIntPipe, DefaultValuePipe, UseGuards, ForbiddenException, Inject, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBadRequestResponse, ApiNotFoundResponse, ApiQuery, ApiParam, ApiBearerAuth, } from '@nestjs/swagger'; import { CreateOrganizationDto, UpdateOrganizationDto, OrganizationResponseDto, OrganizationListResponseDto, } from '../dto/organization.dto'; import { OrganizationMapper } from '../mappers/organization.mapper'; import { OrganizationRepository, ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization, OrganizationType } from '@domain/entities/organization.entity'; import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; 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'; import { NotificationService } from '../services/notification.service'; import { v4 as uuidv4 } from 'uuid'; /** * Organizations Controller * * Manages organization CRUD operations: * - Create organization (admin only) * - Get organization details * - Update organization (admin/manager) * - List organizations */ @ApiTags('Organizations') @Controller('organizations') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() export class OrganizationsController { private readonly logger = new Logger(OrganizationsController.name); constructor( @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, private readonly notificationService: NotificationService ) {} /** * Create a new organization * * Admin-only endpoint to create a new organization. */ @Post() @HttpCode(HttpStatus.CREATED) @Roles('admin') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Create new organization', description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.', }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Organization created successfully', type: OrganizationResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin role', }) @ApiBadRequestResponse({ description: 'Invalid request parameters', }) async createOrganization( @Body() dto: CreateOrganizationDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`); try { // Check for duplicate name const existingByName = await this.organizationRepository.findByName(dto.name); if (existingByName) { throw new ForbiddenException(`Organization with name "${dto.name}" already exists`); } // Check for duplicate SCAC if provided if (dto.scac) { const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac); if (existingBySCAC) { throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`); } } // Create organization entity const organization = Organization.create({ id: uuidv4(), name: dto.name, type: dto.type, scac: dto.scac, siren: dto.siren, siret: dto.siret, eori: dto.eori, contact_phone: dto.contact_phone, contact_email: dto.contact_email, address: OrganizationMapper.mapDtoToAddress(dto.address), logoUrl: dto.logoUrl, documents: [], isActive: true, }); // Save to database const savedOrg = await this.organizationRepository.save(organization); this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`); return OrganizationMapper.toDto(savedOrg); } catch (error: any) { this.logger.error( `Organization creation failed: ${error?.message || 'Unknown error'}`, error?.stack ); throw error; } } /** * Get organization by ID * * Retrieve details of a specific organization. * Users can only view their own organization unless they are admins. */ @Get(':id') @ApiOperation({ summary: 'Get organization by ID', description: 'Retrieve organization details. Users can view their own organization, admins can view any.', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)', example: '550e8400-e29b-41d4-a716-446655440000', }) @ApiResponse({ status: HttpStatus.OK, description: 'Organization details retrieved successfully', type: OrganizationResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiNotFoundResponse({ description: 'Organization not found', }) async getOrganization( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } // Authorization: Users can only view their own organization (unless admin) if (user.role !== 'ADMIN' && organization.id !== user.organizationId) { throw new ForbiddenException('You can only view your own organization'); } return OrganizationMapper.toDto(organization); } /** * Update organization * * Update organization details (name, address, logo, status). * Requires admin or manager role. */ @Patch(':id') @Roles('admin', 'manager') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiOperation({ summary: 'Update organization', description: 'Update organization details (name, address, logo, status). Requires admin or manager role.', }) @ApiParam({ name: 'id', description: 'Organization ID (UUID)', }) @ApiResponse({ status: HttpStatus.OK, description: 'Organization updated successfully', type: OrganizationResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) @ApiResponse({ status: 403, description: 'Forbidden - requires admin or manager role', }) @ApiNotFoundResponse({ description: 'Organization not found', }) async updateOrganization( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOrganizationDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); const organization = await this.organizationRepository.findById(id); if (!organization) { throw new NotFoundException(`Organization ${id} not found`); } // Authorization: Managers can only update their own organization if (user.role === 'manager' && organization.id !== user.organizationId) { throw new ForbiddenException('You can only update your own organization'); } // Update fields if (dto.name) { organization.updateName(dto.name); } if (dto.siren) { organization.updateSiren(dto.siren); } if (dto.siret) { organization.updateSiret(dto.siret); } if (dto.eori) { organization.updateEori(dto.eori); } if (dto.contact_phone) { organization.updateContactPhone(dto.contact_phone); } if (dto.contact_email) { organization.updateContactEmail(dto.contact_email); } if (dto.address) { organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address)); } if (dto.logoUrl !== undefined) { organization.updateLogoUrl(dto.logoUrl); } if (dto.isActive !== undefined) { if (dto.isActive) { organization.activate(); } else { organization.deactivate(); } } // Save updated organization const updatedOrg = await this.organizationRepository.save(organization); this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); return OrganizationMapper.toDto(updatedOrg); } /** * Request SIRET/SIREN approval from admins * * Any authenticated user can call this to notify all admins * that their organization's SIRET/SIREN needs approval. */ @Post('request-siret-approval') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Request SIRET/SIREN approval', description: 'Sends a notification to all admins requesting manual SIRET/SIREN verification.', }) @ApiResponse({ status: 200, description: 'Approval request sent to admins' }) @ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' }) async requestSiretApproval(@CurrentUser() user: UserPayload): Promise<{ message: string }> { const organization = await this.organizationRepository.findById(user.organizationId); if (!organization) { throw new NotFoundException('Organization not found'); } if (!organization.siren && !organization.siret) { throw new BadRequestException( 'Aucun SIRET ou SIREN renseigné sur votre organisation. Veuillez les ajouter avant de demander la validation.' ); } if (organization.siretVerified) { throw new BadRequestException('Votre SIRET/SIREN est déjà vérifié.'); } const admins = await this.userRepository.findByRole('ADMIN'); const identifier = organization.siret ? `SIRET ${organization.siret}` : `SIREN ${organization.siren}`; await Promise.all( admins.map(admin => this.notificationService.createNotification({ userId: admin.id, organizationId: admin.organizationId, type: NotificationType.ORGANIZATION_UPDATE, priority: NotificationPriority.HIGH, title: 'Demande de validation SIRET/SIREN', message: `L'organisation "${organization.name}" demande la validation de son ${identifier}.`, metadata: { organizationId: organization.id, organizationName: organization.name, siret: organization.siret, siren: organization.siren, requestedBy: user.email, }, actionUrl: `/dashboard/admin/organizations`, }) ) ); this.logger.log( `[${user.email}] SIRET/SIREN approval requested for org ${organization.name} (${organization.id})` ); return { message: 'Votre demande a été envoyée aux administrateurs.' }; } /** * List organizations * * Retrieve a paginated list of organizations. * Admins can see all, others see only their own. */ @Get() @ApiOperation({ summary: 'List organizations', description: 'Retrieve a paginated list of organizations. Admins see all, others see only their own.', }) @ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)', example: 1, }) @ApiQuery({ name: 'pageSize', required: false, description: 'Number of items per page', example: 20, }) @ApiQuery({ name: 'type', required: false, description: 'Filter by organization type', enum: OrganizationType, }) @ApiResponse({ status: HttpStatus.OK, description: 'Organizations list retrieved successfully', type: OrganizationListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid token', }) async listOrganizations( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, @Query('type') type: OrganizationType | undefined, @CurrentUser() user: UserPayload ): Promise { this.logger.log( `[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}` ); // Fetch organizations let organizations: Organization[]; if (user.role === 'ADMIN') { // Admins can see all organizations organizations = await this.organizationRepository.findAll(); } else { // Others see only their own organization const userOrg = await this.organizationRepository.findById(user.organizationId); organizations = userOrg ? [userOrg] : []; } // Filter by type if provided const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations; // Paginate const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex); // Convert to DTOs const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs); const totalPages = Math.ceil(filteredOrgs.length / pageSize); return { organizations: orgDtos, total: filteredOrgs.length, page, pageSize, totalPages, }; } }