mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
implement booking flow
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
156
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
156
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCompanyDto } from './create-company.dto';
|
||||
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExternalProfileDto } from './create-external-profile.dto';
|
||||
|
||||
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
export class NotificationsModule { }
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export abstract class NotificationStrategy {
|
||||
abstract send(recipient: string, message: string): Promise<boolean>
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 3 — Power 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 4 — Upload 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"
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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,25 +38,22 @@ 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" />,
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
const USER_TYPE_LEFT_MAP: Record<
|
||||
OnboardingUserType,
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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
5
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user