From 9e4e44ee6a672262018850c39a5926fbd6be8d89 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 11:52:05 +0300 Subject: [PATCH 1/6] feat(file-upload-settings): Add API endpoint and logic to retrieve settings by entity --- .../file-upload-settings.controller.ts | 6 ++++++ .../file-upload-settings.repository.ts | 9 +++++++++ .../file-upload-settings/file-upload-settings.service.ts | 4 ++++ .../file-upload-settings.repository.interface.ts | 1 + 4 files changed, 20 insertions(+) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index 3613d9444..ecdecffc3 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts index 558a7bb74..c14b30052 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts @@ -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 { + return this.repository.find({ + where: { entity }, + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + /** Look up a setting by its stable code. */ findByCode(code: string): Promise { return this.repository.findOne({ diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 8ae5395d7..947bb5ffb 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -33,6 +33,10 @@ export class FileUploadSettingsService { return setting; } + getByEntity(entity: string): Promise { + return this.repository.findByEntity(entity); + } + async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts index 7e833de02..a0aa01d06 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts @@ -13,6 +13,7 @@ export interface IFileUploadSettingsRepository { findAll(): Promise; findById(id: string): Promise; findByCode(code: string): Promise; + findByEntity(entity: string): Promise; create(data: Partial): Promise; update( From c2145b48f549a25a1e963b04050c2e1e0c398baf Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 15:31:29 +0300 Subject: [PATCH 2/6] fix --- .../src/hooks/rule-engine/useRuleEngine.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 8430c8f1c..6342d66fb 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -9,6 +9,7 @@ import type { RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; +import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY"; const CARGO_TYPE_PARENT_PAGE_SIZE = 500; const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500; @@ -46,9 +47,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]; @@ -62,9 +61,12 @@ export const useContainerTypeOptions = (enabled = true) => "select-options", ], queryFn: () => - ruleEngineService.list("container-types", { - page: 1, - pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE, + api.ruleEngine.list.call({ + resource: "container-types", + params: { + page: 1, + pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE, + }, }), enabled, select: (result) => { @@ -123,8 +125,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }); const remove = useMutation({ - mutationFn: (id: string) => - api.ruleEngine.remove.call({ resource, id }), + mutationFn: (id: string) => api.ruleEngine.remove.call({ resource, id }), onSuccess: () => { toast.success("Deleted successfully"); invalidate(); @@ -137,8 +138,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { export const useRateWorkflow = () => { const qc = useQueryClient(); - const invalidate = () => - qc.invalidateQueries({ queryKey: listKey("rates") }); + const invalidate = () => qc.invalidateQueries({ queryKey: listKey("rates") }); const submit = useMutation({ mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }), From dc9244d66ec713fab47cc9f0998e4a3bb4cd7aa8 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:01:07 +0300 Subject: [PATCH 3/6] feat(core): Introduce company and external profile management with dedicated API, services, and data schema --- apps/edr-freight-api/src/app.module.ts | 2 + .../1749200000000-CreateCompaniesModule.ts | 115 +++++++++++++ .../modules/companies/companies.controller.ts | 158 ++++++++++++++++++ .../src/modules/companies/companies.module.ts | 18 ++ .../modules/companies/companies.repository.ts | 35 ++++ .../modules/companies/companies.service.ts | 156 +++++++++++++++++ .../dto/company-info-response.dto.ts | 14 ++ .../dto/create-company-with-profile.dto.ts | 58 +++++++ .../companies/dto/create-company.dto.ts | 59 +++++++ .../dto/create-external-profile.dto.ts | 44 +++++ .../companies/dto/create-ff-client.dto.ts | 24 +++ .../companies/dto/response-company.dto.ts | 42 +++++ .../dto/response-external-profile.dto.ts | 31 ++++ .../companies/dto/response-ff-client.dto.ts | 23 +++ .../companies/dto/update-company.dto.ts | 4 + .../dto/update-external-profile.dto.ts | 4 + .../companies/dto/update-ff-client.dto.ts | 4 + .../companies/entities/company.entity.ts | 64 +++++++ .../entities/external-profile.entity.ts | 39 +++++ .../companies/entities/ff-client.entity.ts | 37 ++++ .../companies/external-profile.repository.ts | 30 ++++ .../modules/companies/ff-client.repository.ts | 32 ++++ 22 files changed, 993 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.controller.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.module.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.service.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/company.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/external-profile.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/ff-client.repository.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 0fcc7a546..a4ecf321e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts new file mode 100644 index 000000000..c02c9fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts @@ -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 { + 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 { + await queryRunner.dropTable('freight.ff_clients'); + await queryRunner.dropTable('freight.external_profiles'); + await queryRunner.dropTable('freight.companies'); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts new file mode 100644 index 000000000..5c646279e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + 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 { + 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 { + const company = await this.companiesService.createCompany(dto); + return new ResponseCompanyDto(company); + } + + @Get() + @ApiOperation({ summary: 'List all companies' }) + async findAll(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.companiesService.deleteFFClient(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts new file mode 100644 index 000000000..8fac2f901 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts new file mode 100644 index 000000000..1156823f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -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 { + constructor( + @InjectRepository(Company) + repo: Repository, + ) { + super(repo); + } + + async findByTin(tin: string): Promise { + return this.repository.findOne({ where: { tin } as any }); + } + + async findByType(type: string): Promise { + return this.repository.find({ where: { type } as any, order: { name: 'ASC' } }); + } + + async findByName(name: string): Promise { + return this.repository + .createQueryBuilder('company') + .where('company.name ILIKE :name', { name: `%${name}%` }) + .getMany(); + } + + async existsByTin(tin: string): Promise { + const count = await this.repository.count({ where: { tin } as any }); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts new file mode 100644 index 000000000..03c104798 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + 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 { + return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + } + + async findCompanyById(id: string): Promise { + 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 { + 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 { + await this.findCompanyById(id); + await this.companiesRepo.softDelete(id); + } + + async createProfile(dto: CreateExternalProfileDto): Promise { + 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 { + 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 { + return this.profilesRepo.findByCompanyId(companyId); + } + + async createFFClient(dto: CreateFFClientDto): Promise { + 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 { + return this.ffClientsRepo.findByForwarder(forwarderCompanyId); + } + + async deleteFFClient(id: string): Promise { + const client = await this.ffClientsRepo.findById(id); + if (!client) throw new NotFoundException(`FFClient ${id} not found`); + await this.ffClientsRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts new file mode 100644 index 000000000..f6ffb8296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts new file mode 100644 index 000000000..4287676d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts new file mode 100644 index 000000000..b22334697 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts new file mode 100644 index 000000000..c694a50e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts new file mode 100644 index 000000000..46375d7ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts new file mode 100644 index 000000000..1879e25d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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 | 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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts new file mode 100644 index 000000000..a33585845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts new file mode 100644 index 000000000..44a48069b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts new file mode 100644 index 000000000..71c3c0739 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCompanyDto } from './create-company.dto'; + +export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts new file mode 100644 index 000000000..3546e5c10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateExternalProfileDto } from './create-external-profile.dto'; + +export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts new file mode 100644 index 000000000..a7ace689d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateFFClientDto } from './create-ff-client.dto'; + +export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts new file mode 100644 index 000000000..ad7407df1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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 | null; + + @OneToMany(() => ExternalProfile, (profile) => profile.company) + profiles?: ExternalProfile[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts new file mode 100644 index 000000000..91a014f10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts new file mode 100644 index 000000000..136dea277 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts new file mode 100644 index 000000000..581dfd72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -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 { + constructor( + @InjectRepository(ExternalProfile) + repo: Repository, + ) { + super(repo); + } + + async findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as any, + relations: ['company'], + }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ where: { companyId } as any }); + } + + async findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } as any }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts new file mode 100644 index 000000000..b94cedec6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts @@ -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 { + constructor( + @InjectRepository(FFClient) + repo: Repository, + ) { + super(repo); + } + + async findByForwarder(forwarderCompanyId: string): Promise { + return this.repository.find({ where: { forwarderCompanyId } as any }); + } + + async findByClient(clientCompanyId: string): Promise { + return this.repository.find({ where: { clientCompanyId } as any }); + } + + async findRelationship( + forwarderCompanyId: string, + clientCompanyId: string, + ): Promise { + return this.repository.findOne({ + where: { forwarderCompanyId, clientCompanyId } as any, + }); + } +} From da10d067f809c6859fbdb7af4338b475c261fb60 Mon Sep 17 00:00:00 2001 From: Tria Date: Wed, 3 Jun 2026 16:17:36 +0300 Subject: [PATCH 4/6] multiple notification strategies --- apps/edr-freight-api/package.json | 5 ++-- .../notifications/notifications.module.ts | 9 +++++-- .../notifications/notifications.service.ts | 27 ++++++++++++++++--- .../strategies/notification.email.strategy.ts | 12 +++++++++ .../strategies/notification.sms.strategy.ts | 25 +++++++++++++++++ .../strategies/notification.strategy.ts | 6 +++++ pnpm-lock.yaml | 5 +++- 7 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 5114e20da..41c570010 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", @@ -76,4 +77,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 39fe1b5c7..2ff2f9727 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -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 { } diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index c17250264..35e8ff07d 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -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 + 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 { - 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}`) } + + } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts new file mode 100644 index 000000000..57873639b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts @@ -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 { + this.logger.log(`${recipient}, ${message}`) + return false; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts new file mode 100644 index 000000000..2f8916845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts new file mode 100644 index 000000000..2bc53120c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export abstract class NotificationStrategy { + abstract send(recipient: string, message: string): Promise +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b737e64..8c735c36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 From e0473b2ffd836d1bb2bceb9fb41e8a7fbd521ad3 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:17:58 +0300 Subject: [PATCH 5/6] fix: add fan_number to companyies --- .../1749300000000-AddFanNumberToCompanies.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts new file mode 100644 index 000000000..647e4fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { + name = 'AddFanNumberToCompanies1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN fan_number varchar(16) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN fan_number; + `); + } +} From f9357ed1b21877d9eacedc714fe4ce49d77a50a2 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:19:04 +0300 Subject: [PATCH 6/6] feat(api): Introduce dedicated company management API and service --- .../portal/src/constants/URLS.ts | 5 + .../portal/src/hooks/useAuth.ts | 19 +- .../src/pages/accounts/CompanyProfileForm.tsx | 192 +++++++++--------- .../src/pages/accounts/DjiboutiAgentForm.tsx | 24 +-- .../src/pages/accounts/OnboardingPage.tsx | 139 +++++++------ .../src/pages/accounts/TransporterForm.tsx | 29 +-- .../portal/src/services/api.ts | 25 +++ .../portal/src/services/companies.service.ts | 83 ++++++++ .../services/fileUploadSettings.service.ts | 8 + 9 files changed, 324 insertions(+), 200 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/services/companies.service.ts diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 84db5e2c2..a9ce8fc00 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index b661ed98a..8d58d3ada 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -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, }; }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index fa82a85a2..eb8d5a343 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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,55 +84,34 @@ const stepFields: Record = { "generalManagerPhoneCountryCode", ], poa: [], + documents: [], }; -const POA_FIELDS: (keyof FormData)[] = [ - "poaName", - "poaPhone", - "poaPhoneCountryCode", - "poaAddress", - "poaEmail", - "poaLocation", -]; - -const POA_LABELS: Record = { - 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, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, - poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, + attributes: { + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + 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("company"); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + 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({ 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 (hasError) return; + if (hasDocuments) { + setStep("documents"); + } else { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); } + 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({ } active={step === "poa"} - completed={false} + completed={hasDocuments ? step === "documents" : step === "personnel"} /> + {hasDocuments && ( + } + active={step === "documents"} + completed={false} + /> + )}

- {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)"}

@@ -449,16 +439,12 @@ export default function CompanyProfileForm({ {step === "poa" && ( <>

- {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.

- - PoA Name - {requirePoA && *} - + PoA Name - - PoA Email - {requirePoA && *} - + PoA Email @@ -493,10 +476,7 @@ export default function CompanyProfileForm({
- - PoA Location - {requirePoA && *} - + PoA Location - - PoA Address - {requirePoA && *} - + PoA Address )} + + {step === "documents" && ( + <> +

+ Upload required documents for your registration. You can skip + this step and upload later from your account settings. +

+ + {loadingDocuments ? ( +
+ +
+ ) : uploadSettings.length === 0 ? ( +

+ No document requirements found for your account type. +

+ ) : ( +
+ {uploadSettings.map((setting) => ( + + ))} +
+ )} + + )}
@@ -534,7 +541,7 @@ export default function CompanyProfileForm({ Submitting... - ) : step === "poa" ? ( + ) : step === "documents" ? ( "Complete Registration" ) : ( <> @@ -560,13 +567,12 @@ function StepIcon({ }) { return (
{completed ? : icon}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index a2f08a012..9422dd3e2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -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 = { 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; }) { diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index b32d2b192..a53b37ee5 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -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"; @@ -23,40 +23,37 @@ const USER_TYPE_CARDS: { description: string; icon: React.ReactNode; }[] = [ - { - id: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - id: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, - { - id: "freight-forwarder-et", - label: "Freight Forwarder (Ethiopia)", - description: - "Ethiopian freight forwarding company handling client cargo.", - icon: , - }, - { - id: "freight-forwarder-dj", - label: "FF Agent (Djibouti)", - description: - "Djibouti-based agent coordinating cross-border logistics.", - icon: , - }, - { - id: "transporter", - label: "Transporter", - description: - "Trucking company providing first/last-mile services.", - icon: , - }, -]; + { + id: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + id: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, + { + id: "freight-forwarder-et", + label: "Freight Forwarder (Ethiopia)", + description: "Ethiopian freight forwarding company handling client cargo.", + icon: , + }, + { + id: "freight-forwarder-dj", + label: "FF Agent (Djibouti)", + description: "Djibouti-based agent coordinating cross-border logistics.", + icon: , + }, + { + id: "transporter", + label: "Transporter", + description: "Trucking company providing first/last-mile services.", + icon: , + }, + ]; const USER_TYPE_LEFT_MAP: Record< OnboardingUserType, @@ -121,21 +118,39 @@ export default function OnboardingPage() { const { user } = useAuth(); const [userType, setUserType] = useState(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 = { + 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 }), - }); + queryClient.invalidateQueries({ + 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}
-

- {card.label} -

+

{card.label}

{card.description}

@@ -197,21 +210,21 @@ export default function OnboardingPage() { features: userType === "transporter" ? [ - "Vehicle & fleet registration", - "TIN & FAN verification", - "First-mile / Last-mile eligibility", - ] + "Vehicle & fleet registration", + "TIN & FAN verification", + "First-mile / Last-mile eligibility", + ] : userType === "freight-forwarder-dj" ? [ - "Company details", - "Representative information", - "Cross-border operations", - ] + "Company details", + "Representative information", + "Cross-border operations", + ] : [ - "Company registration details", - "Contact and management personnel", - "Power of Attorney (optional)", - ], + "Company registration details", + "Contact and management personnel", + "Power of Attorney (optional)", + ], stats: { label: "Active Customers", value: "500+", @@ -226,14 +239,14 @@ export default function OnboardingPage() { ) : userType === "freight-forwarder-dj" ? ( ) : ( @@ -241,7 +254,7 @@ export default function OnboardingPage() { userType={userType} user={user} onSubmit={handleSubmit} - isPending={createCustomerMutation.isPending} + isPending={createCompanyMutation.isPending} onBack={handleBack} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx index 4f077e1ca..7f6a302cf 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx @@ -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; -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; }) { diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 4300cd56b..bb5fcdcc7 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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( + "companies", + "getInfo", + companiesService.getInfo, + ), + + create: endpoint( + "companies", + "create", + companiesService.create, + ), + }, + bookings: { list: endpoint>( "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( "file-upload-settings", "create", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts new file mode 100644 index 000000000..4a68b5aa2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 | 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; +} + +export const companiesService = { + getInfo: async (): Promise => { + try { + const response = await client.get>( + 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 => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.CREATE, + payload, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts b/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts index d4632980e..7d855c5ff 100644 --- a/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts +++ b/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts @@ -43,6 +43,14 @@ export const fileUploadSettingsService = { return unwrap(response.data); }, + // GET /file-upload-settings/by-entity/:entity + getByEntity: async (entity: string): Promise => { + const response = await client.get>( + `${BASE}/by-entity/${encodeURIComponent(entity)}`, + ); + return unwrap(response.data); + }, + // GET /file-upload-settings/by-code/:code getByCode: async (code: string): Promise => { const response = await client.get>(