- Bloc GEO/IA (resume IA, FAQ, points cles, entites) + rendu public "En bref"/"A retenir"/FAQ + JSON-LD Article & FAQPage. - Corbeille: soft-delete (deleted_at), filtres Tous/Publies/Brouillons/ Planifies/Corbeille, restauration + suppression definitive. - Duplication d'un article (nouveau brouillon, slug unique). - Drag & drop de l'image de couverture. - Recadrage auto de la couverture en 16:9 (sharp) via endpoint dedie /admin/blog/cover-images. - Migration AddGeoAndTrashToBlogPosts (ai_summary, faq, key_takeaways, ai_entities, deleted_at). Ajout dependance sharp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
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<OrganizationResponseDto> {
|
|
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<OrganizationResponseDto> {
|
|
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<OrganizationResponseDto> {
|
|
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<OrganizationListResponseDto> {
|
|
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,
|
|
};
|
|
}
|
|
}
|