feat: add poa to the changes approval

This commit is contained in:
Nathnael
2026-07-10 08:45:39 +00:00
parent ea0f264d72
commit e5fce529fe
13 changed files with 1046 additions and 121 deletions

View File

@@ -34,7 +34,10 @@ import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
import {
CompanyDocumentFileView,
ProfileLicenseFileView,
} from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -306,6 +309,49 @@ export class CompaniesController {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@Get("poa-delegation")
@ApiOperation({
summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company",
})
async listPoaDelegation(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.listPoaDelegationFiles(user.id);
}
@Post("poa-delegation")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
"For an approved company the upload is staged for backoffice review; during " +
"onboarding it goes live.",
})
async uploadPoaDelegation(
@CurrentUser() user: CurrentIamUser,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<CompanyDocumentFileView[]> {
const file = files?.[0];
if (!file) {
throw new BadRequestException("A delegation letter file is required");
}
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
}
@Delete("poa-delegation/:fileId")
@ApiOperation({
summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
})
async removePoaDelegation(
@CurrentUser() user: CurrentIamUser,
@Param("fileId", ParseUUIDPipe) fileId: string,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",

View File

@@ -37,6 +37,7 @@ import {
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyDocumentFileView,
CompanyProfile,
ProfileLicenseFileView,
ProfileType,
@@ -45,6 +46,7 @@ import {
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
@@ -56,6 +58,10 @@ const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
"poaName",
@@ -782,6 +788,7 @@ export class CompaniesService {
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
@@ -834,7 +841,12 @@ export class CompaniesService {
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
documents: { documentFileIds: [...prev, ...fileIds] },
// Spread the existing documents blob: a bare object would drop any
// licenseChanges/documentChanges already staged on this request.
documents: {
...existing.documents,
documentFileIds: [...prev, ...fileIds],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
@@ -866,12 +878,17 @@ export class CompaniesService {
);
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(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: [] },
// Staged license/document uploads were just discarded; drop their intents
// so an amended resubmit never re-references deleted files.
documents: {
...request.documents,
licenseChanges: [],
documentChanges: [],
},
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
@@ -1731,6 +1748,254 @@ export class CompaniesService {
}
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
return this.getPoaDelegationView(company.id);
}
/**
* Upload the PoA delegation letter, replacing whatever is already on file.
* On an Active company this stages an `add` for the new file plus a `remove`
* for each live one; a letter still awaiting approval is withdrawn outright
* rather than stacking a second pending upload.
*/
async uploadPoaDelegationLetter(
userId: string,
file: Express.Multer.File,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const gated = company.status === CompanyStatus.Active;
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
const staged = records.filter(
(r) => r.code === POA_DELEGATION_PENDING_CODE,
);
// Supersede an unreviewed upload instead of queueing another one.
for (const r of staged) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
const created = await this.filesService.upload({
resourceId: company.id,
resource: COMPANY_RESOURCE,
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
file,
});
if (gated) {
await this.stageDocumentIntent(
company.id,
[
...live.map((r) => ({
op: "remove" as const,
fileId: r.id,
code: POA_DELEGATION_FILE_KEY,
fileName: r.name,
})),
{
op: "add" as const,
fileId: created.id,
code: POA_DELEGATION_FILE_KEY,
fileName: created.name,
},
],
userId,
);
} else {
// Onboarding: no review, so the old letter is simply replaced.
for (const r of live) await this.filesService.remove(r.id);
}
return this.getPoaDelegationView(company.id);
}
/**
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
* an Active company is kept and flagged for deletion on approval; during
* onboarding it is deleted immediately.
*/
async removePoaDelegationLetter(
userId: string,
fileId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== COMPANY_RESOURCE ||
record.resourceId !== company.id ||
(record.code !== POA_DELEGATION_FILE_KEY &&
record.code !== POA_DELEGATION_PENDING_CODE)
) {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
} else if (company.status === CompanyStatus.Active) {
await this.stageDocumentIntent(
company.id,
[
{
op: "remove",
fileId,
code: POA_DELEGATION_FILE_KEY,
fileName: record.name,
},
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getPoaDelegationView(company.id);
}
private async getPoaDelegationView(
companyId: string,
): Promise<CompanyDocumentFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.documentChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const records = await this.filesService.findByResource(
companyId,
COMPANY_RESOURCE,
);
return records
.filter(
(r) =>
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === POA_DELEGATION_PENDING_CODE
? ("pending_add" as const)
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
}));
}
/** Open or append a pending change request recording document add/remove intents. */
private async stageDocumentIntent(
companyId: string,
changes: DocumentChangeIntent[],
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?.documentChanges ?? [];
// Re-uploading twice before review would otherwise stage a second `remove`
// for the same live file, and the duplicate would fail on approval.
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
if (fresh.length === 0) return;
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
documentChanges: [...prev, ...fresh],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged document intent referencing `fileId`. If that empties the
* request entirely, delete it so the customer's settings page unlocks.
*/
private async withdrawDocumentIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.documentChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.licenseChanges?.length ?? 0) > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, documentChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged document changes: promote adds, delete removes. */
private async applyDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, change.code);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged document uploads (adds only). */
private async discardDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →

View File

@@ -1,6 +1,7 @@
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "../entities/company-change-request.entity";
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
submittedAt: Date | null;
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
this.snapshot = req.snapshot ?? {};
this.documentFileIds = req.documents?.documentFileIds ?? [];
this.licenseChanges = req.documents?.licenseChanges ?? [];
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedAt = req.submittedAt ?? null;

View File

@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
fileName?: string;
}
/**
* A staged change to a company-level document, awaiting review. Same semantics
* as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code`
* (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under
* the pending code, promoted to `code` on approval; `remove` → a live file that
* is deleted on approval. A replace is a `remove` plus an `add`.
*/
export interface DocumentChangeIntent {
op: "add" | "remove";
fileId: string;
/** The live FileRecord code this op targets (the upload setting's fileKey). */
code: 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. */
/**
* FileRecord ids uploaded against the company while this request was open.
* These go live immediately — only their ids are recorded, for the reviewer.
* Contrast `documentChanges`, which stages the file behind the pending code.
*/
documentFileIds?: string[];
/** Staged per-profile business-license add/remove intents. */
licenseChanges?: LicenseChangeIntent[];
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
documentChanges?: DocumentChangeIntent[];
}
@Entity({ schema: "freight", name: "company_change_request" })

View File

@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
mimeType?: string;
}
/**
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
* `pending_remove` — live but flagged for deletion on approval.
*/
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
/** 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";
status: StagedFileStatus;
}
/** A company-level document (e.g. the PoA letter) with its change-review state. */
export interface CompanyDocumentFileView {
id: string;
name: string;
size: number;
mimeType: string;
status: StagedFileStatus;
}
@Entity({ schema: "freight", name: "company_profiles" })