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 { 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 { 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 { 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 { 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 { const record = await this.assertUploader(documentId, actorId); await this.filesService.remove(record.id); } private async assertUploader( documentId: string, actorId: string, ): Promise { 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, }; } }