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 { SetPoaDeclaredDto } from "./dto/set-poa-declared.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 { AccountInfoResponse, ShippingLineInfoResponseDto, } from "./dto/account-info-response.dto"; import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; 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 = { 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, private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) { } /** * 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 { 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 account info for the current user (customer or shipping line)", }) async getInfo( @CurrentUser() user: CurrentIamUser, ): Promise { // A shipping line has no company and no external profile, so the customer // lookup below would 404. Checked first, and reported with an explicit // `accountKind` so the portal can skip onboarding for shipping lines // without inferring it from a missing company. const shippingLine = await this.shippingLineCompaniesService.findByUserId( user.id, ); if (shippingLine) { return new ShippingLineInfoResponseDto(shippingLine); } const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); const review = await this.companiesService.getOpenChangeRequestForCompany( company.id, ); return Object.assign(new CompanyInfoResponseDto(profile, company, review), { accountKind: "customer" as const, }); } @Get("profile") @PortalCustomer() @ApiOperation({ summary: "Get flattened profile for the settings page" }) async getProfile( @CurrentUser() user: CurrentIamUser, ): Promise { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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, dto.cooperative, ); 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 { 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, ): Promise { 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, ): Promise { 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 { 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 { 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 { 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, ): Promise { 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 { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } @Post("identity/fayda/complete") @PortalCustomer() @ApiOperation({ summary: "Bind a completed Fayda verification to the company's single identity. " + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + "`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " + "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 { return this.companiesService.completeIdentityVerification(user.id, dto); } @Patch("identity/poa-declared") @PortalCustomer() @ApiOperation({ summary: "Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " + 'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' + 'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").', }) async setPoaDeclared( @CurrentUser() user: CurrentIamUser, @Body() dto: SetPoaDeclaredDto, ): Promise { return this.companiesService.setPoaDeclared(user.id, dto.declared); } @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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ) { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const profile = await this.companiesService.findProfileByUserId(userId); return new ResponseExternalProfileDto(profile); } }