mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(core): Introduce company and external profile management with dedicated API, services, and data schema
This commit is contained in:
@@ -14,6 +14,7 @@ import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
@@ -59,6 +60,7 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
ConsignmentsModule,
|
||||
TrainsModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
|
||||
|
||||
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
|
||||
name = 'CreateCompaniesModule1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'companies',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'varchar', length: '200' },
|
||||
{ name: 'type', type: 'varchar', length: '32' },
|
||||
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
|
||||
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
|
||||
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
|
||||
{ name: 'address', type: 'text', isNullable: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
|
||||
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
|
||||
{ name: 'attributes', type: 'jsonb', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'external_profiles',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'user_id', type: 'uuid' },
|
||||
{ name: 'company_id', type: 'uuid' },
|
||||
{ name: 'first_name', type: 'varchar', length: '100' },
|
||||
{ name: 'last_name', type: 'varchar', length: '100' },
|
||||
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'is_primary_contact', type: 'boolean', default: false },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'ff_clients',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'forwarder_company_id', type: 'uuid' },
|
||||
{ name: 'client_company_id', type: 'uuid' },
|
||||
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
|
||||
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
|
||||
{ name: 'can_view_documents', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['forwarder_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
{
|
||||
columnNames: ['client_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
|
||||
|
||||
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
|
||||
columnNames: ['forwarder_company_id', 'client_company_id'],
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.ff_clients');
|
||||
await queryRunner.dropTable('freight.external_profiles');
|
||||
await queryRunner.dropTable('freight.companies');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
name?: { en: string; am: string };
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||
async createWithProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyWithProfileDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? '').split(' ');
|
||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(-1)[0] || '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phoneNumber ?? '',
|
||||
},
|
||||
dto,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all companies' })
|
||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('type/:type')
|
||||
@ApiOperation({ summary: 'Find companies by type' })
|
||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search companies by name' })
|
||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies
|
||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get company by ID' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.updateCompany(id, dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: CreateExternalProfileDto,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'List profiles for a company' })
|
||||
async listProfiles(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
): Promise<ResponseExternalProfileDto[]> {
|
||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||
}
|
||||
|
||||
@Get('profile/user/:userId')
|
||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
||||
async findProfileByUser(
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
return new ResponseFFClientDto(client);
|
||||
}
|
||||
|
||||
@Get(':forwarderCompanyId/clients')
|
||||
@ApiOperation({ summary: 'List clients of a forwarder' })
|
||||
async listFFClients(
|
||||
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
||||
): Promise<ResponseFFClientDto[]> {
|
||||
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
||||
return clients.map((c) => new ResponseFFClientDto(c));
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteFFClient(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Company } from './entities/company.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByTin(tin: string): Promise<Company | null> {
|
||||
return this.repository.findOne({ where: { tin } as any });
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<Company[]> {
|
||||
return this.repository.find({ where: { type } as any, order: { name: 'ASC' } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Company[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.name ILIKE :name', { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
156
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
156
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
) {}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
}
|
||||
return this.companiesRepo.create(dto);
|
||||
}
|
||||
|
||||
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
if (dto.tin) {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (existingProfile) {
|
||||
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: dto.companyName,
|
||||
type: dto.companyType,
|
||||
tin: dto.tin ?? '',
|
||||
vatNumber: dto.vatNumber ?? null,
|
||||
businessLicense: dto.fanNumber ?? null,
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? 'Ethiopia',
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: dto.companyPhone ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
});
|
||||
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
||||
}
|
||||
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
return company;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const company = profile.company;
|
||||
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
}
|
||||
|
||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||
await this.findCompanyById(dto.companyId);
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
||||
}
|
||||
|
||||
return this.profilesRepo.create(dto);
|
||||
}
|
||||
|
||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
|
||||
return this.profilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
||||
await this.findCompanyById(dto.forwarderCompanyId);
|
||||
await this.findCompanyById(dto.clientCompanyId);
|
||||
|
||||
const existing = await this.ffClientsRepo.findRelationship(
|
||||
dto.forwarderCompanyId,
|
||||
dto.clientCompanyId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('This forwarder-client relationship already exists');
|
||||
}
|
||||
|
||||
return this.ffClientsRepo.create(dto);
|
||||
}
|
||||
|
||||
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
||||
}
|
||||
|
||||
async deleteFFClient(id: string): Promise<void> {
|
||||
const client = await this.ffClientsRepo.findById(id);
|
||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
||||
await this.ffClientsRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { ResponseCompanyDto } from './response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class CompanyInfoResponseDto {
|
||||
profile: ResponseExternalProfileDto;
|
||||
company: ResponseCompanyDto;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
@IsEnum(CompanyType)
|
||||
companyType!: CompanyType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
companyEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
companyLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
jobTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimaryContact?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@IsEnum(CompanyType)
|
||||
type!: CompanyType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CompanyStatus)
|
||||
status?: CompanyStatus;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
website?: string;
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateExternalProfileDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
jobTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimaryContact?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class CreateFFClientDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(FFClientRelationship)
|
||||
relationshipType?: FFClientRelationship;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canBookOnBehalf?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canViewDocuments?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class ResponseCompanyDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CompanyType;
|
||||
status: CompanyStatus;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
businessLicense?: string | null;
|
||||
fanNumber?: string | null;
|
||||
country: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
this.name = company.name;
|
||||
this.type = company.type;
|
||||
this.status = company.status;
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.businessLicense = company.businessLicense;
|
||||
this.fanNumber = company.fanNumber;
|
||||
this.country = company.country;
|
||||
this.address = company.address;
|
||||
this.phone = company.phone;
|
||||
this.email = company.email;
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
companyId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
this.firstName = profile.firstName;
|
||||
this.lastName = profile.lastName;
|
||||
this.email = profile.email;
|
||||
this.phone = profile.phone;
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class ResponseFFClientDto {
|
||||
id: string;
|
||||
forwarderCompanyId: string;
|
||||
clientCompanyId: string;
|
||||
relationshipType: FFClientRelationship;
|
||||
canBookOnBehalf: boolean;
|
||||
canViewDocuments: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(client: FFClient) {
|
||||
this.id = client.id;
|
||||
this.forwarderCompanyId = client.forwarderCompanyId;
|
||||
this.clientCompanyId = client.clientCompanyId;
|
||||
this.relationshipType = client.relationshipType;
|
||||
this.canBookOnBehalf = client.canBookOnBehalf;
|
||||
this.canViewDocuments = client.canViewDocuments;
|
||||
this.createdAt = client.createdAt;
|
||||
this.updatedAt = client.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCompanyDto } from './create-company.dto';
|
||||
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExternalProfileDto } from './create-external-profile.dto';
|
||||
|
||||
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { ExternalProfile } from './external-profile.entity';
|
||||
|
||||
export enum CompanyType {
|
||||
Customer = 'customer',
|
||||
Forwarder = 'forwarder',
|
||||
Transporter = 'transporter',
|
||||
Broker = 'broker',
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = 'active',
|
||||
Pending = 'pending',
|
||||
Suspended = 'suspended',
|
||||
Blacklisted = 'blacklisted',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'companies' })
|
||||
@Index(['tin'])
|
||||
@Index(['type'])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
||||
status!: CompanyStatus;
|
||||
|
||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
||||
fanNumber?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
||||
country!: string;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||
website?: string | null;
|
||||
|
||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@Index(['companyId'])
|
||||
export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, (company) => company.profiles)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company!: Company;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||
nationalId?: string | null;
|
||||
|
||||
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
|
||||
jobTitle?: string | null;
|
||||
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
export enum FFClientRelationship {
|
||||
ManagedAccount = 'managed_account',
|
||||
SubAgent = 'sub_agent',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'ff_clients' })
|
||||
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
||||
@Index(['forwarderCompanyId'])
|
||||
@Index(['clientCompanyId'])
|
||||
export class FFClient extends BaseEntity {
|
||||
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'forwarder_company_id' })
|
||||
forwarderCompany!: Company;
|
||||
|
||||
@Column({ name: 'client_company_id', type: 'uuid' })
|
||||
clientCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'client_company_id' })
|
||||
clientCompany!: Company;
|
||||
|
||||
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
||||
relationshipType!: FFClientRelationship;
|
||||
|
||||
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
||||
canBookOnBehalf!: boolean;
|
||||
|
||||
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
||||
canViewDocuments!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
repo: Repository<ExternalProfile>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<ExternalProfile | null> {
|
||||
return this.repository.findOne({
|
||||
where: { userId } as any,
|
||||
relations: ['company'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||
return this.repository.find({ where: { companyId } as any });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
||||
return this.repository.findOne({ where: { email } as any });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FFClientRepository extends BaseRepository<FFClient> {
|
||||
constructor(
|
||||
@InjectRepository(FFClient)
|
||||
repo: Repository<FFClient>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { forwarderCompanyId } as any });
|
||||
}
|
||||
|
||||
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { clientCompanyId } as any });
|
||||
}
|
||||
|
||||
async findRelationship(
|
||||
forwarderCompanyId: string,
|
||||
clientCompanyId: string,
|
||||
): Promise<FFClient | null> {
|
||||
return this.repository.findOne({
|
||||
where: { forwarderCompanyId, clientCompanyId } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user