Files
edr-platform/apps/edr-freight-api/src/modules/companies/companies.service.ts
Marshal 9b13fa2ac6 feat: implement wagon transfer management modals and page
- Add TransferFulfillModal for fulfilling wagon transfer requests.
- Create TransferRequestFormModal for filing new wagon transfer requests.
- Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled.
- Develop WagonTransfersPage to manage and display wagon transfer requests.
- Implement utility functions for handling wagon transfer request data and UI components.
- Enhance UI with Mantine components for better user experience.
2026-07-26 15:11:50 +00:00

2347 lines
84 KiB
TypeScript

import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
CompanyDashboardRepository,
DashboardScope,
} from "./company-dashboard.repository";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import {
Company,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyDocumentFileView,
CompanyProfile,
ProfileLicenseFileView,
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
{ key: "poaEmail", label: "PoA email" },
{ key: "poaPhone", label: "PoA phone" },
];
export interface UserIdentity {
userId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
}
@Injectable()
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
) { }
/**
* Required company-information fields that must be filled before onboarding can
* be submitted. The backend owns this list so the portal never has to know
* which fields are mandatory — it just renders what's reported outstanding.
* `get` reads the value from the company (some live in the attributes blob).
*/
private readonly REQUIRED_COMPANY_INFO: {
key: string;
label: string;
get: (company: Company) => unknown;
}[] = [
{
key: "tinNumber",
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
nationality: CompanyNationality | null | undefined,
): string {
return nationality === CompanyNationality.Foreign
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
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.findByUserId(
identity.userId,
);
if (existingProfile) {
throw new ConflictException(
`Profile for user ${identity.userId} already exists`,
);
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
tin: dto.tin ?? "",
vatNumber: dto.vatNumber ?? null,
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: normalizeE164(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,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
onboardingStep: "company",
});
// Persist the operational role(s) chosen during onboarding. Types are
// already constrained to the company type on the client; any that don't
// match are skipped defensively rather than failing the whole signup.
if (dto.companyProfiles?.length) {
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const input of dto.companyProfiles) {
if (!allowedTypes.includes(input.type)) continue;
const existing = await this.companyProfilesRepo.findByType(
company.id,
input.type,
);
if (existing) continue;
// No reference yet — these profiles await backoffice approval, which
// is when the reference is minted (see setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Pending,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
}
return { company, profile };
}
async listCompanies(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
return this.companiesRepo.findPaginated(query);
}
async getCompanyStats(): Promise<CompanyStatsResponseDto> {
return this.companiesRepo.getStats();
}
/**
* Begin onboarding: create a DRAFT company + the user's external profile + the
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
* Idempotent: if the user already has a profile, returns it unchanged (only
* adding any newly-chosen roles). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
async startOnboarding(
identity: UserIdentity,
companyType: CompanyType,
roles: ProfileType[],
nationality?: CompanyNationality,
): Promise<{ profile: ExternalProfile; company: Company }> {
// 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);
}
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const company = await this.companiesRepo.create({
name: identity.firstName
? `${identity.firstName}'s company`
: "New company",
type: companyType,
tin: await this.generateDraftTin(),
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
});
await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
isPrimaryContact: true,
onboardingStep: "company",
onboardingCompleted: false,
});
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
/** Create any of the requested operational profiles that don't exist yet. */
private async ensureCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise<void> {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
for (const type of roles) {
if (!allowedTypes.includes(type)) continue;
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
type,
status: ProfileStatus.Pending,
});
}
}
/**
* A unique 10-char placeholder TIN for a draft company (the column is
* NOT NULL + unique). Overwritten with the real TIN on the company step.
*/
private async generateDraftTin(): Promise<string> {
for (let i = 0; i < 10; i++) {
const candidate =
"D" +
Math.floor(Math.random() * 1_000_000_000)
.toString()
.padStart(9, "0");
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
}
// Extremely unlikely; fall back to a timestamp-derived value.
return ("D" + Date.now().toString()).slice(0, 10);
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
* pick the profile) and any staff booking that pins a profile directly.
*/
async getActiveCompanyProfileForBooking(
companyId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.companyProfilesRepo.findById(profileId);
if (!profile || profile.companyId !== companyId) {
throw new BadRequestException(
"Selected company profile does not belong to the chosen company",
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
}
return profile;
}
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`,
);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
return { profile, company };
}
/**
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
* current user's company bookings. All figures are scoped to that company.
*
* Note: delivered/spend/volume all derive from the bookings table — there is
* no separate data source for them. On-time delivery rate is replaced by
* completion rate (delivered ÷ committed): the schema has no ETA /
* promised-delivery date, so on-time cannot be computed.
*
* Period attribution uses booking.created_at: there is no delivery-date
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(
userId: string,
companyProfileId?: string,
): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
// Company-wide by default (all services' data). An optional companyProfileId
// (from the per-page service filter) narrows to one operational profile —
// but only after we confirm it belongs to this user's company, since the
// dashboard scope has no company guard at the repository layer.
let scope: DashboardScope = { companyId };
if (companyProfileId) {
const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
if (owned.some((p) => p.id === companyProfileId)) {
scope = { companyProfileId };
}
}
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
);
const [
deliveredThis,
committedThis,
spendThisByCcy,
spendPrevByCcy,
tonnageThis,
tonnagePrev,
monthlyRows,
] = await Promise.all([
this.dashboardRepo.countDelivered(scope, yearStart, now),
this.dashboardRepo.countCommitted(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
scope,
this.monthsAgo(now, 5),
now,
),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev =
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate:
committedThis > 0
? Math.round((deliveredThis / committedThis) * 100)
: 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
freightVolume: {
totalTonnes: Math.round(tonnageThis),
totalValue: spend.total,
currency: spend.currency,
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
monthly: this.buildMonthlySeries(now, monthlyRows),
},
};
}
private emptyDashboardSummary(): DashboardSummaryResponseDto {
const now = new Date();
return {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: "ETB",
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: "ETB",
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
};
}
/** First day of the month `n` months before `from`. */
private monthsAgo(from: Date, n: number): Date {
return new Date(from.getFullYear(), from.getMonth() - n, 1);
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
currency: string;
total: number;
} {
if (totals.length === 0) return { currency: "ETB", total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
private changePct(current: number, previous: number): number {
if (previous <= 0) return 0;
return Math.round(((current - previous) / previous) * 100);
}
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
private buildMonthlySeries(
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({
month: labels[d.getMonth()],
tonnes: Math.round(byKey.get(key) ?? 0),
});
}
return series;
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
// This is the only path that writes those statuses.
this.companyNotifier.statusChanged(updated, before.status);
return updated;
}
/** Keep only the keys that were actually provided (drop `undefined`). */
private pickDefined(dto: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(dto)) {
if (v !== undefined) out[k] = v;
}
return out;
}
/**
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
*/
private mapProfileDtoToCompanyUpdates(
company: Company,
dto: Partial<UpdateProfileDto>,
): Record<string, any> {
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 = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPosition !== undefined)
attrUpdates.contactPersonPosition = dto.contactPersonPosition;
if (dto.contactPersonEmail !== undefined)
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
companyUpdates.licenceNumber = dto.licenceNumber;
if (dto.statusDescription !== undefined)
companyUpdates.statusDescription = dto.statusDescription;
if (dto.dateRegistered !== undefined)
companyUpdates.dateRegistered = dto.dateRegistered;
if (dto.renewedFrom !== undefined)
companyUpdates.renewedFrom = dto.renewedFrom;
if (dto.renewalDate !== undefined)
companyUpdates.renewalDate = dto.renewalDate;
if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo;
if (dto.region !== undefined) companyUpdates.region = dto.region;
if (dto.zone !== undefined) companyUpdates.zone = dto.zone;
if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda;
if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele;
if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo;
if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
/** Reject a TIN already registered to a *different* company. */
private async assertTinAvailable(
company: Company,
tin: string | undefined,
): Promise<void> {
if (tin === undefined || tin === company.tin) return;
const owner = await this.companiesRepo.findByTin(tin);
if (owner && owner.id !== company.id) {
throw new ConflictException(
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
);
}
}
/** The company's open (pending or last-rejected) profile change request. */
async getOpenChangeRequestForCompany(
companyId: string,
): Promise<CompanyChangeRequest | null> {
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
}
/**
* Update the current user's profile.
*
* - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run.
* - Company already `active` → do NOT touch the live Company. Stage the edit in
* a pending change request (merging into any open one) so a backoffice
* reviewer can approve (apply) or reject (with a note). This locks the
* customer until the review resolves.
*/
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
if (company.status !== CompanyStatus.Active) {
await this.assertTinAvailable(company, dto.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
const updated = await this.companiesRepo.update(
company.id,
companyUpdates,
);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
// Approved company: stage the change for review, leaving the live row intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const now = new Date();
let request: CompanyChangeRequest;
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields },
submittedBy: userId,
submittedAt: now,
note: null,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
// Rejecting a request leaves it Rejected rather than reopening it, so a
// customer amending after a rejection lands here with a fresh Pending row.
// That is the resubmission case the reviewer needs flagged.
const history = await this.changeRequestRepo.findByCompanyId(company.id);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
});
this.companyNotifier.changeRequestSubmitted(
company,
request.id,
resubmitted,
);
}
// Live company is unchanged; surface the pending state for the settings page.
return new ProfileResponseDto(profile, company, request);
}
/** List a company's change requests, newest first (backoffice review). */
async listChangeRequests(
companyId: string,
): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
}
/**
* Approve a pending change request: apply its snapshot to the live Company and
* mark the request approved. Any staged documents are already attached to the
* company, so nothing else needs promoting.
*/
async approveChangeRequest(
id: string,
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
);
}
const company = await this.companiesRepo.findById(request.companyId);
if (!company)
throw new NotFoundException(`Company ${request.companyId} not found`);
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Approved,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
note: null,
})) ?? request
);
}
/**
* Upload company documents. For an approved company this also opens/updates a
* pending change request (recording the uploaded file ids) so the upload is
* reviewed and the customer is locked until it clears — consistent with the
* field-edit review. During onboarding (company not yet active) it's a plain
* upload with no review.
*/
async uploadCompanyDocuments(
companyId: string,
files: Express.Multer.File[],
submittedBy?: string,
): Promise<FileRecord[]> {
const company = await this.findCompanyById(companyId);
const uploaded = await this.filesService.uploadMany(
companyId,
"companies",
files,
);
await this.resolveDocumentChangeRequests(
companyId,
"companies",
uploaded.map((f) => f.code),
uploaded.map((f) => f.id),
);
if (company.status === CompanyStatus.Active) {
await this.stageDocumentChange(
company.id,
uploaded.map((f) => f.id),
submittedBy,
);
}
return uploaded;
}
/**
* Clear the `change_requested` flag from the documents a fresh upload replaces.
*
* Uploading does not overwrite the old row — it adds a new one under the same
* `code` — so the flagged original would otherwise linger and keep the approval
* gate closed even after the customer did exactly what was asked. Only rows of
* the same code are touched, and never the newly uploaded ones.
*/
private async resolveDocumentChangeRequests(
resourceId: string,
resource: string,
codes: string[],
uploadedIds: string[],
): Promise<void> {
if (codes.length === 0) return;
const replaced = new Set(codes);
const fresh = new Set(uploadedIds);
const open = await this.filesService.findWithOpenChangeRequest(
[resourceId],
resource,
);
await Promise.all(
open
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
.map((f) => this.filesService.clearReview(f.id)),
);
}
/**
* Backoffice: ask the customer to correct one specific document, instead of
* rejecting their whole role over it. Mirrors the contract change-request
* flow — a note the customer sees verbatim, plus a block on approval until
* they re-upload.
*/
async requestDocumentChange(
fileId: string,
note: string,
reviewerId?: string,
): Promise<FileRecord> {
const file = await this.filesService.findById(fileId);
const companyId = await this.resolveDocumentCompanyId(file);
const company = await this.findCompanyById(companyId);
// Flag the document while holding a write lock on its company row. The
// approval gate takes the same lock before it reads the flags, so the two
// serialize: a change request can never land in the window between the gate
// checking "any open corrections?" and writing the profile Active.
const updated = await this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: companyId },
lock: { mode: "pessimistic_write" },
});
return this.filesService.setReviewStatus(
file.id,
"change_requested",
note,
reviewerId,
);
});
this.companyNotifier.documentChangeRequested(
company,
file.name,
note,
file.id,
);
return updated;
}
/**
* Which company a stored document belongs to. Company documents are keyed by
* the company id directly; profile licences and POA letters hang off a company
* profile, so those resolve through it.
*/
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
if (file.resource === "companies") return file.resourceId;
if (file.resource === "company_profiles") {
const profile = await this.companyProfilesRepo.findById(file.resourceId);
if (!profile) {
throw new NotFoundException(
`Company profile ${file.resourceId} not found`,
);
}
return profile.companyId;
}
throw new BadRequestException(
`Documents on "${file.resource}" do not support change requests`,
);
}
/** Open or append a pending change request recording staged document uploads. */
private async stageDocumentChange(
companyId: string,
fileIds: string[],
submittedBy?: string,
): Promise<void> {
if (fileIds.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const company = await this.companiesRepo.findById(companyId);
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
// Spread the existing documents blob: a bare object would drop any
// licenseChanges/documentChanges already staged on this request.
documents: {
...existing.documents,
documentFileIds: [...prev, ...fileIds],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
}
} else {
const history = await this.changeRequestRepo.findByCompanyId(companyId);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
const created = await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentFileIds: fileIds },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(
company,
created.id,
resubmitted,
);
}
}
}
/** Reject a pending change request with a note (customer amends & resubmits). */
async rejectChangeRequest(
id: string,
note: string,
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
);
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
// Staged license/document uploads were just discarded; drop their intents
// so an amended resubmit never re-references deleted files.
documents: {
...request.documents,
licenseChanges: [],
documentChanges: [],
},
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
})) ?? request
);
}
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.findByUserId(dto.userId);
if (existing) {
throw new ConflictException(
`Profile for user ${dto.userId} 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);
}
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
switch (companyType) {
case "customer":
// A customer can operate as an importer and/or exporter, and may also
// add a freight-forwarder service profile under the same company.
return [
ProfileType.importer,
ProfileType.exporter,
ProfileType.freightForwarder,
];
case "freight_forwarder":
return [ProfileType.freightForwarder];
case "dj_freight_forwarder":
return [ProfileType.djFreightForwarder];
case "transporter":
return [ProfileType.transporter];
default:
return [];
}
}
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// Suspension and reactivation must carry a staff explanation — the customer
// sees it, so "why" can never be left blank. Reactivation is the
// active-write that leaves Suspended; a first approval stays note-free.
const reactivating =
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended;
if (
(status === ProfileStatus.Suspended || reactivating) &&
!note?.trim()
) {
throw new BadRequestException(
status === ProfileStatus.Suspended
? "A message explaining the suspension is required — the customer will see it."
: "A message explaining the reactivation is required — the customer will see it.",
);
}
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// Anything other than approval has no document gate and no concurrency
// hazard — no row lock, just the write.
if (status !== ProfileStatus.Active) {
return this.dataSource.transaction((manager) =>
this.applyProfileStatus(manager, existing, status, note, reviewerId),
);
}
// Approving over an outstanding document correction would silently accept the
// very document a reviewer just rejected, and would strand the customer's
// "please fix this" banner with nothing left to fix. The gate check and the
// status write share a write lock on the company row — `requestDocumentChange`
// takes the same lock, so a fresh correction can never land in the window
// between "any open corrections?" and the profile going Active. Suspend and
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
"companies",
),
this.filesService.findWithOpenChangeRequest(
[existing.id],
"company_profiles",
),
]);
const pending = [...companyDocs, ...profileDocs];
if (pending.length > 0) {
const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
);
}
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
});
}
/**
* Write a reviewed profile status (reference minting, note handling, reviewer
* stamp) and promote the company if this is its first approved role. Split out
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
* transaction while every other status skips that overhead.
*/
private async applyProfileStatus(
manager: EntityManager,
existing: CompanyProfile,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
// Every write below goes through `manager`. The approval path holds a
// pessimistic_write lock on the company row, and the injected repositories
// are bound to the DataSource's default pool — writing the same row through
// one of them would block on a lock this very transaction holds, hanging the
// request until the statement timed out. That deadlocked the first approval
// of any customer: the profile went Active on its own connection while the
// company stayed Pending and the caller never got a response.
const profileRepo = manager.getRepository(CompanyProfile);
const companyRepo = manager.getRepository(Company);
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
if (status === ProfileStatus.Active && !existing.reference) {
patch.reference = await this.companyProfilesRepo.generateReference(
existing.type,
);
}
// Track the review outcome. Rejection and suspension keep the note so the
// customer knows why; approval/reactivation clears it. Any decision stamps
// the reviewer + time.
if (
status === ProfileStatus.Rejected ||
status === ProfileStatus.Suspended
) {
patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) {
patch.reviewNote = null;
}
if (status !== ProfileStatus.Pending) {
patch.reviewedBy = reviewerId ?? null;
patch.reviewedAt = new Date();
}
await profileRepo.update(existing.id, patch);
const updated = await profileRepo.findOne({ where: { id: existing.id } });
if (!updated)
throw new NotFoundException(`Company profile ${existing.id} not found`);
// Every reviewed transition that changes what the customer can do is told
// to them, carrying the staff message so they know why. Approval has no
// message (the note is cleared); the others require one.
const change =
status === ProfileStatus.Suspended
? "suspended"
: status === ProfileStatus.Rejected
? "rejected"
: status === ProfileStatus.Active
? existing.status === ProfileStatus.Suspended
? "reactivated"
: "approved"
: null;
if (change) {
const company = await companyRepo.findOne({
where: { id: updated.companyId },
});
if (company) {
this.companyNotifier.profileStatusChanged(
company,
updated.type,
change,
note ?? "",
);
// The first approved role promotes a pending company to active — a
// bigger event (the account itself goes live), so tell them that too.
if (
status === ProfileStatus.Active &&
company.status === CompanyStatus.Pending
) {
await companyRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
this.companyNotifier.companyApproved(company);
}
}
}
return updated;
}
/**
* Customer reapplies for a rejected or suspended operational role (after
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
* back to Pending and clear the review note so it re-enters the approval
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
* backoffice still has to approve before the role goes live again.
*/
async reapplyCompanyProfile(
userId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const target = await this.companyProfilesRepo.findById(profileId);
if (!target || target.companyId !== companyId) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
if (
target.status !== ProfileStatus.Rejected &&
target.status !== ProfileStatus.Suspended
) {
throw new BadRequestException(
"Only a rejected or suspended role can be resubmitted for approval",
);
}
const updated = await this.companyProfilesRepo.update(profileId, {
status: ProfileStatus.Pending,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
// The role is back in the pending queue — tell the reviewers, otherwise the
// resubmission is invisible until someone happens to reopen the customer.
const company = await this.companiesRepo.findById(companyId);
if (company) {
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
}
return updated;
}
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
): Promise<CompanyProfile> {
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const type = profileType ?? allowedTypes[0];
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
);
}
// No reference is minted here: it is issued by setCompanyProfileStatus when
// a reviewer approves the role. Creating it Active would bypass that review.
return this.companyProfilesRepo.create({
companyId,
type,
status: ProfileStatus.Pending,
});
}
async createDefaultProfilesForCompany(
companyId: string,
): Promise<CompanyProfile[]> {
const company = await this.findCompanyById(companyId);
const types = this.getProfileTypeForCompanyType(company.type);
const profiles: CompanyProfile[] = [];
for (const type of types) {
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (!existing) {
profiles.push(await this.createCompanyProfile(companyId, type));
}
}
if (profiles.length === 0) {
throw new BadRequestException(
`Company of type "${company.type}" must have at least one operational profile`,
);
}
return profiles;
}
/**
* Add operational profile(s) to the current user's company (portal settings).
* Add-only and idempotent: each requested type must be allowed for the
* company's type, profiles that already exist are skipped (not re-created or
* rejected), and the full updated list is returned.
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
): Promise<CompanyProfile[]> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const type of types) {
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
status: ProfileStatus.Pending,
});
}
return this.companyProfilesRepo.findByCompanyId(companyId);
}
/**
* Create a single operational profile for the current user's company. The new
* role starts Pending and carries no reference until a backoffice reviewer
* approves it; a booking/contract resolves its profile from the trade
* direction at creation time, so no "active mode" is stored.
*/
async createCompanyProfileForUser(
userId: string,
type: ProfileType,
businessLicense?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
businessLicense: businessLicense ?? null,
status: ProfileStatus.Pending,
});
}
return created;
}
async setOnboardingStep(userId: string, step: string): Promise<void> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
await this.profilesRepo.update(profile.id, { onboardingStep: step });
}
/**
* Server-driven onboarding requirements for the current user's company.
*
* The backend resolves the nationality-based document set, checks which
* company documents and per-profile licenses are already uploaded, and reports
* exactly what is still outstanding. The portal renders this list verbatim and
* relies on `isComplete` to decide when to auto-finish — it never decides for
* itself which documents apply or which fields are mandatory.
*/
async getOnboardingRequirements(
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
const documents = (setting?.fields ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? null,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
displayOrder: f.displayOrder,
uploaded: uploadedCodes.has(f.fileKey),
}));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses (FileRecord-backed).
const licenseProfiles = await Promise.all(
(company.companyProfiles ?? []).map(async (p) => {
const records = await this.filesService.findByResource(
p.id,
LICENSE_RESOURCE,
);
return {
profileId: p.id,
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
};
}),
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
...missingLicenses.map(
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length +
poaItemCount;
const completed =
total -
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
return new OnboardingRequirementsResponseDto({
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
},
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
outstanding,
});
}
/**
* Submit onboarding for review. Validation is delegated entirely to
* getOnboardingRequirements (the same source of truth the portal renders), so
* the gate can never drift from what the UI shows. On success the company and
* all its operational profiles move to PENDING — the backoffice approves each
* profile before it can be used (see setCompanyProfileStatus).
*/
async markOnboardingComplete(
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 companyId = profile.company?.id ?? profile.companyId;
const requirements = await this.getOnboardingRequirements(userId);
if (!requirements.isComplete) {
throw new BadRequestException(
requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.",
);
}
// Send every operational profile in for approval; the company itself becomes
// active once the backoffice approves at least one profile.
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
}
}
await this.profilesRepo.update(profile.id, {
onboardingCompleted: true,
onboardingStep: "done",
});
// Awaiting backoffice approval — stays Pending until an admin activates it.
await this.companiesRepo.update(companyId, {
status: CompanyStatus.Pending,
});
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a self-service action when the company account isn't active, naming
* the actual status — a suspended customer told "awaiting approval" has no
* idea what happened or who to call.
*/
assertCompanyActiveFor(company: Company, action: string): void {
if (company.status === CompanyStatus.Active) return;
switch (company.status) {
case CompanyStatus.Suspended:
throw new ForbiddenException(
`Your company account is suspended — you can't create ${action} right now. ` +
`Please contact EDR support for details.`,
);
case CompanyStatus.Blacklisted:
throw new ForbiddenException(
`Your company account is blacklisted — you can't create ${action}. ` +
`Please contact EDR support.`,
);
default:
throw new ForbiddenException(
`Your company is awaiting approval — you can't create ${action} yet.`,
);
}
}
/**
* Block a customer from booking under a profile that isn't approved yet — or
* that a reviewer has since suspended. Called from the booking/contract
* create path for self-service actions; staff- and government-initiated ones
* bypass this. No-op when the profile can't be found (defensive — resolution
* is best-effort upstream). The message names the profile's real status:
* suspension in particular is per-role, so the customer must learn which
* operation is blocked (their other roles still work).
*/
async assertCompanyProfileApprovedForBooking(
companyProfileId: string,
): Promise<void> {
const profile = await this.companyProfilesRepo.findById(companyProfileId);
if (!profile) return;
if (profile.status === ProfileStatus.Active) return;
const role = profile.type.replace(/_/g, " ");
switch (profile.status) {
case ProfileStatus.Suspended:
throw new ForbiddenException(
`Your ${role} role is suspended${
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
);
case ProfileStatus.Blacklisted:
throw new ForbiddenException(
`Your ${role} role is blacklisted. Please contact EDR support.`,
);
case ProfileStatus.Rejected:
throw new ForbiddenException(
`Your ${role} role was rejected${
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Amend and resubmit it from your settings page.`,
);
default:
throw new ForbiddenException(
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
);
}
}
/**
* 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;
}
// ─── Business-license files ────────────────────────────────────────────────
//
// License documents live in the FileRecord model (`freight.files`) with
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
// code `LICENSE_CODE`; files staged inside an open change request (add /
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
// path regular documents use — so it never hits MinIO directly from the
// browser (which fails on the internal bucket endpoint).
/**
* Upload business-license file(s) for one of the user's profiles. For a role
* not yet approved (a fresh onboarding profile, or a newly added service on an
* already-active company) they go live immediately and are reviewed together
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*/
async addProfileLicenseFiles(
userId: string,
profileId: string,
files: Express.Multer.File[],
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded = await Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: profileId,
resource: LICENSE_RESOURCE,
code,
file,
}),
),
);
if (gated) {
await this.stageLicenseChange(
company.id,
uploaded.map((r) => ({
profileId,
op: "add" as const,
fileId: r.id,
fileName: r.name,
})),
userId,
);
}
// A fresh licence upload answers any correction the reviewer asked for on the
// previous one, so the old row must stop blocking approval.
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
uploaded.map((r) => r.id),
);
return this.getProfileLicenseView(profileId, company.id);
}
/**
* Remove a license file. A staged (pending) file is withdrawn outright
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
* role is kept and recorded as a `remove` intent for review; on a role still
* awaiting approval it is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
profileId: string,
fileId: string,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== LICENSE_RESOURCE ||
record.resourceId !== profileId
) {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = profile.status === ProfileStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
await this.filesService.remove(fileId);
await this.withdrawLicenseIntent(company.id, fileId);
} else if (gated) {
await this.stageLicenseChange(
company.id,
[{ profileId, op: "remove", fileId, fileName: record.name }],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getProfileLicenseView(profileId, company.id);
}
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. On a role still awaiting approval the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
profileId: string,
fileId: string,
file: Express.Multer.File,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const old = await this.filesService.findById(fileId);
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = profile.status === ProfileStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
resource: LICENSE_RESOURCE,
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
file,
});
if (gated) {
await this.stageLicenseChange(
company.id,
[
{ profileId, op: "remove", fileId, fileName: old.name },
{ profileId, op: "add", fileId: created.id, fileName: created.name },
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
[created.id],
);
return this.getProfileLicenseView(profileId, company.id);
}
/** License files for one profile, with each file's review status resolved. */
async listProfileLicenseFiles(
userId: string,
profileId: string,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
return this.getProfileLicenseView(profileId, profile.companyId);
}
/**
* Live license files for a profile, shaped for by-reference reuse (bookings /
* contracts snapshot these). No ownership check — internal callers only.
* Returns the raw stored URLs; pending (unapproved) files are excluded.
*/
async getProfileOnboardingFiles(
profileId: string,
): Promise<BusinessLicenseFile[]> {
const records = await this.filesService.findByResource(
profileId,
LICENSE_RESOURCE,
);
return records
.filter((r) => r.code === LICENSE_CODE)
.map((r) => ({
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
}));
}
/**
* Assemble the review-aware license view for a set of profiles in one pass
* (single change-request lookup). Used to enrich company/profile responses.
*/
async assembleLicenseFilesByProfile(
companyId: string,
profileIds: string[],
): Promise<Record<string, ProfileLicenseFileView[]>> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.licenseChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const result: Record<string, ProfileLicenseFileView[]> = {};
await Promise.all(
profileIds.map(async (pid) => {
result[pid] = await this.mapLicenseRecords(pid, removeIds);
}),
);
return result;
}
/** Single-profile license view (fetches the company's pending request once). */
private async getProfileLicenseView(
profileId: string,
companyId: string,
): Promise<ProfileLicenseFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.licenseChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
return this.mapLicenseRecords(profileId, removeIds);
}
private async mapLicenseRecords(
profileId: string,
pendingRemoveIds: Set<string>,
): Promise<ProfileLicenseFileView[]> {
const records = await this.filesService.findByResource(
profileId,
LICENSE_RESOURCE,
);
return records
.filter(
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === LICENSE_PENDING_CODE
? ("pending_add" as const)
: pendingRemoveIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}
/** Open or append a pending change request recording license add/remove intents. */
private async stageLicenseChange(
companyId: string,
changes: LicenseChangeIntent[],
submittedBy?: string,
): Promise<void> {
if (changes.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (existing) {
const prev = existing.documents?.licenseChanges ?? [];
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
licenseChanges: [...prev, ...changes],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { licenseChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged license intent (add or remove) referencing `fileId` from the
* company's open request. If that empties the request entirely, delete it so
* the customer's settings page unlocks.
*/
private async withdrawLicenseIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.licenseChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, licenseChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged license changes: promote adds, delete removes. */
private async applyLicenseChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.licenseChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, LICENSE_CODE);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged license uploads (adds only). */
private async discardLicenseChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.licenseChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
return this.getPoaDelegationView(company.id);
}
/**
* Upload the PoA delegation letter, replacing whatever is already on file.
* On an Active company this stages an `add` for the new file plus a `remove`
* for each live one; a letter still awaiting approval is withdrawn outright
* rather than stacking a second pending upload.
*/
async uploadPoaDelegationLetter(
userId: string,
file: Express.Multer.File,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const gated = company.status === CompanyStatus.Active;
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
const staged = records.filter(
(r) => r.code === POA_DELEGATION_PENDING_CODE,
);
// Supersede an unreviewed upload instead of queueing another one.
for (const r of staged) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
const created = await this.filesService.upload({
resourceId: company.id,
resource: COMPANY_RESOURCE,
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
file,
});
if (gated) {
await this.stageDocumentIntent(
company.id,
[
...live.map((r) => ({
op: "remove" as const,
fileId: r.id,
code: POA_DELEGATION_FILE_KEY,
fileName: r.name,
})),
{
op: "add" as const,
fileId: created.id,
code: POA_DELEGATION_FILE_KEY,
fileName: created.name,
},
],
userId,
);
} else {
// Onboarding: no review, so the old letter is simply replaced.
for (const r of live) await this.filesService.remove(r.id);
}
await this.resolveDocumentChangeRequests(
company.id,
COMPANY_RESOURCE,
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
[created.id],
);
return this.getPoaDelegationView(company.id);
}
/**
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
* an Active company is kept and flagged for deletion on approval; during
* onboarding it is deleted immediately.
*/
async removePoaDelegationLetter(
userId: string,
fileId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== COMPANY_RESOURCE ||
record.resourceId !== company.id ||
(record.code !== POA_DELEGATION_FILE_KEY &&
record.code !== POA_DELEGATION_PENDING_CODE)
) {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
} else if (company.status === CompanyStatus.Active) {
await this.stageDocumentIntent(
company.id,
[
{
op: "remove",
fileId,
code: POA_DELEGATION_FILE_KEY,
fileName: record.name,
},
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getPoaDelegationView(company.id);
}
private async getPoaDelegationView(
companyId: string,
): Promise<CompanyDocumentFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.documentChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const records = await this.filesService.findByResource(
companyId,
COMPANY_RESOURCE,
);
return records
.filter(
(r) =>
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === POA_DELEGATION_PENDING_CODE
? ("pending_add" as const)
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}
/** Open or append a pending change request recording document add/remove intents. */
private async stageDocumentIntent(
companyId: string,
changes: DocumentChangeIntent[],
submittedBy?: string,
): Promise<void> {
if (changes.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (existing) {
const prev = existing.documents?.documentChanges ?? [];
// Re-uploading twice before review would otherwise stage a second `remove`
// for the same live file, and the duplicate would fail on approval.
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
if (fresh.length === 0) return;
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
documentChanges: [...prev, ...fresh],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged document intent referencing `fileId`. If that empties the
* request entirely, delete it so the customer's settings page unlocks.
*/
private async withdrawDocumentIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.documentChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.licenseChanges?.length ?? 0) > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, documentChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged document changes: promote adds, delete removes. */
private async applyDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, change.code);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged document uploads (adds only). */
private async discardDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
* e.g. a freight forwarder) it falls back to the company's first profile.
* Callers that need a specific role (a forwarder) pass an explicit
* companyProfileId instead. Returns null when the company has no profiles.
*/
async resolveCompanyProfileIdForBooking(
companyId: string,
tradeDirection: string,
): Promise<string | null> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
if (profiles.length === 0) return null;
const naturalType =
tradeDirection === "IMPORT"
? ProfileType.importer
: tradeDirection === "EXPORT"
? ProfileType.exporter
: null;
const match =
(naturalType && profiles.find((p) => p.type === naturalType)) ??
profiles[0];
return match?.id ?? null;
}
async fetchETradeData(tin: string) {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
}