diff --git a/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts new file mode 100644 index 000000000..d3720de33 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Business-license files used to live inline as a jsonb array on + * `company_profiles.business_license_files`. They now belong to the FileRecord + * model (`freight.files`, resource `company_profiles`, code `business_license`) + * so they get stable ids and stream through `GET /api/files/:id` — the same + * proxy path regular documents use — instead of broken direct-MinIO URLs. + * + * This copies each existing inline entry into `freight.files` by reference + * (keeping the stored object URL — no bytes are re-uploaded). The original jsonb + * column is left intact for rollback safety. + */ +export class MigrateLicenseFilesToFileRecords2040000000000 + implements MigrationInterface +{ + name = "MigrateLicenseFilesToFileRecords2040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.files + (id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at) + SELECT + gen_random_uuid(), + cp.id, + 'company_profiles', + 'business_license', + COALESCE(elem->>'name', 'license'), + elem->>'url', + COALESCE(NULLIF(elem->>'size', '')::int, 0), + COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'), + now(), + now() + FROM freight.company_profiles cp + CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem + WHERE cp.business_license_files IS NOT NULL + AND jsonb_typeof(cp.business_license_files) = 'array' + AND elem->>'url' IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.files f + WHERE f.resource_id = cp.id + AND f.resource = 'company_profiles' + AND f.code = 'business_license' + AND f.url = elem->>'url' + AND f.deleted_at IS NULL + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse the model migration by dropping the license FileRecords. The + // original jsonb column was never cleared, so the data still exists there. + await queryRunner.query(` + DELETE FROM freight.files + WHERE resource = 'company_profiles' + AND code = 'business_license'; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 661a204d8..f8fbb26b0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -12,6 +12,7 @@ import { HttpStatus, UseInterceptors, UploadedFiles, + BadRequestException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; @@ -33,7 +34,7 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { BusinessLicenseFile } from "./entities/company-profile.entity"; +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"; @@ -63,6 +64,25 @@ export class CompaniesController { 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 { + 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( @@ -86,7 +106,9 @@ export class CompaniesController { const review = await this.companiesService.getOpenChangeRequestForCompany( company.id, ); - return new ProfileResponseDto(profile, company, review); + const dto = new ProfileResponseDto(profile, company, review); + await this.populateLicenseFiles(company.id, dto.companyProfiles); + return dto; } @Get("profile/change-request") @@ -215,28 +237,72 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Upload business-license document(s) for one of the current user's company profiles", + "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.uploadProfileLicenseFiles( + ): Promise { + 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, + ): 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") + @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") @ApiOperation({ - summary: "List business-license documents for a company profile", + summary: "List business-license documents (with review state) for a profile", }) async listProfileLicense( @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, - ): Promise { + ): Promise { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } @@ -344,7 +410,9 @@ export class CompaniesController { @Param("id", ParseUUIDPipe) id: string, ): Promise { const company = await this.companiesService.findCompanyById(id); - return new ResponseCompanyDto(company); + const dto = new ResponseCompanyDto(company); + await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []); + return dto; } @Patch(":id") diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 559a89a38..e31851aef 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -13,7 +13,6 @@ import { CompanyDashboardRepository, DashboardScope, } from "./company-dashboard.repository"; -import { MinioService } from "../minio/minio.service"; import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; @@ -39,14 +38,22 @@ import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, ProfileType, ProfileStatus, } from "./entities/company-profile.entity"; import { ChangeRequestStatus, CompanyChangeRequest, + LicenseChangeIntent, } from "./entities/company-change-request.entity"; +/** FileRecord `resource` + `code` slots for business-license documents. */ +const LICENSE_RESOURCE = "company_profiles"; +const LICENSE_CODE = "business_license"; +/** Code for a license file staged in an open change request (not yet live). */ +const LICENSE_PENDING_CODE = "business_license_pending"; + export interface UserIdentity { userId: string; firstName: string; @@ -63,7 +70,6 @@ export class CompaniesService { private readonly changeRequestRepo: CompanyChangeRequestRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, - private readonly minioService: MinioService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, @@ -341,28 +347,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); - for (const profile of company.companyProfiles) { - profile.businessLicenseFiles = await this.signLicenseFiles( - profile.businessLicenseFiles, - ); - } return company; } - /** - * Business-license files are stored as raw, unsigned MinIO URLs (see - * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them - * directly. Sign each one with a short-lived URL before it reaches a response. - */ - private async signLicenseFiles( - files?: BusinessLicenseFile[] | null, - ): Promise { - if (!files?.length) return []; - return Promise.all( - files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), - ); - } - /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff @@ -777,6 +764,7 @@ export class CompaniesService { await this.assertTinAvailable(company, snapshot.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); await this.companiesRepo.update(company.id, companyUpdates); + await this.applyLicenseChanges(request); return ( (await this.changeRequestRepo.update(id, { @@ -860,9 +848,13 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.discardLicenseChanges(request); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, + // Staged license uploads were just discarded; drop their intents so an + // amended resubmit never re-references deleted files. + documents: { ...request.documents, licenseChanges: [] }, note, reviewedBy: reviewerId ?? null, reviewedAt: new Date(), @@ -1231,13 +1223,21 @@ export class CompaniesService { })); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); - // 3. Per-operational-profile business licenses. - const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ - profileId: p.id, - type: p.type, - reference: p.reference ?? "", - uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, - })); + // 3. Per-operational-profile business licenses (FileRecord-backed). + const licenseProfiles = await Promise.all( + (company.companyProfiles ?? []).map(async (p) => { + const records = await this.filesService.findByResource( + p.id, + LICENSE_RESOURCE, + ); + return { + profileId: p.id, + type: p.type, + reference: p.reference ?? "", + uploaded: records.some((r) => r.code === LICENSE_CODE), + }; + }), + ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); const outstanding = [ @@ -1354,61 +1354,321 @@ export class CompaniesService { return owned; } + // ─── Business-license files ──────────────────────────────────────────────── + // + // License documents live in the FileRecord model (`freight.files`) with + // `resource = "company_profiles"`, `resourceId = `. Live files use + // code `LICENSE_CODE`; files staged inside an open change request (add / + // replacement) use `LICENSE_PENDING_CODE` and only become live on approval. + // Preview streams through `GET /api/files/:id` (server-side proxy) — the same + // path regular documents use — so it never hits MinIO directly from the + // browser (which fails on the internal bucket endpoint). + /** - * Upload business-license 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. + * Upload business-license file(s) for one of the user's profiles. During + * onboarding (company not yet Active) they go live immediately; for an Active + * company they're staged under the pending code and recorded as `add` intents + * on a pending change request for backoffice review. Returns the updated view. */ - async uploadProfileLicenseFiles( + async addProfileLicenseFiles( userId: string, profileId: string, files: Express.Multer.File[], - ): Promise { + ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; - 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, + const uploaded = await Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code, + file, + }), + ), + ); + + if (gated) { + await this.stageLicenseChange( + company.id, + uploaded.map((r) => ({ + profileId, + op: "add" as const, + fileId: r.id, + fileName: r.name, + })), + userId, ); - 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 { - const profile = await this.resolveOwnedProfile(userId, profileId); - return profile.businessLicenseFiles ?? []; + return this.getProfileLicenseView(profileId, company.id); } /** - * Onboarding documents stored on a company profile, fetched by profile id. - * Internal helper (no ownership check) used when a booking reuses the active - * profile's onboarding documents. Returns [] when the profile is unknown. + * Remove a license file. A staged (pending) file is withdrawn outright + * (soft-deleted, its `add` intent dropped). A live file on an Active company + * is kept and recorded as a `remove` intent for review; during onboarding it + * is deleted immediately. + */ + async removeProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== LICENSE_RESOURCE || + record.resourceId !== profileId + ) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + if (record.code === LICENSE_PENDING_CODE) { + // Withdraw a not-yet-approved upload: delete it and drop its add intent. + await this.filesService.remove(fileId); + await this.withdrawLicenseIntent(company.id, fileId); + } else if (gated) { + await this.stageLicenseChange( + company.id, + [{ profileId, op: "remove", fileId, fileName: record.name }], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** + * Replace a live license file with a freshly uploaded one — recorded as a + * `remove` of the old file plus an `add` of the new, so approval swaps them + * atomically. During onboarding the swap is applied immediately. + */ + async replaceProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + file: Express.Multer.File, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const old = await this.filesService.findById(fileId); + if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + const created = await this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE, + file, + }); + + if (gated) { + await this.stageLicenseChange( + company.id, + [ + { profileId, op: "remove", fileId, fileName: old.name }, + { profileId, op: "add", fileId: created.id, fileName: created.name }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** License files for one profile, with each file's review status resolved. */ + async listProfileLicenseFiles( + userId: string, + profileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + return this.getProfileLicenseView(profileId, profile.companyId); + } + + /** + * Live license files for a profile, shaped for by-reference reuse (bookings / + * contracts snapshot these). No ownership check — internal callers only. + * Returns the raw stored URLs; pending (unapproved) files are excluded. */ async getProfileOnboardingFiles( profileId: string, ): Promise { - const profile = await this.companyProfilesRepo.findById(profileId); - return profile?.businessLicenseFiles ?? []; + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter((r) => r.code === LICENSE_CODE) + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); + } + + /** + * Assemble the review-aware license view for a set of profiles in one pass + * (single change-request lookup). Used to enrich company/profile responses. + */ + async assembleLicenseFilesByProfile( + companyId: string, + profileIds: string[], + ): Promise> { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const result: Record = {}; + await Promise.all( + profileIds.map(async (pid) => { + result[pid] = await this.mapLicenseRecords(pid, removeIds); + }), + ); + return result; + } + + /** Single-profile license view (fetches the company's pending request once). */ + private async getProfileLicenseView( + profileId: string, + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + return this.mapLicenseRecords(profileId, removeIds); + } + + private async mapLicenseRecords( + profileId: string, + pendingRemoveIds: Set, + ): Promise { + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter( + (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === LICENSE_PENDING_CODE + ? ("pending_add" as const) + : pendingRemoveIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording license add/remove intents. */ + private async stageLicenseChange( + companyId: string, + changes: LicenseChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.licenseChanges ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + licenseChanges: [...prev, ...changes], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { licenseChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged license intent (add or remove) referencing `fileId` from the + * company's open request. If that empties the request entirely, delete it so + * the customer's settings page unlocks. + */ + private async withdrawLicenseIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.licenseChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, licenseChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged license changes: promote adds, delete removes. */ + private async applyLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, LICENSE_CODE); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged license uploads (adds only). */ + private async discardLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } } /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index ddda72a64..579ac6ddd 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -1,6 +1,7 @@ import { ChangeRequestStatus, CompanyChangeRequest, + LicenseChangeIntent, } from "../entities/company-change-request.entity"; /** @@ -15,6 +16,8 @@ export class ChangeRequestResponseDto { /** Proposed field values (Partial) — the diff payload. */ snapshot: Record; documentFileIds: string[]; + /** Staged business-license add/remove intents attached to this request. */ + licenseChanges: LicenseChangeIntent[]; note: string | null; submittedBy: string | null; submittedAt: Date | null; @@ -29,6 +32,7 @@ export class ChangeRequestResponseDto { this.status = req.status; this.snapshot = req.snapshot ?? {}; this.documentFileIds = req.documents?.documentFileIds ?? []; + this.licenseChanges = req.documents?.licenseChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; this.submittedAt = req.submittedAt ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index f90b7f88d..0c783cbcf 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -5,8 +5,8 @@ import { CompanyNationality, } from '../entities/company.entity'; import { - BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; @@ -18,8 +18,12 @@ export class ResponseCompanyProfileDto { status: string; /** @deprecated Superseded by licenseFiles. Kept for back-compat. */ businessLicense?: string | null; - /** Business-license documents stored on the profile (multi-file). */ - licenseFiles: BusinessLicenseFile[]; + /** + * Business-license documents (FileRecord-backed) with review state. Left empty + * by the constructor and populated asynchronously by the controller, since the + * files and their pending-change status require DB lookups. + */ + licenseFiles: ProfileLicenseFileView[]; attributes?: Record | null; /** Reviewer note when the role is rejected (drives the reapply prompt). */ reviewNote?: string | null; @@ -33,7 +37,7 @@ export class ResponseCompanyProfileDto { this.reference = profile.reference ?? ''; this.status = profile.status; this.businessLicense = profile.businessLicense; - this.licenseFiles = profile.businessLicenseFiles ?? []; + this.licenseFiles = []; this.attributes = profile.attributes; this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 11e5c09cb..cec670787 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -15,10 +15,27 @@ export enum ChangeRequestStatus { Rejected = "rejected", } +/** + * A single staged business-license change on one company profile, awaiting + * review. `add` → a new file was uploaded under the pending code and becomes + * live on approval; `remove` → an existing live file is deleted on approval. + * A "replace" is recorded as a `remove` of the old file plus an `add` of the + * new one. `fileId` is the FileRecord id the op targets. + */ +export interface LicenseChangeIntent { + profileId: string; + op: "add" | "remove"; + fileId: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + /** File references staged alongside a change request (documents/licenses). */ export interface ChangeRequestDocuments { /** FileRecord ids uploaded against the company while this request was open. */ documentFileIds?: string[]; + /** Staged per-profile business-license add/remove intents. */ + licenseChanges?: LicenseChangeIntent[]; } @Entity({ schema: "freight", name: "company_change_request" }) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 77b62a786..72696766f 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -19,7 +19,11 @@ export enum ProfileStatus { Blacklisted = "blacklisted", } -/** A business-license document stored directly on the company profile. */ +/** + * @deprecated Legacy inline shape. Business-license files now live in the + * FileRecord model (`freight.files`, resource `company_profiles`). Kept only for + * the by-reference reuse shape consumed by bookings/contracts snapshots. + */ export interface BusinessLicenseFile { name: string; url: string; @@ -27,6 +31,19 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** A business-license file plus its change-review state, surfaced to clients. */ +export interface ProfileLicenseFileView { + id: string; + name: string; + size: number; + mimeType: string; + /** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ + status: "live" | "pending_add" | "pending_remove"; +} + @Entity({ schema: "freight", name: "company_profiles" }) @Index(["reference"], { unique: true }) @Index(["type"]) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index aaf064bff..095857959 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -326,10 +326,20 @@ export class ContractsService { companyProfileId: string | null, ): Promise { if (!companyProfileId) return; - const profile = await this.dataSource - .getRepository(CompanyProfile) - .findOne({ where: { id: companyProfileId } }); - const docs = profile?.businessLicenseFiles ?? []; + // Business-license files are FileRecords (resource "company_profiles"); carry + // the live ones by reference. Staged/pending uploads are excluded by code. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const docs = records + .filter((r) => r.code === 'business_license') + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); if (docs.length === 0) return; const slug = (name: string) => diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 4966d7ff9..3fd0752f7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -124,6 +124,15 @@ export class FilesService { await this.filesRepository.softDelete(id); } + /** + * Re-slot a stored file under a new `code` (e.g. promote a staged + * `business_license_pending` file to the live `business_license` code once a + * change request is approved). Bytes and URL are untouched. + */ + async setCode(id: string, code: string): Promise { + await this.filesRepository.update(id, { code }); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index a5de7335a..924d62436 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -1,5 +1,6 @@ import { Alert, + Anchor, Badge, Box, Button, @@ -12,9 +13,17 @@ import { Textarea, } from "@mantine/core"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { AlertTriangle, ClipboardCheck, Clock } from "lucide-react"; +import { + AlertTriangle, + ClipboardCheck, + Clock, + FilePlus2, + FileX2, +} from "lucide-react"; import { useState } from "react"; +import { useFileViewer } from "@edr/ui-common"; +import { fileViewUrl } from "@/constants/apiConfig"; import { api } from "@/services/api"; import type { Company, CompanyChangeRequest } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -129,6 +138,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.rejectChangeRequest.mutationOptions(), ); + const { view, viewer } = useFileViewer(); const [rejectId, setRejectId] = useState(null); const [note, setNote] = useState(""); @@ -142,6 +152,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { ? Object.keys(pending.snapshot ?? {}) : ([] as string[]); const docCount = pending?.documentFileIds?.length ?? 0; + const licenseChanges = pending?.licenseChanges ?? []; const confirmReject = () => { if (!rejectId) return; @@ -206,6 +217,48 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} + {licenseChanges.length > 0 && ( + + + Business license changes + + {licenseChanges.map((c, i) => ( + + {c.op === "add" ? ( + + ) : ( + + )} + + {c.op === "add" ? "Add" : "Remove"} + + + view({ + name: c.fileName ?? "License document", + url: fileViewUrl(c.fileId), + }) + } + style={{ + textDecoration: + c.op === "remove" ? "line-through" : undefined, + }} + > + {c.fileName ?? "License document"} + + + ))} + + )} + @@ -1131,9 +1182,9 @@ export default function NewContractPage({ - License submitted. Your {createTargetLabel.toLowerCase()} profile - is now awaiting staff approval. We'll notify you once it's - approved — then you can create this contract as{" "} + License submitted. Your {createTargetLabel.toLowerCase()}{" "} + profile is now awaiting staff approval. We'll notify you once + it's approved — then you can create this contract as{" "} {createTargetLabel.toLowerCase()}. @@ -1146,9 +1197,9 @@ export default function NewContractPage({ ) : ( - You don't have a {createTargetLabel.toLowerCase()} profile yet. Add - your business license to create one. It goes to staff for approval - before you can use it. + You don't have a {createTargetLabel.toLowerCase()} profile yet. + Add your business license to create one. It goes to staff for + approval before you can use it. - {/* Awaiting-approval modal — the chosen operation maps to a profile that - exists but isn't approved yet. The select was already reverted. */} - setPendingApprovalProfile(null)} - title="Awaiting approval" - centered - radius="lg" - > - - - Your{" "} - {pendingApprovalProfile - ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? - pendingApprovalProfile) - : ""}{" "} - profile was submitted and is under staff review. You can start a - contract under it once it's approved. - - - - - - + {/* Awaiting-approval / rejected modal — the chosen operation maps to a + profile that exists but isn't active. The select was already reverted. */} + {(() => { + const target = pendingApprovalProfile + ? profileByType.get(pendingApprovalProfile) + : undefined; + const isRejected = target?.status === "rejected"; + const label = pendingApprovalProfile + ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? + pendingApprovalProfile) + : ""; + return ( + setPendingApprovalProfile(null)} + title={isRejected ? "Profile not approved" : "Awaiting approval"} + centered + radius="lg" + > + + {isRejected ? ( + <> + + Your {label} profile was not approved. Fix the issue below + and resubmit it for review. + + {target?.reviewNote && ( + + + Reviewer note: {target.reviewNote} + + + )} + + + + + + ) : ( + <> + + Your {label} profile was submitted and is under staff + review. You can start a contract under it once it's approved. + + + + + + )} + + + ); + })()} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx index 34f8d92ea..86b6f61a2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx @@ -156,7 +156,7 @@ export function StepDocuments({ {onboardingDocs.map((doc, i) => ( void; /** Approval state of the profile each operation maps to (for the badges). */ - operationStatus?: (op: OperationType) => "approved" | "pending" | "missing"; + operationStatus?: ( + op: OperationType, + ) => "approved" | "pending" | "rejected" | "missing"; }) { const contractType = form.watch("contractType"); @@ -221,6 +223,11 @@ export function Step1ContractType({ Pending )} + {status === "rejected" && ( + + Rejected + + )} {status === "missing" && ( Add license diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 2b31559ce..9bd8d7ba9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -1,11 +1,21 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { api } from "@/services/api"; -import { companiesService } from "@/services/companies.service"; +import { + companiesService, + type CompanyProfileResponse, + type LicenseFileStatus, +} from "@/services/companies.service"; import { getMinFiles } from "@/types/fileUploadSettings"; import type { ProfileResponse } from "@/types/profile"; -import { SmartFileInput, useFileViewer } from "@edr/ui-common"; import { - // Anchor, + SmartFileInput, + useFileViewer, + type ViewableFile, +} from "@edr/ui-common"; +import { + ActionIcon, + Anchor, + Badge, Button, Card, Center, @@ -13,18 +23,23 @@ import { Stack, Text, Title, + Tooltip, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, CheckCircle2, + Clock, FileCheck, + FileText, Loader2, Paperclip, + RefreshCw, + Trash2, UploadCloud, XCircle, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; const ROLE_LABELS: Record = { importer: "Importer", @@ -137,9 +152,7 @@ export default function TabDocuments({ return errs; }; - const licenseProfiles = profile.companyProfiles.filter( - (p) => p.licenseFiles && p.licenseFiles.length > 0, - ); + const licenseProfiles = profile.companyProfiles; return ( <> @@ -246,24 +259,19 @@ export default function TabDocuments({ Business licenses - License documents uploaded per operational profile + Add, replace or remove the license documents for each operational + profile. Changes are submitted to EDR for review before they take + effect. - + {licenseProfiles.map((p) => ( - - - {ROLE_LABELS[p.type] ?? p.type} · {p.reference} - - {p.licenseFiles.map((f) => ( - - - - {f.name} - - - ))} - + ))} @@ -273,3 +281,263 @@ export default function TabDocuments({ ); } + +const LICENSE_ACCEPT = ".pdf,.png,.jpg,.jpeg"; + +function formatBytes(bytes: number): string { + if (!bytes) return ""; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`; +} + +const STATUS_BADGE: Record< + LicenseFileStatus, + { label: string; color: string; bg: string; fg: string } | null +> = { + live: null, + pending_add: { + label: "Pending approval", + color: "edr-amber", + bg: "var(--mantine-color-edr-amber-soft-0)", + fg: "var(--mantine-color-edr-amber-text-0)", + }, + pending_remove: { + label: "Removal pending", + color: "edr-red", + bg: "var(--mantine-color-edr-red-soft-0)", + fg: "var(--mantine-color-edr-red-0)", + }, +}; + +/** + * One operational profile's business-license documents. Lists each file (click + * to preview via the file proxy) with its review state, and lets the customer + * add / replace / remove files. Every mutation opens a change request the + * backoffice must approve; while one is open the parent locks this whole tab. + */ +function ProfileLicenseRow({ + profile, + onViewFile, + reviewPending, +}: { + profile: CompanyProfileResponse; + onViewFile: (file: ViewableFile) => void; + reviewPending: boolean; +}) { + const queryClient = useQueryClient(); + const addInputRef = useRef(null); + const replaceInputRef = useRef(null); + const replaceTargetId = useRef(null); + const [error, setError] = useState(null); + + const invalidate = () => { + setError(null); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + }; + + const addMutation = useMutation({ + mutationFn: (files: File[]) => + companiesService.uploadProfileLicense(profile.id, files), + onSuccess: invalidate, + onError: () => setError("Upload failed. Please try again."), + }); + const replaceMutation = useMutation({ + mutationFn: ({ fileId, file }: { fileId: string; file: File }) => + companiesService.replaceProfileLicense(profile.id, fileId, file), + onSuccess: invalidate, + onError: () => setError("Replace failed. Please try again."), + }); + const removeMutation = useMutation({ + mutationFn: (fileId: string) => + companiesService.removeProfileLicense(profile.id, fileId), + onSuccess: invalidate, + onError: () => setError("Remove failed. Please try again."), + }); + + const busy = + addMutation.isPending || + replaceMutation.isPending || + removeMutation.isPending; + const files = profile.licenseFiles ?? []; + + return ( + + + + {ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference} + + + + + {files.length === 0 ? ( + + + No license documents yet. + + + ) : ( + + {files.map((f) => { + const badge = STATUS_BADGE[f.status]; + const isPending = f.status !== "live"; + return ( + + + + + + onViewFile({ + name: f.name, + url: fileViewUrl(f.id), + mimeType: f.mimeType, + }) + } + style={{ + textAlign: "left", + textDecoration: + f.status === "pending_remove" + ? "line-through" + : undefined, + }} + lineClamp={1} + > + {f.name} + + {f.size > 0 && ( + + {formatBytes(f.size)} + + )} + + + {badge && ( + } + style={{ + backgroundColor: badge.bg, + color: badge.fg, + flexShrink: 0, + }} + > + {badge.label} + + )} + + + { + replaceTargetId.current = f.id; + replaceInputRef.current?.click(); + }} + > + + + + + removeMutation.mutate(f.id)} + > + + + + + + ); + })} + + )} + + {reviewPending && ( + + + + Awaiting EDR review — further changes are disabled until it clears. + + + )} + {error && ( + + + + {error} + + + )} + + { + const picked = e.target.files ? Array.from(e.target.files) : []; + if (picked.length > 0) addMutation.mutate(picked); + e.target.value = ""; + }} + /> + { + const file = e.target.files?.[0]; + const fileId = replaceTargetId.current; + if (file && fileId) replaceMutation.mutate({ fileId, file }); + replaceTargetId.current = null; + e.target.value = ""; + }} + /> + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 932f6fc46..c5af03f65 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -54,6 +54,7 @@ import { UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; import type { + ChangeRequestResponse, CompanyDocument, CompanyInfoResponse, CompanyNationality, @@ -211,6 +212,18 @@ export const api = { "documents", ({ companyId }) => companiesService.getDocuments(companyId), ), + + changeRequest: endpoint( + "companies", + "changeRequest", + companiesService.getChangeRequest, + ), + + reapplyProfile: endpoint<{ profileId: string }, CompanyProfileResponse>( + "companies", + "reapplyProfile", + ({ profileId }) => companiesService.reapplyProfile(profileId), + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index d3f584170..ad93e03a8 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -14,11 +14,16 @@ export type ProfileTypeValue = export type CompanyNationality = "ethiopian" | "foreign"; +/** Review state of a business-license file (mirrors the API's ProfileLicenseFileView). */ +export type LicenseFileStatus = "live" | "pending_add" | "pending_remove"; + export interface LicenseFile { + id: string; name: string; - url: string; size: number; - mimeType?: string; + mimeType: string; + /** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */ + status: LicenseFileStatus; } export interface ExternalProfileResponse { @@ -73,6 +78,8 @@ export interface CompanyProfileResponse { /** Business-license documents uploaded for this profile. */ licenseFiles: LicenseFile[]; attributes: Record | null; + /** Reviewer note when the role is rejected (drives the reapply prompt). */ + reviewNote?: string | null; createdAt: string; updatedAt: string; } @@ -80,6 +87,28 @@ export interface CompanyProfileResponse { export interface CompanyInfoResponse { profile: ExternalProfileResponse; company: CompanyResponse; + /** + * Open profile-edit review, if any. `pending` locks the settings page + new + * contract/booking creation; `rejected` surfaces the note for reapply. + */ + review?: { + status: "pending" | "rejected"; + note: string | null; + } | null; +} + +/** A staged profile-edit review request (portal view). */ +export interface ChangeRequestResponse { + id: string; + companyId: string; + status: "pending" | "approved" | "rejected"; + snapshot: Record; + documentFileIds: string[]; + note: string | null; + submittedAt: string | null; + reviewedAt: string | null; + createdAt: string; + updatedAt: string; } /** A single company-level document uploaded against a `file_upload_settings` field. */ @@ -328,7 +357,11 @@ export const companiesService = { return unwrap(response.data); }, - /** Upload business-license document(s) for a company profile (multi-file). */ + /** + * Add business-license document(s) to a company profile. For an approved + * company the upload is staged for backoffice review; during onboarding it + * goes live immediately. Returns the profile's full license list with state. + */ uploadProfileLicense: async ( profileId: string, files: File[], @@ -343,7 +376,33 @@ export const companiesService = { return unwrap(response.data); }, - /** List business-license document(s) already uploaded for a company profile. */ + /** Replace a license file with a newly uploaded one (staged for review). */ + replaceProfileLicense: async ( + profileId: string, + fileId: string, + file: File, + ): Promise => { + const formData = new FormData(); + formData.append("business_license", file); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_REPLACE(profileId, fileId), + formData, + ); + return unwrap(response.data); + }, + + /** Remove a license file (staged for review on an approved company). */ + removeProfileLicense: async ( + profileId: string, + fileId: string, + ): Promise => { + const response = await client.delete>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_FILE(profileId, fileId), + ); + return unwrap(response.data); + }, + + /** List business-license document(s) (with review state) for a company profile. */ getProfileLicense: async (profileId: string): Promise => { const response = await client.get>( URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), @@ -351,6 +410,24 @@ export const companiesService = { return unwrap(response.data); }, + /** The current company's open profile change request (pending/rejected), or null. */ + getChangeRequest: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST, + ); + return unwrap(response.data); + }, + + /** Resubmit a rejected operational role for approval (→ pending). */ + reapplyProfile: async ( + profileId: string, + ): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.PROFILE_REAPPLY(profileId), + ); + return unwrap(response.data); + }, + /** Fetch company registration data from eTrade by TIN. */ fetchETradeInfo: async (payload: { tin: string }): Promise => { const response = await client.post>( diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 3d3f2bad6..1e267fa66 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -40,6 +40,14 @@ export interface ProfileResponse { poaLocation: string | null; poaAddress: string | null; profileId: string; + /** + * Open profile-edit review. `pending` → the settings page is read-only until an + * admin decides; `rejected` → the note explains why and the forms prefill the + * declined values so the customer can amend & resubmit. + */ + reviewStatus?: "pending" | "rejected" | null; + reviewNote?: string | null; + pendingChanges?: Record | null; } export interface UpdateProfilePayload { diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index 1e1eaf223..b078c7c00 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -767,9 +767,9 @@ export function SmartFileInput({ )} - + - Replace + Replace file ) : (