diff --git a/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts
new file mode 100644
index 000000000..9d8d2b5c9
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts
@@ -0,0 +1,60 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
+
+/**
+ * Staging table for customer profile edits that require backoffice review. An
+ * already-approved company's settings edits are snapshotted here (Pending)
+ * instead of being written to the live `companies` row; a reviewer approves
+ * (snapshot applied) or rejects with a note (customer amends & resubmits).
+ */
+export class CreateCompanyChangeRequest2000000000000
+ implements MigrationInterface
+{
+ name = 'CreateCompanyChangeRequest2000000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'company_change_request',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
+ { name: 'company_id', type: 'uuid' },
+ { name: 'snapshot', type: 'jsonb' },
+ { name: 'documents', type: 'jsonb', isNullable: true },
+ { name: 'status', type: 'varchar', length: '20', default: "'pending'" },
+ { name: 'note', type: 'text', isNullable: true },
+ { name: 'submitted_by', type: 'uuid', isNullable: true },
+ { name: 'submitted_at', type: 'timestamptz', isNullable: true },
+ { name: 'reviewed_by', type: 'uuid', isNullable: true },
+ { name: 'reviewed_at', type: 'timestamptz', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ foreignKeys: [
+ {
+ columnNames: ['company_id'],
+ referencedSchema: 'freight',
+ referencedTableName: 'companies',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createIndex(
+ 'freight.company_change_request',
+ new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }),
+ );
+ await queryRunner.createIndex(
+ 'freight.company_change_request',
+ new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }),
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.company_change_request', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts
new file mode 100644
index 000000000..2c2fdf3e1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
+
+/**
+ * Adds reviewer note/id/timestamp to company_profiles so a rejected operational
+ * role (new ProfileStatus 'rejected') can carry the reason back to the customer,
+ * who can then amend and reapply.
+ */
+export class AddCompanyProfileReview2000000000001
+ implements MigrationInterface
+{
+ name = 'AddCompanyProfileReview2000000000001';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.addColumns('freight.company_profiles', [
+ new TableColumn({ name: 'review_note', type: 'text', isNullable: true }),
+ new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }),
+ new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }),
+ ]);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropColumns('freight.company_profiles', [
+ 'review_note',
+ 'reviewed_by',
+ 'reviewed_at',
+ ]);
+ }
+}
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 b1761b35f..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";
@@ -43,6 +44,8 @@ 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";
@@ -61,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(
@@ -68,7 +90,10 @@ export class CompaniesController {
): Promise {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
- return new CompanyInfoResponseDto(profile, company);
+ const review = await this.companiesService.getOpenChangeRequestForCompany(
+ company.id,
+ );
+ return new CompanyInfoResponseDto(profile, company, review);
}
@Get("profile")
@@ -78,7 +103,42 @@ export class CompaniesController {
): Promise {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
- return new ProfileResponseDto(profile, company);
+ 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 {
+ 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 {
+ const profile = await this.companiesService.reapplyCompanyProfile(
+ user.id,
+ profileId,
+ );
+ return new ResponseCompanyProfileDto(profile);
}
@Get("dashboard")
@@ -177,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);
}
@@ -306,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")
@@ -354,26 +460,76 @@ export class CompaniesController {
@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,
) {
- return this.filesService.uploadMany(companyId, "companies", files);
+ // 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 {
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 {
+ 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 {
+ 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 {
+ 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" })
diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts
index 42186dd8e..646d01d47 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
+import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
+import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
@Module({
imports: [
- TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
+ TypeOrmModule.forFeature([
+ Company,
+ ExternalProfile,
+ CompanyProfile,
+ CompanyChangeRequest,
+ Booking,
+ ]),
HttpModule,
FilesModule,
FileUploadSettingsModule,
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
+ CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
],
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 fe679627b..e31851aef 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -7,13 +7,14 @@ import {
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
+import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
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";
import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
@@ -37,9 +38,21 @@ 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;
@@ -54,9 +67,9 @@ export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
+ 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,
@@ -334,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
@@ -574,12 +568,24 @@ export class CompaniesService {
return updated;
}
- async updateProfile(
- userId: string,
- dto: UpdateProfileDto,
- ): Promise {
- const { profile, company } = await this.getCompanyInfoByUserId(userId);
+ /** Keep only the keys that were actually provided (drop `undefined`). */
+ private pickDefined(dto: Record): Record {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(dto)) {
+ if (v !== undefined) out[k] = v;
+ }
+ return out;
+ }
+ /**
+ * Translate an UpdateProfileDto (or a staged change-request snapshot) into a
+ * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
+ * PoA live there). Pure — the caller runs the async TIN-uniqueness check.
+ */
+ private mapProfileDtoToCompanyUpdates(
+ company: Company,
+ dto: Partial,
+ ): Record {
const companyUpdates: Record = {};
const attrUpdates: Record = { ...(company.attributes ?? {}) };
@@ -593,21 +599,10 @@ export class CompaniesService {
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
- if (dto.tin !== undefined && dto.tin !== company.tin) {
- // Reject a TIN already taken by a different company (the user's own draft
- // placeholder is fine to overwrite).
- const owner = await this.companiesRepo.findByTin(dto.tin);
- if (owner && owner.id !== company.id) {
- throw new ConflictException(
- `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
- );
- }
+ if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
- }
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
- if (dto.fanNumber !== undefined) {
- companyUpdates.fanNumber = dto.fanNumber;
- }
+ if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -629,8 +624,7 @@ export class CompaniesService {
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
- if (dto.poaLocation !== undefined)
- attrUpdates.poaLocation = dto.poaLocation;
+ if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
@@ -653,11 +647,219 @@ export class CompaniesService {
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates;
+ return companyUpdates;
+ }
- const updated = await this.companiesRepo.update(company.id, companyUpdates);
- if (!updated)
- throw new NotFoundException(`Company ${company.id} not found`);
- return new ProfileResponseDto(profile, updated);
+ /** Reject a TIN already registered to a *different* company. */
+ private async assertTinAvailable(
+ company: Company,
+ tin: string | undefined,
+ ): Promise {
+ if (tin === undefined || tin === company.tin) return;
+ const owner = await this.companiesRepo.findByTin(tin);
+ if (owner && owner.id !== company.id) {
+ throw new ConflictException(
+ `This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
+ );
+ }
+ }
+
+ /** The company's open (pending or last-rejected) profile change request. */
+ async getOpenChangeRequestForCompany(
+ companyId: string,
+ ): Promise {
+ return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
+ }
+
+ /**
+ * Update the current user's profile.
+ *
+ * - Company not yet approved (onboarding) → write straight to the Company row,
+ * as before. The company/role pending→approve gate already covers first-run.
+ * - Company already `active` → do NOT touch the live Company. Stage the edit in
+ * a pending change request (merging into any open one) so a backoffice
+ * reviewer can approve (apply) or reject (with a note). This locks the
+ * customer until the review resolves.
+ */
+ async updateProfile(
+ userId: string,
+ dto: UpdateProfileDto,
+ ): Promise {
+ const { profile, company } = await this.getCompanyInfoByUserId(userId);
+
+ if (company.status !== CompanyStatus.Active) {
+ await this.assertTinAvailable(company, dto.tin);
+ const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
+ const updated = await this.companiesRepo.update(
+ company.id,
+ companyUpdates,
+ );
+ if (!updated)
+ throw new NotFoundException(`Company ${company.id} not found`);
+ return new ProfileResponseDto(profile, updated);
+ }
+
+ // Approved company: stage the change for review, leaving the live row intact.
+ await this.assertTinAvailable(company, dto.tin);
+ const fields = this.pickDefined(dto);
+
+ const existing = await this.changeRequestRepo.findPendingByCompanyId(
+ company.id,
+ );
+ const now = new Date();
+ let request: CompanyChangeRequest;
+ if (existing) {
+ request =
+ (await this.changeRequestRepo.update(existing.id, {
+ snapshot: { ...(existing.snapshot ?? {}), ...fields },
+ submittedBy: userId,
+ submittedAt: now,
+ note: null,
+ })) ?? existing;
+ } else {
+ request = await this.changeRequestRepo.create({
+ companyId: company.id,
+ snapshot: fields,
+ status: ChangeRequestStatus.Pending,
+ submittedBy: userId,
+ submittedAt: now,
+ });
+ }
+
+ // Live company is unchanged; surface the pending state for the settings page.
+ return new ProfileResponseDto(profile, company, request);
+ }
+
+ /** List a company's change requests, newest first (backoffice review). */
+ async listChangeRequests(
+ companyId: string,
+ ): Promise {
+ await this.findCompanyById(companyId);
+ return this.changeRequestRepo.findByCompanyId(companyId);
+ }
+
+ /**
+ * Approve a pending change request: apply its snapshot to the live Company and
+ * mark the request approved. Any staged documents are already attached to the
+ * company, so nothing else needs promoting.
+ */
+ async approveChangeRequest(
+ id: string,
+ reviewerId?: string,
+ ): Promise {
+ const request = await this.changeRequestRepo.findById(id);
+ if (!request)
+ throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== ChangeRequestStatus.Pending) {
+ throw new BadRequestException(
+ `Change request ${id} is already ${request.status}`,
+ );
+ }
+
+ const company = await this.companiesRepo.findById(request.companyId);
+ if (!company)
+ throw new NotFoundException(`Company ${request.companyId} not found`);
+
+ const snapshot = (request.snapshot ?? {}) as Partial;
+ 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, {
+ status: ChangeRequestStatus.Approved,
+ reviewedBy: reviewerId ?? null,
+ reviewedAt: new Date(),
+ note: null,
+ })) ?? request
+ );
+ }
+
+ /**
+ * Upload company documents. For an approved company this also opens/updates a
+ * pending change request (recording the uploaded file ids) so the upload is
+ * reviewed and the customer is locked until it clears — consistent with the
+ * field-edit review. During onboarding (company not yet active) it's a plain
+ * upload with no review.
+ */
+ async uploadCompanyDocuments(
+ companyId: string,
+ files: Express.Multer.File[],
+ submittedBy?: string,
+ ): Promise {
+ const company = await this.findCompanyById(companyId);
+ const uploaded = await this.filesService.uploadMany(
+ companyId,
+ "companies",
+ files,
+ );
+ if (company.status === CompanyStatus.Active) {
+ await this.stageDocumentChange(
+ company.id,
+ uploaded.map((f) => f.id),
+ submittedBy,
+ );
+ }
+ return uploaded;
+ }
+
+ /** Open or append a pending change request recording staged document uploads. */
+ private async stageDocumentChange(
+ companyId: string,
+ fileIds: string[],
+ submittedBy?: string,
+ ): Promise {
+ if (fileIds.length === 0) return;
+ const now = new Date();
+ const existing =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ if (existing) {
+ const prev = existing.documents?.documentFileIds ?? [];
+ await this.changeRequestRepo.update(existing.id, {
+ documents: { documentFileIds: [...prev, ...fileIds] },
+ submittedBy: submittedBy ?? existing.submittedBy ?? null,
+ submittedAt: now,
+ note: null,
+ });
+ } else {
+ await this.changeRequestRepo.create({
+ companyId,
+ snapshot: {},
+ documents: { documentFileIds: fileIds },
+ status: ChangeRequestStatus.Pending,
+ submittedBy: submittedBy ?? null,
+ submittedAt: now,
+ });
+ }
+ }
+
+ /** Reject a pending change request with a note (customer amends & resubmits). */
+ async rejectChangeRequest(
+ id: string,
+ note: string,
+ reviewerId?: string,
+ ): Promise {
+ const request = await this.changeRequestRepo.findById(id);
+ if (!request)
+ throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== ChangeRequestStatus.Pending) {
+ throw new BadRequestException(
+ `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(),
+ })) ?? request
+ );
}
async deleteCompany(id: string): Promise {
@@ -713,6 +915,8 @@ export class CompaniesService {
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
+ note?: string,
+ reviewerId?: string,
): Promise {
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
@@ -727,6 +931,18 @@ export class CompaniesService {
);
}
+ // Track the review outcome. Rejection keeps the note so the customer knows
+ // why; approval clears it. Any decision stamps the reviewer + time.
+ if (status === ProfileStatus.Rejected) {
+ patch.reviewNote = note ?? null;
+ } else if (status === ProfileStatus.Active) {
+ patch.reviewNote = null;
+ }
+ if (status !== ProfileStatus.Pending) {
+ patch.reviewedBy = reviewerId ?? null;
+ patch.reviewedAt = new Date();
+ }
+
const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
@@ -744,6 +960,41 @@ export class CompaniesService {
return updated;
}
+ /**
+ * Customer reapplies for a rejected operational role (after fixing whatever the
+ * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
+ * clear the rejection note so it re-enters the approval queue.
+ */
+ async reapplyCompanyProfile(
+ userId: string,
+ profileId: string,
+ ): Promise {
+ const profile = await this.profilesRepo.findByUserId(userId);
+ if (!profile)
+ throw new NotFoundException(`Profile for user ${userId} not found`);
+ const companyId = profile.company?.id ?? profile.companyId;
+
+ const target = await this.companyProfilesRepo.findById(profileId);
+ if (!target || target.companyId !== companyId) {
+ throw new NotFoundException(`Company profile ${profileId} not found`);
+ }
+ if (target.status !== ProfileStatus.Rejected) {
+ throw new BadRequestException(
+ "Only a rejected role can be resubmitted for approval",
+ );
+ }
+
+ const updated = await this.companyProfilesRepo.update(profileId, {
+ status: ProfileStatus.Pending,
+ reviewNote: null,
+ reviewedBy: null,
+ reviewedAt: null,
+ });
+ if (!updated)
+ throw new NotFoundException(`Company profile ${profileId} not found`);
+ return updated;
+ }
+
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
@@ -833,12 +1084,12 @@ export class CompaniesService {
);
if (existing) continue;
- const reference = await this.companyProfilesRepo.generateReference(type);
+ // Self-service role adds start Pending and carry no reference — a reference
+ // is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
- reference,
- status: ProfileStatus.Active,
+ status: ProfileStatus.Pending,
});
}
@@ -870,13 +1121,14 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
- const reference = await this.companyProfilesRepo.generateReference(type);
+ // New self-service roles start Pending (awaiting backoffice approval) and
+ // carry no reference until approved. The customer can select this mode but
+ // can't book under it until it's cleared.
created = await this.companyProfilesRepo.create({
companyId,
type,
- reference,
businessLicense: businessLicense ?? null,
- status: ProfileStatus.Active,
+ status: ProfileStatus.Pending,
});
}
@@ -971,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 = [
@@ -1094,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/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
new file mode 100644
index 000000000..24d988452
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
@@ -0,0 +1,55 @@
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+import { BaseRepository } from "@edr/api-common";
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from "./entities/company-change-request.entity";
+
+@Injectable()
+export class CompanyChangeRequestRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(CompanyChangeRequest)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ /** The company's current pending request, if any. */
+ async findPendingByCompanyId(
+ companyId: string,
+ ): Promise {
+ return this.repository.findOne({
+ where: { companyId, status: ChangeRequestStatus.Pending },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ /**
+ * The company's latest "open" request — pending (locks the customer) or the
+ * most recent rejected one (drives the reapply banner + prefill). Approved
+ * requests are terminal and ignored here.
+ */
+ async findLatestOpenByCompanyId(
+ companyId: string,
+ ): Promise {
+ const pending = await this.findPendingByCompanyId(companyId);
+ if (pending) return pending;
+ return this.repository.findOne({
+ where: { companyId, status: ChangeRequestStatus.Rejected },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ async findById(id: string): Promise {
+ return this.repository.findOne({ where: { id } });
+ }
+
+ async findByCompanyId(companyId: string): Promise {
+ return this.repository.find({
+ where: { companyId },
+ order: { createdAt: "DESC" },
+ });
+ }
+}
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
new file mode 100644
index 000000000..579ac6ddd
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
@@ -0,0 +1,44 @@
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+ LicenseChangeIntent,
+} from "../entities/company-change-request.entity";
+
+/**
+ * A staged profile change request. Used both by the portal (to lock the settings
+ * page, show the reviewer note, and prefill the proposed values) and by the
+ * backoffice review screen (to render the proposed-vs-current diff).
+ */
+export class ChangeRequestResponseDto {
+ id: string;
+ companyId: string;
+ status: ChangeRequestStatus;
+ /** 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;
+ reviewedBy: string | null;
+ reviewedAt: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+
+ constructor(req: CompanyChangeRequest) {
+ this.id = req.id;
+ this.companyId = req.companyId;
+ 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;
+ this.reviewedBy = req.reviewedBy ?? null;
+ this.reviewedAt = req.reviewedAt ?? null;
+ this.createdAt = req.createdAt;
+ this.updatedAt = req.updatedAt;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
index 04fd42816..9a4fb330a 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
@@ -1,14 +1,43 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from '../entities/company-change-request.entity';
import { ResponseCompanyDto } from './response-company.dto';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class CompanyInfoResponseDto {
profile: ResponseExternalProfileDto;
company: ResponseCompanyDto;
+ /**
+ * Open profile-edit review, if any. Drives the portal-wide lock (pending →
+ * settings + new-contract/booking creation disabled) and the reapply banner.
+ */
+ review: {
+ status: 'pending' | 'rejected';
+ note: string | null;
+ } | null;
- constructor(profile: ExternalProfile, company: Company) {
+ constructor(
+ profile: ExternalProfile,
+ company: Company,
+ changeRequest?: CompanyChangeRequest | null,
+ ) {
this.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(company);
+
+ const open =
+ changeRequest &&
+ (changeRequest.status === ChangeRequestStatus.Pending ||
+ changeRequest.status === ChangeRequestStatus.Rejected)
+ ? changeRequest
+ : null;
+ this.review = open
+ ? {
+ status: open.status as 'pending' | 'rejected',
+ note: open.note ?? null,
+ }
+ : null;
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
index 89a52b5e6..89ab954e7 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -1,5 +1,9 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from '../entities/company-change-request.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
profileId: string;
- constructor(profile: ExternalProfile, company: Company) {
+ /**
+ * Open profile-edit review, if any. `reviewStatus === "pending"` locks the
+ * settings page; `"rejected"` surfaces the note and prefills the (declined)
+ * proposed values from `pendingChanges` so the customer can amend & resubmit.
+ */
+ reviewStatus: "pending" | "rejected" | null;
+ reviewNote: string | null;
+ pendingChanges: Record | null;
+
+ constructor(
+ profile: ExternalProfile,
+ company: Company,
+ changeRequest?: CompanyChangeRequest | null,
+ ) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? null;
+
+ const openReview =
+ changeRequest &&
+ (changeRequest.status === ChangeRequestStatus.Pending ||
+ changeRequest.status === ChangeRequestStatus.Rejected)
+ ? changeRequest
+ : null;
+ this.reviewStatus =
+ openReview?.status === ChangeRequestStatus.Pending
+ ? "pending"
+ : openReview?.status === ChangeRequestStatus.Rejected
+ ? "rejected"
+ : null;
+ this.reviewNote = openReview?.note ?? null;
+ this.pendingChanges = openReview?.snapshot ?? null;
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts
new file mode 100644
index 000000000..c32b44a79
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { IsString, MaxLength, MinLength } from "class-validator";
+
+export class RejectChangeRequestDto {
+ /** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(2000)
+ note!: string;
+}
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 5d90d8d60..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,9 +18,15 @@ 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;
createdAt: Date;
updatedAt: Date;
@@ -31,8 +37,9 @@ 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;
this.updatedAt = profile.updatedAt;
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
index 96c02d846..83beb441f 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
@@ -1,9 +1,16 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsIn } from "class-validator";
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ProfileStatus } from "../entities/company-profile.entity";
export class UpdateCompanyProfileStatusDto {
@ApiProperty({ enum: ProfileStatus })
@IsIn(Object.values(ProfileStatus))
status!: ProfileStatus;
+
+ /** Reviewer note — required in practice when rejecting so the customer knows why. */
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ note?: string;
}
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
new file mode 100644
index 000000000..cec670787
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
@@ -0,0 +1,87 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { Company } from "./company.entity";
+
+/**
+ * Lifecycle of a customer's proposed profile change. Edits made on the portal
+ * settings page by an already-approved company are staged here (not written to
+ * the live Company row) until a backoffice reviewer approves — at which point
+ * the snapshot is applied — or rejects with a note, after which the customer can
+ * amend and resubmit.
+ */
+export enum ChangeRequestStatus {
+ Pending = "pending",
+ Approved = "approved",
+ 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" })
+@Index(["companyId"])
+@Index(["status"])
+export class CompanyChangeRequest extends BaseEntity {
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "company_id" })
+ company?: Company;
+
+ /**
+ * Proposed profile field values, shaped as `Partial`. Covers
+ * the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/
+ * PoA fields land in `Company.attributes` on approval).
+ */
+ @Column({ name: "snapshot", type: "jsonb" })
+ snapshot!: Record;
+
+ /** Staged document/license file references (see {@link ChangeRequestDocuments}). */
+ @Column({ name: "documents", type: "jsonb", nullable: true })
+ documents?: ChangeRequestDocuments | null;
+
+ @Column({
+ name: "status",
+ type: "varchar",
+ length: 20,
+ default: ChangeRequestStatus.Pending,
+ })
+ status!: ChangeRequestStatus;
+
+ /** Backoffice reviewer's rejection note. */
+ @Column({ name: "note", type: "text", nullable: true })
+ note?: string | null;
+
+ @Column({ name: "submitted_by", type: "uuid", nullable: true })
+ submittedBy?: string | null;
+
+ @Column({ name: "submitted_at", type: "timestamptz", nullable: true })
+ submittedAt?: Date | null;
+
+ @Column({ name: "reviewed_by", type: "uuid", nullable: true })
+ reviewedBy?: string | null;
+
+ @Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
+ reviewedAt?: Date | null;
+}
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 e61668a07..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
@@ -13,11 +13,17 @@ export enum ProfileType {
export enum ProfileStatus {
Active = "active",
Pending = "pending",
+ /** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
+ Rejected = "rejected",
Suspended = "suspended",
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;
@@ -25,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"])
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record | null;
+
+ /** Reviewer's note when the role is Rejected (cleared on reapply). */
+ @Column({ name: "review_note", type: "text", nullable: true })
+ reviewNote?: string | null;
+
+ @Column({ name: "reviewed_by", type: "uuid", nullable: true })
+ reviewedBy?: string | null;
+
+ @Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
+ reviewedAt?: Date | null;
}
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/.env.example b/apps/edr-freight-web/backoffice/.env.example
index cbbdd289f..a5e34a35d 100644
--- a/apps/edr-freight-web/backoffice/.env.example
+++ b/apps/edr-freight-web/backoffice/.env.example
@@ -1,2 +1,6 @@
VITE_API_URL=http://localhost:3001
VITE_BASE_API_URL=http://localhost:3001
+
+# Proactive token refresh cadence (minutes). Must stay well under the 60-min
+# server session window. Default: 10.
+VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
index 66834c9f5..8266d96d7 100644
--- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
+++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
@@ -16,6 +16,10 @@ import {
setCookie,
} from "./cookies";
import { applyTokens } from "./http";
+import {
+ startTokenRefreshScheduler,
+ stopTokenRefreshScheduler,
+} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap();
}, []);
+ // Keep the server session alive while a user is logged in. Runs after
+ // login, MFA verification, and page-reload bootstrap alike.
+ useEffect(() => {
+ if (!user) {
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ startTokenRefreshScheduler();
+ return stopTokenRefreshScheduler;
+ }, [user]);
+
const value = useMemo(
() => ({
user,
diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts
index 5aa78e2d3..2b0cf1021 100644
--- a/apps/edr-freight-web/backoffice/src/auth/http.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/http.ts
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
+/**
+ * Single-flight token refresh: concurrent callers (the 401 interceptor and
+ * the proactive scheduler) share one in-flight request so the refresh token
+ * is only rotated once. Throws if no refresh token is stored or the server
+ * rejects it — callers decide how to end the session.
+ */
+const refreshSessionTokens = async (): Promise => {
+ const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
+ if (!refreshToken) {
+ throw new Error("missing refresh token");
+ }
+
+ refreshPromise ??= api
+ .post("/auth/refresh-token", { refreshToken })
+ .then((response) => response.data)
+ .finally(() => {
+ refreshPromise = null;
+ });
+
+ const tokens = await refreshPromise;
+ applyTokens(tokens);
+ return tokens;
+};
+
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error);
}
- const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
- if (!refreshToken) {
+ if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true;
try {
- refreshPromise ??= api
- .post("/auth/refresh-token", { refreshToken })
- .then((response) => response.data)
- .finally(() => {
- refreshPromise = null;
- });
-
- const tokens = await refreshPromise;
- applyTokens(tokens);
+ const tokens = await refreshSessionTokens();
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
@@ -97,4 +112,4 @@ api.interceptors.response.use(
},
);
-export { api, applyTokens };
+export { api, applyTokens, refreshSessionTokens };
diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
new file mode 100644
index 000000000..1d2c14db2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
@@ -0,0 +1,82 @@
+import { isAxiosError } from "axios";
+
+import {
+ REFRESH_TOKEN_COOKIE,
+ clearSessionCookies,
+ getCookie,
+} from "./cookies";
+import { refreshSessionTokens } from "./http";
+
+/**
+ * Proactively refreshes the token pair on a fixed cadence so the server-side
+ * session (a sliding 1-hour window, extended only by /auth/refresh-token) is
+ * kept alive while the app is open. The 401 interceptor in http.ts remains
+ * the reactive fallback; both share the same single-flight refresh call.
+ *
+ * The interval MUST stay well under the server session window (60 min).
+ */
+const DEFAULT_INTERVAL_MINUTES = 10;
+
+const getIntervalMs = () => {
+ const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
+ return (
+ (Number.isFinite(minutes) && minutes > 0
+ ? minutes
+ : DEFAULT_INTERVAL_MINUTES) * 60_000
+ );
+};
+
+let timerId: number | null = null;
+let lastRefreshAt = 0;
+
+const refreshNow = async () => {
+ if (!getCookie(REFRESH_TOKEN_COOKIE)) {
+ // Logged out elsewhere; nothing to keep alive.
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ try {
+ await refreshSessionTokens();
+ lastRefreshAt = Date.now();
+ } catch (error) {
+ // Network hiccups are retried on the next tick; only an explicit server
+ // rejection means the session is dead.
+ if (isAxiosError(error) && error.response) {
+ stopTokenRefreshScheduler();
+ clearSessionCookies();
+ window.location.replace("/auth");
+ }
+ }
+};
+
+/**
+ * Browsers freeze timers in background tabs — a tab waking up past its
+ * refresh deadline refreshes immediately instead of waiting a full interval.
+ */
+const onVisibilityChange = () => {
+ if (document.visibilityState !== "visible") return;
+ if (Date.now() - lastRefreshAt >= getIntervalMs()) {
+ void refreshNow();
+ }
+};
+
+export const startTokenRefreshScheduler = () => {
+ stopTokenRefreshScheduler();
+
+ // Token age is unknown here (fresh login vs. hours-old page reload), so
+ // refresh right away to extend the session window from "now".
+ lastRefreshAt = 0;
+ void refreshNow();
+
+ timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
+ document.addEventListener("visibilitychange", onVisibilityChange);
+};
+
+export const stopTokenRefreshScheduler = () => {
+ if (timerId !== null) {
+ window.clearInterval(timerId);
+ timerId = null;
+ }
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+};
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
new file mode 100644
index 000000000..924d62436
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
@@ -0,0 +1,379 @@
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Group,
+ Modal,
+ SimpleGrid,
+ Stack,
+ Text,
+ Textarea,
+} from "@mantine/core";
+import { useQuery, useMutation } from "@tanstack/react-query";
+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";
+
+/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
+const FIELD_LABELS: Record = {
+ companyName: "Company name",
+ companyEmail: "Company email",
+ companyPhone: "Company phone",
+ companyLocation: "Location",
+ companyAddress: "Address",
+ tin: "TIN",
+ vatNumber: "VAT number",
+ fanNumber: "FAN number",
+ nationality: "Nationality",
+ licenceNumber: "Licence number",
+ contactPersonName: "Contact person",
+ contactPersonPosition: "Contact position",
+ contactPersonEmail: "Contact email",
+ contactPersonPhone: "Contact phone",
+ generalManagerName: "General manager",
+ generalManagerEmail: "GM email",
+ generalManagerPhone: "GM phone",
+ poaName: "PoA name",
+ poaPhone: "PoA phone",
+ poaEmail: "PoA email",
+ poaLocation: "PoA location",
+ poaAddress: "PoA address",
+ region: "Region",
+ zone: "Zone",
+ woreda: "Woreda",
+ kebele: "Kebele",
+ houseNo: "House no.",
+};
+
+/** Best-effort current value on the live company for a proposed field key. */
+function currentValue(company: Company, key: string): string {
+ const c = company as unknown as Record;
+ const attrs = (company.attributes ?? {}) as Record;
+ const map: Record = {
+ companyName: c.name,
+ companyEmail: c.email,
+ companyPhone: c.phone,
+ companyLocation: c.country,
+ companyAddress: c.address,
+ tin: c.tin,
+ vatNumber: c.vatNumber,
+ fanNumber: c.fanNumber,
+ nationality: c.nationality,
+ contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
+ contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
+ generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
+ generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
+ generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
+ };
+ const v = key in map ? map[key] : (c[key] ?? attrs[key]);
+ return v === null || v === undefined || v === "" ? "—" : String(v);
+}
+
+function DiffRow({
+ label,
+ from,
+ to,
+}: {
+ label: string;
+ from: string;
+ to: string;
+}) {
+ const changed = from !== to;
+ return (
+
+
+ {label}
+
+
+
+ {from}
+
+ {changed && (
+ <>
+
+ →
+
+
+ {to}
+
+ >
+ )}
+
+
+ );
+}
+
+/**
+ * Backoffice review surface for a customer's staged profile edits. Shows the
+ * pending change request as a proposed-vs-current diff with Approve / Reject
+ * (with note) actions, plus a short history of past decisions.
+ */
+export function ChangeRequestReview({ company }: { company: Company }) {
+ const query = useQuery(
+ api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
+ );
+ const approve = useMutation(
+ api.customers.approveChangeRequest.mutationOptions(),
+ );
+ const reject = useMutation(
+ api.customers.rejectChangeRequest.mutationOptions(),
+ );
+
+ const { view, viewer } = useFileViewer();
+ const [rejectId, setRejectId] = useState(null);
+ const [note, setNote] = useState("");
+
+ const requests = query.data ?? [];
+ const pending = requests.find((r) => r.status === "pending");
+ const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
+
+ if (!pending && history.length === 0) return null;
+
+ const proposedKeys = pending
+ ? Object.keys(pending.snapshot ?? {})
+ : ([] as string[]);
+ const docCount = pending?.documentFileIds?.length ?? 0;
+ const licenseChanges = pending?.licenseChanges ?? [];
+
+ const confirmReject = () => {
+ if (!rejectId) return;
+ reject.mutate(
+ { id: rejectId, note: note.trim() },
+ {
+ onSuccess: () => {
+ setRejectId(null);
+ setNote("");
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+ {pending && (
+
+
+
+
+
+
+ Profile changes awaiting review
+
+
+ Pending
+
+
+
+ Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
+
+
+
+ {proposedKeys.length > 0 ? (
+
+ {proposedKeys.map((key) => (
+
+ ))}
+
+ ) : (
+
+ No field changes — document uploads only.
+
+ )}
+
+ {docCount > 0 && (
+
+ {docCount} document{docCount === 1 ? "" : "s"} uploaded with this
+ request — review them in the Documents tab.
+
+ )}
+
+ {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"}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {history.length > 0 && (
+
+
+
+ Review history
+
+ {history.map((r: CompanyChangeRequest) => (
+
+
+ {r.status}
+
+
+
+ {formatDate(r.reviewedAt ?? r.updatedAt)}
+
+ {r.note && (
+
+ Note: {r.note}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ setRejectId(null)}
+ title="Reject changes"
+ centered
+ radius="lg"
+ >
+
+ }>
+ The customer will see this note and can amend and resubmit.
+
+
+
+
+ {viewer}
+ >
+ );
+}
+
+/** Compact "N changes pending" pill for the customer list/detail header. */
+export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
+ const query = useQuery(
+ api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
+ );
+ const pending = (query.data ?? []).some((r) => r.status === "pending");
+ if (!pending) return null;
+ return (
+ }
+ >
+ Changes pending review
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
index 5bd6a7527..76555a014 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
@@ -1,6 +1,16 @@
import type { Freight } from "@edr/types";
-import { Badge, Button, Group, Tooltip } from "@mantine/core";
+import {
+ Badge,
+ Button,
+ Group,
+ Modal,
+ Stack,
+ Text,
+ Textarea,
+ Tooltip,
+} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
import { api } from "@/services/api";
import type {
@@ -25,6 +35,7 @@ const badgeStyle = {
const STATUS_COLOR: Record = {
active: "edr-green",
pending: "yellow",
+ rejected: "red",
suspended: "orange",
blacklisted: "red",
};
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
/**
* Inline approval action buttons for a profile row.
- * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
+ * Transitions: pending → approve / reject-with-note | rejected → approve (override) |
+ * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
+ * Rejecting captures a note the customer sees so they can fix and reapply.
*/
export function ProfileApprovalActions({
profileId,
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
+ const [rejectOpen, setRejectOpen] = useState(false);
+ const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
+ const confirmReject = () => {
+ mutate(
+ { profileId, status: "rejected", note: note.trim() },
+ { onSuccess: () => setRejectOpen(false) },
+ );
+ };
+
+ const rejectModal = (
+ setRejectOpen(false)}
+ title="Reject profile"
+ centered
+ radius="lg"
+ >
+
+
+ Tell the customer what needs fixing. They'll see this note and can
+ amend and resubmit the role for approval.
+
+
+
+ );
+
if (status === "pending") {
return (
-
-
-
-
+ <>
+ {rejectModal}
+
+
+
+
+ >
+ );
+ }
+
+ if (status === "rejected") {
+ return (
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 620b5ec35..2a87e3b0c 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -9,5 +9,9 @@ export {
ProfileStatusBadge,
ProfileTypeBadge,
} from "./badges";
+export {
+ ChangeRequestReview,
+ ChangeRequestPendingBadge,
+} from "./ChangeRequestReview";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index bd1c51e47..2ceaae870 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
+ changeRequests: (id: string) =>
+ ["customers", "detail", id, "change-requests"] as const,
},
INVOICES: {
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 05bb06372..77a3bc95e 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`,
+ CHANGE_REQUESTS: (companyId: string) =>
+ `/companies/${companyId}/change-requests`,
+ CHANGE_REQUEST_APPROVE: (id: string) =>
+ `/companies/change-requests/${id}/approve`,
+ CHANGE_REQUEST_REJECT: (id: string) =>
+ `/companies/change-requests/${id}/reject`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index ad888b1f6..c68a4ca25 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -1,6 +1,7 @@
import {
ActionIcon,
Anchor,
+ Badge,
Box,
Button,
Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
+ ChangeRequestPendingBadge,
+ ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
- cell: ({ row }) => ,
- },
- {
- id: "reference",
- header: "Reference",
cell: ({ row }) => (
-
- {row.original.reference}
-
+
+
+
+
+ {row.original.reference}
+
+
),
},
{
- id: "businessLicense",
- header: "Business license",
- cell: ({ row }) => (
-
- {row.original.businessLicense || "—"}
-
- ),
+ id: "licenseFiles",
+ header: "License documents",
+ cell: ({ row }) => {
+ const files = row.original.licenseFiles ?? [];
+ if (files.length === 0) {
+ return (
+
+ —
+
+ );
+ }
+ return (
+
+ {files.map((f) => (
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ >
+
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ style={{
+ maxWidth: 170,
+ textAlign: "left",
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
+ >
+ {f.name}
+
+ {f.status === "pending_add" && (
+
+ Pending
+
+ )}
+ {f.status === "pending_remove" && (
+
+ Removing
+
+ )}
+
+ ))}
+
+ );
+ },
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
- [],
+ [view],
);
const bookingColumns: ColumnDef[] = useMemo(
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${
- company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
+ }`}
meta={
+
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
+
+
-
+ void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
- message: "Failed to load documents.",
- onRetry: () => void documentsQuery.refetch(),
- }
+ message: "Failed to load documents.",
+ onRetry: () => void documentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
{licenseProfiles.map((p) => (
-
+
{humanize(p.type)} · {p.reference}
{(p.licenseFiles ?? []).map((f) => (
-
+
view({
name: f.name,
- url: f.url,
+ url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
size="xs"
+ style={{
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
>
{f.name}
+ {f.status === "pending_add" && (
+
+ Pending approval
+
+ )}
+ {f.status === "pending_remove" && (
+
+ Removal pending
+
+ )}
))}
+ {(p.licenseFiles ?? []).length === 0 && (
+
+ No license documents.
+
+ )}
))}
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
- message: "Failed to load invoices.",
- onRetry: () => void invoicesQuery.refetch(),
- }
+ message: "Failed to load invoices.",
+ onRetry: () => void invoicesQuery.refetch(),
+ }
: undefined
}
pagination={{
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index 7b844e93e..c04c67169 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
import type {
Company,
+ CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -2265,13 +2266,13 @@ export const api = {
),
setProfileStatus: endpoint<
- { profileId: string; status: ProfileStatus },
+ { profileId: string; status: ProfileStatus; note?: string },
CompanyProfile
>(
"customers",
"setProfileStatus",
- ({ profileId, status }) =>
- customersService.setProfileStatus(profileId, status),
+ ({ profileId, status, note }) =>
+ customersService.setProfileStatus(profileId, status, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2279,6 +2280,37 @@ export const api = {
],
),
+ changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
+ "customers",
+ "changeRequests",
+ ({ id }) => customersService.changeRequests(id),
+ ({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
+ ),
+
+ approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
+ "customers",
+ "approveChangeRequest",
+ ({ id }) => customersService.approveChangeRequest(id),
+ undefined,
+ (_input, data) => [
+ QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
+ QUERY_KEYS.CUSTOMERS.byId(data.companyId),
+ QUERY_KEYS.CUSTOMERS.ROOT,
+ ],
+ ),
+
+ rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
+ "customers",
+ "rejectChangeRequest",
+ ({ id, note }) => customersService.rejectChangeRequest(id, note),
+ undefined,
+ (_input, data) => [
+ QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
+ QUERY_KEYS.CUSTOMERS.byId(data.companyId),
+ QUERY_KEYS.CUSTOMERS.ROOT,
+ ],
+ ),
+
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",
diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
index 9ac20093c..cafe0aece 100644
--- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
+ CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -80,11 +81,15 @@ export const customersService = {
.then((r) => r.data);
},
- setProfileStatus(profileId: string, status: ProfileStatus): Promise {
+ setProfileStatus(
+ profileId: string,
+ status: ProfileStatus,
+ note?: string,
+ ): Promise {
return apiClient
.patch(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
- { status },
+ { status, note },
)
.then((r) => r.data);
},
@@ -95,4 +100,32 @@ export const customersService = {
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
+
+ /** List a company's profile-edit change requests (newest first). */
+ changeRequests(companyId: string): Promise {
+ return apiClient
+ .get(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
+ )
+ .then((r) => r.data);
+ },
+
+ /** Approve a pending change request — applies the proposed changes. */
+ approveChangeRequest(id: string): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
+ )
+ .then((r) => r.data);
+ },
+
+ /** Reject a pending change request with a note. */
+ rejectChangeRequest(id: string, note: string): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
+ { note },
+ )
+ .then((r) => r.data);
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index e92d75c89..8d76571d4 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -30,14 +30,24 @@ export type ProfileType =
| "transporter";
/** Mirrors backend `ProfileStatus`. */
-export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
+export type ProfileStatus =
+ | "active"
+ | "pending"
+ | "rejected"
+ | "suspended"
+ | "blacklisted";
-/** A business-license document uploaded for a company profile. */
+/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
+export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
+
+/** A business-license document uploaded for a company profile (FileRecord-backed). */
export interface LicenseFile {
+ id: string;
name: string;
- url: string;
size: number;
- mimeType?: string;
+ mimeType: string;
+ /** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
+ status: LicenseFileStatus;
}
/** A single role a company is registered for, with its reference code. */
@@ -52,6 +62,39 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record | null;
+ /** Reviewer note when the role is rejected. */
+ reviewNote?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Lifecycle of a staged customer profile-edit review. */
+export type ChangeRequestStatus = "pending" | "approved" | "rejected";
+
+/** A staged business-license add/remove on one profile, awaiting review. */
+export interface LicenseChangeIntent {
+ profileId: string;
+ op: "add" | "remove";
+ fileId: string;
+ fileName?: string;
+}
+
+/**
+ * A staged profile-edit change request. The customer's settings edits land here
+ * (pending) until a reviewer approves (applies them) or rejects (with a note).
+ */
+export interface CompanyChangeRequest {
+ id: string;
+ companyId: string;
+ status: ChangeRequestStatus;
+ /** Proposed field values (the diff payload vs. the live company). */
+ snapshot: Record;
+ documentFileIds: string[];
+ /** Staged business-license add/remove intents attached to this request. */
+ licenseChanges: LicenseChangeIntent[];
+ note: string | null;
+ submittedAt: string | null;
+ reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts
index 3e9627445..54104442c 100644
--- a/apps/edr-freight-web/backoffice/src/utils/result.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/result.ts
@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number;
};
+/**
+ * Backend errors arrive as snake_case i18n-style codes (e.g.
+ * "unable_to_log_in"). Map the known ones to friendly copy and prettify
+ * anything else so raw codes never reach the UI. `code` stays raw for
+ * programmatic checks.
+ */
+const API_ERROR_MESSAGES: Record = {
+ unable_to_log_in: "Incorrect email or password.",
+ invalid_refresh_token: "Your session has expired. Please sign in again.",
+ session_expired: "Your session has expired. Please sign in again.",
+ session_not_found: "Your session has expired. Please sign in again.",
+ user_not_found: "No account found for these credentials.",
+};
+
+const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
+
+function humanizeApiMessage(raw: string): string {
+ const known = API_ERROR_MESSAGES[raw];
+ if (known) return known;
+ if (SNAKE_CASE_CODE.test(raw)) {
+ const text = raw.replaceAll("_", " ");
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
+ }
+ return raw;
+}
+
export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") {
const obj = err as Record;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record | undefined;
return {
- code: (data?.error as string) || (data?.message as string) || "api_error",
- message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
+ code: (data?.message as string) || (data?.error as string) || "api_error",
+ message: humanizeApiMessage(
+ (data?.message as string) ||
+ (data?.error as string) ||
+ "An unexpected error occurred",
+ ),
statusCode,
};
}
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 7567c485d..697c2f89c 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -24,6 +24,10 @@ import OnboardingResumeBanner, {
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth";
+import {
+ startTokenRefreshScheduler,
+ stopTokenRefreshScheduler,
+} from "./utils/refreshScheduler";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
@@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
- const { user, company, companyType, createProfileAndSwitch } = useAuth();
+ const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
+ useAuth();
+
+ // Keep the server session alive while a user is logged in. Runs after
+ // login, signup, and page-reload bootstrap alike.
+ useEffect(() => {
+ if (!isAuthenticated) {
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ startTokenRefreshScheduler();
+ return stopTokenRefreshScheduler;
+ }, [isAuthenticated]);
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
index d0024c6f6..21d4d0746 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
-import { ArrowRight, Clock } from "lucide-react";
+import { Link } from "react-router-dom";
+import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
@@ -148,16 +149,74 @@ export default function OnboardingResumeBanner({
}
/**
- * Shown once onboarding is submitted but the company's operational profiles are
- * still being reviewed. Communicates that approval is per-profile and that
- * bookings unlock as each profile is cleared. Self-hides when nothing is pending.
+ * Post-onboarding review banner. Surfaces (in priority order):
+ * 1. A pending profile-edit review — the whole account is locked until an admin
+ * approves the submitted changes.
+ * 2. A rejected profile-edit review — links to Settings to amend & resubmit.
+ * 3. Per-operational-profile approval — bookings unlock as each role clears.
+ * Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
- const { company } = useAuth();
+ const { company, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
+ // 1. Profile-edit review pending — the account-wide lock.
+ if (reviewStatus === "pending") {
+ return (
+
+
+
+
+
+
+
+ Your profile changes are under review
+
+
+ Editing and creating new contracts or bookings is paused until an
+ administrator approves your submitted changes.
+
+
+