367 lines
10 KiB
TypeScript
367 lines
10 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Patch,
|
|
Param,
|
|
Body,
|
|
Query,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Logger,
|
|
UsePipes,
|
|
ValidationPipe,
|
|
NotFoundException,
|
|
ParseUUIDPipe,
|
|
ParseIntPipe,
|
|
DefaultValuePipe,
|
|
UseGuards,
|
|
ForbiddenException,
|
|
} 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 } from '../../domain/ports/out/organization.repository';
|
|
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
|
|
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 { 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('api/v1/organizations')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@ApiBearerAuth()
|
|
export class OrganizationsController {
|
|
private readonly logger = new Logger(OrganizationsController.name);
|
|
|
|
constructor(
|
|
private readonly organizationRepository: OrganizationRepository,
|
|
) {}
|
|
|
|
/**
|
|
* 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,
|
|
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.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);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
}
|