feat: implemented the changes request to the company profile

This commit is contained in:
Nathnael
2026-07-08 08:06:50 +00:00
parent 1dba962502
commit 2cae451edc
14 changed files with 742 additions and 38 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

@@ -43,6 +43,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -68,7 +70,10 @@ export class CompaniesController {
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id,
);
return new CompanyInfoResponseDto(profile, company, review);
}
@Get("profile")
@@ -78,7 +83,40 @@ export class CompaniesController {
): Promise<ProfileResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id,
);
return new ProfileResponseDto(profile, company, review);
}
@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")
@@ -354,26 +392,76 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
@CurrentUser() user: CurrentIamUser,
@Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, "companies", files);
// Routed through the service so an approved company's uploads are staged for
// review (and lock the customer), while onboarding uploads pass straight through.
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
}
@Patch("company-profiles/:profileId/status")
@FreightAdmin()
@ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
dto.note,
user.id,
);
return new ResponseCompanyProfileDto(profile);
}
@Get(":companyId/change-requests")
@FreightAdmin()
@ApiOperation({ summary: "List a company's profile change requests" })
async listChangeRequests(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<ChangeRequestResponseDto[]> {
const requests = await this.companiesService.listChangeRequests(companyId);
return requests.map((r) => new ChangeRequestResponseDto(r));
}
@Post("change-requests/:id/approve")
@FreightAdmin()
@ApiOperation({
summary: "Approve a pending profile change request (applies the changes)",
})
async approveChangeRequest(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.approveChangeRequest(
id,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post("change-requests/:id/reject")
@FreightAdmin()
@ApiOperation({
summary: "Reject a pending profile change request with a note",
})
async rejectChangeRequest(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectChangeRequestDto,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.rejectChangeRequest(
id,
dto.note,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
@Module({
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
TypeOrmModule.forFeature([
Company,
ExternalProfile,
CompanyProfile,
CompanyChangeRequest,
Booking,
]),
HttpModule,
FilesModule,
FileUploadSettingsModule,
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
],

View File

@@ -7,6 +7,7 @@ import {
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
CompanyDashboardRepository,
@@ -14,6 +15,7 @@ import {
} from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
@@ -40,6 +42,10 @@ import {
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
export interface UserIdentity {
userId: string;
@@ -54,6 +60,7 @@ export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
@@ -574,12 +581,24 @@ export class CompaniesService {
return updated;
}
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
/** Keep only the keys that were actually provided (drop `undefined`). */
private pickDefined(dto: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(dto)) {
if (v !== undefined) out[k] = v;
}
return out;
}
/**
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
*/
private mapProfileDtoToCompanyUpdates(
company: Company,
dto: Partial<UpdateProfileDto>,
): Record<string, any> {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
@@ -593,21 +612,10 @@ export class CompaniesService {
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined && dto.tin !== company.tin) {
// Reject a TIN already taken by a different company (the user's own draft
// placeholder is fine to overwrite).
const owner = await this.companiesRepo.findByTin(dto.tin);
if (owner && owner.id !== company.id) {
throw new ConflictException(
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
);
}
if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
}
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -629,8 +637,7 @@ export class CompaniesService {
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
@@ -653,11 +660,214 @@ export class CompaniesService {
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
/** Reject a TIN already registered to a *different* company. */
private async assertTinAvailable(
company: Company,
tin: string | undefined,
): Promise<void> {
if (tin === undefined || tin === company.tin) return;
const owner = await this.companiesRepo.findByTin(tin);
if (owner && owner.id !== company.id) {
throw new ConflictException(
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
);
}
}
/** The company's open (pending or last-rejected) profile change request. */
async getOpenChangeRequestForCompany(
companyId: string,
): Promise<CompanyChangeRequest | null> {
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
}
/**
* Update the current user's profile.
*
* - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run.
* - Company already `active` → do NOT touch the live Company. Stage the edit in
* a pending change request (merging into any open one) so a backoffice
* reviewer can approve (apply) or reject (with a note). This locks the
* customer until the review resolves.
*/
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
if (company.status !== CompanyStatus.Active) {
await this.assertTinAvailable(company, dto.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
const updated = await this.companiesRepo.update(
company.id,
companyUpdates,
);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
// Approved company: stage the change for review, leaving the live row intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const now = new Date();
let request: CompanyChangeRequest;
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields },
submittedBy: userId,
submittedAt: now,
note: null,
})) ?? existing;
} else {
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
});
}
// Live company is unchanged; surface the pending state for the settings page.
return new ProfileResponseDto(profile, company, request);
}
/** List a company's change requests, newest first (backoffice review). */
async listChangeRequests(
companyId: string,
): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
}
/**
* Approve a pending change request: apply its snapshot to the live Company and
* mark the request approved. Any staged documents are already attached to the
* company, so nothing else needs promoting.
*/
async approveChangeRequest(
id: string,
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
);
}
const company = await this.companiesRepo.findById(request.companyId);
if (!company)
throw new NotFoundException(`Company ${request.companyId} not found`);
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates);
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}`,
);
}
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
})) ?? request
);
}
async deleteCompany(id: string): Promise<void> {
@@ -713,6 +923,8 @@ export class CompaniesService {
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
@@ -727,6 +939,18 @@ export class CompaniesService {
);
}
// Track the review outcome. Rejection keeps the note so the customer knows
// why; approval clears it. Any decision stamps the reviewer + time.
if (status === ProfileStatus.Rejected) {
patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) {
patch.reviewNote = null;
}
if (status !== ProfileStatus.Pending) {
patch.reviewedBy = reviewerId ?? null;
patch.reviewedAt = new Date();
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
@@ -744,6 +968,41 @@ export class CompaniesService {
return updated;
}
/**
* Customer reapplies for a rejected operational role (after fixing whatever the
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
* clear the rejection note so it re-enters the approval queue.
*/
async reapplyCompanyProfile(
userId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const target = await this.companyProfilesRepo.findById(profileId);
if (!target || target.companyId !== companyId) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
if (target.status !== ProfileStatus.Rejected) {
throw new BadRequestException(
"Only a rejected role can be resubmitted for approval",
);
}
const updated = await this.companyProfilesRepo.update(profileId, {
status: ProfileStatus.Pending,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
return updated;
}
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
@@ -833,12 +1092,12 @@ export class CompaniesService {
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
@@ -870,13 +1129,14 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
const reference = await this.companyProfilesRepo.generateReference(type);
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. The customer can select this mode but
// can't book under it until it's cleared.
created = await this.companyProfilesRepo.create({
companyId,
type,
reference,
businessLicense: businessLicense ?? null,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}

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,40 @@
import {
ChangeRequestStatus,
CompanyChangeRequest,
} 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[];
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.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedAt = req.submittedAt ?? null;
this.reviewedBy = req.reviewedBy ?? null;
this.reviewedAt = req.reviewedAt ?? null;
this.createdAt = req.createdAt;
this.updatedAt = req.updatedAt;
}
}

View File

@@ -1,14 +1,43 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from '../entities/company-change-request.entity';
import { ResponseCompanyDto } from './response-company.dto';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class CompanyInfoResponseDto {
profile: ResponseExternalProfileDto;
company: ResponseCompanyDto;
/**
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
* settings + new-contract/booking creation disabled) and the reapply banner.
*/
review: {
status: 'pending' | 'rejected';
note: string | null;
} | null;
constructor(profile: ExternalProfile, company: Company) {
constructor(
profile: ExternalProfile,
company: Company,
changeRequest?: CompanyChangeRequest | null,
) {
this.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(company);
const open =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
? changeRequest
: null;
this.review = open
? {
status: open.status as 'pending' | 'rejected',
note: open.note ?? null,
}
: null;
}
}

View File

@@ -1,5 +1,9 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from '../entities/company-change-request.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
profileId: string;
constructor(profile: ExternalProfile, company: Company) {
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
* proposed values from `pendingChanges` so the customer can amend & resubmit.
*/
reviewStatus: "pending" | "rejected" | null;
reviewNote: string | null;
pendingChanges: Record<string, any> | null;
constructor(
profile: ExternalProfile,
company: Company,
changeRequest?: CompanyChangeRequest | null,
) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? null;
const openReview =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected)
? changeRequest
: null;
this.reviewStatus =
openReview?.status === ChangeRequestStatus.Pending
? "pending"
: openReview?.status === ChangeRequestStatus.Rejected
? "rejected"
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
}
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator";
export class RejectChangeRequestDto {
/** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(2000)
note!: string;
}

View File

@@ -21,6 +21,8 @@ export class ResponseCompanyProfileDto {
/** Business-license documents stored on the profile (multi-file). */
licenseFiles: BusinessLicenseFile[];
attributes?: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: Date;
updatedAt: Date;
@@ -33,6 +35,7 @@ export class ResponseCompanyProfileDto {
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];
this.attributes = profile.attributes;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}

View File

@@ -1,9 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ProfileStatus } from "../entities/company-profile.entity";
export class UpdateCompanyProfileStatusDto {
@ApiProperty({ enum: ProfileStatus })
@IsIn(Object.values(ProfileStatus))
status!: ProfileStatus;
/** Reviewer note — required in practice when rejecting so the customer knows why. */
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -0,0 +1,70 @@
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",
}
/** 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[];
}
@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,6 +13,8 @@ export enum ProfileType {
export enum ProfileStatus {
Active = "active",
Pending = "pending",
/** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
Rejected = "rejected",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@@ -80,4 +82,14 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
/** Reviewer's note when the role is Rejected (cleared on reapply). */
@Column({ name: "review_note", type: "text", nullable: true })
reviewNote?: string | null;
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
reviewedBy?: string | null;
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt?: Date | null;
}