diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 59572e4..3f8115e 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -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', diff --git a/apps/backend/src/application/auth/jwt.strategy.ts b/apps/backend/src/application/auth/jwt.strategy.ts index e0af57c..2eaf2a2 100644 --- a/apps/backend/src/application/auth/jwt.strategy.ts +++ b/apps/backend/src/application/auth/jwt.strategy.ts @@ -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, diff --git a/apps/backend/src/application/bookings/bookings.module.ts b/apps/backend/src/application/bookings/bookings.module.ts index be6faf0..d2f6f46 100644 --- a/apps/backend/src/application/bookings/bookings.module.ts +++ b/apps/backend/src/application/bookings/bookings.module.ts @@ -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 {} diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 6f335b3..41c10c6 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -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 }; } /** diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index f0cc76c..c51dc10 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -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) { diff --git a/apps/backend/src/application/mappers/user.mapper.ts b/apps/backend/src/application/mappers/user.mapper.ts index 960899d..76b503f 100644 --- a/apps/backend/src/application/mappers/user.mapper.ts +++ b/apps/backend/src/application/mappers/user.mapper.ts @@ -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, diff --git a/apps/backend/src/domain/entities/user.entity.ts b/apps/backend/src/domain/entities/user.entity.ts index cd82c17..a2a38f8 100644 --- a/apps/backend/src/domain/entities/user.entity.ts +++ b/apps/backend/src/domain/entities/user.entity.ts @@ -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.'); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts new file mode 100644 index 0000000..686da48 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts @@ -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; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts new file mode 100644 index 0000000..51efa76 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts @@ -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; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts new file mode 100644 index 0000000..8b6e2f3 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts @@ -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, + }; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts new file mode 100644 index 0000000..1bf4459 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts @@ -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, + @InjectRepository(ContainerOrmEntity) + private readonly containerRepository: Repository + ) {} + + async save(booking: Booking): Promise { + const orm = BookingOrmMapper.toOrm(booking); + const saved = await this.bookingRepository.save(orm); + return BookingOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.bookingRepository.findOne({ + where: { id }, + relations: ['containers'], + }); + return orm ? BookingOrmMapper.toDomain(orm) : null; + } + + async findByBookingNumber(bookingNumber: BookingNumber): Promise { + const orm = await this.bookingRepository.findOne({ + where: { bookingNumber: bookingNumber.value }, + relations: ['containers'], + }); + return orm ? BookingOrmMapper.toDomain(orm) : null; + } + + async findByUser(userId: string): Promise { + const orms = await this.bookingRepository.find({ + where: { userId }, + relations: ['containers'], + order: { createdAt: 'DESC' }, + }); + return BookingOrmMapper.toDomainMany(orms); + } + + async findByOrganization(organizationId: string): Promise { + const orms = await this.bookingRepository.find({ + where: { organizationId }, + relations: ['containers'], + order: { createdAt: 'DESC' }, + }); + return BookingOrmMapper.toDomainMany(orms); + } + + async findByStatus(status: BookingStatus): Promise { + const orms = await this.bookingRepository.find({ + where: { status: status.value }, + relations: ['containers'], + order: { createdAt: 'DESC' }, + }); + return BookingOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.bookingRepository.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-organization.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-organization.repository.ts index ad311b1..9f72e37 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-organization.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-organization.repository.ts @@ -35,13 +35,20 @@ export class TypeOrmOrganizationRepository implements OrganizationRepository { return orm ? OrganizationOrmMapper.toDomain(orm) : null; } - async findByScac(scac: string): Promise { + async findBySCAC(scac: string): Promise { const orm = await this.repository.findOne({ where: { scac: scac.toUpperCase() }, }); return orm ? OrganizationOrmMapper.toDomain(orm) : null; } + async findAll(): Promise { + const orms = await this.repository.find({ + order: { name: 'ASC' }, + }); + return OrganizationOrmMapper.toDomainMany(orms); + } + async findAllActive(): Promise { const orms = await this.repository.find({ where: { isActive: true },