implement booking flow

This commit is contained in:
marshal
2026-06-04 15:24:18 +03:00
44 changed files with 1444 additions and 214 deletions

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
@@ -27,7 +28,7 @@
"@tria-plc/iamapi-common": "^0.1.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"axios": "^1.16.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",

View File

@@ -14,6 +14,7 @@ import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { TrainsModule } from "./modules/trains/trains.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
@@ -59,6 +60,7 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
ConsignmentsModule,
TrainsModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
NotificationsModule,

View File

@@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
name = 'CreateCompaniesModule1749200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'companies',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'name', type: 'varchar', length: '200' },
{ name: 'type', type: 'varchar', length: '32' },
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
{ name: 'address', type: 'text', isNullable: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
{ name: 'attributes', type: 'jsonb', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'external_profiles',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'user_id', type: 'uuid' },
{ name: 'company_id', type: 'uuid' },
{ name: 'first_name', type: 'varchar', length: '100' },
{ name: 'last_name', type: 'varchar', length: '100' },
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
{ name: 'is_primary_contact', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'ff_clients',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'forwarder_company_id', type: 'uuid' },
{ name: 'client_company_id', type: 'uuid' },
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
{ name: 'can_view_documents', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['forwarder_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
{
columnNames: ['client_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
columnNames: ['forwarder_company_id', 'client_company_id'],
}));
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.ff_clients');
await queryRunner.dropTable('freight.external_profiles');
await queryRunner.dropTable('freight.companies');
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN fan_number varchar(16) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN fan_number;
`);
}
}

View File

@@ -0,0 +1,158 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
interface CurrentIamUser {
id: string;
name?: { en: string; am: string };
email?: string;
phoneNumber?: string;
}
@ApiTags('Companies')
@Controller('companies')
export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
async createWithProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyWithProfileDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? '').split(' ');
const { profile, company } = await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || '',
lastName: nameParts.slice(-1)[0] || '',
email: user.email ?? '',
phone: user.phoneNumber ?? '',
},
dto,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
return new ResponseCompanyDto(company);
}
@Get()
@ApiOperation({ summary: 'List all companies' })
async findAll(): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
@Get('type/:type')
@ApiOperation({ summary: 'Find companies by type' })
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
}
@Get('search')
@ApiOperation({ summary: 'Search companies by name' })
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
}
@Get(':id')
@ApiOperation({ summary: 'Get company by ID' })
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a company' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(':id')
@ApiOperation({ summary: 'Soft-delete a company' })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/profiles')
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Body() dto: CreateExternalProfileDto,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.createProfile({ ...dto, companyId });
return new ResponseExternalProfileDto(profile);
}
@Get(':companyId/profiles')
@ApiOperation({ summary: 'List profiles for a company' })
async listProfiles(
@Param('companyId', ParseUUIDPipe) companyId: string,
): Promise<ResponseExternalProfileDto[]> {
const profiles = await this.companiesService.findProfilesByCompany(companyId);
return profiles.map((p) => new ResponseExternalProfileDto(p));
}
@Get('profile/user/:userId')
@ApiOperation({ summary: 'Get profile by IAM user ID' })
async findProfileByUser(
@Param('userId', ParseUUIDPipe) userId: string,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.findProfileByUserId(userId);
return new ResponseExternalProfileDto(profile);
}
@Post('ff-clients')
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
return new ResponseFFClientDto(client);
}
@Get(':forwarderCompanyId/clients')
@ApiOperation({ summary: 'List clients of a forwarder' })
async listFFClients(
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
): Promise<ResponseFFClientDto[]> {
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
return clients.map((c) => new ResponseFFClientDto(c));
}
@Delete('ff-clients/:id')
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteFFClient(id);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
exports: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Company } from './entities/company.entity';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
) {
super(repo);
}
async findByTin(tin: string): Promise<Company | null> {
return this.repository.findOne({ where: { tin } as any });
}
async findByType(type: string): Promise<Company[]> {
return this.repository.find({ where: { type } as any, order: { name: 'ASC' } });
}
async findByName(name: string): Promise<Company[]> {
return this.repository
.createQueryBuilder('company')
.where('company.name ILIKE :name', { name: `%${name}%` })
.getMany();
}
async existsByTin(tin: string): Promise<boolean> {
const count = await this.repository.count({ where: { tin } as any });
return count > 0;
}
}

View File

@@ -0,0 +1,156 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
export interface UserIdentity {
userId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
}
@Injectable()
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
) {}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
}
return this.companiesRepo.create(dto);
}
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
if (dto.tin) {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
if (existingProfile) {
throw new ConflictException(`Profile with email ${identity.email} already exists`);
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
tin: dto.tin ?? '',
vatNumber: dto.vatNumber ?? null,
businessLicense: dto.fanNumber ?? null,
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? 'Ethiopia',
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null,
});
const profile = await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: identity.phone,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
});
return { company, profile };
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
}
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
return company;
}
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
const company = profile.company;
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
return { profile, company };
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
return updated;
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
}
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
await this.findCompanyById(dto.companyId);
const existing = await this.profilesRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException(`Profile with email ${dto.email} already exists`);
}
return this.profilesRepo.create(dto);
}
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
return profile;
}
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
return this.profilesRepo.findByCompanyId(companyId);
}
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
await this.findCompanyById(dto.forwarderCompanyId);
await this.findCompanyById(dto.clientCompanyId);
const existing = await this.ffClientsRepo.findRelationship(
dto.forwarderCompanyId,
dto.clientCompanyId,
);
if (existing) {
throw new ConflictException('This forwarder-client relationship already exists');
}
return this.ffClientsRepo.create(dto);
}
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
}
async deleteFFClient(id: string): Promise<void> {
const client = await this.ffClientsRepo.findById(id);
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
await this.ffClientsRepo.softDelete(id);
}
}

View File

@@ -0,0 +1,14 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import { ResponseCompanyDto } from './response-company.dto';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class CompanyInfoResponseDto {
profile: ResponseExternalProfileDto;
company: ResponseCompanyDto;
constructor(profile: ExternalProfile, company: Company) {
this.profile = new ResponseExternalProfileDto(profile);
this.company = new ResponseCompanyDto(company);
}
}

View File

@@ -0,0 +1,58 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
import { CompanyType } from '../entities/company.entity';
export class CreateCompanyWithProfileDto {
@IsEnum(CompanyType)
companyType!: CompanyType;
@IsString()
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
companyPhone?: string;
@IsOptional()
@IsString()
@MaxLength(32)
companyLocation?: string;
@IsOptional()
@IsString()
companyAddress?: string;
@IsOptional()
@IsString()
@MaxLength(10)
tin?: string;
@IsOptional()
@IsString()
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
jobTitle?: string;
@IsOptional()
@IsBoolean()
isPrimaryContact?: boolean;
@IsOptional()
attributes?: Record<string, any>;
}

View File

@@ -0,0 +1,59 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity';
export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
@IsEnum(CompanyType)
type!: CompanyType;
@IsOptional()
@IsEnum(CompanyStatus)
status?: CompanyStatus;
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
tin!: string;
@IsOptional()
@IsString()
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
@IsOptional()
@IsString()
@MaxLength(32)
country?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
email?: string;
@IsOptional()
@IsString()
@MaxLength(200)
website?: string;
@IsOptional()
attributes?: Record<string, any>;
}

View File

@@ -0,0 +1,44 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
export class CreateExternalProfileDto {
@IsUUID()
@IsNotEmpty()
userId!: string;
@IsUUID()
@IsNotEmpty()
companyId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(50)
nationalId?: string;
@IsOptional()
@IsString()
@MaxLength(100)
jobTitle?: string;
@IsOptional()
@IsBoolean()
isPrimaryContact?: boolean;
}

View File

@@ -0,0 +1,24 @@
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { FFClientRelationship } from '../entities/ff-client.entity';
export class CreateFFClientDto {
@IsUUID()
@IsNotEmpty()
forwarderCompanyId!: string;
@IsUUID()
@IsNotEmpty()
clientCompanyId!: string;
@IsOptional()
@IsEnum(FFClientRelationship)
relationshipType?: FFClientRelationship;
@IsOptional()
@IsBoolean()
canBookOnBehalf?: boolean;
@IsOptional()
@IsBoolean()
canViewDocuments?: boolean;
}

View File

@@ -0,0 +1,42 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyDto {
id: string;
name: string;
type: CompanyType;
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
businessLicense?: string | null;
fanNumber?: string | null;
country: string;
address?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
createdAt: Date;
updatedAt: Date;
constructor(company: Company) {
this.id = company.id;
this.name = company.name;
this.type = company.type;
this.status = company.status;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.businessLicense = company.businessLicense;
this.fanNumber = company.fanNumber;
this.country = company.country;
this.address = company.address;
this.phone = company.phone;
this.email = company.email;
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}
}

View File

@@ -0,0 +1,31 @@
import { ExternalProfile } from '../entities/external-profile.entity';
export class ResponseExternalProfileDto {
id: string;
userId: string;
companyId: string;
firstName: string;
lastName: string;
email: string;
phone?: string | null;
nationalId?: string | null;
jobTitle?: string | null;
isPrimaryContact: boolean;
createdAt: Date;
updatedAt: Date;
constructor(profile: ExternalProfile) {
this.id = profile.id;
this.userId = profile.userId;
this.companyId = profile.companyId;
this.firstName = profile.firstName;
this.lastName = profile.lastName;
this.email = profile.email;
this.phone = profile.phone;
this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}
}

View File

@@ -0,0 +1,23 @@
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
export class ResponseFFClientDto {
id: string;
forwarderCompanyId: string;
clientCompanyId: string;
relationshipType: FFClientRelationship;
canBookOnBehalf: boolean;
canViewDocuments: boolean;
createdAt: Date;
updatedAt: Date;
constructor(client: FFClient) {
this.id = client.id;
this.forwarderCompanyId = client.forwarderCompanyId;
this.clientCompanyId = client.clientCompanyId;
this.relationshipType = client.relationshipType;
this.canBookOnBehalf = client.canBookOnBehalf;
this.canViewDocuments = client.canViewDocuments;
this.createdAt = client.createdAt;
this.updatedAt = client.updatedAt;
}
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCompanyDto } from './create-company.dto';
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExternalProfileDto } from './create-external-profile.dto';
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFFClientDto } from './create-ff-client.dto';
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { ExternalProfile } from './external-profile.entity';
export enum CompanyType {
Customer = 'customer',
Forwarder = 'forwarder',
Transporter = 'transporter',
Broker = 'broker',
}
export enum CompanyStatus {
Active = 'active',
Pending = 'pending',
Suspended = 'suspended',
Blacklisted = 'blacklisted',
}
@Entity({ schema: 'freight', name: 'companies' })
@Index(['tin'])
@Index(['type'])
export class Company extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 200 })
name!: string;
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
type!: CompanyType;
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
status!: CompanyStatus;
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
tin!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
businessLicense?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
fanNumber?: string | null;
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
country!: string;
@Column({ name: 'address', type: 'text', nullable: true })
address?: string | null;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
email?: string | null;
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
website?: string | null;
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
attributes?: Record<string, any> | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity';
@Entity({ schema: 'freight', name: 'external_profiles' })
@Index(['userId'])
@Index(['companyId'])
export class ExternalProfile extends BaseEntity {
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company, (company) => company.profiles)
@JoinColumn({ name: 'company_id' })
company!: Company;
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
nationalId?: string | null;
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
jobTitle?: string | null;
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
isPrimaryContact!: boolean;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Company } from './company.entity';
export enum FFClientRelationship {
ManagedAccount = 'managed_account',
SubAgent = 'sub_agent',
}
@Entity({ schema: 'freight', name: 'ff_clients' })
@Unique(['forwarderCompanyId', 'clientCompanyId'])
@Index(['forwarderCompanyId'])
@Index(['clientCompanyId'])
export class FFClient extends BaseEntity {
@Column({ name: 'forwarder_company_id', type: 'uuid' })
forwarderCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'forwarder_company_id' })
forwarderCompany!: Company;
@Column({ name: 'client_company_id', type: 'uuid' })
clientCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'client_company_id' })
clientCompany!: Company;
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
relationshipType!: FFClientRelationship;
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
canBookOnBehalf!: boolean;
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
canViewDocuments!: boolean;
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { ExternalProfile } from './entities/external-profile.entity';
@Injectable()
export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
constructor(
@InjectRepository(ExternalProfile)
repo: Repository<ExternalProfile>,
) {
super(repo);
}
async findByUserId(userId: string): Promise<ExternalProfile | null> {
return this.repository.findOne({
where: { userId } as any,
relations: ['company'],
});
}
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
return this.repository.find({ where: { companyId } as any });
}
async findByEmail(email: string): Promise<ExternalProfile | null> {
return this.repository.findOne({ where: { email } as any });
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { FFClient } from './entities/ff-client.entity';
@Injectable()
export class FFClientRepository extends BaseRepository<FFClient> {
constructor(
@InjectRepository(FFClient)
repo: Repository<FFClient>,
) {
super(repo);
}
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { forwarderCompanyId } as any });
}
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { clientCompanyId } as any });
}
async findRelationship(
forwarderCompanyId: string,
clientCompanyId: string,
): Promise<FFClient | null> {
return this.repository.findOne({
where: { forwarderCompanyId, clientCompanyId } as any,
});
}
}

View File

@@ -42,6 +42,12 @@ export class FileUploadSettingsController {
return this.service.getByCode(code);
}
@Get("by-entity/:entity")
@ApiOperation({ summary: "Get all file upload settings for an entity type (customer, booking, etc.)" })
getByEntity(@Param("entity") entity: string) {
return this.service.getByEntity(entity);
}
@Post()
@ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) {

View File

@@ -21,6 +21,15 @@ export class FileUploadSettingsRepository
super(repository);
}
/** Look up all settings for a given entity (e.g. "customer", "booking"). */
findByEntity(entity: string): Promise<FileUploadSetting[]> {
return this.repository.find({
where: { entity },
order: { label: "ASC" },
relations: { fields: true },
});
}
/** Look up a setting by its stable code. */
findByCode(code: string): Promise<FileUploadSetting | null> {
return this.repository.findOne({

View File

@@ -33,6 +33,10 @@ export class FileUploadSettingsService {
return setting;
}
getByEntity(entity: string): Promise<FileUploadSetting[]> {
return this.repository.findByEntity(entity);
}
async getByCode(code: string): Promise<FileUploadSetting> {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);

View File

@@ -13,6 +13,7 @@ export interface IFileUploadSettingsRepository {
findAll(): Promise<FileUploadSetting[]>;
findById(id: string): Promise<FileUploadSetting | null>;
findByCode(code: string): Promise<FileUploadSetting | null>;
findByEntity(entity: string): Promise<FileUploadSetting[]>;
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
update(

View File

@@ -1,9 +1,14 @@
import { Module } from "@nestjs/common";
import { NotificationsService } from "./notifications.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
import { HttpModule } from "@nestjs/axios";
@Module({
providers: [NotificationsService],
imports: [HttpModule],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule { }

View File

@@ -1,14 +1,35 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { NotificationStrategy } from "./strategies/notification.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
type StrategyMethod = "sms" | "email"
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly strategies: Map<StrategyMethod, NotificationStrategy>
constructor(private readonly email: EmailNotificationStrategy, private readonly sms: SmsNotificationStrategy) {
this.strategies = new Map([
["sms", this.sms as NotificationStrategy],
["email", this.email as NotificationStrategy]
])
}
/**
* Dispatch a notification to an operator or customer.
* TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service.
*/
async send(recipient: string, subject: string, body: string): Promise<void> {
this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`);
async directSend(method: StrategyMethod, recipient: string, message: string) {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new NotFoundException();
}
const sent = await strategy.send(recipient, message)
this.logger.log(`is sent - ${sent}`)
}
}

View File

@@ -0,0 +1,12 @@
import { Injectable, Logger } from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
@Injectable()
export class EmailNotificationStrategy implements NotificationStrategy {
private readonly logger = new Logger(EmailNotificationStrategy.name);
constructor() { }
async send(recipient: string, message: string): Promise<boolean> {
this.logger.log(`${recipient}, ${message}`)
return false;
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable} from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { HttpService } from '@nestjs/axios';
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from 'rxjs';
@Injectable()
export class SmsNotificationStrategy implements NotificationStrategy {
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
async send(recipient: string, message: string) {
const url = this.configService.get("OZIKING_SMS_URL")
const body = {
to: recipient,
text: message
}
const response = await firstValueFrom(
this.httpService.post(
url,
body,
),
);
return response.status === 201;
}
}

View File

@@ -0,0 +1,6 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export abstract class NotificationStrategy {
abstract send(recipient: string, message: string): Promise<boolean>
}

View File

@@ -43,9 +43,7 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
const name = String(row.cargoTypeName ?? "").trim();
const code = String(row.code ?? "").trim();
const label =
name && code
? `${name} (${code})`
: name || code || String(row.id);
name && code ? `${name} (${code})` : name || code || String(row.id);
return { label, value: String(row.id) };
});
return [noneOption, ...parents];
@@ -82,9 +80,12 @@ export const useContainerTypeOptions = (
includeNone,
}),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("container-types", {
api.ruleEngine.list.call({
resource: "container-types",
params: {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
},
}),
enabled,
select: (result) =>

View File

@@ -79,6 +79,11 @@ export const URL_CONSTANTS = {
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
},
COMPANIES_API: {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
},
BOOKINGS: {
BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`,

View File

@@ -35,9 +35,8 @@ const useAuth = () => {
}),
);
const customerQuery = useQuery(
api.customers.getByUserId.queryOptions({
input: { id: authQuery.data?.id ?? "" },
const companyQuery = useQuery(
api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id,
retry: false,
staleTime: 10 * 60 * 1000,
@@ -48,12 +47,12 @@ const useAuth = () => {
useEffect(() => {
console.log({
user: authQuery.data,
customer: customerQuery.data,
isCustomer: !!customerQuery.data,
company: companyQuery.data,
isCompany: !!companyQuery.data,
isUserPending: authQuery.isPending,
isCustomerPending: customerQuery.isPending,
isCompanyPending: companyQuery.isPending,
});
}, [authQuery, customerQuery]);
}, [authQuery, companyQuery]);
const hasToken = !!getCookie("auth-token");
const isPending = authQuery.isPending && hasToken;
@@ -180,7 +179,8 @@ const useAuth = () => {
return {
isPending,
user: authQuery.data ?? null,
customer: customerQuery.data ?? null,
company: companyQuery.data ?? null,
customer: companyQuery.data ?? null,
login,
signup,
setPassword,
@@ -189,7 +189,8 @@ const useAuth = () => {
generateVerificationCode,
logout,
authQuery,
customerQuery,
companyQuery,
customerQuery: companyQuery,
};
};

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,10 +12,12 @@ import {
CheckCircle2,
Loader2,
ChevronLeft,
UploadCloud,
} from "lucide-react";
import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
import type { CreateCustomerDto } from "@/types/customers";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -23,9 +26,11 @@ import {
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
} from "@edr/ui-common";
import { api } from "@/services/api";
type CompanyStep = "company" | "personnel" | "poa";
type CompanyStep = "company" | "personnel" | "poa" | "documents";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -79,44 +84,22 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"generalManagerPhoneCountryCode",
],
poa: [],
documents: [],
};
const POA_FIELDS: (keyof FormData)[] = [
"poaName",
"poaPhone",
"poaPhoneCountryCode",
"poaAddress",
"poaEmail",
"poaLocation",
];
const POA_LABELS: Record<string, string> = {
poaName: "PoA name",
poaPhone: "PoA phone",
poaPhoneCountryCode: "PoA country code",
poaAddress: "PoA address",
poaEmail: "PoA email",
poaLocation: "PoA location",
};
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
const nameParts = (user.name?.en ?? "").split(" ");
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email,
phone: user.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
tinNumber: data.tinNumber,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
@@ -128,6 +111,7 @@ function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
@@ -140,21 +124,26 @@ export default function CompanyProfileForm({
}: {
userType: OnboardingUserType;
user: AuthUser;
onSubmit: (data: CreateCustomerDto) => void;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const requirePoA = userType === "freight-forwarder-et";
const [step, setStep] = useState<CompanyStep>("company");
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
setError,
clearErrors,
getValues,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
@@ -184,26 +173,18 @@ export default function CompanyProfileForm({
},
});
const hasDocuments = uploadSettings.length > 0;
const nextStep = async () => {
if (step === "poa") {
if (requirePoA) {
clearErrors(POA_FIELDS);
const values = getValues();
let hasError = false;
for (const field of POA_FIELDS) {
const val = values[field];
if (!val || val.toString().trim().length === 0) {
setError(field, {
message: `${
POA_LABELS[field].charAt(0).toUpperCase() +
POA_LABELS[field].slice(1)
} is required for Freight Forwarders`,
});
hasError = true;
if (hasDocuments) {
setStep("documents");
} else {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
}
return;
}
if (hasError) return;
}
if (step === "documents") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -220,6 +201,8 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
} else {
setStep("poa");
}
};
@@ -250,14 +233,21 @@ export default function CompanyProfileForm({
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={hasDocuments ? step === "documents" : step === "personnel"}
/>
{hasDocuments && (
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={false}
/>
)}
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"}
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
{step === "poa" &&
`Step 3 of 3Power of Attorney ${requirePoA ? "(Required)" : "(Optional)"}`}
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
{step === "documents" && "Step 4 of 4Upload Documents (Optional)"}
</p>
</div>
@@ -449,16 +439,12 @@ export default function CompanyProfileForm({
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
{requirePoA
? "Power of Attorney details are required for Freight Forwarder registration."
: "Power of Attorney details are optional. Skip if not applicable."}
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>
PoA Name
{requirePoA && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
@@ -469,10 +455,7 @@ export default function CompanyProfileForm({
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>
PoA Email
{requirePoA && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
@@ -485,7 +468,7 @@ export default function CompanyProfileForm({
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label={`PoA Phone${requirePoA ? " *" : ""}`}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
@@ -493,10 +476,7 @@ export default function CompanyProfileForm({
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>
PoA Location
{requirePoA && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
@@ -506,10 +486,7 @@ export default function CompanyProfileForm({
</Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>
PoA Address
{requirePoA && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
@@ -520,6 +497,36 @@ export default function CompanyProfileForm({
</div>
</>
)}
{step === "documents" && (
<>
<p className="text-sm text-muted-foreground">
Upload required documents for your registration. You can skip
this step and upload later from your account settings.
</p>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : uploadSettings.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
{uploadSettings.map((setting) => (
<SmartFileInput
key={setting.id}
file={setting}
value={documentFiles}
onChange={setDocumentFiles}
/>
))}
</div>
)}
</>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
@@ -534,7 +541,7 @@ export default function CompanyProfileForm({
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
) : step === "documents" ? (
"Complete Registration"
) : (
<>
@@ -560,8 +567,7 @@ function StepIcon({
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"

View File

@@ -12,7 +12,7 @@ import {
ChevronLeft,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCustomerDto } from "@/types/customers";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -45,27 +45,21 @@ const stepLabels: Record<DjiboutiStep, string> = {
representative: "Step 2 of 2 — Representative Details",
};
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
const nameParts = (user.name?.en ?? "").split(" ");
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email,
phone: user.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
contactPersonName: data.repName,
contactPersonPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
tinNumber: "",
tin: "",
vatNumber: "",
fanNumber: "",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
attributes: {
repName: data.repName,
repEmail: data.repEmail,
repPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
},
};
}
@@ -76,7 +70,7 @@ export default function DjiboutiAgentForm({
onBack,
}: {
user: AuthUser;
onSubmit: (data: CreateCustomerDto) => void;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
ArrowUpFromLine,
@@ -10,7 +10,7 @@ import {
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CreateCustomerDto } from "@/types/customers";
import type { CreateCompanyPayload } from "@/services/companies.service";
import AuthLayout from "@/components/auth/AuthLayout";
import CompanyProfileForm from "./CompanyProfileForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm";
@@ -38,22 +38,19 @@ const USER_TYPE_CARDS: {
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description:
"Ethiopian freight forwarding company handling client cargo.",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 className="size-6" />,
},
{
id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)",
description:
"Djibouti-based agent coordinating cross-border logistics.",
description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship className="size-6" />,
},
{
id: "transporter",
label: "Transporter",
description:
"Trucking company providing first/last-mile services.",
description: "Trucking company providing first/last-mile services.",
icon: <Truck className="size-6" />,
},
];
@@ -121,21 +118,39 @@ export default function OnboardingPage() {
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const createCustomerMutation = useMutation({
mutationFn: (payload: CreateCustomerDto) =>
api.customers.create.call(payload),
useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
refetchOnMount: false,
}),
);
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer",
exporter: "customer",
"freight-forwarder-et": "forwarder",
"freight-forwarder-dj": "forwarder",
transporter: "transporter",
};
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
queryKey: api.companies.getInfo.queryKey(),
});
},
});
if (!user) return null;
const handleSubmit = (payload: CreateCustomerDto) => {
createCustomerMutation.mutate(payload);
const handleSubmit = (payload: CreateCompanyPayload) => {
const enriched: CreateCompanyPayload = {
...payload,
companyType: COMPANY_TYPE_MAP[userType!],
};
createCompanyMutation.mutate(enriched);
};
const handleSelectType = (type: OnboardingUserType) => {
@@ -172,9 +187,7 @@ export default function OnboardingPage() {
{card.icon}
</div>
<div>
<p className="font-semibold text-foreground">
{card.label}
</p>
<p className="font-semibold text-foreground">{card.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
{card.description}
</p>
@@ -226,14 +239,14 @@ export default function OnboardingPage() {
<TransporterForm
user={user}
onSubmit={handleSubmit}
isPending={createCustomerMutation.isPending}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm
user={user}
onSubmit={handleSubmit}
isPending={createCustomerMutation.isPending}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : (
@@ -241,7 +254,7 @@ export default function OnboardingPage() {
userType={userType}
user={user}
onSubmit={handleSubmit}
isPending={createCustomerMutation.isPending}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
)}

View File

@@ -8,7 +8,7 @@ import {
Info,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCustomerDto } from "@/types/customers";
import type { CreateCompanyPayload } from "@/services/companies.service";
import {
Button,
Input,
@@ -56,34 +56,23 @@ const transporterSchema = z
type FormData = z.infer<typeof transporterSchema>;
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
const nameParts = (user.name?.en ?? "").split(" ");
function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
return {
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email,
phone: user.phoneNumber,
companyName: "",
companyEmail: "",
companyPhone: "",
companyName: user.name?.en ?? "",
companyEmail: user.email,
companyPhone: user.phoneNumber,
companyLocation: "",
companyAddress: "",
contactPersonName: "",
contactPersonPhone: "",
tinNumber: data.tinNumber,
tin: data.tinNumber,
vatNumber: "",
fanNumber: data.fanNumber,
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
notes: JSON.stringify({
attributes: {
truckType: data.truckType,
plateNumber: data.plateNumber,
plateNumber2: data.plateNumber2 || null,
vehicleModel: data.vehicleModel,
yearOfManufacturing: data.yearOfManufacturing,
}),
},
};
}
@@ -94,7 +83,7 @@ export default function TransporterForm({
onBack,
}: {
user: AuthUser;
onSubmit: (data: CreateCustomerDto) => void;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {

View File

@@ -15,6 +15,7 @@ import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { authService } from "./auth.service";
import { customersService } from "./customers.service";
import { companiesService } from "./companies.service";
import {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
@@ -28,6 +29,10 @@ import {
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import type {
CompanyInfoResponse,
CreateCompanyPayload,
} from "./companies.service";
import type {
AuthUser,
GenerateVerificationCodePayload,
@@ -118,6 +123,20 @@ export const api = {
),
},
companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>(
"companies",
"getInfo",
companiesService.getInfo,
),
create: endpoint<CreateCompanyPayload, CompanyInfoResponse>(
"companies",
"create",
companiesService.create,
),
},
bookings: {
list: endpoint<void, PaginatedResponse<Freight.IBooking>>(
"bookings",
@@ -190,6 +209,12 @@ export const api = {
({ code }) => fileUploadSettingsService.getByCode(code),
),
getByEntity: endpoint<{ entity: string }, FileUploadSetting[]>(
"file-upload-settings",
"getByEntity",
({ entity }) => fileUploadSettingsService.getByEntity(entity),
),
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
"file-upload-settings",
"create",

View File

@@ -0,0 +1,83 @@
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import { isAxiosError } from "axios";
export interface ExternalProfileResponse {
id: string;
userId: string;
companyId: string;
firstName: string;
lastName: string;
email: string;
phone: string | null;
nationalId: string | null;
jobTitle: string | null;
isPrimaryContact: boolean;
createdAt: string;
updatedAt: string;
}
export interface CompanyResponse {
id: string;
name: string;
type: string;
status: string;
tin: string;
vatNumber: string | null;
businessLicense: string | null;
fanNumber: string | null;
country: string;
address: string | null;
phone: string | null;
email: string | null;
website: string | null;
attributes: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}
export interface CompanyInfoResponse {
profile: ExternalProfileResponse;
company: CompanyResponse;
}
export interface CreateCompanyPayload {
companyType?: string;
companyName: string;
companyEmail?: string;
companyPhone?: string;
companyLocation?: string;
companyAddress?: string;
tin?: string;
vatNumber?: string;
fanNumber?: string;
jobTitle?: string;
isPrimaryContact?: boolean;
attributes?: Record<string, any>;
}
export const companiesService = {
getInfo: async (): Promise<CompanyInfoResponse | null> => {
try {
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.GET_INFO,
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
);
return unwrap(response.data);
},
};

View File

@@ -43,6 +43,14 @@ export const fileUploadSettingsService = {
return unwrap(response.data);
},
// GET /file-upload-settings/by-entity/:entity
getByEntity: async (entity: string): Promise<FileUploadSetting[]> => {
const response = await client.get<ApiResponse<FileUploadSetting[]>>(
`${BASE}/by-entity/${encodeURIComponent(entity)}`,
);
return unwrap(response.data);
},
// GET /file-upload-settings/by-code/:code
getByCode: async (code: string): Promise<FileUploadSetting> => {
const response = await client.get<ApiResponse<FileUploadSetting>>(

5
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
'@nestjs/axios':
specifier: ^4.0.1
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -81,7 +84,7 @@ importers:
specifier: ^2.0.1
version: 2.0.1
axios:
specifier: ^1.7.7
specifier: ^1.16.1
version: 1.16.1
class-transformer:
specifier: ^0.5.1