Merge pull request #543 from Tria-plc/dev

merge
This commit is contained in:
Abubeker Yasin
2026-07-08 15:17:05 +03:00
committed by GitHub
53 changed files with 3366 additions and 586 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, HttpStatus,
UseInterceptors, UseInterceptors,
UploadedFiles, UploadedFiles,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
@@ -33,7 +34,7 @@ import {
ResponseCompanyDto, ResponseCompanyDto,
ResponseCompanyProfileDto, ResponseCompanyProfileDto,
} from "./dto/response-company.dto"; } 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 { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.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 { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.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 { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -61,6 +64,25 @@ export class CompaniesController {
private readonly filesService: FilesService, 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") @Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" }) @ApiOperation({ summary: "Get company info for the current user" })
async getInfo( async getInfo(
@@ -68,7 +90,10 @@ export class CompaniesController {
): Promise<CompanyInfoResponseDto> { ): Promise<CompanyInfoResponseDto> {
const { profile, company } = const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id); 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") @Get("profile")
@@ -78,7 +103,42 @@ export class CompaniesController {
): Promise<ProfileResponseDto> { ): Promise<ProfileResponseDto> {
const { profile, company } = const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id); 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") @Get("dashboard")
@@ -177,28 +237,72 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: 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( async uploadProfileLicense(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
@UploadedFiles() files: Array<Express.Multer.File>, @UploadedFiles() files: Array<Express.Multer.File>,
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
return this.companiesService.uploadProfileLicenseFiles( return this.companiesService.addProfileLicenseFiles(
user.id, user.id,
profileId, profileId,
files, 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") @Get("company-profiles/:profileId/license")
@ApiOperation({ @ApiOperation({
summary: "List business-license documents for a company profile", summary: "List business-license documents (with review state) for a profile",
}) })
async listProfileLicense( async listProfileLicense(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
return this.companiesService.listProfileLicenseFiles(user.id, profileId); return this.companiesService.listProfileLicenseFiles(user.id, profileId);
} }
@@ -306,7 +410,9 @@ export class CompaniesController {
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> { ): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id); 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") @Patch(":id")
@@ -354,26 +460,76 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" }) @ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments( async uploadDocuments(
@CurrentUser() user: CurrentIamUser,
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>, @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") @Patch("company-profiles/:profileId/status")
@FreightAdmin() @FreightAdmin()
@ApiOperation({ summary: "Update a company profile's approval status" }) @ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus( async updateCompanyProfileStatus(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto, @Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> { ): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.setCompanyProfileStatus( const profile = await this.companiesService.setCompanyProfileStatus(
profileId, profileId,
dto.status, dto.status,
dto.note,
user.id,
); );
return new ResponseCompanyProfileDto(profile); 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") @Post(":companyId/profiles")
@FreightAdmin() @FreightAdmin()
@ApiOperation({ summary: "Add a profile (employee) to a company" }) @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 { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity"; import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-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 { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service"; import { ETradeService } from "./services/etrade.service";
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), TypeOrmModule.forFeature([
Company,
ExternalProfile,
CompanyProfile,
CompanyChangeRequest,
Booking,
]),
HttpModule, HttpModule,
FilesModule, FilesModule,
FileUploadSettingsModule, FileUploadSettingsModule,
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
CompaniesRepository, CompaniesRepository,
ExternalProfileRepository, ExternalProfileRepository,
CompanyProfileRepository, CompanyProfileRepository,
CompanyChangeRequestRepository,
CompanyDashboardRepository, CompanyDashboardRepository,
ETradeService, ETradeService,
], ],

View File

@@ -7,13 +7,14 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository"; import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository";
import { import {
CompanyDashboardRepository, CompanyDashboardRepository,
DashboardScope, DashboardScope,
} from "./company-dashboard.repository"; } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.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 { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service"; import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
@@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity";
import { import {
BusinessLicenseFile, BusinessLicenseFile,
CompanyProfile, CompanyProfile,
ProfileLicenseFileView,
ProfileType, ProfileType,
ProfileStatus, ProfileStatus,
} from "./entities/company-profile.entity"; } 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 { export interface UserIdentity {
userId: string; userId: string;
@@ -54,9 +67,9 @@ export class CompaniesService {
constructor( constructor(
private readonly companiesRepo: CompaniesRepository, private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository, private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly profilesRepo: ExternalProfileRepository, private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository, private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService, private readonly etradeService: ETradeService,
@@ -334,28 +347,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id); const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`); if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
for (const profile of company.companyProfiles) {
profile.businessLicenseFiles = await this.signLicenseFiles(
profile.businessLicenseFiles,
);
}
return company; 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 * 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 * to the booking's company and be Active. Used for government bookings (staff
@@ -574,12 +568,24 @@ export class CompaniesService {
return updated; return updated;
} }
async updateProfile( /** Keep only the keys that were actually provided (drop `undefined`). */
userId: string, private pickDefined(dto: Record<string, any>): Record<string, any> {
dto: UpdateProfileDto, const out: Record<string, any> = {};
): Promise<ProfileResponseDto> { for (const [k, v] of Object.entries(dto)) {
const { profile, company } = await this.getCompanyInfoByUserId(userId); 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 companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) }; const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
@@ -593,21 +599,10 @@ export class CompaniesService {
companyUpdates.country = dto.companyLocation; companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress; companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined && dto.tin !== company.tin) { 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.`,
);
}
companyUpdates.tin = dto.tin; companyUpdates.tin = dto.tin;
}
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) { if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName; attrUpdates.contactPersonName = dto.contactPersonName;
@@ -629,8 +624,7 @@ export class CompaniesService {
if (dto.poaPhone !== undefined) if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone); attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined) if (dto.licenceNumber !== undefined)
@@ -653,11 +647,219 @@ export class CompaniesService {
companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates; companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
const updated = await this.companiesRepo.update(company.id, companyUpdates); /** Reject a TIN already registered to a *different* company. */
if (!updated) private async assertTinAvailable(
throw new NotFoundException(`Company ${company.id} not found`); company: Company,
return new ProfileResponseDto(profile, updated); 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> { async deleteCompany(id: string): Promise<void> {
@@ -713,6 +915,8 @@ export class CompaniesService {
async setCompanyProfileStatus( async setCompanyProfileStatus(
profileId: string, profileId: string,
status: ProfileStatus, status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> { ): Promise<CompanyProfile> {
const existing = await this.companyProfilesRepo.findById(profileId); const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing) 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); const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated) if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`); throw new NotFoundException(`Company profile ${profileId} not found`);
@@ -744,6 +960,41 @@ export class CompaniesService {
return updated; 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( async createCompanyProfile(
companyId: string, companyId: string,
profileType?: ProfileType, profileType?: ProfileType,
@@ -833,12 +1084,12 @@ export class CompaniesService {
); );
if (existing) continue; 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({ await this.companyProfilesRepo.create({
companyId, companyId,
type, type,
reference, status: ProfileStatus.Pending,
status: ProfileStatus.Active,
}); });
} }
@@ -870,13 +1121,14 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type); let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) { 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({ created = await this.companyProfilesRepo.create({
companyId, companyId,
type, type,
reference,
businessLicense: businessLicense ?? null, 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); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses. // 3. Per-operational-profile business licenses (FileRecord-backed).
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ const licenseProfiles = await Promise.all(
profileId: p.id, (company.companyProfiles ?? []).map(async (p) => {
type: p.type, const records = await this.filesService.findByResource(
reference: p.reference ?? "", p.id,
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, 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 missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [ const outstanding = [
@@ -1094,61 +1354,321 @@ export class CompaniesService {
return owned; 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 * Upload business-license file(s) for one of the user's profiles. During
* profile (multi-file). Bytes go to object storage; only metadata/URLs are * onboarding (company not yet Active) they go live immediately; for an Active
* persisted on the profile — intentionally not via the FileRecord file model. * company they're staged under the pending code and recorded as `add` intents
* New files are appended to any already present. Returns the full list. * on a pending change request for backoffice review. Returns the updated view.
*/ */
async uploadProfileLicenseFiles( async addProfileLicenseFiles(
userId: string, userId: string,
profileId: string, profileId: string,
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId); 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[] = []; const uploaded = await Promise.all(
for (const file of files) { files.map((file) =>
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; this.filesService.upload({
const url = await this.minioService.uploadFile( resourceId: profileId,
objectName, resource: LICENSE_RESOURCE,
file.buffer, code,
file.mimetype, 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]; return this.getProfileLicenseView(profileId, company.id);
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 ?? [];
} }
/** /**
* Onboarding documents stored on a company profile, fetched by profile id. * Remove a license file. A staged (pending) file is withdrawn outright
* Internal helper (no ownership check) used when a booking reuses the active * (soft-deleted, its `add` intent dropped). A live file on an Active company
* profile's onboarding documents. Returns [] when the profile is unknown. * 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( async getProfileOnboardingFiles(
profileId: string, profileId: string,
): Promise<BusinessLicenseFile[]> { ): Promise<BusinessLicenseFile[]> {
const profile = await this.companyProfilesRepo.findById(profileId); const records = await this.filesService.findByResource(
return profile?.businessLicenseFiles ?? []; 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 { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity'; import { ExternalProfile } from '../entities/external-profile.entity';
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from '../entities/company-change-request.entity';
import { ResponseCompanyDto } from './response-company.dto'; import { ResponseCompanyDto } from './response-company.dto';
import { ResponseExternalProfileDto } from './response-external-profile.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class CompanyInfoResponseDto { export class CompanyInfoResponseDto {
profile: ResponseExternalProfileDto; profile: ResponseExternalProfileDto;
company: ResponseCompanyDto; 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.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(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 { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity'; import { ExternalProfile } from '../entities/external-profile.entity';
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from '../entities/company-change-request.entity';
import { ResponseCompanyProfileDto } from './response-company.dto'; import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto { export class ProfileResponseDto {
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
profileId: string; 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.companyId = company.id;
this.companyName = company.name; this.companyName = company.name;
this.companyType = company.type; this.companyType = company.type;
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
this.poaEmail = attrs.poaEmail ?? null; this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null; this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? 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, CompanyNationality,
} from '../entities/company.entity'; } from '../entities/company.entity';
import { import {
BusinessLicenseFile,
CompanyProfile, CompanyProfile,
ProfileLicenseFileView,
} from '../entities/company-profile.entity'; } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto';
@@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto {
status: string; status: string;
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */ /** @deprecated Superseded by licenseFiles. Kept for back-compat. */
businessLicense?: string | null; 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; attributes?: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto {
this.reference = profile.reference ?? ''; this.reference = profile.reference ?? '';
this.status = profile.status; this.status = profile.status;
this.businessLicense = profile.businessLicense; this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? []; this.licenseFiles = [];
this.attributes = profile.attributes; this.attributes = profile.attributes;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt; this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt; this.updatedAt = profile.updatedAt;
} }

View File

@@ -1,9 +1,16 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn } from "class-validator"; import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ProfileStatus } from "../entities/company-profile.entity"; import { ProfileStatus } from "../entities/company-profile.entity";
export class UpdateCompanyProfileStatusDto { export class UpdateCompanyProfileStatusDto {
@ApiProperty({ enum: ProfileStatus }) @ApiProperty({ enum: ProfileStatus })
@IsIn(Object.values(ProfileStatus)) @IsIn(Object.values(ProfileStatus))
status!: 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 { export enum ProfileStatus {
Active = "active", Active = "active",
Pending = "pending", Pending = "pending",
/** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
Rejected = "rejected",
Suspended = "suspended", Suspended = "suspended",
Blacklisted = "blacklisted", 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 { export interface BusinessLicenseFile {
name: string; name: string;
url: string; url: string;
@@ -25,6 +31,19 @@ export interface BusinessLicenseFile {
mimeType?: string; 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" }) @Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true }) @Index(["reference"], { unique: true })
@Index(["type"]) @Index(["type"])
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "attributes", type: "jsonb", nullable: true }) @Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null; 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 { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service'; 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 { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
@@ -326,10 +326,20 @@ export class ContractsService {
companyProfileId: string | null, companyProfileId: string | null,
): Promise<void> { ): Promise<void> {
if (!companyProfileId) return; if (!companyProfileId) return;
const profile = await this.dataSource // Business-license files are FileRecords (resource "company_profiles"); carry
.getRepository(CompanyProfile) // the live ones by reference. Staged/pending uploads are excluded by code.
.findOne({ where: { id: companyProfileId } }); const records = await this.filesService.findByResource(
const docs = profile?.businessLicenseFiles ?? []; 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; if (docs.length === 0) return;
const slug = (name: string) => const slug = (name: string) =>

View File

@@ -124,6 +124,15 @@ export class FilesService {
await this.filesRepository.softDelete(id); 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[]> { findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource); return this.filesRepository.findByResource(resourceId, resource);
} }

View File

@@ -12,6 +12,7 @@ import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-ru
import { Route } from "../modules/routes/entities/route.entity"; import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity";
import { deriveTradeDirection } from "../common/derive-trade-direction.util";
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
@@ -295,6 +296,7 @@ export class PricingDataSeeder {
originYardId: addis.id, originYardId: addis.id,
destinationYardId: direDawa.id, destinationYardId: direDawa.id,
status: 'AVAILABLE', status: 'AVAILABLE',
direction: deriveTradeDirection(addis, direDawa),
}), }),
); );
await milestoneRepo.save([ await milestoneRepo.save([

View File

@@ -1,2 +1,6 @@
VITE_API_URL=http://localhost:3001 VITE_API_URL=http://localhost:3001
VITE_BASE_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, setCookie,
} from "./cookies"; } from "./cookies";
import { applyTokens } from "./http"; import { applyTokens } from "./http";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types"; import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload { interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap(); 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>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
user, user,

View File

@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken); 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) => { api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE); const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); if (!getCookie(REFRESH_TOKEN_COOKIE)) {
if (!refreshToken) {
clearSessionCookies(); clearSessionCookies();
return Promise.reject(error); return Promise.reject(error);
} }
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true; originalRequest._retry = true;
try { try {
refreshPromise ??= api const tokens = await refreshSessionTokens();
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
originalRequest.headers = { originalRequest.headers = {
...originalRequest.headers, ...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`, 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 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 { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { import type {
@@ -25,6 +35,7 @@ const badgeStyle = {
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = { const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
active: "edr-green", active: "edr-green",
pending: "yellow", pending: "yellow",
rejected: "red",
suspended: "orange", suspended: "orange",
blacklisted: "red", blacklisted: "red",
}; };
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
/** /**
* Inline approval action buttons for a profile row. * 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({ export function ProfileApprovalActions({
profileId, profileId,
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation( const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(), api.customers.setProfileStatus.mutationOptions(),
); );
const [rejectOpen, setRejectOpen] = useState(false);
const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next }); 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") { if (status === "pending") {
return ( return (
<Group gap={6} wrap="nowrap"> <>
<Button {rejectModal}
size="xs" <Group gap={6} wrap="nowrap">
variant="light" <Button
color="edr-green" size="xs"
radius="md" variant="light"
loading={isPending} color="edr-green"
onClick={() => act("active")} radius="md"
> loading={isPending}
Approve onClick={() => act("active")}
</Button> >
<Button Approve
size="xs" </Button>
variant="light" <Button
color="red" size="xs"
radius="md" variant="light"
loading={isPending} color="red"
onClick={() => act("blacklisted")} radius="md"
> onClick={() => setRejectOpen(true)}
Reject >
</Button> Reject
</Group> </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, ProfileStatusBadge,
ProfileTypeBadge, ProfileTypeBadge,
} from "./badges"; } from "./badges";
export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard"; export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) => documents: (id: string) =>
["customers", "detail", id, "documents"] as const, ["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const, payments: (id: string) => ["customers", "detail", id, "payments"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
}, },
INVOICES: { INVOICES: {

View File

@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/companies/${id}/documents`, DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) => PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`, `/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_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`, `/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) => PAYMENTS_CUSTOMER_VIEW: (id: string) =>

View File

@@ -1,6 +1,7 @@
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Badge,
Box, Box,
Button, Button,
Card, Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { import {
BookingStatusBadge, BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge, CompanyStatusBadge,
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge, InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{ {
id: "type", id: "type",
header: "Role", header: "Role",
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text"> <div className="space-y-2">
{row.original.reference} <ProfileTypeBadge type={row.original.type} />
</Text>
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
</div>
), ),
}, },
{ {
id: "businessLicense", id: "licenseFiles",
header: "Business license", header: "License documents",
cell: ({ row }) => ( cell: ({ row }) => {
<Text size="sm" c="dimmed"> const files = row.original.licenseFiles ?? [];
{row.original.businessLicense || "—"} if (files.length === 0) {
</Text> 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", id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
), ),
}, },
], ],
[], [view],
); );
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo( const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
company.country ? ` · ${company.country}` : "" }`}
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} /> <CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group> </Group>
} }
/> />
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */} {/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg"> <Tabs.Panel value="overview" pt="lg">
<Stack gap="lg"> <Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip <KpiStrip
items={[ items={[
{ {
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} /> <ProfileChips profiles={company.companyProfiles} />
</Group> </Group>
<Box style={{ overflowX: "auto" }} w="100%"> <Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}> <Box miw={1040}>
<DataTable <DataTable
columns={profileColumns} columns={profileColumns}
data={company.companyProfiles} data={company.companyProfiles}
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={ error={
documentsQuery.isError documentsQuery.isError
? { ? {
message: "Failed to load documents.", message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(), onRetry: () => void documentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
</Text> </Text>
<Stack gap="md"> <Stack gap="md">
{licenseProfiles.map((p) => ( {licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}> <Stack key={p.id} gap={6}>
<Text size="sm" fw={600} c="edr-text"> <Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference} {humanize(p.type)} · {p.reference}
</Text> </Text>
{(p.licenseFiles ?? []).map((f) => ( {(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" /> <Paperclip size={13} className="text-edr-muted" />
<Anchor <Anchor
component="button" component="button"
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
onClick={() => onClick={() =>
view({ view({
name: f.name, name: f.name,
url: f.url, url: fileViewUrl(f.id),
mimeType: f.mimeType, mimeType: f.mimeType,
}) })
} }
size="xs" size="xs"
style={{
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
> >
{f.name} {f.name}
</Anchor> </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> </Group>
))} ))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={ error={
invoicesQuery.isError invoicesQuery.isError
? { ? {
message: "Failed to load invoices.", message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(), onRetry: () => void invoicesQuery.refetch(),
} }
: undefined : undefined
} }
pagination={{ pagination={{

View File

@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import type { import type {
Company, Company,
CompanyChangeRequest,
CompanyListFilter, CompanyListFilter,
CompanyProfile, CompanyProfile,
CompanyStats, CompanyStats,
@@ -2265,13 +2266,13 @@ export const api = {
), ),
setProfileStatus: endpoint< setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus }, { profileId: string; status: ProfileStatus; note?: string },
CompanyProfile CompanyProfile
>( >(
"customers", "customers",
"setProfileStatus", "setProfileStatus",
({ profileId, status }) => ({ profileId, status, note }) =>
customersService.setProfileStatus(profileId, status), customersService.setProfileStatus(profileId, status, note),
undefined, undefined,
(_input, data) => [ (_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId), 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>( setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers", "customers",
"setCompanyStatus", "setCompanyStatus",

View File

@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { import type {
Company, Company,
CompanyChangeRequest,
CompanyListFilter, CompanyListFilter,
CompanyProfile, CompanyProfile,
CompanyStats, CompanyStats,
@@ -80,11 +81,15 @@ export const customersService = {
.then((r) => r.data); .then((r) => r.data);
}, },
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> { setProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
): Promise<CompanyProfile> {
return apiClient return apiClient
.patch<CompanyProfile>( .patch<CompanyProfile>(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId), URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
{ status }, { status, note },
) )
.then((r) => r.data); .then((r) => r.data);
}, },
@@ -95,4 +100,32 @@ export const customersService = {
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status }) .patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data); .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"; | "transporter";
/** Mirrors backend `ProfileStatus`. */ /** 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 { export interface LicenseFile {
id: string;
name: string; name: string;
url: string;
size: number; 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. */ /** 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. */ /** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[]; licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null; 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; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number; 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 { export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const obj = err as Record<string, unknown>; 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 statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined; const data = response.data as Record<string, unknown> | undefined;
return { return {
code: (data?.error as string) || (data?.message as string) || "api_error", code: (data?.message as string) || (data?.error as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", message: humanizeApiMessage(
(data?.message as string) ||
(data?.error as string) ||
"An unexpected error occurred",
),
statusCode, statusCode,
}; };
} }

View File

@@ -24,6 +24,10 @@ import OnboardingResumeBanner, {
} from "./components/onboarding/OnboardingResumeBanner"; } from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth"; import useAuth from "./hooks/useAuth";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./utils/refreshScheduler";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage"; import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage"; import MySignaturePage from "./pages/MySignaturePage";
@@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email; const userEmail = user?.email;

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; 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 { api } from "@/services/api";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service"; 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 * Post-onboarding review banner. Surfaces (in priority order):
* still being reviewed. Communicates that approval is per-profile and that * 1. A pending profile-edit review — the whole account is locked until an admin
* bookings unlock as each profile is cleared. Self-hides when nothing is pending. * 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() { export function AccountReviewBanner() {
const { company } = useAuth(); const { company, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? []; const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending"); const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active"); 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; if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending const pendingLabel = pending

View File

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

View File

@@ -96,6 +96,13 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) => PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`, `/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: { BOOKINGS: {

View File

@@ -44,7 +44,21 @@ const useAuth = () => {
api.companies.getInfo.queryOptions({ api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id, enabled: !!authQuery.data?.id,
retry: false, 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, refetchOnWindowFocus: false,
}), }),
); );
@@ -166,6 +180,14 @@ const useAuth = () => {
const activeProfileStatus = activeProfile?.status ?? null; const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active"; 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. */ /** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => { const invalidateScopedData = async () => {
await Promise.all([ await Promise.all([
@@ -179,9 +201,7 @@ const useAuth = () => {
]); ]);
}; };
const switchMode = async ( const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
type: ProfileTypeValue,
): Promise<Result<void>> => {
try { try {
await api.companies.setActiveMode.call({ type }); await api.companies.setActiveMode.call({ type });
await invalidateScopedData(); 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 () => { const logout = async () => {
try { try {
await api.auth.logout.call(); await api.auth.logout.call();
@@ -239,10 +272,14 @@ const useAuth = () => {
companyType, companyType,
companyStatus, companyStatus,
isCompanyApproved, isCompanyApproved,
reviewStatus,
reviewNote,
isUnderReview,
onboardingCompleted, onboardingCompleted,
onboardingStep, onboardingStep,
switchMode, switchMode,
createProfileAndSwitch, createProfileAndSwitch,
reapplyProfile,
login, login,
signup, signup,
setPassword, setPassword,

View File

@@ -1,11 +1,13 @@
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { import {
Alert,
Badge, Badge,
Box, Box,
Card, Card,
Center, Center,
Container, Container,
Fieldset,
Group, Group,
Loader, Loader,
Stack, Stack,
@@ -17,9 +19,11 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle,
BadgeCheck, BadgeCheck,
Briefcase, Briefcase,
Building2, Building2,
Clock,
FileCheck, FileCheck,
Globe, Globe,
ShieldCheck, ShieldCheck,
@@ -225,6 +229,9 @@ export default function SettingsPage() {
); );
} }
const reviewStatus = profile.reviewStatus ?? null;
const locked = reviewStatus === "pending";
return ( return (
<Container size="xl" px="lg" py="xl"> <Container size="xl" px="lg" py="xl">
<Stack gap="xl"> <Stack gap="xl">
@@ -239,6 +246,39 @@ export default function SettingsPage() {
</Text> </Text>
</div> </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 <Tabs
value={tab} value={tab}
onChange={(value) => value && setTab(value as SettingsTab)} onChange={(value) => value && setTab(value as SettingsTab)}
@@ -269,20 +309,33 @@ export default function SettingsPage() {
))} ))}
</Tabs.List> </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"> <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>
<Tabs.Panel value="contact"> <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>
<Tabs.Panel value="gm"> <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>
<Tabs.Panel value="poa"> <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>
<Tabs.Panel value="documents"> <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.Panel>
</Tabs> </Tabs>
</Stack> </Stack>

View File

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

View File

@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
</Text> </Text>
{onboardingDocs.map((doc, i) => ( {onboardingDocs.map((doc, i) => (
<Group <Group
key={`${doc.url}-${i}`} key={`${doc.id}-${i}`}
gap={12} gap={12}
align="center" align="center"
wrap="nowrap" 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] = const [pricingData, setPricingData] =
useState<GenerateContractPriceResponse | null>(null); useState<GenerateContractPriceResponse | null>(null);
// In edit mode the contract already exists, so seed its id — this makes // In edit mode the contract already exists, so seed its id — this makes
@@ -191,14 +203,18 @@ export default function NewContractPage({
contractId = contract.id; 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 }; return { contractId, pricing, mode };
}, },
onSuccess: ({ contractId, pricing, mode }) => { onSuccess: ({ contractId, pricing, mode }) => {
setPriceContractId(contractId); setPriceContractId(contractId);
setPricingData(pricing); setPricingData(pricing);
setPriceModalMode(mode); 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(); clearContractDraft();
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -228,7 +246,9 @@ export default function NewContractPage({
clearContractDraft(); clearContractDraft();
setPriceChangeResult(null); setPriceChangeResult(null);
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -242,7 +262,9 @@ export default function NewContractPage({
clearContractDraft(); clearContractDraft();
setPriceModalMode(null); setPriceModalMode(null);
setPriceContractId(null); setPriceContractId(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -325,6 +347,17 @@ export default function NewContractPage({
m.set(p.type, p.status); m.set(p.type, p.status);
return m; return m;
}, [auth.company]); }, [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( const profileTypes = useMemo(
() => [...profileStatusByType.keys()], () => [...profileStatusByType.keys()],
[profileStatusByType], [profileStatusByType],
@@ -343,12 +376,14 @@ export default function NewContractPage({
// badges. Intercity rides any customer profile, so always "approved". // badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo( const operationStatus = useMemo(
() => () =>
(op: OperationType): "approved" | "pending" | "missing" => { (op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
if (op === "intercity") return "approved"; if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes); const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target); const status = profileStatusByType.get(target);
if (!status) return "missing"; if (!status) return "missing";
return status === "active" ? "approved" : "pending"; if (status === "active") return "approved";
if (status === "rejected") return "rejected";
return "pending";
}, },
[profileStatusByType, profileTypes], [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) => { const handleOperationSelect = (op: OperationType) => {
// Intercity (domestic) runs on any existing customer profile — no switch. // Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return; if (op === "intercity") return;
@@ -551,23 +597,23 @@ export default function NewContractPage({
: {}), : {}),
...(serviceType?.includesFirstMile && data.firstMile.enabled ...(serviceType?.includesFirstMile && data.firstMile.enabled
? { ? {
firstMilePickupAddress: data.firstMile.pickUpAddress, firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined, firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined, firstMilePickupLng: data.firstMile.lng ?? undefined,
} }
: {}), : {}),
...(serviceType?.includesLastMile && data.lastMile.enabled ...(serviceType?.includesLastMile && data.lastMile.enabled
? { ? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress, lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined, lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined, lastMileDeliveryLng: data.lastMile.lng ?? undefined,
} }
: {}), : {}),
...(serviceType?.includesCustoms && data.customsClearingEnabled ...(serviceType?.includesCustoms && data.customsClearingEnabled
? { ? {
customsClearingEnabled: true, customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent || undefined, customsClearingAgent: data.customsClearingAgent || undefined,
} }
: { customsClearingEnabled: false }), : { customsClearingEnabled: false }),
cargoScope, cargoScope,
routes, routes,
@@ -652,7 +698,12 @@ export default function NewContractPage({
mb="lg" mb="lg"
> >
<Box> <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"} {isEdit ? "Edit Contract" : "New Contract"}
</Title> </Title>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
@@ -791,12 +842,12 @@ export default function NewContractPage({
errors={ errors={
showDocErrors showDocErrors
? Object.fromEntries( ? Object.fromEntries(
missingRequiredDocKeys( missingRequiredDocKeys(
editDocSettingQuery.data, editDocSettingQuery.data,
editContract, editContract,
editDocuments, editDocuments,
).map((k) => [k, "Required"]), ).map((k) => [k, "Required"]),
) )
: {} : {}
} }
/> />
@@ -814,9 +865,9 @@ export default function NewContractPage({
pricing={ pricing={
pricingData pricingData
? { ? {
currency: pricingData.currency, currency: pricingData.currency,
lineItems: pricingData.lineItems, lineItems: pricingData.lineItems,
} }
: null : null
} }
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -926,8 +977,8 @@ export default function NewContractPage({
Pricing schedule Pricing schedule
</Text> </Text>
<Text size="xs" c="dimmed" mb="md"> <Text size="xs" c="dimmed" mb="md">
Final amount is calculated at booking quantities are unknown at Final amount is calculated at booking quantities are unknown
the contract stage. at the contract stage.
</Text> </Text>
<Stack gap={10}> <Stack gap={10}>
{pricingData.lineItems.map((item) => ( {pricingData.lineItems.map((item) => (
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
loading={confirmSubmitMutation.isPending} loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()} onClick={() => confirmSubmitMutation.mutate()}
> >
Confirm &amp; submit Confirm {"&"} submit
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
<Check size={18} /> <Check size={18} />
</Box> </Box>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
License submitted. Your {createTargetLabel.toLowerCase()} profile License submitted. Your {createTargetLabel.toLowerCase()}{" "}
is now awaiting staff approval. We'll notify you once it's profile is now awaiting staff approval. We'll notify you once
approved then you can create this contract as{" "} it's approved then you can create this contract as{" "}
{createTargetLabel.toLowerCase()}. {createTargetLabel.toLowerCase()}.
</Text> </Text>
</Group> </Group>
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
) : ( ) : (
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add You don't have a {createTargetLabel.toLowerCase()} profile yet.
your business license to create one. It goes to staff for approval Add your business license to create one. It goes to staff for
before you can use it. approval before you can use it.
</Text> </Text>
<FileInput <FileInput
label="Business license" label="Business license"
@@ -1181,35 +1232,78 @@ export default function NewContractPage({
)} )}
</Modal> </Modal>
{/* Awaiting-approval modal — the chosen operation maps to a profile that {/* Awaiting-approval / rejected modal — the chosen operation maps to a
exists but isn't approved yet. The select was already reverted. */} profile that exists but isn't active. The select was already reverted. */}
<Modal {(() => {
opened={pendingApprovalProfile !== null} const target = pendingApprovalProfile
onClose={() => setPendingApprovalProfile(null)} ? profileByType.get(pendingApprovalProfile)
title="Awaiting approval" : undefined;
centered const isRejected = target?.status === "rejected";
radius="lg" const label = pendingApprovalProfile
> ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
<Stack gap="md"> pendingApprovalProfile)
<Text size="sm" c="dimmed"> : "";
Your{" "} return (
{pendingApprovalProfile <Modal
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? opened={pendingApprovalProfile !== null}
pendingApprovalProfile) onClose={() => setPendingApprovalProfile(null)}
: ""}{" "} title={isRejected ? "Profile not approved" : "Awaiting approval"}
profile was submitted and is under staff review. You can start a centered
contract under it once it's approved. radius="lg"
</Text> >
<Group justify="flex-end" gap="sm"> <Stack gap="md">
<Button {isRejected ? (
color="edr-green" <>
onClick={() => setPendingApprovalProfile(null)} <Text size="sm" c="dimmed">
> Your {label} profile was not approved. Fix the issue below
OK and resubmit it for review.
</Button> </Text>
</Group> {target?.reviewNote && (
</Stack> <Alert color="red" variant="light" radius="md">
</Modal> <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> </Box>
); );
} }

View File

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

View File

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

View File

@@ -34,6 +34,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
companyAddress: z.string().min(1, "Address is required"), companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 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>; export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
@@ -63,6 +69,7 @@ export default function TabCompanyProfile({
companyAddress: profile.companyAddress ?? "", companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber, tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "", fanNumber: profile.fanNumber ?? "",
vatNumber: profile.vatNumber ?? "",
}; };
} }
return { return {
@@ -73,6 +80,7 @@ export default function TabCompanyProfile({
companyAddress: "", companyAddress: "",
tinNumber: "", tinNumber: "",
fanNumber: "", fanNumber: "",
vatNumber: "",
}; };
}, [profile]); }, [profile]);
@@ -97,6 +105,7 @@ export default function TabCompanyProfile({
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
fanNumber: data.fanNumber, fanNumber: data.fanNumber,
vatNumber: data.vatNumber ?? "",
}; };
if (isCreate) { if (isCreate) {
@@ -223,6 +232,18 @@ export default function TabCompanyProfile({
/> />
</Grid.Col> </Grid.Col>
</Grid> </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> </Stack>
<Group <Group

View File

@@ -1,11 +1,21 @@
import { fileViewUrl } from "@/constants/apiConfig"; import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api"; 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 { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import { import {
// Anchor, SmartFileInput,
useFileViewer,
type ViewableFile,
} from "@edr/ui-common";
import {
ActionIcon,
Anchor,
Badge,
Button, Button,
Card, Card,
Center, Center,
@@ -13,18 +23,23 @@ import {
Stack, Stack,
Text, Text,
Title, Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
Clock,
FileCheck, FileCheck,
FileText,
Loader2, Loader2,
Paperclip, Paperclip,
RefreshCw,
Trash2,
UploadCloud, UploadCloud,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
importer: "Importer", importer: "Importer",
@@ -137,9 +152,7 @@ export default function TabDocuments({
return errs; return errs;
}; };
const licenseProfiles = profile.companyProfiles.filter( const licenseProfiles = profile.companyProfiles;
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
return ( return (
<> <>
@@ -246,24 +259,19 @@ export default function TabDocuments({
<Title order={3}>Business licenses</Title> <Title order={3}>Business licenses</Title>
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <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> </Text>
<Stack gap="md"> <Stack gap="xl">
{licenseProfiles.map((p) => ( {licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}> <ProfileLicenseRow
<Text size="sm" fw={600} c="edr-text"> key={p.id}
{ROLE_LABELS[p.type] ?? p.type} · {p.reference} profile={p}
</Text> onViewFile={view}
{p.licenseFiles.map((f) => ( reviewPending={profile.reviewStatus === "pending"}
<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>
))} ))}
</Stack> </Stack>
</Card> </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, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
import type { import type {
ChangeRequestResponse,
CompanyDocument, CompanyDocument,
CompanyInfoResponse, CompanyInfoResponse,
CompanyNationality, CompanyNationality,
@@ -211,6 +212,18 @@ export const api = {
"documents", "documents",
({ companyId }) => companiesService.getDocuments(companyId), ({ 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: { bookings: {

View File

@@ -14,11 +14,16 @@ export type ProfileTypeValue =
export type CompanyNationality = "ethiopian" | "foreign"; 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 { export interface LicenseFile {
id: string;
name: string; name: string;
url: string;
size: number; size: number;
mimeType?: string; mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
status: LicenseFileStatus;
} }
export interface ExternalProfileResponse { export interface ExternalProfileResponse {
@@ -73,6 +78,8 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */ /** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[]; licenseFiles: LicenseFile[];
attributes: Record<string, any> | null; attributes: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -80,6 +87,28 @@ export interface CompanyProfileResponse {
export interface CompanyInfoResponse { export interface CompanyInfoResponse {
profile: ExternalProfileResponse; profile: ExternalProfileResponse;
company: CompanyResponse; 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. */ /** A single company-level document uploaded against a `file_upload_settings` field. */
@@ -328,7 +357,11 @@ export const companiesService = {
return unwrap(response.data); 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 ( uploadProfileLicense: async (
profileId: string, profileId: string,
files: File[], files: File[],
@@ -343,7 +376,33 @@ export const companiesService = {
return unwrap(response.data); 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[]> => { getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>( const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
@@ -351,6 +410,24 @@ export const companiesService = {
return unwrap(response.data); 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. */ /** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => { fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
const response = await client.post<ApiResponse<any>>( const response = await client.post<ApiResponse<any>>(

View File

@@ -40,6 +40,14 @@ export interface ProfileResponse {
poaLocation: string | null; poaLocation: string | null;
poaAddress: string | null; poaAddress: string | null;
profileId: string; 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 { export interface UpdateProfilePayload {

View File

@@ -42,21 +42,41 @@ client.interceptors.request.use((config) => {
}); });
// Token refresh state // Token refresh state
let isRefreshing = false; let refreshPromise: Promise<string> | null = null;
let failedQueue: {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}[] = [];
function processQueue(error: unknown, token?: string) { /**
failedQueue.forEach(({ resolve, reject }) => { * Single-flight token refresh: concurrent callers (the 401 interceptor and
if (error) { * the proactive scheduler) share one in-flight request so the refresh token
reject(error); * is only rotated once. Throws if no refresh token is stored or the server
} else { * rejects it — callers decide how to end the session.
resolve(token!); */
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 // Handle auth errors globally with token refresh
@@ -72,54 +92,41 @@ client.interceptors.response.use(
// - status is not 401 // - status is not 401
// - already retried // - already retried
// - it's the refresh endpoint itself // - 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 ( if (
!error.response || !error.response ||
error.response.status !== 401 || error.response.status !== 401 ||
originalRequest._retry || 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); return Promise.reject(error);
} }
if (isRefreshing) { // Nothing to refresh with (e.g. not logged in yet) — surface the
return new Promise<string>((resolve, reject) => { // original error instead of a confusing refresh failure.
failedQueue.push({ resolve, reject }); if (!getCookie("refresh-token")) {
}).then((token) => { if (getCookie("auth-token")) {
originalRequest.headers.Authorization = `Bearer ${token}`; // Half-broken cookie state; reset it.
return client(originalRequest); clearAuthCookies();
}); }
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
isRefreshing = false;
clearAuthCookies();
return Promise.reject(error); return Promise.reject(error);
} }
originalRequest._retry = true;
try { try {
const { data } = await client.post<{ const token = await refreshSessionTokens();
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);
originalRequest.headers.Authorization = `Bearer ${token}`; originalRequest.headers.Authorization = `Bearer ${token}`;
processQueue(null, token);
return client(originalRequest); return client(originalRequest);
} catch (refreshError) { } catch (refreshError) {
processQueue(refreshError, undefined);
clearAuthCookies(); clearAuthCookies();
return Promise.reject(refreshError); return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
}, },
); );
export { client }; export { client, clearAuthCookies, getCookie, refreshSessionTokens };
export type { UseQueryOptions, QueryObserverOptions }; 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; 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 { export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const obj = err as Record<string, unknown>; 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 statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined; const data = response.data as Record<string, unknown> | undefined;
return { return {
code: (data?.error as string) || (data?.message as string) || "api_error", code: (data?.message as string) || (data?.error as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", message: humanizeApiMessage(
(data?.message as string) ||
(data?.error as string) ||
"An unexpected error occurred",
),
statusCode, statusCode,
}; };
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { import {
@@ -26,7 +26,7 @@ const WAAFI_SUCCESS_CODE = "2001";
const WAAFI_HPP_SESSION_MS = 5 * 60_000; const WAAFI_HPP_SESSION_MS = 5 * 60_000;
@Injectable() @Injectable()
export class WaafiProvider implements PaymentProvider { export class WaafiProvider implements PaymentProvider, OnModuleInit {
readonly method = ProviderMethod.WAAFI; readonly method = ProviderMethod.WAAFI;
private readonly logger = new Logger(WaafiProvider.name); private readonly logger = new Logger(WaafiProvider.name);
private readonly httpsAgent: https.Agent; private readonly httpsAgent: https.Agent;
@@ -44,16 +44,59 @@ export class WaafiProvider implements PaymentProvider {
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
} }
/** Log the effective Waafi config once at startup (secrets masked) so misconfig is visible. */
onModuleInit(): void {
this.logger.log(
`Waafi config resolved: ${JSON.stringify(this.effectiveConfig())}`,
);
}
/** Snapshot of every resolved Waafi env value; secret fields are masked, not printed raw. */
private effectiveConfig(): Record<string, unknown> {
return {
WAAFI_BASE_URL: this.baseUrl,
WAAFI_MERCHANT_UID: this.merchantUid || "(empty)",
WAAFI_STORE_ID: this.storeId || "(empty)",
WAAFI_HPP_KEY: this.mask(this.hppKey),
WAAFI_WEBHOOK_SECRET: this.mask(this.webhookSecret),
WAAFI_PAYMENT_METHOD: this.paymentMethod,
WAAFI_HPP_SUCCESS_URL: this.successUrl || "(empty)",
WAAFI_HPP_FAILURE_URL: this.failureUrl || "(empty)",
WAAFI_HPP_RESP_FORMAT: this.respDataFormat,
WAAFI_INSECURE_TLS: this.config.get<boolean>("waafi.insecureTls") ?? false,
};
}
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
private mask(value: string): string {
if (!value) return "(empty)";
const tail = value.length > 4 ? value.slice(-4) : "";
return `set(len=${value.length},…${tail})`;
}
async initiate( async initiate(
input: ProviderInitiationInput, input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> { ): Promise<ProviderInitiationResult> {
const requestBody = this.buildPurchaseRequest(input); const requestBody = this.buildPurchaseRequest(input);
const url = `${this.baseUrl}/asm`;
this.logger.log(
`Waafi HPP_PURCHASE → ${url} | currency=${input.currency} amount=${this.toAmount(input.amountMinor)} (amountMinorIn=${input.amountMinor}) ref=${input.merchantOrderId}`,
);
this.logger.debug(
`Waafi HPP_PURCHASE request body: ${JSON.stringify(this.sanitize(requestBody))}`,
);
this.logger.debug(
`Waafi effective config: ${JSON.stringify(this.effectiveConfig())}`,
);
const response = await this.postJson<WaafiHppPurchaseResponse>( const response = await this.postJson<WaafiHppPurchaseResponse>(
`${this.baseUrl}/asm`, url,
requestBody, requestBody,
); );
if (response.responseCode !== WAAFI_SUCCESS_CODE) { if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.error(
`Waafi HPP_PURCHASE rejected — sent currency=${input.currency} amount=${this.toAmount(input.amountMinor)} paymentMethod=${this.paymentMethod} | full response: ${JSON.stringify(response)}`,
);
throw new Error( throw new Error(
`Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`, `Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`,
); );

View File

@@ -767,9 +767,9 @@ export function SmartFileInput({
)} )}
</div> </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" /> <UploadCloud className="h-3.5 w-3.5" />
Replace Replace file
</span> </span>
</div> </div>
) : ( ) : (