mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
feat: add poa to the changes approval
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user