mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
946 lines
33 KiB
TypeScript
946 lines
33 KiB
TypeScript
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
ConflictException,
|
|
BadRequestException,
|
|
} from "@nestjs/common";
|
|
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 { ETradeService } from "./services/etrade.service";
|
|
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 {
|
|
Company,
|
|
CompanyNationality,
|
|
CompanyStatus,
|
|
CompanyType,
|
|
} from "./entities/company.entity";
|
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
|
import {
|
|
BusinessLicenseFile,
|
|
CompanyProfile,
|
|
ProfileType,
|
|
ProfileStatus,
|
|
} from "./entities/company-profile.entity";
|
|
|
|
export interface UserIdentity {
|
|
userId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
phone: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CompaniesService {
|
|
constructor(
|
|
private readonly companiesRepo: CompaniesRepository,
|
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
|
private readonly profilesRepo: ExternalProfileRepository,
|
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
|
private readonly minioService: MinioService,
|
|
private readonly etradeService: ETradeService,
|
|
) { }
|
|
|
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
|
if (exists) {
|
|
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
|
}
|
|
return this.companiesRepo.create(dto);
|
|
}
|
|
|
|
async createCompanyWithProfile(
|
|
identity: UserIdentity,
|
|
dto: CreateCompanyWithProfileDto,
|
|
): Promise<{ company: Company; profile: ExternalProfile }> {
|
|
if (dto.tin) {
|
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
|
if (exists) {
|
|
throw new ConflictException(
|
|
`Company with TIN ${dto.tin} already exists`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
|
if (existingProfile) {
|
|
throw new ConflictException(
|
|
`Profile with email ${identity.email} already exists`,
|
|
);
|
|
}
|
|
|
|
const company = await this.companiesRepo.create({
|
|
name: dto.companyName,
|
|
type: dto.companyType,
|
|
tin: dto.tin ?? "",
|
|
vatNumber: dto.vatNumber ?? null,
|
|
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,
|
|
});
|
|
|
|
// Default active mode from the chosen role(s): importer wins when both are
|
|
// picked, otherwise the first allowed type chosen.
|
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
const chosenTypes = (dto.companyProfiles ?? [])
|
|
.map((p) => p.type)
|
|
.filter((t) => allowedTypes.includes(t));
|
|
const activeProfileType =
|
|
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
chosenTypes[0] ??
|
|
allowedTypes[0] ??
|
|
null;
|
|
|
|
const profile = await this.profilesRepo.create({
|
|
userId: identity.userId,
|
|
companyId: company.id,
|
|
firstName: identity.firstName,
|
|
lastName: identity.lastName,
|
|
email: identity.email,
|
|
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
jobTitle: dto.jobTitle ?? null,
|
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
|
activeProfileType,
|
|
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;
|
|
const reference = await this.companyProfilesRepo.generateReference(
|
|
input.type,
|
|
);
|
|
await this.companyProfilesRepo.create({
|
|
companyId: company.id,
|
|
type: input.type,
|
|
reference,
|
|
businessLicense: input.businessLicense ?? null,
|
|
status: ProfileStatus.Active,
|
|
});
|
|
}
|
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
|
company.id,
|
|
);
|
|
}
|
|
|
|
return { company, profile };
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// A profile may exist for the same email under a different IAM id — block
|
|
// duplicates as the final create does.
|
|
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
|
if (byEmail) {
|
|
throw new ConflictException(
|
|
`Profile with email ${identity.email} already exists`,
|
|
);
|
|
}
|
|
|
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
|
const activeProfileType =
|
|
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
chosenTypes[0] ??
|
|
allowedTypes[0] ??
|
|
null;
|
|
|
|
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,
|
|
email: identity.email,
|
|
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
isPrimaryContact: true,
|
|
activeProfileType,
|
|
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;
|
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
|
await this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
reference,
|
|
status: ProfileStatus.Active,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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`);
|
|
return company;
|
|
}
|
|
|
|
async getCompanyInfoByUserId(
|
|
userId: string,
|
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const company = profile.company;
|
|
if (!company)
|
|
throw new NotFoundException(
|
|
`Company for profile ${profile.id} not found`,
|
|
);
|
|
|
|
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,
|
|
): 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();
|
|
|
|
// Scope KPIs to the active operational profile (importer/exporter mode) when
|
|
// one resolves; otherwise aggregate across the whole company.
|
|
const companyProfileId = profile?.activeProfileType
|
|
? ((await this.companyProfilesRepo.findByType(
|
|
companyId,
|
|
profile.activeProfileType,
|
|
)) ?? null)
|
|
: null;
|
|
const scope = companyProfileId
|
|
? { companyProfileId: companyProfileId.id }
|
|
: { companyId };
|
|
|
|
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> {
|
|
await this.findCompanyById(id);
|
|
const updated = await this.companiesRepo.update(id, dto);
|
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
|
return updated;
|
|
}
|
|
|
|
async updateProfile(
|
|
userId: string,
|
|
dto: UpdateProfileDto,
|
|
): Promise<ProfileResponseDto> {
|
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
|
|
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) {
|
|
// Reject a TIN already taken by a different company (the user's own draft
|
|
// placeholder is fine to overwrite).
|
|
const owner = await this.companiesRepo.findByTin(dto.tin);
|
|
if (owner && owner.id !== company.id) {
|
|
throw new ConflictException(
|
|
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
|
);
|
|
}
|
|
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.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;
|
|
|
|
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
|
if (!updated)
|
|
throw new NotFoundException(`Company ${company.id} not found`);
|
|
return new ProfileResponseDto(profile, updated);
|
|
}
|
|
|
|
async deleteCompany(id: string): Promise<void> {
|
|
await this.findCompanyById(id);
|
|
await this.companiesRepo.softDelete(id);
|
|
}
|
|
|
|
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
|
await this.findCompanyById(dto.companyId);
|
|
|
|
const existing = await this.profilesRepo.findByEmail(dto.email);
|
|
if (existing) {
|
|
throw new ConflictException(
|
|
`Profile with email ${dto.email} already exists`,
|
|
);
|
|
}
|
|
|
|
return this.profilesRepo.create(dto);
|
|
}
|
|
|
|
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
return profile;
|
|
}
|
|
|
|
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
|
|
return this.profilesRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
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 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})`,
|
|
);
|
|
}
|
|
|
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
|
|
|
return this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
reference,
|
|
status: ProfileStatus.Active,
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
|
await this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
reference,
|
|
status: ProfileStatus.Active,
|
|
});
|
|
}
|
|
|
|
return this.companyProfilesRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
/**
|
|
* Create a single operational profile for the current user's company and
|
|
* make it the active mode in the same call. Powers the header "Switch to
|
|
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
|
*/
|
|
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) {
|
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
|
created = await this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
reference,
|
|
businessLicense: businessLicense ?? null,
|
|
status: ProfileStatus.Active,
|
|
});
|
|
}
|
|
|
|
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Switch the user's active operational mode. The target profile must already
|
|
* exist — clients create it first via createCompanyProfileForUser.
|
|
*/
|
|
async setActiveMode(
|
|
userId: string,
|
|
type: ProfileType,
|
|
): 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 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}"`,
|
|
);
|
|
}
|
|
|
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
|
if (!existing) {
|
|
throw new ConflictException(
|
|
`No ${type} profile exists yet — create it before switching`,
|
|
);
|
|
}
|
|
|
|
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
|
|
return this.getCompanyInfoByUserId(userId);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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 company = await this.findCompanyById(companyId);
|
|
|
|
// Guard against finishing on a still-draft company (TIN never filled in).
|
|
if (!company.tin || company.tin.startsWith("D")) {
|
|
throw new BadRequestException(
|
|
"Company information is incomplete — please fill in your company details before finishing.",
|
|
);
|
|
}
|
|
|
|
// 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",
|
|
});
|
|
await this.companiesRepo.update(companyId, {
|
|
status: CompanyStatus.Active,
|
|
});
|
|
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 →
|
|
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
|
* when the natural profile doesn't exist) it falls back to the user's active
|
|
* profile, then the company's first profile. Returns null when the company
|
|
* has no profiles at all.
|
|
*/
|
|
async resolveCompanyProfileIdForBooking(
|
|
companyId: string,
|
|
tradeDirection: string,
|
|
fallbackType?: ProfileType | null,
|
|
): 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 byType = (type?: ProfileType | null) =>
|
|
type ? profiles.find((p) => p.type === type) : undefined;
|
|
|
|
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
|
return match?.id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the company_profile a customer's data should be scoped to, from
|
|
* their persisted active mode. Returns null when nothing can be resolved
|
|
* (not onboarded yet) so callers can fall back to company-level scoping.
|
|
*/
|
|
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
|
try {
|
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
const type = profile.activeProfileType;
|
|
if (!type) return null;
|
|
const match = company.companyProfiles?.find((p) => p.type === type);
|
|
return match?.id ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async fetchETradeData(tin: string) {
|
|
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
|
if (!businessInfo) {
|
|
throw new BadRequestException(
|
|
"No business license found for this TIN. Please check the number and try again.",
|
|
);
|
|
}
|
|
return this.etradeService.extractRegistrationData(businessInfo);
|
|
}
|
|
}
|