mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
199 lines
6.5 KiB
TypeScript
199 lines
6.5 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type { Freight } from '@edr/types';
|
|
|
|
import { FilesService } from '../files/files.service';
|
|
import type { FileRecord } from '../files/entities/file.entity';
|
|
|
|
/**
|
|
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
|
|
* thread is keyed by the entity the two desks are working on — a booking id on
|
|
* the per-booking clearance pages, a contract id on the pre-booking ones — so
|
|
* both desks opening the same record see the same documents.
|
|
*/
|
|
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
|
|
|
export type GlExchangeSide = 'ET' | 'DJ';
|
|
|
|
export interface GlExchangeActor {
|
|
userId: string;
|
|
name?: string | null;
|
|
side: GlExchangeSide;
|
|
}
|
|
|
|
export interface GlExchangeUploadInput {
|
|
title: string;
|
|
visibleToCustomer: boolean;
|
|
}
|
|
|
|
/**
|
|
* Free-form document exchange between the two Global Logistics desks. Anything
|
|
* either side needs the other to have (scans, correspondence, corrected forms)
|
|
* lands here under a title they choose, instead of a fixed clearance slot.
|
|
*
|
|
* Rules, all enforced here rather than in the UI:
|
|
* - both desks read every document in a thread, whoever uploaded it;
|
|
* - only the uploader may retitle, replace or remove one;
|
|
* - the customer sees only what its uploader marked visible.
|
|
*/
|
|
@Injectable()
|
|
export class GlExchangeService {
|
|
constructor(private readonly filesService: FilesService) {}
|
|
|
|
/** Every document on one thread, newest first, from a GL desk's view. */
|
|
async list(
|
|
entityId: string,
|
|
viewerId: string,
|
|
): Promise<Freight.GlExchangeDocument[]> {
|
|
const records = await this.filesService.findByResource(
|
|
entityId,
|
|
GL_EXCHANGE_RESOURCE,
|
|
);
|
|
return this.sort(records.map((r) => this.toDto(r, viewerId)));
|
|
}
|
|
|
|
/**
|
|
* The customer-facing slice across several threads (a booking and the
|
|
* contract it belongs to). Never exposes internal documents, and never marks
|
|
* anything editable — the customer is not a GL desk.
|
|
*/
|
|
async listVisibleToCustomer(
|
|
entityIds: string[],
|
|
): Promise<Freight.GlExchangeDocument[]> {
|
|
const ids = [...new Set(entityIds.filter(Boolean))];
|
|
if (ids.length === 0) return [];
|
|
const grouped = await this.filesService.findByResourceIdsGrouped(
|
|
ids,
|
|
GL_EXCHANGE_RESOURCE,
|
|
);
|
|
const visible = [...grouped.values()]
|
|
.flat()
|
|
.filter((r) => r.visibleToCustomer);
|
|
return this.sort(visible.map((r) => this.toDto(r, null)));
|
|
}
|
|
|
|
async upload(
|
|
entityId: string,
|
|
file: Express.Multer.File | undefined,
|
|
input: GlExchangeUploadInput,
|
|
actor: GlExchangeActor,
|
|
): Promise<Freight.GlExchangeDocument> {
|
|
const title = input.title?.trim();
|
|
if (!title) throw new BadRequestException('A document title is required.');
|
|
if (!file) throw new BadRequestException('A file is required.');
|
|
|
|
const record = await this.filesService.upload({
|
|
resourceId: entityId,
|
|
resource: GL_EXCHANGE_RESOURCE,
|
|
// No fixed slot exists for these — `code` carries the uploading desk, so
|
|
// a document's origin survives even if the uploader leaves the org.
|
|
code: actor.side,
|
|
file,
|
|
title,
|
|
visibleToCustomer: input.visibleToCustomer,
|
|
uploadedByUserId: actor.userId,
|
|
uploadedByName: actor.name ?? null,
|
|
});
|
|
return this.toDto(record, actor.userId);
|
|
}
|
|
|
|
/**
|
|
* Retitle, re-share or replace a document. Uploader only — the other desk
|
|
* reads it but never edits it. A replacement file supersedes the old record
|
|
* (soft-deleted, bytes kept) and carries its metadata forward.
|
|
*/
|
|
async update(
|
|
documentId: string,
|
|
patch: { title?: string; visibleToCustomer?: boolean },
|
|
file: Express.Multer.File | undefined,
|
|
actorId: string,
|
|
): Promise<Freight.GlExchangeDocument> {
|
|
const record = await this.assertUploader(documentId, actorId);
|
|
const title = patch.title?.trim();
|
|
if (patch.title != null && !title) {
|
|
throw new BadRequestException('A document title is required.');
|
|
}
|
|
|
|
if (file) {
|
|
const replacement = await this.filesService.upload({
|
|
resourceId: record.resourceId,
|
|
resource: GL_EXCHANGE_RESOURCE,
|
|
code: record.code,
|
|
file,
|
|
title: title ?? record.title,
|
|
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
|
|
uploadedByUserId: record.uploadedByUserId,
|
|
uploadedByName: record.uploadedByName,
|
|
});
|
|
await this.filesService.remove(record.id);
|
|
return this.toDto(replacement, actorId);
|
|
}
|
|
|
|
const updated = await this.filesService.updateMeta(record.id, {
|
|
...(title ? { title } : {}),
|
|
...(patch.visibleToCustomer != null
|
|
? { visibleToCustomer: patch.visibleToCustomer }
|
|
: {}),
|
|
});
|
|
return this.toDto(updated, actorId);
|
|
}
|
|
|
|
/** Uploader-only removal (soft delete — the stored bytes are kept). */
|
|
async remove(documentId: string, actorId: string): Promise<void> {
|
|
const record = await this.assertUploader(documentId, actorId);
|
|
await this.filesService.remove(record.id);
|
|
}
|
|
|
|
private async assertUploader(
|
|
documentId: string,
|
|
actorId: string,
|
|
): Promise<FileRecord> {
|
|
const record = await this.filesService.findById(documentId);
|
|
if (record.resource !== GL_EXCHANGE_RESOURCE) {
|
|
throw new NotFoundException(`Exchange document ${documentId} not found`);
|
|
}
|
|
if (record.uploadedByUserId !== actorId) {
|
|
throw new ForbiddenException(
|
|
'Only the person who uploaded this document can change it.',
|
|
);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
private sort(
|
|
docs: Freight.GlExchangeDocument[],
|
|
): Freight.GlExchangeDocument[] {
|
|
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
|
|
}
|
|
|
|
private toDto(
|
|
record: FileRecord,
|
|
viewerId: string | null,
|
|
): Freight.GlExchangeDocument {
|
|
return {
|
|
id: record.id,
|
|
entityId: record.resourceId,
|
|
// Pre-title rows (none in practice) fall back to the filename so a list
|
|
// never renders a blank row.
|
|
title: record.title ?? record.name,
|
|
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
|
visibleToCustomer: record.visibleToCustomer,
|
|
uploadedById: record.uploadedByUserId,
|
|
uploadedByName: record.uploadedByName,
|
|
uploadedAt: record.createdAt.toISOString(),
|
|
file: {
|
|
id: record.id,
|
|
name: record.name,
|
|
url: record.url,
|
|
size: record.size,
|
|
mimeType: record.mimeType,
|
|
},
|
|
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
|
|
};
|
|
}
|
|
}
|