feat(core): Introduce company and external profile management with dedicated API, services, and data schema

This commit is contained in:
ghost2023
2026-06-03 16:01:07 +03:00
parent c2145b48f5
commit dc9244d66e
22 changed files with 993 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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