This commit is contained in:
David-Henri ARNAUD 2025-10-09 16:38:22 +02:00
parent 177606bbbe
commit cfef7005b3
12 changed files with 439 additions and 36 deletions

View File

@ -3,8 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { UserRepository } from '../../domain/ports/out/user.repository';
import { User } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.vo';
import { User, UserRole } from '../../domain/entities/user.entity';
import { v4 as uuidv4 } from 'uuid';
export interface JwtPayload {
@ -38,8 +37,7 @@ export class AuthService {
this.logger.log(`Registering new user: ${email}`);
// Check if user already exists
const emailVo = Email.create(email);
const existingUser = await this.userRepository.findByEmail(emailVo);
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new ConflictException('User with this email already exists');
@ -57,14 +55,11 @@ export class AuthService {
const user = User.create({
id: uuidv4(),
organizationId,
email: emailVo,
email,
passwordHash,
firstName,
lastName,
role: 'user', // Default role
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
role: UserRole.USER, // Default role
});
// Save to database
@ -79,7 +74,7 @@ export class AuthService {
...tokens,
user: {
id: savedUser.id,
email: savedUser.email.value,
email: savedUser.email,
firstName: savedUser.firstName,
lastName: savedUser.lastName,
role: savedUser.role,
@ -98,8 +93,7 @@ export class AuthService {
this.logger.log(`Login attempt for: ${email}`);
// Find user by email
const emailVo = Email.create(email);
const user = await this.userRepository.findByEmail(emailVo);
const user = await this.userRepository.findByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
@ -125,7 +119,7 @@ export class AuthService {
...tokens,
user: {
id: user.id,
email: user.email.value,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
@ -158,7 +152,7 @@ export class AuthService {
// Generate new tokens
const tokens = await this.generateTokens(user);
this.logger.log(`Access token refreshed for user: ${user.email.value}`);
this.logger.log(`Access token refreshed for user: ${user.email}`);
return tokens;
} catch (error: any) {
@ -186,7 +180,7 @@ export class AuthService {
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
const accessPayload: JwtPayload = {
sub: user.id,
email: user.email.value,
email: user.email,
role: user.role,
organizationId: user.organizationId,
type: 'access',
@ -194,7 +188,7 @@ export class AuthService {
const refreshPayload: JwtPayload = {
sub: user.id,
email: user.email.value,
email: user.email,
role: user.role,
organizationId: user.organizationId,
type: 'refresh',

View File

@ -67,7 +67,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
// This object will be attached to request.user
return {
id: user.id,
email: user.email.value,
email: user.email,
role: user.role,
organizationId: user.organizationId,
firstName: user.firstName,

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsController } from '../controllers/bookings.controller';
// Import domain ports
@ -7,6 +8,11 @@ import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.reposit
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
// Import ORM entities
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
/**
* Bookings Module
*
@ -17,6 +23,9 @@ import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typ
* - Update booking status
*/
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrmEntity, ContainerOrmEntity, RateQuoteOrmEntity]),
],
controllers: [BookingsController],
providers: [
{
@ -28,6 +37,6 @@ import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typ
useClass: TypeOrmRateQuoteRepository,
},
],
exports: [],
exports: [BOOKING_REPOSITORY],
})
export class BookingsModule {}

View File

@ -150,10 +150,10 @@ export class AuthController {
async refresh(
@Body() dto: RefreshTokenDto,
): Promise<{ accessToken: string }> {
const accessToken =
const result =
await this.authService.refreshAccessToken(dto.refreshToken);
return { accessToken };
return { accessToken: result.accessToken };
}
/**

View File

@ -39,8 +39,7 @@ import {
} from '../dto/user.dto';
import { UserMapper } from '../mappers/user.mapper';
import { UserRepository } from '../../domain/ports/out/user.repository';
import { User } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.vo';
import { User, UserRole as DomainUserRole } from '../../domain/entities/user.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
@ -116,8 +115,7 @@ export class UsersController {
}
// Check if user already exists
const emailVo = Email.create(dto.email);
const existingUser = await this.userRepository.findByEmail(emailVo);
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
@ -134,18 +132,18 @@ export class UsersController {
parallelism: 4,
});
// Map DTO role to Domain role
const domainRole = dto.role as unknown as DomainUserRole;
// Create user entity
const newUser = User.create({
id: uuidv4(),
organizationId: dto.organizationId,
email: emailVo,
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
role: dto.role,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
role: domainRole,
});
// Save to database
@ -264,7 +262,8 @@ export class UsersController {
}
if (dto.role) {
user.updateRole(dto.role);
const domainRole = dto.role as unknown as DomainUserRole;
user.updateRole(domainRole);
}
if (dto.isActive !== undefined) {

View File

@ -13,7 +13,7 @@ export class UserMapper {
static toDto(user: User): UserResponseDto {
return {
id: user.id,
email: user.email.value,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role as any,

View File

@ -11,10 +11,10 @@
*/
export enum UserRole {
ADMIN = 'ADMIN', // Full system access
MANAGER = 'MANAGER', // Manage bookings and users within organization
USER = 'USER', // Create and view bookings
VIEWER = 'VIEWER', // Read-only access
ADMIN = 'admin', // Full system access
MANAGER = 'manager', // Manage bookings and users within organization
USER = 'user', // Create and view bookings
VIEWER = 'viewer', // Read-only access
}
export interface UserProps {
@ -182,6 +182,22 @@ export class User {
this.props.updatedAt = new Date();
}
updateFirstName(firstName: string): void {
if (!firstName || firstName.trim().length === 0) {
throw new Error('First name cannot be empty.');
}
this.props.firstName = firstName.trim();
this.props.updatedAt = new Date();
}
updateLastName(lastName: string): void {
if (!lastName || lastName.trim().length === 0) {
throw new Error('Last name cannot be empty.');
}
this.props.lastName = lastName.trim();
this.props.updatedAt = new Date();
}
updateProfile(firstName: string, lastName: string, phoneNumber?: string): void {
if (!firstName || firstName.trim().length === 0) {
throw new Error('First name cannot be empty.');

View File

@ -0,0 +1,100 @@
/**
* Booking ORM Entity (Infrastructure Layer)
*
* TypeORM entity for booking persistence
*/
import {
Entity,
Column,
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
import { OrganizationOrmEntity } from './organization.orm-entity';
import { ContainerOrmEntity } from './container.orm-entity';
/**
* Address stored as JSON
*/
export interface AddressJson {
street: string;
city: string;
postalCode: string;
country: string;
}
/**
* Party (shipper/consignee) stored as JSON
*/
export interface PartyJson {
name: string;
address: AddressJson;
contactName: string;
contactEmail: string;
contactPhone: string;
}
@Entity('bookings')
@Index('idx_bookings_booking_number', ['bookingNumber'], { unique: true })
@Index('idx_bookings_user', ['userId'])
@Index('idx_bookings_organization', ['organizationId'])
@Index('idx_bookings_rate_quote', ['rateQuoteId'])
@Index('idx_bookings_status', ['status'])
@Index('idx_bookings_created_at', ['createdAt'])
export class BookingOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'booking_number', type: 'varchar', length: 20, unique: true })
bookingNumber: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
@Column({ name: 'organization_id', type: 'uuid' })
organizationId: string;
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization: OrganizationOrmEntity;
@Column({ name: 'rate_quote_id', type: 'uuid' })
rateQuoteId: string;
@Column({ type: 'varchar', length: 50 })
status: string;
@Column({ type: 'jsonb' })
shipper: PartyJson;
@Column({ type: 'jsonb' })
consignee: PartyJson;
@Column({ name: 'cargo_description', type: 'text' })
cargoDescription: string;
@OneToMany(() => ContainerOrmEntity, (container) => container.booking, {
cascade: true,
eager: true,
})
containers: ContainerOrmEntity[];
@Column({ name: 'special_instructions', type: 'text', nullable: true })
specialInstructions: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,47 @@
/**
* Container ORM Entity (Infrastructure Layer)
*
* TypeORM entity for container persistence
*/
import {
Entity,
Column,
PrimaryColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { BookingOrmEntity } from './booking.orm-entity';
@Entity('containers')
@Index('idx_containers_booking', ['bookingId'])
@Index('idx_containers_container_number', ['containerNumber'])
export class ContainerOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId: string;
@ManyToOne(() => BookingOrmEntity, (booking) => booking.containers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'booking_id' })
booking: BookingOrmEntity;
@Column({ type: 'varchar', length: 50 })
type: string;
@Column({ name: 'container_number', type: 'varchar', length: 20, nullable: true })
containerNumber: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
vgm: number | null;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
temperature: number | null;
@Column({ name: 'seal_number', type: 'varchar', length: 50, nullable: true })
sealNumber: string | null;
}

View File

@ -0,0 +1,152 @@
/**
* Booking ORM Mapper
*
* Maps between Booking domain entity and BookingOrmEntity
*/
import {
Booking,
BookingProps,
Party,
BookingContainer,
} from '../../../../domain/entities/booking.entity';
import { BookingNumber } from '../../../../domain/value-objects/booking-number.vo';
import { BookingStatus } from '../../../../domain/value-objects/booking-status.vo';
import {
BookingOrmEntity,
PartyJson,
} from '../entities/booking.orm-entity';
import { ContainerOrmEntity } from '../entities/container.orm-entity';
export class BookingOrmMapper {
/**
* Map domain entity to ORM entity
*/
static toOrm(domain: Booking): BookingOrmEntity {
const orm = new BookingOrmEntity();
orm.id = domain.id;
orm.bookingNumber = domain.bookingNumber.value;
orm.userId = domain.userId;
orm.organizationId = domain.organizationId;
orm.rateQuoteId = domain.rateQuoteId;
orm.status = domain.status.value;
orm.shipper = this.partyToJson(domain.shipper);
orm.consignee = this.partyToJson(domain.consignee);
orm.cargoDescription = domain.cargoDescription;
orm.specialInstructions = domain.specialInstructions || null;
orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt;
// Map containers
orm.containers = domain.containers.map((container) =>
this.containerToOrm(container, domain.id)
);
return orm;
}
/**
* Map ORM entity to domain entity
*/
static toDomain(orm: BookingOrmEntity): Booking {
const props: BookingProps = {
id: orm.id,
bookingNumber: BookingNumber.fromString(orm.bookingNumber),
userId: orm.userId,
organizationId: orm.organizationId,
rateQuoteId: orm.rateQuoteId,
status: BookingStatus.create(orm.status as any),
shipper: this.jsonToParty(orm.shipper),
consignee: this.jsonToParty(orm.consignee),
cargoDescription: orm.cargoDescription,
containers: orm.containers
? orm.containers.map((c) => this.ormToContainer(c))
: [],
specialInstructions: orm.specialInstructions || undefined,
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
};
return Booking.create({
...props,
bookingNumber: props.bookingNumber,
status: props.status,
});
}
/**
* Map array of ORM entities to domain entities
*/
static toDomainMany(orms: BookingOrmEntity[]): Booking[] {
return orms.map((orm) => this.toDomain(orm));
}
/**
* Convert domain Party to JSON
*/
private static partyToJson(party: Party): PartyJson {
return {
name: party.name,
address: {
street: party.address.street,
city: party.address.city,
postalCode: party.address.postalCode,
country: party.address.country,
},
contactName: party.contactName,
contactEmail: party.contactEmail,
contactPhone: party.contactPhone,
};
}
/**
* Convert JSON to domain Party
*/
private static jsonToParty(json: PartyJson): Party {
return {
name: json.name,
address: {
street: json.address.street,
city: json.address.city,
postalCode: json.address.postalCode,
country: json.address.country,
},
contactName: json.contactName,
contactEmail: json.contactEmail,
contactPhone: json.contactPhone,
};
}
/**
* Convert domain BookingContainer to ORM entity
*/
private static containerToOrm(
container: BookingContainer,
bookingId: string
): ContainerOrmEntity {
const orm = new ContainerOrmEntity();
orm.id = container.id;
orm.bookingId = bookingId;
orm.type = container.type;
orm.containerNumber = container.containerNumber || null;
orm.vgm = container.vgm || null;
orm.temperature = container.temperature || null;
orm.sealNumber = container.sealNumber || null;
return orm;
}
/**
* Convert ORM entity to domain BookingContainer
*/
private static ormToContainer(orm: ContainerOrmEntity): BookingContainer {
return {
id: orm.id,
type: orm.type,
containerNumber: orm.containerNumber || undefined,
vgm: orm.vgm || undefined,
temperature: orm.temperature || undefined,
sealNumber: orm.sealNumber || undefined,
};
}
}

View File

@ -0,0 +1,79 @@
/**
* TypeORM Booking Repository
*
* Implements BookingRepository interface using TypeORM
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Booking } from '../../../../domain/entities/booking.entity';
import { BookingNumber } from '../../../../domain/value-objects/booking-number.vo';
import { BookingStatus } from '../../../../domain/value-objects/booking-status.vo';
import { BookingRepository } from '../../../../domain/ports/out/booking.repository';
import { BookingOrmEntity } from '../entities/booking.orm-entity';
import { ContainerOrmEntity } from '../entities/container.orm-entity';
import { BookingOrmMapper } from '../mappers/booking-orm.mapper';
@Injectable()
export class TypeOrmBookingRepository implements BookingRepository {
constructor(
@InjectRepository(BookingOrmEntity)
private readonly bookingRepository: Repository<BookingOrmEntity>,
@InjectRepository(ContainerOrmEntity)
private readonly containerRepository: Repository<ContainerOrmEntity>
) {}
async save(booking: Booking): Promise<Booking> {
const orm = BookingOrmMapper.toOrm(booking);
const saved = await this.bookingRepository.save(orm);
return BookingOrmMapper.toDomain(saved);
}
async findById(id: string): Promise<Booking | null> {
const orm = await this.bookingRepository.findOne({
where: { id },
relations: ['containers'],
});
return orm ? BookingOrmMapper.toDomain(orm) : null;
}
async findByBookingNumber(bookingNumber: BookingNumber): Promise<Booking | null> {
const orm = await this.bookingRepository.findOne({
where: { bookingNumber: bookingNumber.value },
relations: ['containers'],
});
return orm ? BookingOrmMapper.toDomain(orm) : null;
}
async findByUser(userId: string): Promise<Booking[]> {
const orms = await this.bookingRepository.find({
where: { userId },
relations: ['containers'],
order: { createdAt: 'DESC' },
});
return BookingOrmMapper.toDomainMany(orms);
}
async findByOrganization(organizationId: string): Promise<Booking[]> {
const orms = await this.bookingRepository.find({
where: { organizationId },
relations: ['containers'],
order: { createdAt: 'DESC' },
});
return BookingOrmMapper.toDomainMany(orms);
}
async findByStatus(status: BookingStatus): Promise<Booking[]> {
const orms = await this.bookingRepository.find({
where: { status: status.value },
relations: ['containers'],
order: { createdAt: 'DESC' },
});
return BookingOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.bookingRepository.delete({ id });
}
}

View File

@ -35,13 +35,20 @@ export class TypeOrmOrganizationRepository implements OrganizationRepository {
return orm ? OrganizationOrmMapper.toDomain(orm) : null;
}
async findByScac(scac: string): Promise<Organization | null> {
async findBySCAC(scac: string): Promise<Organization | null> {
const orm = await this.repository.findOne({
where: { scac: scac.toUpperCase() },
});
return orm ? OrganizationOrmMapper.toDomain(orm) : null;
}
async findAll(): Promise<Organization[]> {
const orms = await this.repository.find({
order: { name: 'ASC' },
});
return OrganizationOrmMapper.toDomainMany(orms);
}
async findAllActive(): Promise<Organization[]> {
const orms = await this.repository.find({
where: { isActive: true },