mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
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:
@@ -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)",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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)) ??
|
||||
[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user