mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
feat: fix the settings preview for the business licence and changes request to the approval
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@@ -63,6 +64,25 @@ export class CompaniesController {
|
||||
private readonly filesService: FilesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
|
||||
* (the client builds that URL from the returned `id`). Populate each profile
|
||||
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
|
||||
*/
|
||||
private async populateLicenseFiles(
|
||||
companyId: string,
|
||||
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
|
||||
): Promise<void> {
|
||||
if (profiles.length === 0) return;
|
||||
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
|
||||
companyId,
|
||||
profiles.map((p) => p.id),
|
||||
);
|
||||
for (const p of profiles) {
|
||||
p.licenseFiles = byProfile[p.id] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get("getInfo")
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@@ -86,7 +106,9 @@ export class CompaniesController {
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new ProfileResponseDto(profile, company, review);
|
||||
const dto = new ProfileResponseDto(profile, company, review);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Get("profile/change-request")
|
||||
@@ -215,28 +237,72 @@ export class CompaniesController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload business-license document(s) for one of the current user's company profiles",
|
||||
"Add business-license document(s) to a profile. For an approved company " +
|
||||
"the upload is staged for backoffice review; during onboarding it goes live.",
|
||||
})
|
||||
async uploadProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
return this.companiesService.uploadProfileLicenseFiles(
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.addProfileLicenseFiles(
|
||||
user.id,
|
||||
profileId,
|
||||
files,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license/:fileId/replace")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Replace a business-license file with a newly uploaded one (staged for " +
|
||||
"review on an approved company).",
|
||||
})
|
||||
async replaceProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const file = files?.[0];
|
||||
if (!file) {
|
||||
throw new BadRequestException("A replacement file is required");
|
||||
}
|
||||
return this.companiesService.replaceProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("company-profiles/:profileId/license/:fileId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove a business-license file (staged for review on an approved company).",
|
||||
})
|
||||
async removeProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.removeProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("company-profiles/:profileId/license")
|
||||
@ApiOperation({
|
||||
summary: "List business-license documents for a company profile",
|
||||
summary: "List business-license documents (with review state) for a profile",
|
||||
})
|
||||
async listProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@@ -344,7 +410,9 @@ export class CompaniesController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
const dto = new ResponseCompanyDto(company);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
CompanyDashboardRepository,
|
||||
DashboardScope,
|
||||
} 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";
|
||||
@@ -39,14 +38,22 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
} from "./entities/company-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
@@ -63,7 +70,6 @@ export class CompaniesService {
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
@@ -341,28 +347,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
for (const profile of company.companyProfiles) {
|
||||
profile.businessLicenseFiles = await this.signLicenseFiles(
|
||||
profile.businessLicenseFiles,
|
||||
);
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Business-license files are stored as raw, unsigned MinIO URLs (see
|
||||
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
|
||||
* directly. Sign each one with a short-lived URL before it reaches a response.
|
||||
*/
|
||||
private async signLicenseFiles(
|
||||
files?: BusinessLicenseFile[] | null,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
if (!files?.length) return [];
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
@@ -777,6 +764,7 @@ export class CompaniesService {
|
||||
await this.assertTinAvailable(company, snapshot.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
@@ -860,9 +848,13 @@ export class CompaniesService {
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
await this.discardLicenseChanges(request);
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
// Staged license uploads were just discarded; drop their intents so an
|
||||
// amended resubmit never re-references deleted files.
|
||||
documents: { ...request.documents, licenseChanges: [] },
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
@@ -1231,13 +1223,21 @@ export class CompaniesService {
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses.
|
||||
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
||||
}));
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||
const licenseProfiles = await Promise.all(
|
||||
(company.companyProfiles ?? []).map(async (p) => {
|
||||
const records = await this.filesService.findByResource(
|
||||
p.id,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return {
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
const outstanding = [
|
||||
@@ -1354,61 +1354,321 @@ export class CompaniesService {
|
||||
return owned;
|
||||
}
|
||||
|
||||
// ─── Business-license files ────────────────────────────────────────────────
|
||||
//
|
||||
// License documents live in the FileRecord model (`freight.files`) with
|
||||
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
|
||||
// code `LICENSE_CODE`; files staged inside an open change request (add /
|
||||
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
|
||||
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
|
||||
// path regular documents use — so it never hits MinIO directly from the
|
||||
// browser (which fails on the internal bucket endpoint).
|
||||
|
||||
/**
|
||||
* Upload business-license document(s) and store them directly on the company
|
||||
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
||||
* persisted on the profile — intentionally not via the FileRecord file model.
|
||||
* New files are appended to any already present. Returns the full list.
|
||||
* Upload business-license file(s) for one of the user's profiles. During
|
||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
||||
* company they're staged under the pending code and recorded as `add` intents
|
||||
* on a pending change request for backoffice review. Returns the updated view.
|
||||
*/
|
||||
async uploadProfileLicenseFiles(
|
||||
async addProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||
|
||||
const uploaded: BusinessLicenseFile[] = [];
|
||||
for (const file of files) {
|
||||
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
const uploaded = await Promise.all(
|
||||
files.map((file) =>
|
||||
this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
uploaded.map((r) => ({
|
||||
profileId,
|
||||
op: "add" as const,
|
||||
fileId: r.id,
|
||||
fileName: r.name,
|
||||
})),
|
||||
userId,
|
||||
);
|
||||
uploaded.push({
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
||||
await this.companyProfilesRepo.update(profileId, {
|
||||
businessLicenseFiles: next,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The business-license files stored on a single company profile. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return profile.businessLicenseFiles ?? [];
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding documents stored on a company profile, fetched by profile id.
|
||||
* Internal helper (no ownership check) used when a booking reuses the active
|
||||
* profile's onboarding documents. Returns [] when the profile is unknown.
|
||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
||||
* is deleted immediately.
|
||||
*/
|
||||
async removeProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
if (
|
||||
record.resource !== LICENSE_RESOURCE ||
|
||||
record.resourceId !== profileId
|
||||
) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
if (record.code === LICENSE_PENDING_CODE) {
|
||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawLicenseIntent(company.id, fileId);
|
||||
} else if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[{ profileId, op: "remove", fileId, fileName: record.name }],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||
* atomically. During onboarding the swap is applied immediately.
|
||||
*/
|
||||
async replaceProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const old = await this.filesService.findById(fileId);
|
||||
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
|
||||
file,
|
||||
});
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[
|
||||
{ profileId, op: "remove", fileId, fileName: old.name },
|
||||
{ profileId, op: "add", fileId: created.id, fileName: created.name },
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/** License files for one profile, with each file's review status resolved. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return this.getProfileLicenseView(profileId, profile.companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live license files for a profile, shaped for by-reference reuse (bookings /
|
||||
* contracts snapshot these). No ownership check — internal callers only.
|
||||
* Returns the raw stored URLs; pending (unapproved) files are excluded.
|
||||
*/
|
||||
async getProfileOnboardingFiles(
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
return profile?.businessLicenseFiles ?? [];
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter((r) => r.code === LICENSE_CODE)
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the review-aware license view for a set of profiles in one pass
|
||||
* (single change-request lookup). Used to enrich company/profile responses.
|
||||
*/
|
||||
async assembleLicenseFilesByProfile(
|
||||
companyId: string,
|
||||
profileIds: string[],
|
||||
): Promise<Record<string, ProfileLicenseFileView[]>> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
const result: Record<string, ProfileLicenseFileView[]> = {};
|
||||
await Promise.all(
|
||||
profileIds.map(async (pid) => {
|
||||
result[pid] = await this.mapLicenseRecords(pid, removeIds);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Single-profile license view (fetches the company's pending request once). */
|
||||
private async getProfileLicenseView(
|
||||
profileId: string,
|
||||
companyId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
return this.mapLicenseRecords(profileId, removeIds);
|
||||
}
|
||||
|
||||
private async mapLicenseRecords(
|
||||
profileId: string,
|
||||
pendingRemoveIds: Set<string>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
status:
|
||||
r.code === LICENSE_PENDING_CODE
|
||||
? ("pending_add" as const)
|
||||
: pendingRemoveIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording license add/remove intents. */
|
||||
private async stageLicenseChange(
|
||||
companyId: string,
|
||||
changes: LicenseChangeIntent[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.licenseChanges ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: {
|
||||
...existing.documents,
|
||||
licenseChanges: [...prev, ...changes],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { licenseChanges: changes },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a staged license intent (add or remove) referencing `fileId` from the
|
||||
* company's open request. If that empties the request entirely, delete it so
|
||||
* the customer's settings page unlocks.
|
||||
*/
|
||||
private async withdrawLicenseIntent(
|
||||
companyId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (!existing) return;
|
||||
const remaining = (existing.documents?.licenseChanges ?? []).filter(
|
||||
(c) => c.fileId !== fileId,
|
||||
);
|
||||
const docs = existing.documents ?? {};
|
||||
const stillHasWork =
|
||||
remaining.length > 0 ||
|
||||
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||
|
||||
if (stillHasWork) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { ...docs, licenseChanges: remaining },
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.softDelete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a request's staged license changes: promote adds, delete removes. */
|
||||
private async applyLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.setCode(change.fileId, LICENSE_CODE);
|
||||
} else {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard a rejected request's staged license uploads (adds only). */
|
||||
private async discardLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "../entities/company-change-request.entity";
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,8 @@ export class ChangeRequestResponseDto {
|
||||
/** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */
|
||||
snapshot: Record<string, any>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedBy: string | null;
|
||||
submittedAt: Date | null;
|
||||
@@ -29,6 +32,7 @@ export class ChangeRequestResponseDto {
|
||||
this.status = req.status;
|
||||
this.snapshot = req.snapshot ?? {};
|
||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||
this.note = req.note ?? null;
|
||||
this.submittedBy = req.submittedBy ?? null;
|
||||
this.submittedAt = req.submittedAt ?? null;
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
CompanyNationality,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
@@ -18,8 +18,12 @@ export class ResponseCompanyProfileDto {
|
||||
status: string;
|
||||
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||
businessLicense?: string | null;
|
||||
/** Business-license documents stored on the profile (multi-file). */
|
||||
licenseFiles: BusinessLicenseFile[];
|
||||
/**
|
||||
* Business-license documents (FileRecord-backed) with review state. Left empty
|
||||
* by the constructor and populated asynchronously by the controller, since the
|
||||
* files and their pending-change status require DB lookups.
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
@@ -33,7 +37,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.reference = profile.reference ?? '';
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
|
||||
@@ -15,10 +15,27 @@ export enum ChangeRequestStatus {
|
||||
Rejected = "rejected",
|
||||
}
|
||||
|
||||
/**
|
||||
* A single staged business-license change on one company profile, awaiting
|
||||
* review. `add` → a new file was uploaded under the pending code and becomes
|
||||
* live on approval; `remove` → an existing live file is deleted on approval.
|
||||
* A "replace" is recorded as a `remove` of the old file plus an `add` of the
|
||||
* new one. `fileId` is the FileRecord id the op targets.
|
||||
*/
|
||||
export interface LicenseChangeIntent {
|
||||
profileId: string;
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
/** File name, snapshotted for the backoffice review screen. */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** File references staged alongside a change request (documents/licenses). */
|
||||
export interface ChangeRequestDocuments {
|
||||
/** FileRecord ids uploaded against the company while this request was open. */
|
||||
documentFileIds?: string[];
|
||||
/** Staged per-profile business-license add/remove intents. */
|
||||
licenseChanges?: LicenseChangeIntent[];
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_change_request" })
|
||||
|
||||
@@ -19,7 +19,11 @@ export enum ProfileStatus {
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
/** A business-license document stored directly on the company profile. */
|
||||
/**
|
||||
* @deprecated Legacy inline shape. Business-license files now live in the
|
||||
* FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
|
||||
* the by-reference reuse shape consumed by bookings/contracts snapshots.
|
||||
*/
|
||||
export interface BusinessLicenseFile {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -27,6 +31,19 @@ export interface BusinessLicenseFile {
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
status: "live" | "pending_add" | "pending_remove";
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
|
||||
@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -326,10 +326,20 @@ export class ContractsService {
|
||||
companyProfileId: string | null,
|
||||
): Promise<void> {
|
||||
if (!companyProfileId) return;
|
||||
const profile = await this.dataSource
|
||||
.getRepository(CompanyProfile)
|
||||
.findOne({ where: { id: companyProfileId } });
|
||||
const docs = profile?.businessLicenseFiles ?? [];
|
||||
// Business-license files are FileRecords (resource "company_profiles"); carry
|
||||
// the live ones by reference. Staged/pending uploads are excluded by code.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
);
|
||||
const docs = records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
if (docs.length === 0) return;
|
||||
|
||||
const slug = (name: string) =>
|
||||
|
||||
@@ -124,6 +124,15 @@ export class FilesService {
|
||||
await this.filesRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-slot a stored file under a new `code` (e.g. promote a staged
|
||||
* `business_license_pending` file to the live `business_license` code once a
|
||||
* change request is approved). Bytes and URL are untouched.
|
||||
*/
|
||||
async setCode(id: string, code: string): Promise<void> {
|
||||
await this.filesRepository.update(id, { code });
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user