feat(companies): per-document change requests and resubmission review queue

Two review-workflow gaps for freight customer onboarding:

Request for change per document. Backoffice can now flag a single uploaded
document (company document, profile licence, or POA delegation letter) with a
note the customer sees, instead of rejecting the whole role over it. Adds
review_status/review_note/reviewed_by/reviewed_at to freight.files (migration
AddFileReviewStatus, partial index for the gate), a POST
documents/:fileId/request-change endpoint, the backoffice action + modal, and a
portal banner/badge so the customer knows what to re-upload. Re-uploading clears
the flag. Approving a role is blocked while any of its documents has an open
correction; the gate check and the status write share a pessimistic write lock
on the company row (as does the change-request write) so a correction can never
slip in between the check and the profile going Active.

Resubmission is visible to reviewers. When a customer resubmits a rejected role
or amends a change request, backoffice staff are notified (allBackoffice inbox
item, deep-linked to the customer) and the resubmission surfaces in a new
"Pending changes" list view + KPI, since such companies are status = active and
never matched the pending-approval filter.
This commit is contained in:
Nathnael
2026-07-21 07:34:40 +00:00
parent 1cfc0f0fa8
commit fd5aedcec7
22 changed files with 948 additions and 60 deletions

View File

@@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -494,6 +495,9 @@ export class CompaniesController {
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
reviewStatus: f.reviewStatus,
reviewNote: f.reviewNote,
reviewedAt: f.reviewedAt,
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
// it so the file previews/downloads in the client.
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
@@ -501,6 +505,35 @@ export class CompaniesController {
);
}
@Post("documents/:fileId/request-change")
@FreightAdmin()
@ApiOperation({
summary: "Ask the customer to correct one uploaded document",
description:
"Flags a single document with a reason the customer sees, notifies them, " +
"and blocks role approval until they re-upload. Narrower than rejecting " +
"the whole role.",
})
async requestDocumentChange(
@CurrentUser() user: CurrentIamUser,
@Param("fileId", ParseUUIDPipe) fileId: string,
@Body() dto: RequestDocumentChangeDto,
) {
const file = await this.companiesService.requestDocumentChange(
fileId,
dto.note,
user.id,
);
return {
id: file.id,
name: file.name,
code: file.code,
reviewStatus: file.reviewStatus,
reviewNote: file.reviewNote,
reviewedAt: file.reviewedAt,
};
}
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")

View File

@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
)
)`;
/**
* A company waiting on a reviewer to decide an edit it submitted after being
* approved. These rows are `status = active`, so the pending-application filter
* can never surface them — the review queue needs its own predicate.
*/
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = company.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -67,6 +79,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
kind,
status,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'name',
sortOrder = 'ASC',
} = query;
@@ -99,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
);
}
if (hasPendingChangeRequest !== undefined) {
qb.andWhere(
hasPendingChangeRequest
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -143,6 +164,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const pendingChanges = await this.repository
.createQueryBuilder('company')
.where('company.deleted_at IS NULL')
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
.getCount();
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
@@ -160,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
pendingChanges,
};
}
}

View File

@@ -5,6 +5,7 @@ import {
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -98,6 +99,7 @@ export class CompaniesService {
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
) { }
/**
@@ -748,7 +750,15 @@ export class CompaniesService {
submittedAt: now,
note: null,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
// Rejecting a request leaves it Rejected rather than reopening it, so a
// customer amending after a rejection lands here with a fresh Pending row.
// That is the resubmission case the reviewer needs flagged.
const history = await this.changeRequestRepo.findByCompanyId(company.id);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
@@ -756,6 +766,11 @@ export class CompaniesService {
submittedBy: userId,
submittedAt: now,
});
this.companyNotifier.changeRequestSubmitted(
company,
request.id,
resubmitted,
);
}
// Live company is unchanged; surface the pending state for the settings page.
@@ -827,6 +842,12 @@ export class CompaniesService {
"companies",
files,
);
await this.resolveDocumentChangeRequests(
companyId,
"companies",
uploaded.map((f) => f.code),
uploaded.map((f) => f.id),
);
if (company.status === CompanyStatus.Active) {
await this.stageDocumentChange(
company.id,
@@ -837,6 +858,95 @@ export class CompaniesService {
return uploaded;
}
/**
* Clear the `change_requested` flag from the documents a fresh upload replaces.
*
* Uploading does not overwrite the old row — it adds a new one under the same
* `code` — so the flagged original would otherwise linger and keep the approval
* gate closed even after the customer did exactly what was asked. Only rows of
* the same code are touched, and never the newly uploaded ones.
*/
private async resolveDocumentChangeRequests(
resourceId: string,
resource: string,
codes: string[],
uploadedIds: string[],
): Promise<void> {
if (codes.length === 0) return;
const replaced = new Set(codes);
const fresh = new Set(uploadedIds);
const open = await this.filesService.findWithOpenChangeRequest(
[resourceId],
resource,
);
await Promise.all(
open
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
.map((f) => this.filesService.clearReview(f.id)),
);
}
/**
* Backoffice: ask the customer to correct one specific document, instead of
* rejecting their whole role over it. Mirrors the contract change-request
* flow — a note the customer sees verbatim, plus a block on approval until
* they re-upload.
*/
async requestDocumentChange(
fileId: string,
note: string,
reviewerId?: string,
): Promise<FileRecord> {
const file = await this.filesService.findById(fileId);
const companyId = await this.resolveDocumentCompanyId(file);
const company = await this.findCompanyById(companyId);
// Flag the document while holding a write lock on its company row. The
// approval gate takes the same lock before it reads the flags, so the two
// serialize: a change request can never land in the window between the gate
// checking "any open corrections?" and writing the profile Active.
const updated = await this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: companyId },
lock: { mode: "pessimistic_write" },
});
return this.filesService.setReviewStatus(
file.id,
"change_requested",
note,
reviewerId,
);
});
this.companyNotifier.documentChangeRequested(
company,
file.name,
note,
file.id,
);
return updated;
}
/**
* Which company a stored document belongs to. Company documents are keyed by
* the company id directly; profile licences and POA letters hang off a company
* profile, so those resolve through it.
*/
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
if (file.resource === "companies") return file.resourceId;
if (file.resource === "company_profiles") {
const profile = await this.companyProfilesRepo.findById(file.resourceId);
if (!profile) {
throw new NotFoundException(
`Company profile ${file.resourceId} not found`,
);
}
return profile.companyId;
}
throw new BadRequestException(
`Documents on "${file.resource}" do not support change requests`,
);
}
/** Open or append a pending change request recording staged document uploads. */
private async stageDocumentChange(
companyId: string,
@@ -847,6 +957,7 @@ export class CompaniesService {
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const company = await this.companiesRepo.findById(companyId);
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
@@ -860,8 +971,15 @@ export class CompaniesService {
submittedAt: now,
note: null,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
}
} else {
await this.changeRequestRepo.create({
const history = await this.changeRequestRepo.findByCompanyId(companyId);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
const created = await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentFileIds: fileIds },
@@ -869,6 +987,13 @@ export class CompaniesService {
submittedBy: submittedBy ?? null,
submittedAt: now,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(
company,
created.id,
resubmitted,
);
}
}
}
@@ -987,6 +1112,61 @@ export class CompaniesService {
}
}
// Anything other than approval has no document gate and no concurrency
// hazard — apply it directly.
if (status !== ProfileStatus.Active) {
return this.applyProfileStatus(existing, status, note, reviewerId);
}
// Approving over an outstanding document correction would silently accept the
// very document a reviewer just rejected, and would strand the customer's
// "please fix this" banner with nothing left to fix. The gate check and the
// status write share a write lock on the company row — `requestDocumentChange`
// takes the same lock, so a fresh correction can never land in the window
// between "any open corrections?" and the profile going Active. Suspend and
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
"companies",
),
this.filesService.findWithOpenChangeRequest(
[existing.id],
"company_profiles",
),
]);
const pending = [...companyDocs, ...profileDocs];
if (pending.length > 0) {
const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
);
}
return this.applyProfileStatus(existing, status, note, reviewerId);
});
}
/**
* Write a reviewed profile status (reference minting, note handling, reviewer
* stamp) and promote the company if this is its first approved role. Split out
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
* transaction while every other status skips that overhead.
*/
private async applyProfileStatus(
existing: CompanyProfile,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
@@ -1008,9 +1188,9 @@ export class CompaniesService {
patch.reviewedAt = new Date();
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
const updated = await this.companyProfilesRepo.update(existing.id, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
throw new NotFoundException(`Company profile ${existing.id} not found`);
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
@@ -1057,6 +1237,13 @@ export class CompaniesService {
});
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
// The role is back in the pending queue — tell the reviewers, otherwise the
// resubmission is invisible until someone happens to reopen the customer.
const company = await this.companiesRepo.findById(companyId);
if (company) {
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
}
return updated;
}
@@ -1512,6 +1699,15 @@ export class CompaniesService {
);
}
// A fresh licence upload answers any correction the reviewer asked for on the
// previous one, so the old row must stop blocking approval.
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
uploaded.map((r) => r.id),
);
return this.getProfileLicenseView(profileId, company.id);
}
@@ -1593,6 +1789,13 @@ export class CompaniesService {
await this.filesService.remove(fileId);
}
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
[created.id],
);
return this.getProfileLicenseView(profileId, company.id);
}
@@ -1689,6 +1892,8 @@ export class CompaniesService {
: pendingRemoveIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}
@@ -1855,6 +2060,13 @@ export class CompaniesService {
for (const r of live) await this.filesService.remove(r.id);
}
await this.resolveDocumentChangeRequests(
company.id,
COMPANY_RESOURCE,
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
[created.id],
);
return this.getPoaDelegationView(company.id);
}
@@ -1932,6 +2144,8 @@ export class CompaniesService {
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}

View File

@@ -88,4 +88,106 @@ export class CompanyNotifierService {
priority: NotificationPriority.HIGH,
});
}
// ── Backoffice-facing: work has arrived back in the review queue ────────────
/**
* Persist + push an in-app item to every backoffice staff user, deep-linked to
* the customer's detail page.
*
* The recipient resolver has no role/permission targeting (see
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
* selector available, so marketing is reached by notifying all staff.
*/
private notifyStaff(
company: Company,
title: string,
body: string,
data: Record<string, unknown> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/customers/${company.id}`,
data: { companyId: company.id, companyName: company.name, ...data },
});
}
/**
* A customer resubmitted an operational role after it was rejected for
* adjustment. Without this the role silently flips back to Pending and nobody
* is told there is anything to look at again.
*/
roleReapplied(company: Company, profileId: string, profileType: string): void {
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
this.notifyStaff(
company,
"Customer resubmitted a role for approval",
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
`It is back in the pending approval queue for review.`,
{ profileId, profileType },
);
}
/**
* A customer submitted (or amended and resubmitted) a profile change request.
* `resubmitted` distinguishes the two so the reviewer knows this is a second
* look at something they already sent back.
*/
changeRequestSubmitted(
company: Company,
changeRequestId: string,
resubmitted: boolean,
): void {
this.logger.log(
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"}${company.id}`,
);
this.notifyStaff(
company,
resubmitted
? "Customer resubmitted profile changes"
: "Customer submitted profile changes",
resubmitted
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
`them. They are pending your review.`
: `${company.name} has submitted profile changes that are pending review.`,
{ changeRequestId },
);
}
// ── Customer-facing: a specific document needs correcting ──────────────────
/**
* Tell the customer a reviewer wants one specific document corrected. Mirrors
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
* deep-linked to the documents tab where they can re-upload.
*/
documentChangeRequested(
company: Company,
documentName: string,
note: string,
fileId: string,
): void {
const title = "Document change requested";
const body =
`A reviewer has asked you to correct "${documentName}". ` +
`Reason: ${note} ` +
`Please upload a corrected version from your settings page.`;
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title,
body,
link: "/settings",
data: { companyId: company.id, fileId, documentName },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -7,4 +7,10 @@ export class CompanyStatsResponseDto {
onboarding!: number;
suspended!: number;
blacklisted!: number;
/**
* Approved customers with an open profile change request. Counted separately
* because they are `active` and so are invisible to the `pending` KPI, even
* though they are just as much waiting on a reviewer.
*/
pendingChanges!: number;
}

View File

@@ -48,6 +48,17 @@ export class ListCompaniesQueryDto {
@IsBoolean()
onboardingCompleted?: boolean;
@ApiPropertyOptional({
description:
"`true` = only companies with an open (pending) profile change request. " +
"These are already-approved customers, so they never appear under " +
"`status=pending` and would otherwise be invisible in the review queue.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
hasPendingChangeRequest?: boolean;
@ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"],
default: "name",

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator";
export class RequestDocumentChangeDto {
/** What is wrong with this document — shown verbatim to the customer. */
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(2000)
note!: string;
}

View File

@@ -37,8 +37,20 @@ export interface BusinessLicenseFile {
*/
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
/**
* Reviewer verdict on a document, as surfaced to clients. Distinct from
* {@link StagedFileStatus}: that describes where the file sits in the staged
* add/remove workflow, this describes whether a reviewer wants it corrected.
*/
export interface FileReviewView {
/** `change_requested` while the customer still owes a corrected upload. */
reviewStatus?: "change_requested" | "approved" | null;
/** The reviewer's reason, shown verbatim to the customer. */
reviewNote?: string | null;
}
/** A business-license file plus its change-review state, surfaced to clients. */
export interface ProfileLicenseFileView {
export interface ProfileLicenseFileView extends FileReviewView {
id: string;
name: string;
size: number;
@@ -47,7 +59,7 @@ export interface ProfileLicenseFileView {
}
/** A company-level document (e.g. the PoA letter) with its change-review state. */
export interface CompanyDocumentFileView {
export interface CompanyDocumentFileView extends FileReviewView {
id: string;
name: string;
size: number;

View File

@@ -1,6 +1,16 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* Reviewer verdict on a single stored document.
*
* `null` (the default) means "not reviewed" — the state every file starts in and
* the only state the customer is not blocked by. `change_requested` is raised by
* a backoffice reviewer against one specific document and is what the customer
* must clear by re-uploading; `approved` records an explicit sign-off.
*/
export type FileReviewStatus = "change_requested" | "approved";
@Entity({ schema: "freight", name: "files" })
export class FileRecord extends BaseEntity {
@Column({ name: "resource_id", type: "uuid" })
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
@Column({ name: "mime_type", type: "varchar", length: 255 })
mimeType!: string;
/** Reviewer verdict, or `null` while the document has never been reviewed. */
@Column({
name: "review_status",
type: "varchar",
length: 32,
nullable: true,
default: null,
})
reviewStatus!: FileReviewStatus | null;
/** Why a change was requested — shown verbatim to the customer. */
@Column({ name: "review_note", type: "text", nullable: true })
reviewNote!: string | null;
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
reviewedBy!: string | null;
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt!: Date | null;
}

View File

@@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of
* resource ids (a company plus each of its company profiles) in one query.
*/
async findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
if (resourceIds.length === 0) return [];
return this.repository.find({
where: {
resourceId: In(resourceIds),
resource,
reviewStatus: "change_requested",
},
order: { createdAt: "ASC" },
});
}
}

View File

@@ -8,7 +8,7 @@ import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord } from "./entities/file.entity";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
export interface CreateFileInput {
resourceId: string;
@@ -169,6 +169,53 @@ export class FilesService {
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
* reason can never outlive the request it explained.
*/
async setReviewStatus(
id: string,
status: FileReviewStatus,
note: string | null,
reviewerId?: string,
): Promise<FileRecord> {
const record = await this.findById(id);
const updated = await this.filesRepository.update(record.id, {
reviewStatus: status,
reviewNote: status === "change_requested" ? (note ?? null) : null,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
});
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Drop any reviewer verdict from a document, returning it to "not reviewed".
* Called when a customer re-uploads: the new bytes have not been looked at, so
* carrying the old `change_requested` forward would keep them blocked forever.
*/
async clearReview(id: string): Promise<void> {
await this.filesRepository.update(id, {
reviewStatus: null,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
}
/** Documents across these resources still awaiting a customer correction. */
findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findWithOpenChangeRequest(
resourceIds,
resource,
);
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);