mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
878 lines
31 KiB
TypeScript
878 lines
31 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Patch,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
ParseUUIDPipe,
|
|
HttpCode,
|
|
HttpStatus,
|
|
UseInterceptors,
|
|
UploadedFiles,
|
|
BadRequestException,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
|
import { CurrentUser } from "@edr/api-common";
|
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
|
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
|
|
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
|
import {
|
|
assertFreightPermission,
|
|
hasFreightPermission,
|
|
} from "../../common/freight-permission.util";
|
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|
import { FilesService } from "../files/files.service";
|
|
import { CompaniesService } from "./companies.service";
|
|
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 { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
|
import {
|
|
CompanyIdentityStateDto,
|
|
CompleteIdentityVerificationDto,
|
|
} from "./dto/complete-identity-verification.dto";
|
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
|
import {
|
|
ResponseCompanyDto,
|
|
ResponseCompanyProfileDto,
|
|
} from "./dto/response-company.dto";
|
|
import {
|
|
CompanyDocumentFileView,
|
|
ProfileLicenseFileView,
|
|
} from "./entities/company-profile.entity";
|
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
|
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 { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
|
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
|
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
|
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
|
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
|
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
|
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
|
|
|
interface CurrentIamUser {
|
|
id: string;
|
|
name?: { en: string; am: string };
|
|
email?: string;
|
|
phoneNumber?: string;
|
|
}
|
|
|
|
/**
|
|
* Which permission a status write needs. Approving/reactivating is a different
|
|
* authority from suspending, but both arrive on the same route with the target
|
|
* in the BODY — a route-level guard can't tell them apart, so the handlers
|
|
* assert against this map instead.
|
|
*
|
|
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
|
|
* (a superset: it adds `rejected`).
|
|
*/
|
|
const STATUS_PERM: Record<string, string> = {
|
|
active: FREIGHT_PERMS.customers.verify,
|
|
pending: FREIGHT_PERMS.customers.verify,
|
|
rejected: FREIGHT_PERMS.customers.verify,
|
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
|
};
|
|
|
|
@ApiTags("Companies")
|
|
@Controller("companies")
|
|
export class CompaniesController {
|
|
constructor(
|
|
private readonly companiesService: CompaniesService,
|
|
private readonly filesService: FilesService,
|
|
) { }
|
|
|
|
/**
|
|
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
|
|
* (the client builds that URL from the returned `id`). Populate each profile
|
|
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
|
|
*/
|
|
private async populateLicenseFiles(
|
|
companyId: string,
|
|
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
|
|
): Promise<void> {
|
|
if (profiles.length === 0) return;
|
|
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
|
|
companyId,
|
|
profiles.map((p) => p.id),
|
|
);
|
|
for (const p of profiles) {
|
|
p.licenseFiles = byProfile[p.id] ?? [];
|
|
}
|
|
}
|
|
|
|
@Get("getInfo")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Get company info for the current user" })
|
|
async getInfo(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyInfoResponseDto> {
|
|
const { profile, company } =
|
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
|
company.id,
|
|
);
|
|
return new CompanyInfoResponseDto(profile, company, review);
|
|
}
|
|
|
|
@Get("profile")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
|
async getProfile(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<ProfileResponseDto> {
|
|
const { profile, company } =
|
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
|
company.id,
|
|
);
|
|
const dto = new ProfileResponseDto(profile, company, review);
|
|
await this.populateLicenseFiles(company.id, dto.companyProfiles);
|
|
return dto;
|
|
}
|
|
|
|
@Get("profile/change-request")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary: "Current user's open profile change request (pending/rejected)",
|
|
})
|
|
async getMyChangeRequest(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<ChangeRequestResponseDto | null> {
|
|
const { company } =
|
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
|
company.id,
|
|
);
|
|
return review ? new ChangeRequestResponseDto(review) : null;
|
|
}
|
|
|
|
@Post("company-profiles/:profileId/reapply")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary: "Resubmit a rejected operational role for approval (→ pending)",
|
|
})
|
|
async reapplyCompanyProfile(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
): Promise<ResponseCompanyProfileDto> {
|
|
const profile = await this.companiesService.reapplyCompanyProfile(
|
|
user.id,
|
|
profileId,
|
|
);
|
|
return new ResponseCompanyProfileDto(profile);
|
|
}
|
|
|
|
@Get("dashboard")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
|
|
})
|
|
async getDashboard(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Query() query: DashboardQueryDto,
|
|
): Promise<DashboardSummaryResponseDto> {
|
|
return this.companiesService.getDashboardSummary(
|
|
user.id,
|
|
query.companyProfileId,
|
|
);
|
|
}
|
|
|
|
@Post("fetch-etrade-info")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
|
async fetchETradeInfo(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: FetchETradeDto,
|
|
): Promise<ETradeResponseDto> {
|
|
// Best-effort: a first-run onboarding draft may not exist yet, in which
|
|
// case there is no company to exclude and `tinTaken` checks every row —
|
|
// the correct behaviour for a brand-new lookup.
|
|
const companyId = await this.companiesService
|
|
.getCompanyInfoByUserId(user.id)
|
|
.then(({ company }) => company.id)
|
|
.catch(() => undefined);
|
|
const data = await this.companiesService.fetchETradeData(
|
|
dto.tin,
|
|
companyId,
|
|
dto.licenceNumber,
|
|
);
|
|
return new ETradeResponseDto(data);
|
|
}
|
|
|
|
@Patch("profile")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
|
async updateProfile(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: UpdateProfileDto,
|
|
): Promise<ProfileResponseDto> {
|
|
return this.companiesService.updateProfile(user.id, dto);
|
|
}
|
|
|
|
@Post("company-profiles")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
|
|
})
|
|
async addCompanyProfiles(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: AddCompanyProfilesDto,
|
|
): Promise<ResponseCompanyProfileDto[]> {
|
|
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
|
user.id,
|
|
dto.types,
|
|
);
|
|
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
|
}
|
|
|
|
@Post("onboarding/start")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
|
|
})
|
|
async startOnboarding(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: StartOnboardingDto,
|
|
): Promise<CompanyInfoResponseDto> {
|
|
const nameParts = (user.name?.en ?? "").split(" ");
|
|
const { profile, company } = await this.companiesService.startOnboarding(
|
|
{
|
|
userId: user.id,
|
|
firstName: nameParts[0] || "",
|
|
lastName: nameParts.slice(-1)[0] || "",
|
|
email: user.email ?? "",
|
|
phone: user.phoneNumber ?? "",
|
|
},
|
|
dto.companyType,
|
|
dto.roles,
|
|
dto.nationality,
|
|
);
|
|
return new CompanyInfoResponseDto(profile, company);
|
|
}
|
|
|
|
@Post("company-profile")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
|
|
})
|
|
async createCompanyProfile(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: CreateCompanyProfileDto,
|
|
): Promise<ResponseCompanyProfileDto> {
|
|
const profile = await this.companiesService.createCompanyProfileForUser(
|
|
user.id,
|
|
dto.type,
|
|
dto.businessLicense,
|
|
);
|
|
return new ResponseCompanyProfileDto(profile);
|
|
}
|
|
|
|
@Post("company-profiles/:profileId/license")
|
|
@PortalCustomer()
|
|
@UseInterceptors(AnyFilesInterceptor())
|
|
@ApiConsumes("multipart/form-data")
|
|
@ApiOperation({
|
|
summary:
|
|
"Add business-license document(s) to a profile. For an approved company " +
|
|
"the upload is staged for backoffice review; during onboarding it goes live.",
|
|
})
|
|
async uploadProfileLicense(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
return this.companiesService.addProfileLicenseFiles(
|
|
user.id,
|
|
profileId,
|
|
files,
|
|
);
|
|
}
|
|
|
|
@Post("company-profiles/:profileId/license/:fileId/replace")
|
|
@PortalCustomer()
|
|
@UseInterceptors(AnyFilesInterceptor())
|
|
@ApiConsumes("multipart/form-data")
|
|
@ApiOperation({
|
|
summary:
|
|
"Replace a business-license file with a newly uploaded one (staged for " +
|
|
"review on an approved company).",
|
|
})
|
|
async replaceProfileLicense(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const file = files?.[0];
|
|
if (!file) {
|
|
throw new BadRequestException("A replacement file is required");
|
|
}
|
|
return this.companiesService.replaceProfileLicenseFile(
|
|
user.id,
|
|
profileId,
|
|
fileId,
|
|
file,
|
|
);
|
|
}
|
|
|
|
@Delete("company-profiles/:profileId/license/:fileId")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Remove a business-license file (staged for review on an approved company).",
|
|
})
|
|
async removeProfileLicense(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
return this.companiesService.removeProfileLicenseFile(
|
|
user.id,
|
|
profileId,
|
|
fileId,
|
|
);
|
|
}
|
|
|
|
@Get("company-profiles/:profileId/license")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary: "List business-license documents (with review state) for a profile",
|
|
})
|
|
async listProfileLicense(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
|
}
|
|
|
|
@Get("poa-delegation")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"List the Power of Attorney delegation letter (with review state) for the current user's company",
|
|
})
|
|
async listPoaDelegation(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
return this.companiesService.listPoaDelegationFiles(user.id);
|
|
}
|
|
|
|
@Post("poa-delegation")
|
|
@PortalCustomer()
|
|
@UseInterceptors(AnyFilesInterceptor())
|
|
@ApiConsumes("multipart/form-data")
|
|
@ApiOperation({
|
|
summary:
|
|
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
|
|
"For an approved company the upload is staged for backoffice review; during " +
|
|
"onboarding it goes live.",
|
|
})
|
|
async uploadPoaDelegation(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
const file = files?.[0];
|
|
if (!file) {
|
|
throw new BadRequestException("A delegation letter file is required");
|
|
}
|
|
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
|
|
}
|
|
|
|
@Delete("poa-delegation/:fileId")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
|
|
})
|
|
async removePoaDelegation(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
|
}
|
|
|
|
@Post("identity/fayda/complete")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
|
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
|
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
|
})
|
|
async completeIdentityVerification(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: CompleteIdentityVerificationDto,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.completeIdentityVerification(user.id, dto, {
|
|
email: user.email,
|
|
phoneNumber: user.phoneNumber,
|
|
});
|
|
}
|
|
|
|
@Post("identity/gm/same-as-owner")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
|
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
|
})
|
|
async setGmSameAsOwner(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.setGmSameAsOwner(user.id, {
|
|
email: user.email,
|
|
phoneNumber: user.phoneNumber,
|
|
});
|
|
}
|
|
|
|
@Delete("identity/gm")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
|
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
|
})
|
|
async clearGmIdentity(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.clearGmIdentity(user.id);
|
|
}
|
|
|
|
@Post("identity/poa/same-as-owner")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
|
|
"Waives the DARS delegation paper — nobody delegates to themselves. " +
|
|
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
|
|
})
|
|
async setPoaSameAsOwner(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.setPoaSameAsOwner(user.id, {
|
|
email: user.email,
|
|
phoneNumber: user.phoneNumber,
|
|
});
|
|
}
|
|
|
|
@Delete("identity/poa/same-as-owner")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
|
|
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
|
|
})
|
|
async clearPoaSameAsOwner(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.clearPoaSameAsOwner(user.id);
|
|
}
|
|
|
|
@Delete("identity/fayda/poa")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
|
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
|
|
})
|
|
async removePoaIdentity(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
return this.companiesService.removePoaIdentity(user.id);
|
|
}
|
|
|
|
@Patch("onboarding-step")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async setOnboardingStep(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: SetOnboardingStepDto,
|
|
): Promise<void> {
|
|
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
|
}
|
|
|
|
@Get("onboarding/requirements")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
|
|
})
|
|
async getOnboardingRequirements(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<OnboardingRequirementsResponseDto> {
|
|
return this.companiesService.getOnboardingRequirements(user.id);
|
|
}
|
|
|
|
@Post("onboarding/complete")
|
|
@PortalCustomer()
|
|
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
|
async completeOnboarding(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
): Promise<CompanyInfoResponseDto> {
|
|
const { profile, company } =
|
|
await this.companiesService.markOnboardingComplete(user.id);
|
|
return new CompanyInfoResponseDto(profile, company);
|
|
}
|
|
|
|
// Used by portal
|
|
@Post("create")
|
|
@PortalCustomer()
|
|
@ApiOperation({
|
|
summary:
|
|
"Create a company with its associated external profile (onboarding)",
|
|
})
|
|
async createWithProfile(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Body() dto: CreateCompanyWithProfileDto,
|
|
): Promise<CompanyInfoResponseDto> {
|
|
const nameParts = (user.name?.en ?? "").split(" ");
|
|
const { profile, company } =
|
|
await this.companiesService.createCompanyWithProfile(
|
|
{
|
|
userId: user.id,
|
|
firstName: nameParts[0] || "",
|
|
lastName: nameParts.slice(-1)[0] || "",
|
|
email: user.email ?? "",
|
|
phone: user.phoneNumber ?? "",
|
|
},
|
|
dto,
|
|
);
|
|
return new CompanyInfoResponseDto(profile, company);
|
|
}
|
|
|
|
// Used by backoffice
|
|
@Post()
|
|
@BookingStaff(FREIGHT_PERMS.customers.create)
|
|
@ApiOperation({
|
|
summary:
|
|
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
|
})
|
|
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
|
const company = await this.companiesService.createCompany(dto);
|
|
return new ResponseCompanyDto(company);
|
|
}
|
|
|
|
@Get("stats")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
|
return this.companiesService.getCompanyStats();
|
|
}
|
|
|
|
@Get()
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
|
async findAll(
|
|
@Query() query: ListCompaniesQueryDto,
|
|
): Promise<{ items: ResponseCompanyDto[]; total: number }> {
|
|
const { items, total } = await this.companiesService.listCompanies(query);
|
|
return { items: items.map((c) => new ResponseCompanyDto(c)), total };
|
|
}
|
|
|
|
@Get(":id")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "Get company by ID" })
|
|
async findById(
|
|
@Param("id", ParseUUIDPipe) id: string,
|
|
): Promise<ResponseCompanyDto> {
|
|
const company = await this.companiesService.findCompanyById(id);
|
|
const dto = new ResponseCompanyDto(company);
|
|
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
|
|
return dto;
|
|
}
|
|
|
|
/**
|
|
* Edits fields AND carries `status`, so it spans two authorities. The route
|
|
* guard is one-of (a status-only caller must get in); the asserts below are
|
|
* what actually authorize: touching `status` needs the permission
|
|
* {@link STATUS_PERM} maps it to, touching anything else needs
|
|
* `customers:update`. Both checks are required — without the second, a
|
|
* caller holding only `customers:deactivate` could rename the company.
|
|
*/
|
|
@Patch(":id")
|
|
@BookingStaff([
|
|
FREIGHT_PERMS.customers.update,
|
|
FREIGHT_PERMS.customers.verify,
|
|
FREIGHT_PERMS.customers.deactivate,
|
|
])
|
|
@ApiOperation({ summary: "Update a company" })
|
|
async update(
|
|
@Param("id", ParseUUIDPipe) id: string,
|
|
@Body() dto: UpdateCompanyDto,
|
|
@CurrentUser() user: TCurrentUser,
|
|
): Promise<ResponseCompanyDto> {
|
|
const { status, ...fields } = dto;
|
|
if (status) assertFreightPermission(user, STATUS_PERM[status]);
|
|
if (Object.keys(fields).length > 0) {
|
|
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
|
|
}
|
|
const company = await this.companiesService.updateCompany(id, dto);
|
|
return new ResponseCompanyDto(company);
|
|
}
|
|
|
|
@Delete(":id")
|
|
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
|
@ApiOperation({ summary: "Soft-delete a company" })
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
|
await this.companiesService.deleteCompany(id);
|
|
}
|
|
|
|
/**
|
|
* Dual-audience: staff read any customer's documents, and the portal reads
|
|
* its OWN during onboarding (`companiesService.getDocuments`). So the route
|
|
* is authenticated-only and the split happens here — same shape as
|
|
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
|
|
* customer on their own documents.
|
|
*
|
|
* The staff arm is one-of because two pages consume it: the customer detail
|
|
* page (`customers:view`) and the contract-request detail page, whose route
|
|
* is gated on `contracts:view` — a contract reviewer without the customer
|
|
* permission still needs the applicant's documents.
|
|
*/
|
|
@Get(":companyId/documents")
|
|
@MixedAudience([
|
|
FREIGHT_PERMS.customers.view,
|
|
FREIGHT_PERMS.contracts.view,
|
|
FREIGHT_PERMS.bookings.view,
|
|
])
|
|
@ApiOperation({ summary: "List documents uploaded for a company" })
|
|
async listDocuments(
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
@CurrentUser() user: TCurrentUser,
|
|
) {
|
|
const isStaff = [
|
|
FREIGHT_PERMS.customers.view,
|
|
FREIGHT_PERMS.contracts.view,
|
|
FREIGHT_PERMS.bookings.view,
|
|
].some((p) => hasFreightPermission(user, p));
|
|
|
|
if (!isStaff) {
|
|
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
|
user.id,
|
|
);
|
|
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
|
|
if (company.id !== companyId) {
|
|
throw new NotFoundException(`Company ${companyId} not found`);
|
|
}
|
|
}
|
|
const files = await this.filesService.findByResource(companyId, "companies");
|
|
return Promise.all(
|
|
files.map(async (f) => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
code: f.code,
|
|
mimeType: f.mimeType,
|
|
size: f.size,
|
|
uploadedAt: f.createdAt,
|
|
reviewStatus: f.reviewStatus,
|
|
reviewNote: f.reviewNote,
|
|
reviewedAt: f.reviewedAt,
|
|
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
|
// it so the file previews/downloads in the client.
|
|
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
|
})),
|
|
);
|
|
}
|
|
|
|
@Post("documents/:fileId/request-change")
|
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
|
@ApiOperation({
|
|
summary: "Ask the customer to correct one uploaded document",
|
|
description:
|
|
"Flags a single document with a reason the customer sees, notifies them, " +
|
|
"and blocks role approval until they re-upload. Narrower than rejecting " +
|
|
"the whole role.",
|
|
})
|
|
async requestDocumentChange(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
|
@Body() dto: RequestDocumentChangeDto,
|
|
) {
|
|
const file = await this.companiesService.requestDocumentChange(
|
|
fileId,
|
|
dto.note,
|
|
user.id,
|
|
);
|
|
return {
|
|
id: file.id,
|
|
name: file.name,
|
|
code: file.code,
|
|
reviewStatus: file.reviewStatus,
|
|
reviewNote: file.reviewNote,
|
|
reviewedAt: file.reviewedAt,
|
|
};
|
|
}
|
|
|
|
@Post(":companyId/documents")
|
|
@MixedAudience(FREIGHT_PERMS.customers.update)
|
|
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
|
|
@ApiConsumes("multipart/form-data")
|
|
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
|
async uploadDocuments(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
|
) {
|
|
// Routed through the service so an approved company's uploads are staged for
|
|
// review (and lock the customer), while onboarding uploads pass straight through.
|
|
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
|
}
|
|
|
|
/**
|
|
* Approve / reject / suspend / blacklist all arrive here with the target in
|
|
* the body, so authorization is per-status via {@link STATUS_PERM} rather
|
|
* than on the route (the guard is only the one-of gate).
|
|
*/
|
|
@Patch("company-profiles/:profileId/status")
|
|
@BookingStaff([
|
|
FREIGHT_PERMS.customers.verify,
|
|
FREIGHT_PERMS.customers.deactivate,
|
|
])
|
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
|
async updateCompanyProfileStatus(
|
|
@CurrentUser() user: TCurrentUser,
|
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
|
): Promise<ResponseCompanyProfileDto> {
|
|
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
|
profileId,
|
|
dto.status,
|
|
dto.note,
|
|
user.id,
|
|
);
|
|
return new ResponseCompanyProfileDto(profile);
|
|
}
|
|
|
|
@Get(":companyId/change-requests")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "List a company's profile change requests" })
|
|
async listChangeRequests(
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
): Promise<ChangeRequestResponseDto[]> {
|
|
const requests = await this.companiesService.listChangeRequests(companyId);
|
|
return requests.map((r) => new ChangeRequestResponseDto(r));
|
|
}
|
|
|
|
@Get(":companyId/revisions")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
|
|
async listCompanyRevisions(
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
): Promise<CompanyRevisionResponseDto[]> {
|
|
const revisions =
|
|
await this.companiesService.listCompanyRevisions(companyId);
|
|
return revisions.map((r) => new CompanyRevisionResponseDto(r));
|
|
}
|
|
|
|
@Post("change-requests/:id/approve")
|
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
|
@ApiOperation({
|
|
summary: "Approve a pending profile change request (applies the changes)",
|
|
})
|
|
async approveChangeRequest(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("id", ParseUUIDPipe) id: string,
|
|
): Promise<ChangeRequestResponseDto> {
|
|
const request = await this.companiesService.approveChangeRequest(
|
|
id,
|
|
user.id,
|
|
);
|
|
return new ChangeRequestResponseDto(request);
|
|
}
|
|
|
|
@Post("change-requests/:id/reject")
|
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
|
@ApiOperation({
|
|
summary: "Reject a pending profile change request with a note",
|
|
})
|
|
async rejectChangeRequest(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("id", ParseUUIDPipe) id: string,
|
|
@Body() dto: RejectChangeRequestDto,
|
|
): Promise<ChangeRequestResponseDto> {
|
|
const request = await this.companiesService.rejectChangeRequest(
|
|
id,
|
|
dto.note,
|
|
user.id,
|
|
);
|
|
return new ChangeRequestResponseDto(request);
|
|
}
|
|
|
|
@Post("change-requests/:id/request-changes")
|
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
|
@ApiOperation({
|
|
summary:
|
|
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
|
})
|
|
async requestChangeRequestChanges(
|
|
@CurrentUser() user: CurrentIamUser,
|
|
@Param("id", ParseUUIDPipe) id: string,
|
|
@Body() dto: RejectChangeRequestDto,
|
|
): Promise<ChangeRequestResponseDto> {
|
|
const request = await this.companiesService.requestChangeRequestChanges(
|
|
id,
|
|
dto.note,
|
|
user.id,
|
|
);
|
|
return new ChangeRequestResponseDto(request);
|
|
}
|
|
|
|
@Post(":companyId/profiles")
|
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
|
async createProfile(
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
@Body() dto: CreateExternalProfileDto,
|
|
): Promise<ResponseExternalProfileDto> {
|
|
const profile = await this.companiesService.createProfile({
|
|
...dto,
|
|
companyId,
|
|
});
|
|
return new ResponseExternalProfileDto(profile);
|
|
}
|
|
|
|
@Get(":companyId/profiles")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "List profiles for a company" })
|
|
async listProfiles(
|
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
|
): Promise<ResponseExternalProfileDto[]> {
|
|
const profiles =
|
|
await this.companiesService.findProfilesByCompany(companyId);
|
|
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
|
}
|
|
|
|
@Get("profile/user/:userId")
|
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
|
async findProfileByUser(
|
|
@Param("userId", ParseUUIDPipe) userId: string,
|
|
): Promise<ResponseExternalProfileDto> {
|
|
const profile = await this.companiesService.findProfileByUserId(userId);
|
|
return new ResponseExternalProfileDto(profile);
|
|
}
|
|
}
|