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

@@ -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;