Files
edr-platform/apps/edr-freight-api/src/modules/companies/companies.controller.ts

566 lines
19 KiB
TypeScript

import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFiles,
BadRequestException,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards";
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 { SetActiveModeDto } from "./dto/set-active-mode.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 { 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 { ChangeRequestResponseDto } from "./dto/change-request-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;
}
@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")
@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")
@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")
@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")
@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")
@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")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
return new ETradeResponseDto(data);
}
@Patch("profile")
@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")
@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")
@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")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it 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")
@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")
@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")
@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")
@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);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",
})
async setActiveMode(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetActiveModeDto,
): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.setActiveMode(
user.id,
dto.type,
);
return new CompanyInfoResponseDto(profile, company);
}
@Patch("onboarding-step")
@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")
@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")
@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")
@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()
@FreightAdmin()
@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")
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
async getStats(): Promise<CompanyStatsResponseDto> {
return this.companiesService.getCompanyStats();
}
@Get()
@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")
@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;
}
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a company" })
async update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
@Get(":companyId/documents")
@ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
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,
// 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(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@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);
}
@Patch("company-profiles/:profileId/status")
@FreightAdmin()
@ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
dto.note,
user.id,
);
return new ResponseCompanyProfileDto(profile);
}
@Get(":companyId/change-requests")
@FreightAdmin()
@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));
}
@Post("change-requests/:id/approve")
@FreightAdmin()
@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")
@FreightAdmin()
@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(":companyId/profiles")
@FreightAdmin()
@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")
@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")
@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);
}
}