fix
This commit is contained in:
parent
22b17ef8c3
commit
68e321a08f
@ -11,7 +11,11 @@
|
|||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(k6:*)",
|
"Bash(k6:*)",
|
||||||
"Bash(npx playwright:*)",
|
"Bash(npx playwright:*)",
|
||||||
"Bash(npx newman:*)"
|
"Bash(npx newman:*)",
|
||||||
|
"Bash(chmod:*)",
|
||||||
|
"Bash(netstat -ano)",
|
||||||
|
"Bash(findstr \":5432\")",
|
||||||
|
"Bash(findstr \"LISTENING\")"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -69,7 +69,7 @@ temp
|
|||||||
# Docker
|
# Docker
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
docker-compose*.yml
|
docker-compose.yaml
|
||||||
|
|
||||||
# CI/CD
|
# CI/CD
|
||||||
.gitlab-ci.yml
|
.gitlab-ci.yml
|
||||||
|
|||||||
19
apps/backend/docker-compose.yaml
Normal file
19
apps/backend/docker-compose.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:latest
|
||||||
|
container_name: xpeditis-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: xpeditis
|
||||||
|
POSTGRES_PASSWORD: xpeditis_dev_password
|
||||||
|
POSTGRES_DB: xpeditis_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: xpeditis-redis
|
||||||
|
command: redis-server --requirepass xpeditis_redis_password
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: xpeditis_redis_password
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { AuthController } from '../controllers/auth.controller';
|
import { AuthController } from '../controllers/auth.controller';
|
||||||
@ -9,18 +10,8 @@ import { AuthController } from '../controllers/auth.controller';
|
|||||||
// Import domain and infrastructure dependencies
|
// Import domain and infrastructure dependencies
|
||||||
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication Module
|
|
||||||
*
|
|
||||||
* Wires together the authentication system:
|
|
||||||
* - JWT configuration with access/refresh tokens
|
|
||||||
* - Passport JWT strategy
|
|
||||||
* - Auth service and controller
|
|
||||||
* - User repository for database access
|
|
||||||
*
|
|
||||||
* This module should be imported in AppModule.
|
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Passport configuration
|
// Passport configuration
|
||||||
@ -37,6 +28,9 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 👇 Add this to register TypeORM repository for UserOrmEntity
|
||||||
|
TypeOrmModule.forFeature([UserOrmEntity]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Injectable, UnauthorizedException, ConflictException, Logger } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { UserRepository } from '../../domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||||
import { User, UserRole } from '../../domain/entities/user.entity';
|
import { User, UserRole } from '../../domain/entities/user.entity';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@ -19,7 +19,8 @@ export class AuthService {
|
|||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY)
|
||||||
|
private readonly userRepository: UserRepository, // ✅ Correct injection
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
@ -36,14 +37,12 @@ export class AuthService {
|
|||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Registering new user: ${email}`);
|
this.logger.log(`Registering new user: ${email}`);
|
||||||
|
|
||||||
// Check if user already exists
|
|
||||||
const existingUser = await this.userRepository.findByEmail(email);
|
const existingUser = await this.userRepository.findByEmail(email);
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new ConflictException('User with this email already exists');
|
throw new ConflictException('User with this email already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password with Argon2
|
|
||||||
const passwordHash = await argon2.hash(password, {
|
const passwordHash = await argon2.hash(password, {
|
||||||
type: argon2.argon2id,
|
type: argon2.argon2id,
|
||||||
memoryCost: 65536, // 64 MB
|
memoryCost: 65536, // 64 MB
|
||||||
@ -51,7 +50,6 @@ export class AuthService {
|
|||||||
parallelism: 4,
|
parallelism: 4,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create user entity
|
|
||||||
const user = User.create({
|
const user = User.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
organizationId,
|
organizationId,
|
||||||
@ -59,13 +57,11 @@ export class AuthService {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
role: UserRole.USER, // Default role
|
role: UserRole.USER,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
|
||||||
const savedUser = await this.userRepository.save(user);
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
|
||||||
// Generate tokens
|
|
||||||
const tokens = await this.generateTokens(savedUser);
|
const tokens = await this.generateTokens(savedUser);
|
||||||
|
|
||||||
this.logger.log(`User registered successfully: ${email}`);
|
this.logger.log(`User registered successfully: ${email}`);
|
||||||
@ -92,7 +88,6 @@ export class AuthService {
|
|||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Login attempt for: ${email}`);
|
this.logger.log(`Login attempt for: ${email}`);
|
||||||
|
|
||||||
// Find user by email
|
|
||||||
const user = await this.userRepository.findByEmail(email);
|
const user = await this.userRepository.findByEmail(email);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -103,14 +98,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('User account is inactive');
|
throw new UnauthorizedException('User account is inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
|
||||||
const isPasswordValid = await argon2.verify(user.passwordHash, password);
|
const isPasswordValid = await argon2.verify(user.passwordHash, password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tokens
|
|
||||||
const tokens = await this.generateTokens(user);
|
const tokens = await this.generateTokens(user);
|
||||||
|
|
||||||
this.logger.log(`User logged in successfully: ${email}`);
|
this.logger.log(`User logged in successfully: ${email}`);
|
||||||
@ -133,7 +126,6 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
try {
|
try {
|
||||||
// Verify refresh token
|
|
||||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||||
secret: this.configService.get('JWT_SECRET'),
|
secret: this.configService.get('JWT_SECRET'),
|
||||||
});
|
});
|
||||||
@ -142,14 +134,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Invalid token type');
|
throw new UnauthorizedException('Invalid token type');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user
|
|
||||||
const user = await this.userRepository.findById(payload.sub);
|
const user = await this.userRepository.findById(payload.sub);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
throw new UnauthorizedException('User not found or inactive');
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new tokens
|
|
||||||
const tokens = await this.generateTokens(user);
|
const tokens = await this.generateTokens(user);
|
||||||
|
|
||||||
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
||||||
|
|||||||
@ -101,17 +101,13 @@ export class UsersController {
|
|||||||
})
|
})
|
||||||
async createUser(
|
async createUser(
|
||||||
@Body() dto: CreateUserDto,
|
@Body() dto: CreateUserDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<UserResponseDto> {
|
): Promise<UserResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`);
|
||||||
`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Authorization: Managers can only create users in their own organization
|
// Authorization: Managers can only create users in their own organization
|
||||||
if (user.role === 'manager' && dto.organizationId !== user.organizationId) {
|
if (user.role === 'manager' && dto.organizationId !== user.organizationId) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException('You can only create users in your own organization');
|
||||||
'You can only create users in your own organization',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
@ -121,8 +117,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate temporary password if not provided
|
// Generate temporary password if not provided
|
||||||
const tempPassword =
|
const tempPassword = dto.password || this.generateTemporaryPassword();
|
||||||
dto.password || this.generateTemporaryPassword();
|
|
||||||
|
|
||||||
// Hash password with Argon2id
|
// Hash password with Argon2id
|
||||||
const passwordHash = await argon2.hash(tempPassword, {
|
const passwordHash = await argon2.hash(tempPassword, {
|
||||||
@ -153,7 +148,7 @@ export class UsersController {
|
|||||||
|
|
||||||
// TODO: Send invitation email with temporary password
|
// TODO: Send invitation email with temporary password
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`,
|
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return UserMapper.toDto(savedUser);
|
return UserMapper.toDto(savedUser);
|
||||||
@ -165,8 +160,7 @@ export class UsersController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get user by ID',
|
summary: 'Get user by ID',
|
||||||
description:
|
description: 'Retrieve user details. Users can view users in their org, admins can view any.',
|
||||||
'Retrieve user details. Users can view users in their org, admins can view any.',
|
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
@ -183,7 +177,7 @@ export class UsersController {
|
|||||||
})
|
})
|
||||||
async getUser(
|
async getUser(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() currentUser: UserPayload,
|
@CurrentUser() currentUser: UserPayload
|
||||||
): Promise<UserResponseDto> {
|
): Promise<UserResponseDto> {
|
||||||
this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`);
|
this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`);
|
||||||
|
|
||||||
@ -193,10 +187,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: Can only view users in same organization (unless admin)
|
// Authorization: Can only view users in same organization (unless admin)
|
||||||
if (
|
if (currentUser.role !== 'admin' && user.organizationId !== currentUser.organizationId) {
|
||||||
currentUser.role !== 'admin' &&
|
|
||||||
user.organizationId !== currentUser.organizationId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException('You can only view users in your organization');
|
throw new ForbiddenException('You can only view users in your organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,8 +202,7 @@ export class UsersController {
|
|||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update user',
|
summary: 'Update user',
|
||||||
description:
|
description: 'Update user details (name, role, status). Admin/manager only.',
|
||||||
'Update user details (name, role, status). Admin/manager only.',
|
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
@ -233,7 +223,7 @@ export class UsersController {
|
|||||||
async updateUser(
|
async updateUser(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateUserDto,
|
@Body() dto: UpdateUserDto,
|
||||||
@CurrentUser() currentUser: UserPayload,
|
@CurrentUser() currentUser: UserPayload
|
||||||
): Promise<UserResponseDto> {
|
): Promise<UserResponseDto> {
|
||||||
this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`);
|
this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`);
|
||||||
|
|
||||||
@ -243,13 +233,8 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: Managers can only update users in their own organization
|
// Authorization: Managers can only update users in their own organization
|
||||||
if (
|
if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) {
|
||||||
currentUser.role === 'manager' &&
|
throw new ForbiddenException('You can only update users in your own organization');
|
||||||
user.organizationId !== currentUser.organizationId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'You can only update users in your own organization',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update fields
|
// Update fields
|
||||||
@ -308,7 +293,7 @@ export class UsersController {
|
|||||||
})
|
})
|
||||||
async deleteUser(
|
async deleteUser(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() currentUser: UserPayload,
|
@CurrentUser() currentUser: UserPayload
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`[Admin: ${currentUser.email}] Deactivating user: ${id}`);
|
this.logger.log(`[Admin: ${currentUser.email}] Deactivating user: ${id}`);
|
||||||
|
|
||||||
@ -360,21 +345,17 @@ export class UsersController {
|
|||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
@Query('role') role: string | undefined,
|
@Query('role') role: string | undefined,
|
||||||
@CurrentUser() currentUser: UserPayload,
|
@CurrentUser() currentUser: UserPayload
|
||||||
): Promise<UserListResponseDto> {
|
): Promise<UserListResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`,
|
`[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch users by organization
|
// Fetch users by organization
|
||||||
const users = await this.userRepository.findByOrganization(
|
const users = await this.userRepository.findByOrganization(currentUser.organizationId);
|
||||||
currentUser.organizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter by role if provided
|
// Filter by role if provided
|
||||||
const filteredUsers = role
|
const filteredUsers = role ? users.filter(u => u.role === role) : users;
|
||||||
? users.filter(u => u.role === role)
|
|
||||||
: users;
|
|
||||||
|
|
||||||
// Paginate
|
// Paginate
|
||||||
const startIndex = (page - 1) * pageSize;
|
const startIndex = (page - 1) * pageSize;
|
||||||
@ -418,7 +399,7 @@ export class UsersController {
|
|||||||
})
|
})
|
||||||
async updatePassword(
|
async updatePassword(
|
||||||
@Body() dto: UpdatePasswordDto,
|
@Body() dto: UpdatePasswordDto,
|
||||||
@CurrentUser() currentUser: UserPayload,
|
@CurrentUser() currentUser: UserPayload
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
this.logger.log(`[User: ${currentUser.email}] Updating password`);
|
this.logger.log(`[User: ${currentUser.email}] Updating password`);
|
||||||
|
|
||||||
@ -428,10 +409,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
const isPasswordValid = await argon2.verify(
|
const isPasswordValid = await argon2.verify(user.passwordHash, dto.currentPassword);
|
||||||
user.passwordHash,
|
|
||||||
dto.currentPassword,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw new ForbiddenException('Current password is incorrect');
|
throw new ForbiddenException('Current password is incorrect');
|
||||||
@ -459,8 +437,7 @@ export class UsersController {
|
|||||||
*/
|
*/
|
||||||
private generateTemporaryPassword(): string {
|
private generateTemporaryPassword(): string {
|
||||||
const length = 16;
|
const length = 16;
|
||||||
const charset =
|
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
||||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
|
||||||
let password = '';
|
let password = '';
|
||||||
|
|
||||||
const randomBytes = crypto.randomBytes(length);
|
const randomBytes = crypto.randomBytes(length);
|
||||||
|
|||||||
@ -1,20 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { OrganizationsController } from '../controllers/organizations.controller';
|
import { OrganizationsController } from '../controllers/organizations.controller';
|
||||||
|
|
||||||
// Import domain ports
|
// Import domain ports
|
||||||
import { ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
|
import { ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
|
||||||
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||||
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* Organizations Module
|
|
||||||
*
|
|
||||||
* Handles organization management functionality:
|
|
||||||
* - Create organizations (admin only)
|
|
||||||
* - View organization details
|
|
||||||
* - Update organization (admin/manager)
|
|
||||||
* - List organizations
|
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
|
||||||
|
],
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -22,6 +18,8 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
|
|||||||
useClass: TypeOrmOrganizationRepository,
|
useClass: TypeOrmOrganizationRepository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [
|
||||||
|
ORGANIZATION_REPOSITORY, // optional, if other modules need it
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class OrganizationsModule {}
|
export class OrganizationsModule {}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { RatesController } from '../controllers/rates.controller';
|
import { RatesController } from '../controllers/rates.controller';
|
||||||
import { CacheModule } from '../../infrastructure/cache/cache.module';
|
import { CacheModule } from '../../infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
|
import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
|
||||||
@ -6,18 +7,14 @@ import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
|
|||||||
// Import domain ports
|
// Import domain ports
|
||||||
import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
|
import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
|
||||||
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
||||||
|
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* Rates Module
|
|
||||||
*
|
|
||||||
* Handles rate search functionality:
|
|
||||||
* - Rate search API endpoint
|
|
||||||
* - Integration with carrier APIs
|
|
||||||
* - Redis caching for rate quotes
|
|
||||||
* - Rate quote persistence
|
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CacheModule, CarrierModule],
|
imports: [
|
||||||
|
CacheModule,
|
||||||
|
CarrierModule,
|
||||||
|
TypeOrmModule.forFeature([RateQuoteOrmEntity]), // 👈 Add this
|
||||||
|
],
|
||||||
controllers: [RatesController],
|
controllers: [RatesController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -25,6 +22,8 @@ import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typ
|
|||||||
useClass: TypeOrmRateQuoteRepository,
|
useClass: TypeOrmRateQuoteRepository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [
|
||||||
|
RATE_QUOTE_REPOSITORY, // optional, if used in other modules
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class RatesModule {}
|
export class RatesModule {}
|
||||||
|
|||||||
@ -1,22 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { UsersController } from '../controllers/users.controller';
|
import { UsersController } from '../controllers/users.controller';
|
||||||
|
|
||||||
// Import domain ports
|
// Import domain ports
|
||||||
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* Users Module
|
|
||||||
*
|
|
||||||
* Handles user management functionality:
|
|
||||||
* - Create/invite users (admin/manager)
|
|
||||||
* - View user details
|
|
||||||
* - Update user (admin/manager)
|
|
||||||
* - Deactivate user (admin)
|
|
||||||
* - List users in organization
|
|
||||||
* - Update own password
|
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
|
||||||
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -24,6 +18,8 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/
|
|||||||
useClass: TypeOrmUserRepository,
|
useClass: TypeOrmUserRepository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [
|
||||||
|
USER_REPOSITORY, // optional, export if other modules need it
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
/**
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
* BookingService (Domain Service)
|
import { Booking } from '../entities/booking.entity';
|
||||||
*
|
|
||||||
* Business logic for booking management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Booking, BookingContainer } from '../entities/booking.entity';
|
|
||||||
import { BookingNumber } from '../value-objects/booking-number.vo';
|
|
||||||
import { BookingStatus } from '../value-objects/booking-status.vo';
|
|
||||||
import { BookingRepository } from '../ports/out/booking.repository';
|
import { BookingRepository } from '../ports/out/booking.repository';
|
||||||
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
||||||
|
import { BOOKING_REPOSITORY } from '../ports/out/booking.repository';
|
||||||
|
import { RATE_QUOTE_REPOSITORY } from '../ports/out/rate-quote.repository';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export interface CreateBookingInput {
|
export interface CreateBookingInput {
|
||||||
@ -24,7 +18,10 @@ export interface CreateBookingInput {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingService {
|
export class BookingService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(BOOKING_REPOSITORY)
|
||||||
private readonly bookingRepository: BookingRepository,
|
private readonly bookingRepository: BookingRepository,
|
||||||
|
|
||||||
|
@Inject(RATE_QUOTE_REPOSITORY)
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository
|
private readonly rateQuoteRepository: RateQuoteRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -35,7 +32,7 @@ export class BookingService {
|
|||||||
// Validate rate quote exists
|
// Validate rate quote exists
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
|
||||||
if (!rateQuote) {
|
if (!rateQuote) {
|
||||||
throw new Error(`Rate quote ${input.rateQuoteId} not found`);
|
throw new NotFoundException(`Rate quote ${input.rateQuoteId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Get userId and organizationId from context
|
// TODO: Get userId and organizationId from context
|
||||||
@ -51,7 +48,7 @@ export class BookingService {
|
|||||||
shipper: input.shipper,
|
shipper: input.shipper,
|
||||||
consignee: input.consignee,
|
consignee: input.consignee,
|
||||||
cargoDescription: input.cargoDescription,
|
cargoDescription: input.cargoDescription,
|
||||||
containers: input.containers.map((c) => ({
|
containers: input.containers.map(c => ({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: c.type,
|
type: c.type,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
import CircuitBreaker from 'opossum';
|
import * as CircuitBreaker from 'opossum'; // ✅ Correction ici
|
||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
@ -45,28 +45,28 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add request interceptor for logging
|
// Request interceptor
|
||||||
this.httpClient.interceptors.request.use(
|
this.httpClient.interceptors.request.use(
|
||||||
(request: any) => {
|
request => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Request: ${request.method?.toUpperCase()} ${request.url}`,
|
`Request: ${request.method?.toUpperCase()} ${request.url}`,
|
||||||
request.data ? JSON.stringify(request.data).substring(0, 200) : ''
|
request.data ? JSON.stringify(request.data).substring(0, 200) : ''
|
||||||
);
|
);
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
(error: any) => {
|
error => {
|
||||||
this.logger.error(`Request error: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Request error: ${error?.message || 'Unknown error'}`);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add response interceptor for logging
|
// Response interceptor
|
||||||
this.httpClient.interceptors.response.use(
|
this.httpClient.interceptors.response.use(
|
||||||
(response: any) => {
|
response => {
|
||||||
this.logger.debug(`Response: ${response.status} ${response.statusText}`);
|
this.logger.debug(`Response: ${response.status} ${response.statusText}`);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error: any) => {
|
error => {
|
||||||
if (error?.code === 'ECONNABORTED') {
|
if (error?.code === 'ECONNABORTED') {
|
||||||
this.logger.warn(`Request timeout after ${config.timeout}ms`);
|
this.logger.warn(`Request timeout after ${config.timeout}ms`);
|
||||||
throw new CarrierTimeoutException(config.name, config.timeout);
|
throw new CarrierTimeoutException(config.name, config.timeout);
|
||||||
@ -76,7 +76,7 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create circuit breaker
|
// Circuit breaker
|
||||||
this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), {
|
this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), {
|
||||||
timeout: config.timeout,
|
timeout: config.timeout,
|
||||||
errorThresholdPercentage: config.circuitBreakerThreshold,
|
errorThresholdPercentage: config.circuitBreakerThreshold,
|
||||||
@ -84,18 +84,15 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
name: `${config.name}-circuit-breaker`,
|
name: `${config.name}-circuit-breaker`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Circuit breaker event handlers
|
this.circuitBreaker.on('open', () =>
|
||||||
this.circuitBreaker.on('open', () => {
|
this.logger.warn('Circuit breaker opened - carrier unavailable')
|
||||||
this.logger.warn('Circuit breaker opened - carrier unavailable');
|
);
|
||||||
});
|
this.circuitBreaker.on('halfOpen', () =>
|
||||||
|
this.logger.log('Circuit breaker half-open - testing carrier availability')
|
||||||
this.circuitBreaker.on('halfOpen', () => {
|
);
|
||||||
this.logger.log('Circuit breaker half-open - testing carrier availability');
|
this.circuitBreaker.on('close', () =>
|
||||||
});
|
this.logger.log('Circuit breaker closed - carrier available')
|
||||||
|
);
|
||||||
this.circuitBreaker.on('close', () => {
|
|
||||||
this.logger.log('Circuit breaker closed - carrier available');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCarrierName(): string {
|
getCarrierName(): string {
|
||||||
@ -106,9 +103,6 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
return this.config.code;
|
return this.config.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Make HTTP request with retry logic
|
|
||||||
*/
|
|
||||||
protected async makeRequest<T>(
|
protected async makeRequest<T>(
|
||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
retries = this.config.maxRetries
|
retries = this.config.maxRetries
|
||||||
@ -126,41 +120,27 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if error is retryable
|
|
||||||
*/
|
|
||||||
protected isRetryableError(error: any): boolean {
|
protected isRetryableError(error: any): boolean {
|
||||||
// Retry on network errors, timeouts, and 5xx server errors
|
if (error.code === 'ECONNABORTED') return false;
|
||||||
if (error.code === 'ECONNABORTED') return false; // Don't retry timeouts
|
if (error.code === 'ENOTFOUND') return false;
|
||||||
if (error.code === 'ENOTFOUND') return false; // Don't retry DNS errors
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
return status >= 500 && status < 600;
|
return status >= 500 && status < 600;
|
||||||
}
|
}
|
||||||
return true; // Retry network errors
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate retry delay with exponential backoff
|
|
||||||
*/
|
|
||||||
protected calculateRetryDelay(attempt: number): number {
|
protected calculateRetryDelay(attempt: number): number {
|
||||||
const baseDelay = 1000; // 1 second
|
const baseDelay = 1000;
|
||||||
const maxDelay = 5000; // 5 seconds
|
const maxDelay = 5000;
|
||||||
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
||||||
// Add jitter to prevent thundering herd
|
return delay + Math.random() * 1000; // jitter
|
||||||
return delay + Math.random() * 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep utility
|
|
||||||
*/
|
|
||||||
protected sleep(ms: number): Promise<void> {
|
protected sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Make request with circuit breaker protection
|
|
||||||
*/
|
|
||||||
protected async requestWithCircuitBreaker<T>(
|
protected async requestWithCircuitBreaker<T>(
|
||||||
config: AxiosRequestConfig
|
config: AxiosRequestConfig
|
||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
@ -174,16 +154,9 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check implementation
|
|
||||||
*/
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.requestWithCircuitBreaker({
|
await this.requestWithCircuitBreaker({ method: 'GET', url: '/health', timeout: 5000 });
|
||||||
method: 'GET',
|
|
||||||
url: '/health',
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Health check failed: ${error?.message || 'Unknown error'}`);
|
this.logger.warn(`Health check failed: ${error?.message || 'Unknown error'}`);
|
||||||
@ -191,9 +164,6 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract methods to be implemented by specific carriers
|
|
||||||
*/
|
|
||||||
abstract searchRates(input: CarrierRateSearchInput): Promise<RateQuote[]>;
|
abstract searchRates(input: CarrierRateSearchInput): Promise<RateQuote[]>;
|
||||||
abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>;
|
abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user