import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; import { YardCountry } from '@edr/types'; // import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContractsRepository } from './contracts.repository'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { diffContractFields } from './contract-document-diff.util'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractPricingService } from './contract-pricing.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedContracts { items: Contract[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; } /** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */ function describeRoutes(routes?: ContractRoute[]): string | null { if (!routes?.length) return null; return [...routes] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .map( (r) => `${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`, ) .join(', '); } /** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */ function describeCargoScope(scope?: ContractCargoScope[]): string | null { if (!scope?.length) return null; return scope .map((row) => { const label = row.containerSize ?? row.cargoType?.cargoTypeName ?? row.cargoFreeText ?? row.cargoTypeId ?? 'cargo'; return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label); }) .sort() .join(', '); } /** * Order-independent identity of a cargo scope — two contracts cover the same * cargo only when they list the same container sizes / commodities. Quantity * caps are deliberately ignored: they size a GENERAL contract, they don't make * it a different scope. */ function cargoScopeKey( scope?: Array< Pick > | null, ): string { if (!scope?.length) return ''; return scope .map((row) => [ row.containerSize?.trim().toLowerCase() ?? '', row.cargoTypeId ?? '', row.cargoFreeText?.trim().toLowerCase() ?? '', ].join('|'), ) .sort() .join(','); } const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', 'SIGNED_CUSTOMER', ] as const; @Injectable() export class ContractsService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly contractsRepository: ContractsRepository, private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly documentHistory: ContractDocumentHistoryService, private readonly pricingService: ContractPricingService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ private async generateReference(): Promise { const year = new Date().getFullYear(); const seq = await this.contractsRepository.maxReferenceSequence(year); return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } /** The service type a contract is sold under (null when the id is unknown). */ private resolveServiceType(serviceTypeId: string): Promise { return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } }); } /** Whether a service type bundles customs clearance. */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { const serviceType = await this.resolveServiceType(serviceTypeId); return serviceType?.includesCustoms ?? false; } /** Validate cargo-scope rows against freight type (doc §5.4). */ private assertCargoScopeShape( freightType: string, cargoScope: CreateContractDto['cargoScope'], ): void { if (freightType === 'CONTAINER') { const sizes = cargoScope.filter((c) => ['20ft', '40ft'].includes(c.containerSize ?? ''), ); if (sizes.length === 0) { throw new BadRequestException( 'CONTAINER contracts require at least one container size (20ft/40ft) in scope', ); } } else { const bulk = cargoScope.filter((c) => c.cargoTypeId); if (bulk.length !== 1) { throw new BadRequestException( 'BULK contracts require exactly one cargo-type scope row', ); } if (cargoScope.some((c) => c.containerSize)) { throw new BadRequestException('BULK contracts must not set a container size'); } } } /** Validate route count against contract kind (doc §5.3). */ private assertRouteShape( contractKind: string, routes: CreateContractDto['routes'], ): void { if (contractKind === 'ONE_TIME' && routes.length !== 1) { throw new BadRequestException('ONE_TIME contracts require exactly one route'); } if (routes.length < 1) { throw new BadRequestException('A contract requires at least one route'); } } /** * Every route must match the contract's declared trade direction as derived * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC = * intercity). Intercity is Ethiopian-domestic only: both yards must be in * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches * (e.g. an export lane on an import contract) are rejected for every kind. */ private async assertRoutesMatchDirection( tradeDirection: string, routes: CreateContractDto['routes'], ): Promise { const yardIds = [ ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])), ]; const yards = await this.dataSource .getRepository(Yard) .find({ where: yardIds.map((id) => ({ id })) }); const yardById = new Map(yards.map((y) => [y.id, y])); for (const route of routes) { const origin = yardById.get(route.originYardId); const destination = yardById.get(route.destinationYardId); if (!origin || !destination) { throw new BadRequestException('Route references a yard that does not exist'); } const derived = deriveTradeDirection(origin, destination); if (derived !== tradeDirection) { throw new BadRequestException( `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`, ); } if ( derived === 'DOMESTIC' && (origin.country !== YardCountry.ETHIOPIA || destination.country !== YardCountry.ETHIOPIA) ) { throw new BadRequestException( `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`, ); } } } /** * A live contract only blocks a new request when EVERY commercial dimension * of the wizard matches it: service type, operation type (trade direction), * contract kind, cargo scope and route. Change any one of them — a different * lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the * customer may request another contract. * * A route "overlaps" if any origin/destination pair matches; cargo scope * matches only when the two scope sets are identical (same freight type and * the same container sizes / commodities). */ private async assertNoDuplicateContract( companyId: string, dto: CreateContractDto, ): Promise { const candidates = await this.contractsRepository.findDuplicateCandidates( companyId, dto.serviceTypeId, ); const incomingScope = cargoScopeKey(dto.cargoScope); const duplicate = candidates.find( (c) => !isEffectivelyExpired(c) && c.tradeDirection === dto.tradeDirection && c.contractKind === dto.contractKind && c.freightType === dto.freightType && cargoScopeKey(c.cargoScope) === incomingScope && (c.routes ?? []).some((existingRoute) => dto.routes.some( (r) => r.originYardId === existingRoute.originYardId && r.destinationYardId === existingRoute.destinationYardId, ), ), ); if (duplicate) { const until = duplicate.contractValidUntil ? duplicate.contractValidUntil.toISOString().slice(0, 10) : 'its approval completes'; throw new ConflictException( `An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`, ); } } /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, files: Express.Multer.File[], userId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const warnings: string[] = []; const isGovernment = dto.isGovernment === true; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { if (!dto.governmentInstitution?.trim()) { throw new BadRequestException( 'governmentInstitution is required for government contracts', ); } companyId = dto.companyId ?? null; } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); this.companiesService.assertCompanyActiveFor(company, 'contracts'); companyId = company.id; } this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); if (companyId) { await this.assertNoDuplicateContract(companyId, dto); } // Stamp the operational profile for portal scoping. A forwarder contract // pins its profile explicitly (trade direction can't tell it apart from a // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { if (dto.companyProfileId) { const profile = await this.companiesService.getActiveCompanyProfileForBooking( companyId, dto.companyProfileId, ); companyProfileId = profile.id; } else { companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, dto.tradeDirection, ); const customerSelfBooking = !dto.companyId && !!userId; if (customerSelfBooking && companyProfileId) { await this.companiesService.assertCompanyProfileApprovedForBooking( companyProfileId, ); } } } // Customs clearing is owned by the service type, not the customer. const serviceType = await this.resolveServiceType(dto.serviceTypeId); const includesCustoms = serviceType?.includesCustoms ?? false; // Intercity never crosses a border, so a customs-including service type is // a contradiction — the wizard hides them, the API enforces it. if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) { throw new BadRequestException( 'Intercity contracts cannot use a service type that includes customs clearing', ); } // Price the contract BEFORE anything persists: a lane with no configured // rate 422s here and the wizard shows its blocking modal — with no orphan // DRAFT row left behind for the customer to trip over on retry. The probe // carries exactly the fields buildBreakdown prices from; relation-only // niceties (cargoType labels) are absent, which only affects display // lines, never the missing-rate gates. await this.pricingService.buildBreakdown({ tradeDirection: dto.tradeDirection, freightType: dto.freightType, paymentCurrency: 'USD', customsClearingEnabled: includesCustoms, // Decides which customs fee the probe looks up (Ethiopian-only vs full). serviceType, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, equipmentReturn: dto.equipmentReturn ?? null, firstMilePickupAddress: dto.firstMilePickupAddress ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, routes: (dto.routes ?? []).map((r, i) => ({ originYardId: r.originYardId, destinationYardId: r.destinationYardId, sortOrder: r.sortOrder ?? i, })), cargoScope: (dto.cargoScope ?? []).map((c) => ({ containerSize: c.containerSize ?? null, cargoTypeId: c.cargoTypeId ?? null, })), } as unknown as Contract); // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a // concurrent insert that grabbed the same sequence number. const contract = dto.reference ? await this.insertContract(dto.reference, { companyId, companyProfileId, isGovernment, includesCustoms, dto, }) : await insertWithGeneratedReference( () => this.generateReference(), (reference) => this.insertContract(reference, { companyId, companyProfileId, isGovernment, includesCustoms, dto, }), ); await this.persistRoutes(contract.id, dto.routes); await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); if (files.length > 0) { try { await this.filesService.uploadMany(contract.id, 'contracts', files); } catch { warnings.push('File upload failed — contract was created without attached files.'); } } // Attach the company's onboarding documents (TIN, licenses, IDs) and the // profile's business-license documents to the contract by reference. The // separate "Documents" intake step was removed — the profile documents are // simply carried onto every contract automatically. await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId); return { contract: await this.findById(contract.id), warnings }; } /** Insert one DRAFT contract row with the given reference (no children). */ private insertContract( reference: string, ctx: { companyId: string | null | undefined; companyProfileId: string | null; isGovernment: boolean; includesCustoms: boolean; dto: CreateContractDto; }, ): Promise { const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx; return this.contractsRepository.create({ reference, companyId: companyId ?? null, companyProfileId, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, contractKind: dto.contractKind, renewalOfId: dto.renewalOfId ?? null, tradeDirection: dto.tradeDirection, freightType: dto.freightType, serviceTypeId: dto.serviceTypeId, // A contract is always QUOTED in USD — the billing currency is chosen per // booking (or on the shipment request when GL books for the customer), so // any client-supplied currency here is ignored. Contracts created before // this rule keep whatever they stored; update() never rewrites it. paymentCurrency: 'USD', customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn ?? null, firstMilePickupAddress: dto.firstMilePickupAddress ?? null, firstMilePickupLat: dto.firstMilePickupLat ?? null, firstMilePickupLng: dto.firstMilePickupLng ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, // Hazard class / UN number only exist on a hazardous contract — a stale // pair from an earlier draft must never survive the flag being turned off. hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null, unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null, isReefer: dto.isReefer ?? false, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never); } /** * Copy the company's onboarding documents (TIN certificate, commercial / * investment license, national ID, passport — resource "companies", coded by * the upload-setting fileKey) and the company profile's business-license * documents (resource "company_profiles") onto a contract by reference (no * byte re-upload). Idempotent: codes already present on the contract — user * uploads or an earlier carry — are never duplicated or overwritten, so it is * safe to run on every create and update. No-op when there is nothing to copy. */ private async attachProfileDocuments( contractId: string, companyId: string | null, companyProfileId: string | null, ): Promise { if (!companyId && !companyProfileId) return; const existingCodes = new Set( (await this.filesService.findByResource(contractId, 'contracts')).map( (r) => r.code, ), ); const docs: Array<{ code: string; name: string; url: string; size: number; mimeType?: string; }> = []; if (companyId) { // Company onboarding documents keep their fileKey codes (tin_certificate, // commercial_license, …) so the portal can match them against the // onboarding upload-setting fields. Re-uploads append rows, so keep only // the newest record per code. const companyRecords = await this.filesService.findByResource( companyId, 'companies', ); const latestByCode = new Map(); for (const r of companyRecords) { const prev = latestByCode.get(r.code); if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r); } for (const r of latestByCode.values()) { if (existingCodes.has(r.code)) continue; docs.push({ code: r.code, name: r.name, url: r.url, size: r.size, mimeType: r.mimeType, }); } } if (companyProfileId) { // Business-license files are FileRecords (resource "company_profiles"); // carry the live ones by reference. Staged/pending uploads are excluded by // code. The `business_license` prefix is preserved so the portal groups // them under "Business license" instead of the clearance catch-all — the // index suffix keeps multiple licences distinct. const records = await this.filesService.findByResource( companyProfileId, 'company_profiles', ); records .filter((r) => r.code === 'business_license') .forEach((r, i) => { const code = `business_license_${i + 1}`; if (existingCodes.has(code)) return; docs.push({ code, name: r.name, url: r.url, size: r.size, mimeType: r.mimeType, }); }); } if (docs.length === 0) return; try { await this.filesService.attachExistingFiles(contractId, 'contracts', docs); } catch { // Non-fatal — the contract is still valid without the carried documents. } } private async persistRoutes( contractId: string, routes: CreateContractDto['routes'], ): Promise { const repo = this.dataSource.getRepository(ContractRoute); await repo.save( routes.map((r, i) => repo.create({ contractId, originYardId: r.originYardId, destinationYardId: r.destinationYardId, km: r.km ?? null, sortOrder: r.sortOrder ?? i, }), ), ); } private async persistCargoScope( contractId: string, cargoScope: CreateContractDto['cargoScope'], contractKind: string, ): Promise { const repo = this.dataSource.getRepository(ContractCargoScope); // A quantity cap only governs GENERAL contracts (multi-shipment draw-down). // ONE_TIME allows a single booking, so any cap on it is meaningless → null. const isGeneral = contractKind === 'GENERAL'; await repo.save( cargoScope.map((c) => repo.create({ contractId, containerSize: c.containerSize ?? null, cargoTypeId: c.cargoTypeId ?? null, cargoFreeText: c.cargoFreeText ?? null, quantityCap: isGeneral ? (c.quantityCap ?? null) : null, }), ), ); } /** Update a DRAFT / CHANGES_REQUESTED contract. */ async update( id: string, dto: UpdateContractDto, files: Express.Multer.File[], actorId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const existing = await this.findById(id); if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { throw new BadRequestException( 'Only DRAFT or CHANGES_REQUESTED contracts can be updated', ); } const warnings: string[] = []; const freightType = dto.freightType ?? existing.freightType; const contractKind = dto.contractKind ?? existing.contractKind; if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.routes) this.assertRouteShape(contractKind, dto.routes); if (dto.routes) { await this.assertRoutesMatchDirection( dto.tradeDirection ?? existing.tradeDirection, dto.routes, ); } const updates: Record = { contractKind, tradeDirection: dto.tradeDirection ?? existing.tradeDirection, freightType, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, // Never rewritten: grandfathered contracts keep the currency (and frozen // snapshots) they were signed with. paymentCurrency: existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, // Same rule as create: clearing the flag clears the declaration with it. hazardClass: (dto.isHazardous ?? existing.isHazardous) ? (dto.hazardClass ?? existing.hazardClass ?? null) : null, unNumber: (dto.isHazardous ?? existing.isHazardous) ? (dto.unNumber ?? existing.unNumber ?? null) : null, equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng, lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) { throw new BadRequestException( 'Intercity contracts cannot use a service type that includes customs clearing', ); } updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); await this.contractsRepository.update(id, updates); if (dto.routes) { await this.dataSource.getRepository(ContractRoute).delete({ contractId: id }); await this.persistRoutes(id, dto.routes); } if (dto.cargoScope) { await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id }); await this.persistCargoScope(id, dto.cargoScope, existing.contractKind); } if (files.length > 0) { await this.filesService.uploadMany(id, 'contracts', files); } // Re-carry any company/profile document that is still missing from the // contract (runs after the upload so fresh replacements keep their slot). // Backfills contracts created before profile documents were carried over. await this.attachProfileDocuments( id, existing.companyId ?? null, existing.companyProfileId ?? null, ); const updated = await this.findById(id); // Audit what this edit actually changed. Runs after the writes so the // "after" side is read back from the contract rather than from the DTO. await this.recordFieldRevision(existing, updated, actorId); return { contract: updated, warnings }; } /** Fields worth auditing on a customer edit, read off a loaded contract. */ private auditableFields(contract: Contract): Record { return { contractKind: contract.contractKind, tradeDirection: contract.tradeDirection, freightType: contract.freightType, serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId, paymentCurrency: contract.paymentCurrency, contractType: contract.contractType, isHazardous: contract.isHazardous, hazardClass: contract.hazardClass, unNumber: contract.unNumber, isReefer: contract.isReefer, equipmentReturn: contract.equipmentReturn, customsClearingAgent: contract.customsClearingAgent, firstMilePickupAddress: contract.firstMilePickupAddress, lastMileDeliveryAddress: contract.lastMileDeliveryAddress, routes: describeRoutes(contract.routes), cargoScope: describeCargoScope(contract.cargoScope), }; } /** Append a revision describing a customer's edit to the contract itself. */ private async recordFieldRevision( before: Contract, after: Contract, actorId?: string, ): Promise { const changes = diffContractFields( this.auditableFields(before), this.auditableFields(after), ); await this.documentHistory.recordChanges({ contractId: after.id, changes, actorId: actorId ?? null, actorRole: 'Customer', }); } /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterContractDto): { statuses?: string[]; status?: string; } { const allowed = new Set(CONTRACT_STATUSES); const raw = filter.statuses; const statusList = raw ? raw .split(',') .map((s) => s.trim()) .filter((s) => allowed.has(s)) : []; if (statusList.length > 0) return { statuses: statusList }; if (filter.status && allowed.has(filter.status)) return { status: filter.status }; return {}; } async findAll( filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); return this.contractsRepository.findAllPaginated({ page, pageSize, ...statusFilter, companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractKind: filter.contractKind, serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, tradeDirections, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** Aggregate metrics and status counts for the backoffice contract list. */ async getListSummary(filter: FilterContractDto): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const listFilter = { ...statusFilter, companyId: filter.companyId, contractKind: filter.contractKind, serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, }; const [facets, metrics] = await Promise.all([ this.contractsRepository.getFacets(listFilter), this.contractsRepository.getListSummaryMetrics({ ...listFilter, page, pageSize, needsActionStatuses: NEEDS_ACTION_STATUSES, }), ]); // statusCounts kept for existing callers; now filter-scoped like every // other facet instead of the unfiltered global count `getStatusCounts` gave. const statusCounts = Object.fromEntries( (facets.status ?? []).map((b) => [b.value, b.count]), ); return { metrics, statusCounts, facets }; } /** Get a single contract by ID with relations and signed file URLs. */ async findById(id: string): Promise { const contract = await this.contractsRepository.findByIdWithRelations(id); if (!contract) { throw new NotFoundException(`Contract ${id} not found`); } // Lazy expiry flip: the nightly cron only sweeps once a day, so a // contract can be past contract_valid_until for hours before it shows // EXPIRED. Flip it here so the detail page never shows a stale status. if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') { const flipped = await this.contractsRepository.expireIfLapsed(id); if (flipped) { contract.status = 'EXPIRED'; } } // Entry state for every contract flow (submit, approve, sign, suspend…) — // see the equivalent in BookingsService.findById. logCtx( { id: contract.id, reference: contract.reference, statusAtEntry: contract.status, companyId: contract.companyId, }, { path: "contract" }, ); if (contract.files && contract.files.length > 0) { contract.files = await Promise.all( contract.files.map(async (file: FileRecord) => { const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl } as FileRecord; }), ); } // Surface the staff "request changes" note so the portal can show the // customer what to fix. Degrade to null on lookup failure — a missing note // must never 500 a contract fetch. if (contract.status === 'CHANGES_REQUESTED') { try { const note = await this.contractsRepository.findLatestReviewNote( contract.id, 'CHANGES_REQUESTED', ); contract.latestChangeRequestNote = note?.body ?? null; } catch { contract.latestChangeRequestNote = null; } } // Surface the rejection reason. The approval-step note is wiped on // send-back resets, so the review-note trail is the only durable source. if (contract.status === 'REJECTED') { try { const note = await this.contractsRepository.findLatestReviewNote( contract.id, 'REJECTION', ); contract.latestRejectionNote = note?.body ?? null; } catch { contract.latestRejectionNote = null; } } // Surface the send-back reason to the returned-to approver, but only while // it is still actionable: once any step acts after the send-back the note // is stale and stays out of the response (the trail keeps it in the DB). if (contract.status === 'PENDING_APPROVAL') { try { const note = await this.contractsRepository.findLatestReviewNote( contract.id, 'STAFF_NOTE', ); // Stale when any step acted after it (send-back resolved) or when the // chain itself is newer than the note (fresh cycle after a resubmit). const staleAfter = Math.max( 0, ...(contract.approvalSteps ?? []).flatMap((s) => [ s.actedAt ? new Date(s.actedAt).getTime() : 0, s.createdAt ? new Date(s.createdAt).getTime() : 0, ]), ); contract.latestSendBackNote = note && new Date(note.createdAt).getTime() > staleAfter ? note.body : null; } catch { contract.latestSendBackNote = null; } } // Why the contract is frozen — shown to staff and customer alike. if (contract.status === 'SUSPENDED') { try { const note = await this.contractsRepository.findLatestReviewNote( contract.id, 'SUSPENSION', ); contract.latestSuspensionNote = note?.body ?? null; } catch { contract.latestSuspensionNote = null; } } // Lets the portal disable "Cancel contract" instead of letting the customer // click it and read a 400. The API re-checks on cancel regardless. contract.activeBookingCount = await this.contractsRepository.countActiveBookings(contract.id); return contract; } async findByReference(reference: string): Promise { const found = await this.contractsRepository.findByReference(reference); if (!found) { throw new NotFoundException(`Contract with reference "${reference}" not found`); } return this.findById(found.id); } /** Upload intake documents for a DRAFT contract. */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const contract = await this.findById(id); if (contract.status !== 'DRAFT') { throw new BadRequestException( 'Documents can only be uploaded for DRAFT contracts', ); } await this.filesService.uploadMany(id, 'contracts', files); return this.findById(id); } async remove(id: string): Promise { const contract = await this.findById(id); if (contract.status !== 'DRAFT') { throw new BadRequestException('Only DRAFT contracts can be deleted'); } await this.contractsRepository.softDelete(id); } /** Resolve the company a customer user belongs to, for scoping their contracts. */ async resolveCustomerCompanyId(userId: string): Promise { try { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return company?.id ?? null; } catch { return null; } } /** Authorize a customer's access to a single contract (hides as NotFound otherwise). */ async assertCustomerCanAccessContract( userId: string | undefined, contract: Contract, ): Promise { if (!userId) { throw new ForbiddenException('Authentication required'); } const companyId = await this.resolveCustomerCompanyId(userId); if (!companyId || contract.companyId !== companyId) { throw new NotFoundException(`Contract ${contract.id} not found`); } } }