feat: add nationality selection and business license upload functionality

- Introduced a new step in the onboarding process to select company nationality (Ethiopian or Foreign).
- Updated API and service layers to handle nationality data for companies.
- Added support for uploading multiple business license files for each operational profile.
- Refactored company and forwarder forms to include new license upload step.
- Created a reusable RoleLicenseStep component for managing license file uploads.
- Implemented utility functions for phone number handling.
- Added migrations to update the database schema for nationality and business license files.
This commit is contained in:
Marshal
2026-06-20 08:28:01 +00:00
parent ce8189d5fe
commit 54587a8c55
27 changed files with 978 additions and 278 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddNationalityToCompanies1791000000002
implements MigrationInterface
{
name = "AddNationalityToCompanies1791000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS nationality varchar(32);
`);
// Existing companies default to Ethiopian (country defaults to Ethiopia).
await queryRunner.query(`
UPDATE freight.companies
SET nationality = 'ethiopian'
WHERE nationality IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS nationality;
`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
implements MigrationInterface
{
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS business_license_files;
`);
}
}

View File

@@ -32,6 +32,7 @@ import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { BusinessLicenseFile } from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -129,6 +130,7 @@ export class CompaniesController {
},
dto.companyType,
dto.roles,
dto.nationality,
);
return new CompanyInfoResponseDto(profile, company);
}
@@ -150,6 +152,36 @@ export class CompaniesController {
return new ResponseCompanyProfileDto(profile);
}
@Post("company-profiles/:profileId/license")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Upload business-license document(s) for one of the current user's company profiles",
})
async uploadProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<BusinessLicenseFile[]> {
return this.companiesService.uploadProfileLicenseFiles(
user.id,
profileId,
files,
);
}
@Get("company-profiles/:profileId/license")
@ApiOperation({
summary: "List business-license documents for a company profile",
})
async listProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
): Promise<BusinessLicenseFile[]> {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",

View File

@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -16,6 +17,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
FilesModule,
MinioModule,
],
controllers: [CompaniesController],
providers: [

View File

@@ -8,6 +8,7 @@ import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -15,9 +16,15 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company, CompanyStatus, CompanyType } from "./entities/company.entity";
import {
Company,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyProfile,
ProfileType,
ProfileStatus,
@@ -38,6 +45,7 @@ export class CompaniesService {
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
) { }
async createCompany(dto: CreateCompanyDto): Promise<Company> {
@@ -151,12 +159,17 @@ export class CompaniesService {
identity: UserIdentity,
companyType: CompanyType,
roles: ProfileType[],
nationality?: CompanyNationality,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist.
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
await this.ensureCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
}
return this.getCompanyInfoByUserId(identity.userId);
}
@@ -184,6 +197,7 @@ export class CompaniesService {
type: companyType,
tin: await this.generateDraftTin(),
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
});
@@ -455,6 +469,8 @@ export class CompaniesService {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
if (dto.nationality !== undefined)
companyUpdates.nationality = dto.nationality;
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
@@ -746,6 +762,17 @@ export class CompaniesService {
);
}
// Every operational profile must have at least one business-license file
// (stored directly on the profile).
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
throw new BadRequestException(
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
);
}
}
await this.profilesRepo.update(profile.id, {
onboardingCompleted: true,
onboardingStep: "done",
@@ -756,6 +783,69 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Authorize and resolve a company_profile that must belong to the current
* user's company — used before accepting/returning its license files.
*/
async resolveOwnedProfile(
userId: string,
profileId: string,
): Promise<CompanyProfile> {
const { company } = await this.getCompanyInfoByUserId(userId);
const owned = (company.companyProfiles ?? []).find(
(p) => p.id === profileId,
);
if (!owned) {
throw new NotFoundException(`Profile ${profileId} not found`);
}
return owned;
}
/**
* Upload business-license document(s) and store them directly on the company
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
* persisted on the profile — intentionally not via the FileRecord file model.
* New files are appended to any already present. Returns the full list.
*/
async uploadProfileLicenseFiles(
userId: string,
profileId: string,
files: Express.Multer.File[],
): Promise<BusinessLicenseFile[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const uploaded: BusinessLicenseFile[] = [];
for (const file of files) {
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
const url = await this.minioService.uploadFile(
objectName,
file.buffer,
file.mimetype,
);
uploaded.push({
name: file.originalname,
url,
size: file.size,
mimeType: file.mimetype,
});
}
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
await this.companyProfilesRepo.update(profileId, {
businessLicenseFiles: next,
});
return next;
}
/** The business-license files stored on a single company profile. */
async listProfileLicenseFiles(
userId: string,
profileId: string,
): Promise<BusinessLicenseFile[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
return profile.businessLicenseFiles ?? [];
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →

View File

@@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "EX",
[ProfileType.importer]: "IM",
[ProfileType.freightForwarder]: "FFE",
[ProfileType.freightForwarder]: "FF",
[ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR",
};

View File

@@ -6,6 +6,7 @@ export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
nationality: string | null;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -34,6 +35,7 @@ export class ProfileResponseDto {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];

View File

@@ -1,5 +1,13 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { CompanyProfile } from '../entities/company-profile.entity';
import {
Company,
CompanyType,
CompanyStatus,
CompanyNationality,
} from '../entities/company.entity';
import {
BusinessLicenseFile,
CompanyProfile,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyProfileDto {
@@ -7,7 +15,10 @@ export class ResponseCompanyProfileDto {
type: string;
reference: string;
status: string;
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
businessLicense?: string | null;
/** Business-license documents stored on the profile (multi-file). */
licenseFiles: BusinessLicenseFile[];
attributes?: Record<string, any> | null;
createdAt: Date;
updatedAt: Date;
@@ -18,6 +29,7 @@ export class ResponseCompanyProfileDto {
this.reference = profile.reference;
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];
this.attributes = profile.attributes;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
@@ -29,6 +41,7 @@ export class ResponseCompanyDto {
name: string;
type: CompanyType;
status: CompanyStatus;
nationality?: CompanyNationality | null;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -48,6 +61,7 @@ export class ResponseCompanyDto {
this.name = company.name;
this.type = company.type;
this.status = company.status;
this.nationality = company.nationality ?? null;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;
@@ -58,7 +72,9 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,5 +1,5 @@
import { ArrayMinSize, IsArray, IsEnum } from "class-validator";
import { CompanyType } from "../entities/company.entity";
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class StartOnboardingDto {
@@ -10,4 +10,8 @@ export class StartOnboardingDto {
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
roles!: ProfileType[];
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
}

View File

@@ -1,6 +1,11 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity';
export class UpdateProfileDto {
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
@IsOptional()
@IsString()
@MaxLength(200)

View File

@@ -17,6 +17,14 @@ export enum ProfileStatus {
Blacklisted = "blacklisted",
}
/** A business-license document stored directly on the company profile. */
export interface BusinessLicenseFile {
name: string;
url: string;
size: number;
mimeType?: string;
}
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@@ -57,6 +65,14 @@ export class CompanyProfile extends BaseEntity {
})
businessLicense?: string | null;
/**
* Business-license documents for this profile, stored directly on the profile
* (multi-file). The bytes live in object storage; only the metadata/URLs are
* persisted here — this is intentionally NOT modelled via the FileRecord table.
*/
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
businessLicenseFiles?: BusinessLicenseFile[] | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
}

View File

@@ -17,6 +17,11 @@ export enum CompanyStatus {
Blacklisted = "blacklisted",
}
export enum CompanyNationality {
Ethiopian = "ethiopian",
Foreign = "foreign",
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@@ -47,6 +52,16 @@ export class Company extends BaseEntity {
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
/** Whether the company is Ethiopian or Foreign — drives the required onboarding documents. */
@Column({
name: "nationality",
type: "varchar",
length: 32,
nullable: true,
enum: CompanyNationality,
})
nationality?: CompanyNationality | null;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;

View File

@@ -0,0 +1,23 @@
import { AppDataSource } from "../data-source";
import { FileUploadSettingsSeeder } from "../seed/file-upload-settings.seeder";
/**
* Idempotently (re)seed the company onboarding file-upload settings, including
* the nationality-based document sets (ethiopian / foreign). Run on demand:
* pnpm --filter @edr/freight-api seed:file-upload-settings
*/
async function run() {
await AppDataSource.initialize();
try {
const seeder = new FileUploadSettingsSeeder(AppDataSource);
await seeder.run();
console.log("Seeded company onboarding file-upload settings.");
} finally {
await AppDataSource.destroy();
}
}
run().catch((error) => {
console.error("Failed to seed file-upload settings:", error);
process.exit(1);
});

View File

@@ -4,33 +4,107 @@ import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
const COMPANY_ONBOARDING_DOCUMENTS = [
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
},
] as const;
interface OnboardingField {
fileKey: string;
fileLabel: string;
helpText: string;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
}
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
const COMPANY_ONBOARDING_FIELDS = [
/** Documents required from an Ethiopian company at onboarding. */
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "commercial_license",
fileLabel: "Commercial License",
helpText: "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
];
/** Documents required from a Foreign company at onboarding. */
const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "investment_license",
fileLabel: "Investment License",
helpText: "Investment license issued for operating in Ethiopia.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "National ID of the company's authorized representative.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
{
fileKey: "passport",
fileLabel: "Passport",
helpText: "Passport of the company's authorized representative.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 4,
},
];
/** Legacy combined set, kept for the older per-company-type codes. */
const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
@@ -38,7 +112,7 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
@@ -49,7 +123,7 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
@@ -60,11 +134,63 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
] as const;
];
interface OnboardingDocumentSetting {
code: string;
label: string;
entity: string;
fields: OnboardingField[];
}
const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
// Nationality-based sets — the document requirements depend only on whether
// the company is Ethiopian or Foreign (same for importer/exporter/forwarder).
{
code: "company_onboarding_documents_ethiopian",
label: "Ethiopian company onboarding documents",
entity: "customer",
fields: ETHIOPIAN_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_foreign",
label: "Foreign company onboarding documents",
entity: "customer",
fields: FOREIGN_ONBOARDING_FIELDS,
},
// Legacy per-company-type codes (kept for back-compat; no longer used by the portal).
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
];
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding, by company nationality.";
@Injectable()
export class FileUploadSettingsSeeder {
@@ -102,7 +228,7 @@ export class FileUploadSettingsSeeder {
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
documentSetting.fields.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,