Merge pull request #540 from Tria-plc/freight/feat/fixes-v1

Added a changes approval system to the customer data and fix various things
This commit is contained in:
Nathnael Wondisha
2026-07-08 14:09:15 +03:00
committed by GitHub
51 changed files with 3318 additions and 583 deletions

View File

@@ -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<void> {
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<void> {
await queryRunner.dropTable('freight.company_change_request', true);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.dropColumns('freight.company_profiles', [
'review_note',
'reviewed_by',
'reviewed_at',
]);
}
}

View File

@@ -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<void> {
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<void> {
// 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';
`);
}
}

View File

@@ -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<void> {
if (profiles.length === 0) return;
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
companyId,
profiles.map((p) => p.id),
);
for (const p of profiles) {
p.licenseFiles = byProfile[p.id] ?? [];
}
}
@Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@@ -68,7 +90,10 @@ export class CompaniesController {
): Promise<CompanyInfoResponseDto> {
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<ProfileResponseDto> {
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<ChangeRequestResponseDto | null> {
const { company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id,
);
return review ? new ChangeRequestResponseDto(review) : null;
}
@Post("company-profiles/:profileId/reapply")
@ApiOperation({
summary: "Resubmit a rejected operational role for approval (→ pending)",
})
async reapplyCompanyProfile(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.reapplyCompanyProfile(
user.id,
profileId,
);
return new ResponseCompanyProfileDto(profile);
}
@Get("dashboard")
@@ -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<Express.Multer.File>,
): Promise<BusinessLicenseFile[]> {
return this.companiesService.uploadProfileLicenseFiles(
): Promise<ProfileLicenseFileView[]> {
return this.companiesService.addProfileLicenseFiles(
user.id,
profileId,
files,
);
}
@Post("company-profiles/:profileId/license/:fileId/replace")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Replace a business-license file with a newly uploaded one (staged for " +
"review on an approved company).",
})
async replaceProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<ProfileLicenseFileView[]> {
const file = files?.[0];
if (!file) {
throw new BadRequestException("A replacement file is required");
}
return this.companiesService.replaceProfileLicenseFile(
user.id,
profileId,
fileId,
file,
);
}
@Delete("company-profiles/:profileId/license/:fileId")
@ApiOperation({
summary:
"Remove a business-license file (staged for review on an approved company).",
})
async removeProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
): Promise<ProfileLicenseFileView[]> {
return this.companiesService.removeProfileLicenseFile(
user.id,
profileId,
fileId,
);
}
@Get("company-profiles/:profileId/license")
@ApiOperation({
summary: "List business-license documents 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<BusinessLicenseFile[]> {
): Promise<ProfileLicenseFileView[]> {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@@ -306,7 +410,9 @@ export class CompaniesController {
@Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> {
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<Express.Multer.File>,
) {
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<ResponseCompanyProfileDto> {
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
dto.note,
user.id,
);
return new ResponseCompanyProfileDto(profile);
}
@Get(":companyId/change-requests")
@FreightAdmin()
@ApiOperation({ summary: "List a company's profile change requests" })
async listChangeRequests(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<ChangeRequestResponseDto[]> {
const requests = await this.companiesService.listChangeRequests(companyId);
return requests.map((r) => new ChangeRequestResponseDto(r));
}
@Post("change-requests/:id/approve")
@FreightAdmin()
@ApiOperation({
summary: "Approve a pending profile change request (applies the changes)",
})
async approveChangeRequest(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.approveChangeRequest(
id,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post("change-requests/:id/reject")
@FreightAdmin()
@ApiOperation({
summary: "Reject a pending profile change request with a note",
})
async rejectChangeRequest(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectChangeRequestDto,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.rejectChangeRequest(
id,
dto.note,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -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,
],

View File

@@ -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<BusinessLicenseFile[]> {
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<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
/** Keep only the keys that were actually provided (drop `undefined`). */
private pickDefined(dto: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {};
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<UpdateProfileDto>,
): Record<string, any> {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(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<void> {
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<CompanyChangeRequest | null> {
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<ProfileResponseDto> {
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<CompanyChangeRequest[]> {
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<CompanyChangeRequest> {
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<UpdateProfileDto>;
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<FileRecord[]> {
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<void> {
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<CompanyChangeRequest> {
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<void> {
@@ -713,6 +915,8 @@ export class CompaniesService {
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
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<CompanyProfile> {
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 = <profileId>`. 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<BusinessLicenseFile[]> {
): Promise<ProfileLicenseFileView[]> {
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<BusinessLicenseFile[]> {
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<ProfileLicenseFileView[]> {
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<ProfileLicenseFileView[]> {
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<ProfileLicenseFileView[]> {
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<BusinessLicenseFile[]> {
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<Record<string, ProfileLicenseFileView[]>> {
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<string, ProfileLicenseFileView[]> = {};
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<ProfileLicenseFileView[]> {
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<string>,
): Promise<ProfileLicenseFileView[]> {
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<void> {
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<void> {
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<void> {
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<void> {
for (const change of request.documents?.licenseChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
/**

View File

@@ -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<CompanyChangeRequest> {
constructor(
@InjectRepository(CompanyChangeRequest)
repo: Repository<CompanyChangeRequest>,
) {
super(repo);
}
/** The company's current pending request, if any. */
async findPendingByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
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<CompanyChangeRequest | null> {
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<CompanyChangeRequest | null> {
return this.repository.findOne({ where: { id } });
}
async findByCompanyId(companyId: string): Promise<CompanyChangeRequest[]> {
return this.repository.find({
where: { companyId },
order: { createdAt: "DESC" },
});
}
}

View File

@@ -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<UpdateProfileDto>) — the diff payload. */
snapshot: Record<string, any>;
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<string, any> | 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;
}
}

View File

@@ -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;
}

View File

@@ -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<string, any> | 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;
}

View File

@@ -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;
}

View File

@@ -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<UpdateProfileDto>`. 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<string, any>;
/** 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;
}

View File

@@ -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<string, any> | 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;
}

View File

@@ -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<void> {
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) =>

View File

@@ -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<void> {
await this.filesRepository.update(id, { code });
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -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

View File

@@ -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<AuthContextValue>(
() => ({
user,

View File

@@ -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<AuthTokens> => {
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
throw new Error("missing refresh token");
}
refreshPromise ??= api
.post<AuthTokens>("/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<AuthTokens>("/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 };

View File

@@ -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);
};

View File

@@ -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<string, string> = {
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<string, unknown>;
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
const map: Record<string, unknown> = {
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 (
<Stack gap={2}>
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{label}
</Text>
<Group gap={8} wrap="nowrap" align="center">
<Text
size="sm"
c="dimmed"
td={changed ? "line-through" : undefined}
style={{ wordBreak: "break-word" }}
>
{from}
</Text>
{changed && (
<>
<Text size="sm" c="edr-muted">
</Text>
<Text size="sm" fw={600} c="edr-text">
{to}
</Text>
</>
)}
</Group>
</Stack>
);
}
/**
* 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<string | null>(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 && (
<Card withBorder>
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ClipboardCheck size={18} className="text-edr-muted" />
<Text fw={600} c="edr-text">
Profile changes awaiting review
</Text>
<Badge color="yellow" variant="light" radius="md">
Pending
</Badge>
</Group>
<Text size="xs" c="dimmed">
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
</Text>
</Group>
{proposedKeys.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{proposedKeys.map((key) => (
<DiffRow
key={key}
label={FIELD_LABELS[key] ?? humanize(key)}
from={currentValue(company, key)}
to={
pending.snapshot[key] === null ||
pending.snapshot[key] === undefined ||
pending.snapshot[key] === ""
? "—"
: String(pending.snapshot[key])
}
/>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No field changes document uploads only.
</Text>
)}
{docCount > 0 && (
<Text size="sm" c="dimmed">
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
request review them in the Documents tab.
</Text>
)}
{licenseChanges.length > 0 && (
<Stack gap={8}>
<Text size="sm" fw={600} c="edr-text">
Business license changes
</Text>
{licenseChanges.map((c, i) => (
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
{c.op === "add" ? (
<FilePlus2 size={15} className="text-edr-muted" />
) : (
<FileX2 size={15} className="text-edr-muted" />
)}
<Badge
size="sm"
radius="sm"
variant="light"
color={c.op === "add" ? "green" : "red"}
>
{c.op === "add" ? "Add" : "Remove"}
</Badge>
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
view({
name: c.fileName ?? "License document",
url: fileViewUrl(c.fileId),
})
}
style={{
textDecoration:
c.op === "remove" ? "line-through" : undefined,
}}
>
{c.fileName ?? "License document"}
</Anchor>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setNote("");
}}
>
Reject
</Button>
<Button
color="edr-green"
loading={approve.isPending}
onClick={() => approve.mutate({ id: pending.id })}
>
Approve changes
</Button>
</Group>
</Stack>
</Card>
)}
{history.length > 0 && (
<Card withBorder>
<Stack gap="sm">
<Text fw={600} c="edr-text">
Review history
</Text>
{history.map((r: CompanyChangeRequest) => (
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
<Badge
color={r.status === "approved" ? "edr-green" : "red"}
variant="light"
radius="md"
tt="capitalize"
>
{r.status}
</Badge>
<Box style={{ flex: 1 }}>
<Text size="sm" c="edr-text">
{formatDate(r.reviewedAt ?? r.updatedAt)}
</Text>
{r.note && (
<Text size="xs" c="dimmed">
Note: {r.note}
</Text>
)}
</Box>
</Group>
))}
</Stack>
</Card>
)}
<Modal
opened={rejectId !== null}
onClose={() => setRejectId(null)}
title="Reject changes"
centered
radius="lg"
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
The customer will see this note and can amend and resubmit.
</Alert>
<Textarea
label="Reason for rejection"
placeholder="e.g. The company address doesn't match the trade license."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectId(null)}
disabled={reject.isPending}
>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
>
Reject changes
</Button>
</Group>
</Stack>
</Modal>
{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 (
<Badge
color="yellow"
variant="light"
radius="md"
leftSection={<Clock size={12} />}
>
Changes pending review
</Badge>
);
}

View File

@@ -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<CompanyStatus | ProfileStatus, string> = {
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 = (
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject profile"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can
amend and resubmit the role for approval.
</Text>
<Textarea
label="Reason for rejection"
placeholder="e.g. The uploaded business license is expired."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectOpen(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
color="red"
loading={isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
>
Reject profile
</Button>
</Group>
</Stack>
</Modal>
);
if (status === "pending") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Reject
</Button>
</Group>
<>
{rejectModal}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
</Group>
</>
);
}
if (status === "rejected") {
return (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
);
}

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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) =>

View File

@@ -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 }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
</div>
),
},
{
id: "businessLicense",
header: "Business license",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.businessLicense || "—"}
</Text>
),
id: "licenseFiles",
header: "License documents",
cell: ({ row }) => {
const files = row.original.licenseFiles ?? [];
if (files.length === 0) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Stack gap={4}>
{files.map((f) => (
<Group key={f.id} gap={6} wrap="nowrap">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
>
<Eye size={14} />
</ActionIcon>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
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}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removing
</Badge>
)}
</Group>
))}
</Stack>
);
},
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
[],
[view],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = 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={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip
items={[
{
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<Box miw={1040}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => 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() {
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Stack key={p.id} gap={6}>
<Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference}
</Text>
{(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Group key={f.id} gap={8} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
onClick={() =>
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}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removal pending
</Badge>
)}
</Group>
))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack>
))}
</Stack>
@@ -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={{

View File

@@ -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",

View File

@@ -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<CompanyProfile> {
setProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
): Promise<CompanyProfile> {
return apiClient
.patch<CompanyProfile>(
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<CompanyChangeRequest[]> {
return apiClient
.get<CompanyChangeRequest[]>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
)
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
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<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
{ note },
)
.then((r) => r.data);
},
};

View File

@@ -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<string, unknown> | 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<string, unknown>;
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;
}

View File

@@ -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<string, string> = {
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<string, unknown>;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | 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,
};
}

View File

@@ -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;

View File

@@ -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 (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your profile changes are under review
</span>
<span className="text-xs text-amber-800">
Editing and creating new contracts or bookings is paused until an
administrator approves your submitted changes.
</span>
</span>
</div>
</div>
);
}
// 2. Profile-edit review rejected — prompt to fix & resubmit.
if (reviewStatus === "rejected") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-red-900">
Your recent changes were not approved
</span>
<span className="text-xs text-red-800">
{reviewNote
? `Reviewer note: ${reviewNote}`
: "Please update your details and resubmit for review."}
</span>
</span>
</div>
<Link
to="/settings"
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
>
Review &amp; resubmit
<ArrowRight size={16} />
</Link>
</div>
</div>
);
}
// 3. Per-operational-profile approval (existing behaviour).
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending

View File

@@ -4,6 +4,7 @@ import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import { fileViewUrl } from "@/constants/apiConfig";
import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record<string, string> = {
@@ -107,10 +108,10 @@ export default function RoleLicenseStep({
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Group key={f.id} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
href={f.url}
href={fileViewUrl(f.id)}
target="_blank"
rel="noopener noreferrer"
size="xs"

View File

@@ -96,6 +96,13 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,
PROFILE_LICENSE_FILE: (profileId: string, fileId: string) =>
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
PROFILE_REAPPLY: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/reapply`,
},
BOOKINGS: {

View File

@@ -44,7 +44,21 @@ const useAuth = () => {
api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id,
retry: false,
staleTime: 10 * 60 * 1000,
staleTime(query) {
// Fast-poll while anything is awaiting a backoffice decision: an
// unapproved role, or a pending profile-edit review. This surfaces
// approvals/rejections to the portal within a minute.
if (
query.state.data?.review?.status === "pending" ||
query.state.data?.company?.companyProfiles?.find(
(p) => p.status !== "active",
)
)
return 60;
return 10 * 60 * 1000;
},
refetchOnWindowFocus: false,
}),
);
@@ -166,6 +180,14 @@ const useAuth = () => {
const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active";
// Profile-edit review: while a change request is pending the customer is
// locked out of editing and of creating new contracts/bookings; a rejected
// request surfaces the reviewer note so they can amend and resubmit.
const review = companyInfo?.review ?? null;
const reviewStatus = review?.status ?? null;
const reviewNote = review?.note ?? null;
const isUnderReview = reviewStatus === "pending";
/** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => {
await Promise.all([
@@ -179,9 +201,7 @@ const useAuth = () => {
]);
};
const switchMode = async (
type: ProfileTypeValue,
): Promise<Result<void>> => {
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
try {
await api.companies.setActiveMode.call({ type });
await invalidateScopedData();
@@ -207,6 +227,19 @@ const useAuth = () => {
}
};
/** Resubmit a rejected operational role for approval, then refresh. */
const reapplyProfile = async (
profileId: string,
): Promise<Result<void>> => {
try {
await api.companies.reapplyProfile.call({ profileId });
await invalidateScopedData();
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const logout = async () => {
try {
await api.auth.logout.call();
@@ -239,10 +272,14 @@ const useAuth = () => {
companyType,
companyStatus,
isCompanyApproved,
reviewStatus,
reviewNote,
isUnderReview,
onboardingCompleted,
onboardingStep,
switchMode,
createProfileAndSwitch,
reapplyProfile,
login,
signup,
setPassword,

View File

@@ -1,11 +1,13 @@
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Badge,
Box,
Card,
Center,
Container,
Fieldset,
Group,
Loader,
Stack,
@@ -17,9 +19,11 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
BadgeCheck,
Briefcase,
Building2,
Clock,
FileCheck,
Globe,
ShieldCheck,
@@ -225,6 +229,9 @@ export default function SettingsPage() {
);
}
const reviewStatus = profile.reviewStatus ?? null;
const locked = reviewStatus === "pending";
return (
<Container size="xl" px="lg" py="xl">
<Stack gap="xl">
@@ -239,6 +246,39 @@ export default function SettingsPage() {
</Text>
</div>
{reviewStatus === "pending" && (
<Alert
color="yellow"
variant="light"
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
</Alert>
)}
{reviewStatus === "rejected" && (
<Alert
color="red"
variant="light"
icon={<AlertTriangle size={18} />}
title="Changes were not approved"
>
<Stack gap={4}>
{profile.reviewNote && (
<Text size="sm">
<strong>Reviewer note:</strong> {profile.reviewNote}
</Text>
)}
<Text size="sm">
Please update the details below and save again to resubmit for
review.
</Text>
</Stack>
</Alert>
)}
<Tabs
value={tab}
onChange={(value) => value && setTab(value as SettingsTab)}
@@ -269,20 +309,33 @@ export default function SettingsPage() {
))}
</Tabs.List>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
<Tabs.Panel value="company">
<TabCompanyProfile mode="edit" profile={profile} />
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="documents">
<TabDocuments profile={profile} mode="edit" />
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabDocuments profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
</Tabs>
</Stack>

View File

@@ -242,270 +242,259 @@ export default function SignupPage() {
return (
<AuthShell
tagline= "Smart Freight Operations"
taglineBody = "Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
>
<div className="flex w-full flex-col" >
{ stage === "form" ? (
<form
onSubmit= { handleSubmit(requestOtp) }
className = "flex w-full flex-col"
>
<div className="mb-4 space-y-1.5 text-center sm:mb-5" >
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
Create account
</h1>
< p className = "text-sm leading-relaxed text-gray-500" >
Register to access EDR Freight services.
<div className="flex w-full flex-col">
{stage === "form" ? (
<form
onSubmit={handleSubmit(requestOtp)}
className="flex w-full flex-col"
>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services.
</p>
</div>
</div>
< Stack gap = "sm" >
<SimpleGrid cols={ { base: 1, sm: 2 } } spacing = "md" >
<TextInput
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="First name"
placeholder = "John"
required
disabled = { sending }
error = { errors.firstName?.en?.message }
{...register("firstName.en") }
placeholder="John"
required
disabled={sending}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
/>
< TextInput
label = "Last name"
placeholder = "Doe"
required
disabled = { sending }
error = { errors.lastName?.en?.message }
{...register("lastName.en") }
<TextInput
label="Last name"
placeholder="Doe"
required
disabled={sending}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
</SimpleGrid>
< TextInput
label = "Email"
type = "email"
placeholder = "john@example.com"
required
disabled = { sending }
error = { errors.email?.message }
{...register("email") }
<TextInput
label="Email"
type="email"
placeholder="john@example.com"
required
disabled={sending}
error={errors.email?.message}
{...register("email")}
/>
< ControlledPhoneField
control = { control }
name = "phone"
label = "Phone"
required
disabled = { sending }
/>
<ControlledPhoneField
control={control}
name="phone"
label="Phone"
required
disabled={sending}
/>
<div className="space-y-1.5" >
<Text size="sm" fw = { 500} c = "edr-text" >
Send verification code via
</Text>
< SegmentedControl
fullWidth
disabled = { sending }
value = { channel }
onChange = {(v) => setChannel(v as OtpChannel)
}
data = {
[
{
value: "phone",
label: (
<span className= "flex items-center justify-center gap-1.5" >
<Smartphone size={ 14} /> Phone
</span>
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
Send verification code via
</Text>
<SegmentedControl
fullWidth
disabled={sending}
value={channel}
onChange={(v) => setChannel(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className= "flex items-center justify-center gap-1.5" >
<Mail size={ 14 } /> Email
</span>
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
</div>
< div >
<PasswordInput
<div>
<PasswordInput
label="Password"
placeholder = "Create a strong password"
required
disabled = { sending }
error = { errors.password?.message }
{...register("password") }
placeholder="Create a strong password"
required
disabled={sending}
error={errors.password?.message}
{...register("password")}
/>
{
passwordValue.length > 0 ? (
<div className= "mt-2 space-y-1" >
{
passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div
key= { req.label }
className = "flex items-center gap-2"
>
<span
className={
`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`
}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div
key={req.label}
className="flex items-center gap-2"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{
met?(
<Check className = "h-2.5 w-2.5" />
): (
<X className = "h-2.5 w-2.5" />
)
}
</span>
< span
className = {`text-xs ${met ? "text-primary" : "text-gray-500"}`
}
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
>
{ req.label }
</span>
</div>
{req.label}
</span>
</div>
);
})}
</div>
</div>
) : null}
</div>
</div>
< PasswordInput
label = "Confirm password"
placeholder = "Re-enter your password"
required
disabled = { sending }
error = { errors.confirmPassword?.message }
{...register("confirmPassword") }
<PasswordInput
label="Confirm password"
placeholder="Re-enter your password"
required
disabled={sending}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{
error ? (
<Alert
color= "red"
variant = "light"
icon = {< AlertCircle size = { 18} />}
{error ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{ error }
</Alert>
{error}
</Alert>
) : null}
<Button
<Button
type="submit"
color = "edr-green"
fullWidth
loading = { sending }
rightSection = {!sending ? <ArrowRight size={ 16 } /> : undefined}
color="edr-green"
fullWidth
loading={sending}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
>
Continue
</Button>
Continue
</Button>
< p className = "text-center text-sm text-gray-500" >
Already have an account ? { " "}
< button
type = "button"
onClick = {() => navigate("/login")}
className = "font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</Stack>
</form>
) : (
<Stack gap= "md" >
<div className="mb-1 flex justify-center" >
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary" >
<ShieldCheck size={ 22 } />
</span>
</div>
< div className = "space-y-1.5 text-center" >
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
Verify your { otpChannel === "email" ? "email" : "phone" }
</h1>
< p className = "text-sm leading-relaxed text-gray-500" >
We sent a 6 - digit code to{ " " }
<span className="font-medium text-gray-700" >
{ otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
.Enter it to finish creating your account.
<p className="text-center text-sm text-gray-500">
Already have an account ?{" "}
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</div>
</Stack>
</form>
) : (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Verify your {otpChannel === "email" ? "email" : "phone"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a 6 - digit code to{" "}
<span className="font-medium text-gray-700">
{otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
.Enter it to finish creating your account.
</p>
</div>
{
otpError ? (
<Alert
color= "red"
variant = "light"
icon = {< AlertCircle size = { 18} />}
{otpError ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{ otpError }
</Alert>
{otpError}
</Alert>
) : null}
<Stack gap={ 6 } align = "center" >
<Text size="sm" fw = { 500} c = "edr-text" >
Verification code
</Text>
< PinInput
length = { 6}
type = "number"
oneTimeCode
value = { otpCode }
placeholder = "0"
disabled = { verifying }
styles = {{ input: { textAlign: "center" } }}
onChange = { setOtpCode }
/>
</Stack>
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
< Button
color = "edr-green"
fullWidth
loading = { verifying }
disabled = { verifying || otpCode.trim().length !== 6}
onClick = { confirmOtp }
>
Verify & amp; create account
</Button>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify & create account
</Button>
< div className = "flex items-center justify-between" >
<Button
<div className="flex items-center justify-between">
<Button
variant="subtle"
color = "gray"
leftSection = {< ArrowLeft size = { 14} />}
disabled = { sending || verifying}
onClick = {() => {
setStage("form");
setOtpError(null);
}}
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={sending || verifying}
onClick={() => {
setStage("form");
setOtpError(null);
}}
>
Back
</Button>
< Button
variant = "subtle"
color = "edr-green"
leftSection = {< RotateCw size = { 14} />}
disabled = { resendIn > 0 || sending || verifying}
onClick = { resendOtp }
>
{ resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || sending || verifying}
onClick={resendOtp}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
)}
</div>
</AuthShell>
</div>
</AuthShell>
);
}

View File

@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
</Text>
{onboardingDocs.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
key={`${doc.id}-${i}`}
gap={12}
align="center"
wrap="nowrap"

View File

@@ -144,6 +144,18 @@ export default function NewContractPage({
);
}
// Profile changes pending review lock out new contract creation too.
if (!auth.isPending && auth.isUnderReview) {
return (
<GateNotice
title="Profile Changes Under Review"
body="Your recent profile changes are awaiting administrator approval. Creating contracts is paused until the review is complete."
actionLabel="Back to Contracts"
onAction={() => navigate("/contracts")}
/>
);
}
const [pricingData, setPricingData] =
useState<GenerateContractPriceResponse | null>(null);
// In edit mode the contract already exists, so seed its id — this makes
@@ -191,14 +203,18 @@ export default function NewContractPage({
contractId = contract.id;
}
const pricing = await api.contracts.generatePrice.call({ id: contractId });
const pricing = await api.contracts.generatePrice.call({
id: contractId,
});
return { contractId, pricing, mode };
},
onSuccess: ({ contractId, pricing, mode }) => {
setPriceContractId(contractId);
setPricingData(pricing);
setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
},
});
@@ -214,7 +230,9 @@ export default function NewContractPage({
}
clearContractDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts");
},
});
@@ -228,7 +246,9 @@ export default function NewContractPage({
clearContractDraft();
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts");
},
});
@@ -242,7 +262,9 @@ export default function NewContractPage({
clearContractDraft();
setPriceModalMode(null);
setPriceContractId(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts");
},
});
@@ -325,6 +347,17 @@ export default function NewContractPage({
m.set(p.type, p.status);
return m;
}, [auth.company]);
// Full profile per type, so the awaiting/rejected modal can show the reviewer
// note and offer a reapply for a rejected role.
const profileByType = useMemo(() => {
const m = new Map<
string,
{ id: string; status: string; reviewNote?: string | null }
>();
for (const p of auth.company?.company?.companyProfiles ?? [])
m.set(p.type, { id: p.id, status: p.status, reviewNote: p.reviewNote });
return m;
}, [auth.company]);
const profileTypes = useMemo(
() => [...profileStatusByType.keys()],
[profileStatusByType],
@@ -343,12 +376,14 @@ export default function NewContractPage({
// badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo(
() =>
(op: OperationType): "approved" | "pending" | "missing" => {
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target);
if (!status) return "missing";
return status === "active" ? "approved" : "pending";
if (status === "active") return "approved";
if (status === "rejected") return "rejected";
return "pending";
},
[profileStatusByType, profileTypes],
);
@@ -398,6 +433,17 @@ export default function NewContractPage({
},
});
// Resubmit a rejected operational role for approval (from the block modal).
const reapplyMutation = useMutation({
mutationFn: async (profileId: string) => {
const res = await auth.reapplyProfile(profileId);
if (!res.success) {
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
}
},
onSuccess: () => setPendingApprovalProfile(null),
});
const handleOperationSelect = (op: OperationType) => {
// Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return;
@@ -551,23 +597,23 @@ export default function NewContractPage({
: {}),
...(serviceType?.includesFirstMile && data.firstMile.enabled
? {
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
: {}),
...(serviceType?.includesLastMile && data.lastMile.enabled
? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
...(serviceType?.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent || undefined,
}
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent || undefined,
}
: { customsClearingEnabled: false }),
cargoScope,
routes,
@@ -652,7 +698,12 @@ export default function NewContractPage({
mb="lg"
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
{isEdit ? "Edit Contract" : "New Contract"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
@@ -791,12 +842,12 @@ export default function NewContractPage({
errors={
showDocErrors
? Object.fromEntries(
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
).map((k) => [k, "Required"]),
)
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
).map((k) => [k, "Required"]),
)
: {}
}
/>
@@ -814,9 +865,9 @@ export default function NewContractPage({
pricing={
pricingData
? {
currency: pricingData.currency,
lineItems: pricingData.lineItems,
}
currency: pricingData.currency,
lineItems: pricingData.lineItems,
}
: null
}
onSaveDraft={handleSaveDraft}
@@ -926,8 +977,8 @@ export default function NewContractPage({
Pricing schedule
</Text>
<Text size="xs" c="dimmed" mb="md">
Final amount is calculated at booking quantities are unknown at
the contract stage.
Final amount is calculated at booking quantities are unknown
at the contract stage.
</Text>
<Stack gap={10}>
{pricingData.lineItems.map((item) => (
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm &amp; submit
Confirm {"&"} submit
</Button>
</Group>
</Stack>
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
<Check size={18} />
</Box>
<Text size="sm" c="dimmed">
License submitted. Your {createTargetLabel.toLowerCase()} profile
is now awaiting staff approval. We'll notify you once it's
approved then you can create this contract as{" "}
License submitted. Your {createTargetLabel.toLowerCase()}{" "}
profile is now awaiting staff approval. We'll notify you once
it's approved then you can create this contract as{" "}
{createTargetLabel.toLowerCase()}.
</Text>
</Group>
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
) : (
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
your business license to create one. It goes to staff for approval
before you can use it.
You don't have a {createTargetLabel.toLowerCase()} profile yet.
Add your business license to create one. It goes to staff for
approval before you can use it.
</Text>
<FileInput
label="Business license"
@@ -1181,35 +1232,78 @@ export default function NewContractPage({
)}
</Modal>
{/* Awaiting-approval modal — the chosen operation maps to a profile that
exists but isn't approved yet. The select was already reverted. */}
<Modal
opened={pendingApprovalProfile !== null}
onClose={() => setPendingApprovalProfile(null)}
title="Awaiting approval"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Your{" "}
{pendingApprovalProfile
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
pendingApprovalProfile)
: ""}{" "}
profile was submitted and is under staff review. You can start a
contract under it once it's approved.
</Text>
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
onClick={() => setPendingApprovalProfile(null)}
>
OK
</Button>
</Group>
</Stack>
</Modal>
{/* Awaiting-approval / rejected modal — the chosen operation maps to a
profile that exists but isn't active. The select was already reverted. */}
{(() => {
const target = pendingApprovalProfile
? profileByType.get(pendingApprovalProfile)
: undefined;
const isRejected = target?.status === "rejected";
const label = pendingApprovalProfile
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
pendingApprovalProfile)
: "";
return (
<Modal
opened={pendingApprovalProfile !== null}
onClose={() => setPendingApprovalProfile(null)}
title={isRejected ? "Profile not approved" : "Awaiting approval"}
centered
radius="lg"
>
<Stack gap="md">
{isRejected ? (
<>
<Text size="sm" c="dimmed">
Your {label} profile was not approved. Fix the issue below
and resubmit it for review.
</Text>
{target?.reviewNote && (
<Alert color="red" variant="light" radius="md">
<Text size="sm">
<strong>Reviewer note:</strong> {target.reviewNote}
</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setPendingApprovalProfile(null)}
disabled={reapplyMutation.isPending}
>
Close
</Button>
<Button
color="edr-green"
loading={reapplyMutation.isPending}
onClick={() =>
target && reapplyMutation.mutate(target.id)
}
>
Resubmit for approval
</Button>
</Group>
</>
) : (
<>
<Text size="sm" c="dimmed">
Your {label} profile was submitted and is under staff
review. You can start a contract under it once it's approved.
</Text>
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
onClick={() => setPendingApprovalProfile(null)}
>
OK
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
})()}
</Box>
);
}

View File

@@ -156,7 +156,7 @@ export function StepDocuments({
</Text>
{onboardingDocs.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
key={`${doc.id}-${i}`}
gap={12}
align="center"
wrap="nowrap"

View File

@@ -43,7 +43,9 @@ export function Step1ContractType({
allowedOperations: OperationType[];
onOperationSelect?: (op: OperationType) => void;
/** Approval state of the profile each operation maps to (for the badges). */
operationStatus?: (op: OperationType) => "approved" | "pending" | "missing";
operationStatus?: (
op: OperationType,
) => "approved" | "pending" | "rejected" | "missing";
}) {
const contractType = form.watch("contractType");
@@ -221,6 +223,11 @@ export function Step1ContractType({
Pending
</Badge>
)}
{status === "rejected" && (
<Badge size="xs" color="red" variant="light" radius="sm">
Rejected
</Badge>
)}
{status === "missing" && (
<Badge size="xs" color="gray" variant="light" radius="sm">
Add license

View File

@@ -34,6 +34,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
vatNumber: z
.string()
.trim()
.max(20, "VAT number is too long")
.optional()
.or(z.literal("")),
});
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
@@ -63,6 +69,7 @@ export default function TabCompanyProfile({
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "",
vatNumber: profile.vatNumber ?? "",
};
}
return {
@@ -73,6 +80,7 @@ export default function TabCompanyProfile({
companyAddress: "",
tinNumber: "",
fanNumber: "",
vatNumber: "",
};
}, [profile]);
@@ -97,6 +105,7 @@ export default function TabCompanyProfile({
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
vatNumber: data.vatNumber ?? "",
};
if (isCreate) {
@@ -223,6 +232,18 @@ export default function TabCompanyProfile({
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="VAT Number (optional)"
placeholder="e.g. 0012345678"
maxLength={20}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</Grid.Col>
</Grid>
</Stack>
<Group

View File

@@ -1,11 +1,21 @@
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import {
companiesService,
type CompanyProfileResponse,
type LicenseFileStatus,
} from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import {
// Anchor,
SmartFileInput,
useFileViewer,
type ViewableFile,
} from "@edr/ui-common";
import {
ActionIcon,
Anchor,
Badge,
Button,
Card,
Center,
@@ -13,18 +23,23 @@ import {
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
CheckCircle2,
Clock,
FileCheck,
FileText,
Loader2,
Paperclip,
RefreshCw,
Trash2,
UploadCloud,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
@@ -137,9 +152,7 @@ export default function TabDocuments({
return errs;
};
const licenseProfiles = profile.companyProfiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
const licenseProfiles = profile.companyProfiles;
return (
<>
@@ -246,24 +259,19 @@ export default function TabDocuments({
<Title order={3}>Business licenses</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
License documents uploaded per operational profile
Add, replace or remove the license documents for each operational
profile. Changes are submitted to EDR for review before they take
effect.
</Text>
<Stack gap="md">
<Stack gap="xl">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Text size="sm" fw={600} c="edr-text">
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
</Text>
{p.licenseFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Text component="button" type="button" size="xs">
{f.name}
</Text>
</Group>
))}
</Stack>
<ProfileLicenseRow
key={p.id}
profile={p}
onViewFile={view}
reviewPending={profile.reviewStatus === "pending"}
/>
))}
</Stack>
</Card>
@@ -273,3 +281,263 @@ export default function TabDocuments({
</>
);
}
const LICENSE_ACCEPT = ".pdf,.png,.jpg,.jpeg";
function formatBytes(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`;
}
const STATUS_BADGE: Record<
LicenseFileStatus,
{ label: string; color: string; bg: string; fg: string } | null
> = {
live: null,
pending_add: {
label: "Pending approval",
color: "edr-amber",
bg: "var(--mantine-color-edr-amber-soft-0)",
fg: "var(--mantine-color-edr-amber-text-0)",
},
pending_remove: {
label: "Removal pending",
color: "edr-red",
bg: "var(--mantine-color-edr-red-soft-0)",
fg: "var(--mantine-color-edr-red-0)",
},
};
/**
* One operational profile's business-license documents. Lists each file (click
* to preview via the file proxy) with its review state, and lets the customer
* add / replace / remove files. Every mutation opens a change request the
* backoffice must approve; while one is open the parent locks this whole tab.
*/
function ProfileLicenseRow({
profile,
onViewFile,
reviewPending,
}: {
profile: CompanyProfileResponse;
onViewFile: (file: ViewableFile) => void;
reviewPending: boolean;
}) {
const queryClient = useQueryClient();
const addInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const replaceTargetId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invalidate = () => {
setError(null);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
};
const addMutation = useMutation({
mutationFn: (files: File[]) =>
companiesService.uploadProfileLicense(profile.id, files),
onSuccess: invalidate,
onError: () => setError("Upload failed. Please try again."),
});
const replaceMutation = useMutation({
mutationFn: ({ fileId, file }: { fileId: string; file: File }) =>
companiesService.replaceProfileLicense(profile.id, fileId, file),
onSuccess: invalidate,
onError: () => setError("Replace failed. Please try again."),
});
const removeMutation = useMutation({
mutationFn: (fileId: string) =>
companiesService.removeProfileLicense(profile.id, fileId),
onSuccess: invalidate,
onError: () => setError("Remove failed. Please try again."),
});
const busy =
addMutation.isPending ||
replaceMutation.isPending ||
removeMutation.isPending;
const files = profile.licenseFiles ?? [];
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" fw={700} c="edr-text">
{ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
</Text>
<Button
variant="light"
size="xs"
leftSection={<UploadCloud size={14} />}
loading={addMutation.isPending}
disabled={busy}
onClick={() => addInputRef.current?.click()}
>
Add document
</Button>
</Group>
{files.length === 0 ? (
<Card
padding="md"
radius="md"
style={{
borderStyle: "dashed",
backgroundColor: "var(--mantine-color-edr-bg-0)",
}}
>
<Text size="sm" c="edr-muted" ta="center">
No license documents yet.
</Text>
</Card>
) : (
<Stack gap="xs">
{files.map((f) => {
const badge = STATUS_BADGE[f.status];
const isPending = f.status !== "live";
return (
<Card
key={f.id}
padding="sm"
radius="md"
withBorder
style={{ backgroundColor: "var(--mantine-color-edr-card-0)" }}
>
<Group gap="sm" wrap="nowrap">
<FileText
size={18}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Anchor
component="button"
type="button"
size="sm"
fw={600}
onClick={() =>
onViewFile({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
lineClamp={1}
>
{f.name}
</Anchor>
{f.size > 0 && (
<Text size="xs" c="edr-muted">
{formatBytes(f.size)}
</Text>
)}
</Stack>
{badge && (
<Badge
size="sm"
radius="sm"
variant="light"
leftSection={<Clock size={11} />}
style={{
backgroundColor: badge.bg,
color: badge.fg,
flexShrink: 0,
}}
>
{badge.label}
</Badge>
)}
<Tooltip label="Replace" withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Replace ${f.name}`}
disabled={busy || isPending}
onClick={() => {
replaceTargetId.current = f.id;
replaceInputRef.current?.click();
}}
>
<RefreshCw size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove" withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove ${f.name}`}
disabled={busy || isPending}
loading={
removeMutation.isPending &&
removeMutation.variables === f.id
}
onClick={() => removeMutation.mutate(f.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Card>
);
})}
</Stack>
)}
{reviewPending && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
Awaiting EDR review further changes are disabled until it clears.
</Text>
</Group>
)}
{error && (
<Group gap={6} c="red">
<XCircle size={13} />
<Text size="xs" fw={500}>
{error}
</Text>
</Group>
)}
<input
ref={addInputRef}
type="file"
multiple
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const picked = e.target.files ? Array.from(e.target.files) : [];
if (picked.length > 0) addMutation.mutate(picked);
e.target.value = "";
}}
/>
<input
ref={replaceInputRef}
type="file"
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
const fileId = replaceTargetId.current;
if (file && fileId) replaceMutation.mutate({ fileId, file });
replaceTargetId.current = null;
e.target.value = "";
}}
/>
</Stack>
);
}

View File

@@ -54,6 +54,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
ChangeRequestResponse,
CompanyDocument,
CompanyInfoResponse,
CompanyNationality,
@@ -211,6 +212,18 @@ export const api = {
"documents",
({ companyId }) => companiesService.getDocuments(companyId),
),
changeRequest: endpoint<void, ChangeRequestResponse | null>(
"companies",
"changeRequest",
companiesService.getChangeRequest,
),
reapplyProfile: endpoint<{ profileId: string }, CompanyProfileResponse>(
"companies",
"reapplyProfile",
({ profileId }) => companiesService.reapplyProfile(profileId),
),
},
bookings: {

View File

@@ -14,11 +14,16 @@ export type ProfileTypeValue =
export type CompanyNationality = "ethiopian" | "foreign";
/** Review state of a business-license file (mirrors the API's ProfileLicenseFileView). */
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
export interface LicenseFile {
id: string;
name: string;
url: string;
size: number;
mimeType?: string;
mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
status: LicenseFileStatus;
}
export interface ExternalProfileResponse {
@@ -73,6 +78,8 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
attributes: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -80,6 +87,28 @@ export interface CompanyProfileResponse {
export interface CompanyInfoResponse {
profile: ExternalProfileResponse;
company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
* contract/booking creation; `rejected` surfaces the note for reapply.
*/
review?: {
status: "pending" | "rejected";
note: string | null;
} | null;
}
/** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse {
id: string;
companyId: string;
status: "pending" | "approved" | "rejected";
snapshot: Record<string, any>;
documentFileIds: string[];
note: string | null;
submittedAt: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
/** A single company-level document uploaded against a `file_upload_settings` field. */
@@ -328,7 +357,11 @@ export const companiesService = {
return unwrap(response.data);
},
/** Upload business-license document(s) for a company profile (multi-file). */
/**
* Add business-license document(s) to a company profile. For an approved
* company the upload is staged for backoffice review; during onboarding it
* goes live immediately. Returns the profile's full license list with state.
*/
uploadProfileLicense: async (
profileId: string,
files: File[],
@@ -343,7 +376,33 @@ export const companiesService = {
return unwrap(response.data);
},
/** List business-license document(s) already uploaded for a company profile. */
/** Replace a license file with a newly uploaded one (staged for review). */
replaceProfileLicense: async (
profileId: string,
fileId: string,
file: File,
): Promise<LicenseFile[]> => {
const formData = new FormData();
formData.append("business_license", file);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_REPLACE(profileId, fileId),
formData,
);
return unwrap(response.data);
},
/** Remove a license file (staged for review on an approved company). */
removeProfileLicense: async (
profileId: string,
fileId: string,
): Promise<LicenseFile[]> => {
const response = await client.delete<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_FILE(profileId, fileId),
);
return unwrap(response.data);
},
/** List business-license document(s) (with review state) for a company profile. */
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
@@ -351,6 +410,24 @@ export const companiesService = {
return unwrap(response.data);
},
/** The current company's open profile change request (pending/rejected), or null. */
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
const response = await client.get<ApiResponse<ChangeRequestResponse | null>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST,
);
return unwrap(response.data);
},
/** Resubmit a rejected operational role for approval (→ pending). */
reapplyProfile: async (
profileId: string,
): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_REAPPLY(profileId),
);
return unwrap(response.data);
},
/** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
const response = await client.post<ApiResponse<any>>(

View File

@@ -40,6 +40,14 @@ export interface ProfileResponse {
poaLocation: string | null;
poaAddress: string | null;
profileId: string;
/**
* Open profile-edit review. `pending` → the settings page is read-only until an
* admin decides; `rejected` → the note explains why and the forms prefill the
* declined values so the customer can amend & resubmit.
*/
reviewStatus?: "pending" | "rejected" | null;
reviewNote?: string | null;
pendingChanges?: Record<string, any> | null;
}
export interface UpdateProfilePayload {

View File

@@ -42,21 +42,41 @@ client.interceptors.request.use((config) => {
});
// Token refresh state
let isRefreshing = false;
let failedQueue: {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}[] = [];
let refreshPromise: Promise<string> | null = null;
function processQueue(error: unknown, token?: string) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token!);
/**
* 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.
*/
async function refreshSessionTokens(): Promise<string> {
refreshPromise ??= (async () => {
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
throw new Error("missing refresh token");
}
type TokenPair = { token: string; refreshToken: string };
const { data } = await client.post<Partial<TokenPair> & { data?: TokenPair }>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken },
);
// The API returns the pair flat ({ success, token, refreshToken }); accept
// a { data: { ... } }-wrapped shape too so a transform change can't
// silently break refresh again.
const payload = data.data ?? data;
if (!payload.token || !payload.refreshToken) {
throw new Error("malformed refresh-token response");
}
setCookie("auth-token", payload.token, 7);
setCookie("refresh-token", payload.refreshToken, 7);
return payload.token;
})().finally(() => {
refreshPromise = null;
});
failedQueue = [];
return refreshPromise;
}
// Handle auth errors globally with token refresh
@@ -72,54 +92,41 @@ client.interceptors.response.use(
// - status is not 401
// - already retried
// - it's the refresh endpoint itself
// - it's a credential endpoint (401 there = wrong credentials, not an
// expired session — refreshing would mask the real error)
if (
!error.response ||
error.response.status !== 401 ||
originalRequest._retry ||
originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN
originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN ||
originalRequest.url === URL_CONSTANTS.AUTH.LOGIN ||
originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP
) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
isRefreshing = false;
clearAuthCookies();
// Nothing to refresh with (e.g. not logged in yet) — surface the
// original error instead of a confusing refresh failure.
if (!getCookie("refresh-token")) {
if (getCookie("auth-token")) {
// Half-broken cookie state; reset it.
clearAuthCookies();
}
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const { data } = await client.post<{
data: { token: string; refreshToken: string };
}>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
const { token, refreshToken: newRefreshToken } = data.data;
setCookie("auth-token", token, 7);
setCookie("refresh-token", newRefreshToken, 7);
const token = await refreshSessionTokens();
originalRequest.headers.Authorization = `Bearer ${token}`;
processQueue(null, token);
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, undefined);
clearAuthCookies();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
export { client };
export { client, clearAuthCookies, getCookie, refreshSessionTokens };
export type { UseQueryOptions, QueryObserverOptions };

View File

@@ -0,0 +1,81 @@
import { isAxiosError } from "axios";
import {
clearAuthCookies,
getCookie,
refreshSessionTokens,
} from "./api";
/**
* 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 api.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")) {
// 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();
clearAuthCookies();
window.location.replace("/login");
}
}
};
/**
* 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);
};

View File

@@ -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<string, string> = {
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<string, unknown>;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | 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,
};
}

View File

@@ -767,9 +767,9 @@ export function SmartFileInput({
)}
</div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-primary/40 bg-card px-3 py-1.5 text-xs font-semibold text-primary shadow-2xs transition group-hover:border-primary group-hover:bg-primary/5">
<UploadCloud className="h-3.5 w-3.5" />
Replace
Replace file
</span>
</div>
) : (