mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #1057 from Tria-plc/freight/chore/payment-test
Freight/chore/payment test
This commit is contained in:
@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -685,6 +686,17 @@ export class CompaniesController {
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Get(":companyId/revisions")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
|
||||
async listCompanyRevisions(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CompanyRevisionResponseDto[]> {
|
||||
const revisions =
|
||||
await this.companiesService.listCompanyRevisions(companyId);
|
||||
return revisions.map((r) => new CompanyRevisionResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
@@ -719,6 +731,25 @@ export class CompaniesController {
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/request-changes")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
||||
})
|
||||
async requestChangeRequestChanges(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.requestChangeRequestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -120,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -144,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -15,9 +15,11 @@ 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 { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyRevisionRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
CompanyNotifierService,
|
||||
|
||||
@@ -91,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -121,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import {
|
||||
diffCompanyUpdate,
|
||||
summarizeCompanyChanges,
|
||||
} from "./company-revision-diff.util";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
@@ -64,6 +70,10 @@ import {
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "./entities/company-revision.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
@@ -176,10 +186,13 @@ export interface UserIdentity {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
private readonly logger = new Logger(CompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly revisionRepo: CompanyRevisionRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -682,7 +695,16 @@ export class CompaniesService {
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
const before = await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
|
||||
// Staff can also promote Pending -> Active directly through this generic
|
||||
// endpoint (not just via the first-profile-approval path), so stamp it here too.
|
||||
if (
|
||||
dto.status === CompanyStatus.Active &&
|
||||
before.status !== CompanyStatus.Active
|
||||
) {
|
||||
patch.approvedAt = new Date();
|
||||
}
|
||||
const updated = await this.companiesRepo.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
|
||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||
@@ -840,6 +862,34 @@ export class CompaniesService {
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a version-history entry for an onboarding-phase edit (the company
|
||||
* is not yet Active, so the change went straight to the live row with no
|
||||
* approval gate to carry a record of it). Best-effort: a no-op patch or a
|
||||
* failure to write history must never break the edit that triggered it.
|
||||
*/
|
||||
private async recordCompanyRevision(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
actorId?: string | null,
|
||||
extraChanges: CompanyRevisionChange[] = [],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
||||
if (changes.length === 0) return;
|
||||
await this.revisionRepo.create({
|
||||
companyId: before.id,
|
||||
actorId: actorId ?? null,
|
||||
summary: summarizeCompanyChanges(changes),
|
||||
changes,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record company revision for ${before.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
@@ -902,6 +952,7 @@ export class CompaniesService {
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
await this.recordCompanyRevision(company, companyUpdates, userId);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
@@ -945,10 +996,14 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
// Note is left untouched: if this request was ChangesRequested, the
|
||||
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
||||
// clearing it here would hide what was asked for right when the
|
||||
// reviewer comes back to check whether it was actually addressed.
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
@@ -986,6 +1041,53 @@ export class CompaniesService {
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
||||
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.revisionRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after revision
|
||||
* change — that's exactly how a "replace" is staged (see
|
||||
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
||||
* pushed together, and later merges only ever append after that pair, so
|
||||
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
||||
* add, or a pure removal) stands alone.
|
||||
*/
|
||||
private pairReplaceIntents<
|
||||
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
||||
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(next),
|
||||
from: current.fileName ?? null,
|
||||
to: next.fileName ?? null,
|
||||
fromFileId: current.fileId,
|
||||
toFileId: next.fileId,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(current),
|
||||
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
||||
to: current.op === "add" ? (current.fileName ?? null) : null,
|
||||
fromFileId: current.op === "remove" ? current.fileId : null,
|
||||
toFileId: current.op === "add" ? current.fileId : null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1015,6 +1117,30 @@ export class CompaniesService {
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
||||
// land on the live row — without this call, everything the #419
|
||||
// change-request flow does to those is invisible in Version History.
|
||||
// `documentFileIds` (the general bulk company-documents upload) is
|
||||
// deliberately NOT re-recorded here — those documents go live immediately
|
||||
// at upload time and are already recorded there (see
|
||||
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
||||
const documentChanges: CompanyRevisionChange[] = [
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.licenseChanges ?? [],
|
||||
() => "Business license",
|
||||
),
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.documentChanges ?? [],
|
||||
(intent) => intent.code,
|
||||
),
|
||||
];
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
companyUpdates,
|
||||
reviewerId,
|
||||
documentChanges,
|
||||
);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
@@ -1025,12 +1151,62 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
||||
* replaces whatever was there, not adds to it — soft-delete the prior live
|
||||
* file(s) for that code, and describe each replacement (plus each genuinely
|
||||
* new upload) as a revision change carrying both file ids, so the reviewer
|
||||
* can open the previous and current file. Multi-file slots are left alone
|
||||
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
||||
* codes (no matching field in the nationality's document setting) are also
|
||||
* left alone — safer to under-clean than to guess wrong. Independent of the
|
||||
* change-request review outcome: nothing else in this flow ever retires a
|
||||
* superseded document, on approve OR reject — these documents go live the
|
||||
* moment they're uploaded.
|
||||
*/
|
||||
private async replaceSingleFileCompanyDocuments(
|
||||
company: Company,
|
||||
before: FileRecord[],
|
||||
uploaded: FileRecord[],
|
||||
): Promise<CompanyRevisionChange[]> {
|
||||
const setting = await this.fileUploadSettingsService
|
||||
.getByCode(this.documentSettingCodeFor(company.nationality))
|
||||
.catch(() => null);
|
||||
const fields = setting?.fields ?? [];
|
||||
const singleFileCodes = new Set(
|
||||
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
||||
);
|
||||
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
||||
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
||||
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const toRemove: FileRecord[] = [];
|
||||
for (const file of uploaded) {
|
||||
if (!singleFileCodes.has(file.code)) continue;
|
||||
const prior = before.find(
|
||||
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
||||
);
|
||||
changes.push({
|
||||
field: `document:${file.code}`,
|
||||
label: labelByCode.get(file.code) ?? file.code,
|
||||
from: prior?.name ?? null,
|
||||
to: file.name,
|
||||
fromFileId: prior?.id ?? null,
|
||||
toFileId: file.id,
|
||||
});
|
||||
if (prior) toRemove.push(prior);
|
||||
}
|
||||
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* upload with no review. Either way the documents go live immediately, so
|
||||
* the revision history is recorded right away too, not gated on a decision.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
@@ -1038,11 +1214,20 @@ export class CompaniesService {
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const before = await this.filesService.findByResource(
|
||||
companyId,
|
||||
"companies",
|
||||
);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||
company,
|
||||
before,
|
||||
uploaded,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
@@ -1056,6 +1241,9 @@ export class CompaniesService {
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@@ -1170,7 +1358,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
@@ -1231,6 +1420,37 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for specific fixes without rejecting outright: unlike
|
||||
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
||||
* row stays open), so the customer's next edit is appended to this SAME
|
||||
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
||||
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
||||
* of starting a fresh cycle.
|
||||
*/
|
||||
async requestChangeRequestChanges(
|
||||
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.ChangesRequested,
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
@@ -1472,6 +1692,7 @@ export class CompaniesService {
|
||||
) {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
@@ -2264,7 +2485,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
@@ -2590,7 +2812,8 @@ export class CompaniesService {
|
||||
snapshot,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
return;
|
||||
@@ -2870,7 +3093,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOperator, Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||
function statusMatches(
|
||||
rowStatus: ChangeRequestStatus,
|
||||
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||
return rowStatus === where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
statusMatches(row.status, where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a changes-requested request as open, same as pending", async () => {
|
||||
const changesRequested: Row = {
|
||||
id: "changes-requested",
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
changesRequested,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result?.id).toBe("changes-requested");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||
const OPEN_FOR_EDIT_STATUSES = [
|
||||
ChangeRequestStatus.Pending,
|
||||
ChangeRequestStatus.ChangesRequested,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Company } from "./entities/company.entity";
|
||||
import type { CompanyRevisionChange } from "./entities/company-revision.entity";
|
||||
|
||||
/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
|
||||
export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Company name",
|
||||
phone: "Phone",
|
||||
email: "Email",
|
||||
address: "Address",
|
||||
country: "Country",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
website: "Website",
|
||||
licenceNumber: "Licence number",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House No",
|
||||
contactPersonName: "Contact person name",
|
||||
contactPersonPhone: "Contact person phone",
|
||||
contactPersonEmail: "Contact person email",
|
||||
contactPersonPosition: "Contact person position",
|
||||
generalManagerName: "General manager name",
|
||||
generalManagerPhone: "General manager phone",
|
||||
generalManagerEmail: "General manager email",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
documents: "Document",
|
||||
};
|
||||
|
||||
function displayValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the company row before a write against the patch about to be
|
||||
* applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
|
||||
* columns plus a merged `attributes` blob). Only fields with a known label
|
||||
* are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
|
||||
* shows up as noise.
|
||||
*/
|
||||
export function diffCompanyUpdate(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const { attributes: attrPatch, ...columnPatch } = patch;
|
||||
|
||||
for (const [field, nextRaw] of Object.entries(columnPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue((before as unknown as Record<string, unknown>)[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
|
||||
if (attrPatch) {
|
||||
const beforeAttrs = before.attributes ?? {};
|
||||
for (const [field, nextRaw] of Object.entries(attrPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue(beforeAttrs[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "phone, address changed". */
|
||||
export function summarizeCompanyChanges(
|
||||
changes: CompanyRevisionChange[],
|
||||
): string {
|
||||
if (changes.length === 0) return "No changes";
|
||||
const labels = changes.map((c) => c.label.toLowerCase());
|
||||
return labels.length <= 3
|
||||
? `${labels.join(", ")} changed`
|
||||
: `${labels.length} fields changed`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyRevisionRepository extends BaseRepository<CompanyRevision> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyRevision)
|
||||
repo: Repository<CompanyRevision>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** Revision history for a company, newest first. */
|
||||
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||
* call to action, but the customer's edit appends to this SAME request
|
||||
* instead of starting a fresh one.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
status: 'pending' | 'rejected' | 'changes_requested';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "../entities/company-revision.entity";
|
||||
|
||||
/** One version-history entry, shown on the backoffice customer detail page. */
|
||||
export class CompanyRevisionResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: Date;
|
||||
|
||||
constructor(revision: CompanyRevision) {
|
||||
this.id = revision.id;
|
||||
this.companyId = revision.companyId;
|
||||
this.actorId = revision.actorId ?? null;
|
||||
this.summary = revision.summary;
|
||||
this.changes = revision.changes ?? [];
|
||||
this.createdAt = revision.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||
* request instead of starting a fresh one.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||
? "changes_requested"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
|
||||
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
this.approvedAt = company.approvedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ 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.
|
||||
* the live Company row) until a backoffice reviewer resolves it:
|
||||
* - Approved — the snapshot is applied to the live Company row.
|
||||
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||
* of starting a new cycle.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
ChangesRequested = "changes_requested",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
/**
|
||||
* One recorded field/document change, as shown on the customer's version
|
||||
* history. A document change carries `fromFileId`/`toFileId` alongside the
|
||||
* display names, so the reviewer can open the previous and current file —
|
||||
* not just read that "a document changed."
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only audit of edits made to a company record BEFORE it reaches
|
||||
* `Active` (the onboarding phase), where {@link CompaniesService.updateProfile}
|
||||
* and {@link CompaniesService.uploadCompanyDocuments} write straight to the
|
||||
* live row with no approval gate — and, until this entity, no trace at all.
|
||||
* Post-approval edits already get history via `CompanyChangeRequest`; this
|
||||
* covers the gap before that gate exists.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "company_revisions" })
|
||||
@Index(["companyId"])
|
||||
export class CompanyRevision extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
@Column({ name: "actor_id", type: "uuid", nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
@Column({ name: "summary", type: "varchar", length: 255 })
|
||||
summary!: string;
|
||||
|
||||
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
|
||||
changes!: CompanyRevisionChange[];
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
|
||||
})
|
||||
status!: CompanyStatus;
|
||||
|
||||
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
|
||||
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
|
||||
approvedAt?: Date | null;
|
||||
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.filesService.findById(fileId);
|
||||
// Includes soft-deleted records: a superseded document (replaced via a
|
||||
// single-file document slot, or resolved as part of a license/PoA swap)
|
||||
// is only reachable by UUID through the change-request/version-history
|
||||
// diff, where reviewers need to open the "previous" file to compare it.
|
||||
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
@@ -63,7 +67,9 @@ export class FilesController {
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const { stream } = await this.filesService.streamById(fileId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -245,6 +245,23 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link findById}, but also matches a soft-deleted record — a
|
||||
* superseded document (replaced via a single-file slot, or a resolved
|
||||
* license/PoA swap) is exactly this: gone from every live listing, but its
|
||||
* id is still handed to reviewers in the change-request/version-history
|
||||
* diff so they can open the "previous" file for comparison. Only the
|
||||
* preview/download route should use this; every other caller wants the
|
||||
* default (soft-deleted = not found).
|
||||
*/
|
||||
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id, {
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||
@@ -360,8 +377,11 @@ export class FilesService {
|
||||
|
||||
async streamById(
|
||||
id: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const record = opts.includeDeleted
|
||||
? await this.findByIdIncludingDeleted(id)
|
||||
: await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
|
||||
Reference in New Issue
Block a user