From bf6bd15baee675e49ce29f1acf06d26ac02c2607 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:12:57 +0000 Subject: [PATCH 01/12] feat: add SeaRates ocean tracking compatibility gateway Co-authored-by: Akshay Dodeja --- .env.local.example | 10 + api/info/sealines.ts | 3 + api/tracking.ts | 3 + package-lock.json | 17 + package.json | 2 +- packages/searates-compat/README.md | 101 ++++ packages/searates-compat/package.json | 25 + .../searates-compat/src/__fixtures__/t49.ts | 139 +++++ packages/searates-compat/src/client.test.ts | 99 ++++ packages/searates-compat/src/client.ts | 228 ++++++++ packages/searates-compat/src/http.ts | 137 +++++ packages/searates-compat/src/index.ts | 22 + packages/searates-compat/src/mapping.test.ts | 117 ++++ packages/searates-compat/src/mapping.ts | 538 ++++++++++++++++++ packages/searates-compat/src/service.test.ts | 113 ++++ packages/searates-compat/src/service.ts | 211 +++++++ packages/searates-compat/src/types.ts | 79 +++ packages/searates-compat/tsconfig.json | 20 + tsconfig.json | 14 +- vercel.json | 16 +- 20 files changed, 1890 insertions(+), 4 deletions(-) create mode 100644 api/info/sealines.ts create mode 100644 api/tracking.ts create mode 100644 packages/searates-compat/README.md create mode 100644 packages/searates-compat/package.json create mode 100644 packages/searates-compat/src/__fixtures__/t49.ts create mode 100644 packages/searates-compat/src/client.test.ts create mode 100644 packages/searates-compat/src/client.ts create mode 100644 packages/searates-compat/src/http.ts create mode 100644 packages/searates-compat/src/index.ts create mode 100644 packages/searates-compat/src/mapping.test.ts create mode 100644 packages/searates-compat/src/mapping.ts create mode 100644 packages/searates-compat/src/service.test.ts create mode 100644 packages/searates-compat/src/service.ts create mode 100644 packages/searates-compat/src/types.ts create mode 100644 packages/searates-compat/tsconfig.json diff --git a/.env.local.example b/.env.local.example index 0121f08b..47347932 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,3 +22,13 @@ T49_API_BASE_URL=http://localhost:3000/v2 # POSTHOG_PROJECT_API_KEY=phc_... # POSTHOG_HOST=https://f.terminal49.com # POSTHOG_DEBUG=true + +# --- SeaRates ocean-tracking compatibility gateway --- +# Pass-through mode (default): leave these unset and send a Terminal49 API key +# in the SeaRates-compatible `api_key` query parameter. +# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`, and +# the gateway uses T49_SEARATES_API_TOKEN only for public T49 API requests. +# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY +# T49_SEARATES_POLL_TIMEOUT_MS=4000 +# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/api/info/sealines.ts b/api/info/sealines.ts new file mode 100644 index 00000000..08db7046 --- /dev/null +++ b/api/info/sealines.ts @@ -0,0 +1,3 @@ +import { createShippingLinesHandler } from '../../packages/searates-compat/src/http.js'; + +export default createShippingLinesHandler(); diff --git a/api/tracking.ts b/api/tracking.ts new file mode 100644 index 00000000..fba878cb --- /dev/null +++ b/api/tracking.ts @@ -0,0 +1,3 @@ +import { createTrackingHandler } from '../packages/searates-compat/src/http.js'; + +export default createTrackingHandler(); diff --git a/package-lock.json b/package-lock.json index d0d920f6..bde4642f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5370,6 +5370,10 @@ "resolved": "sdks/typescript-sdk", "link": true }, + "node_modules/@terminal49/searates-compat": { + "resolved": "packages/searates-compat", + "link": true + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -18554,6 +18558,19 @@ "fsevents": "~2.3.3" } }, + "packages/searates-compat": { + "name": "@terminal49/searates-compat", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.10.13", + "@vitest/coverage-v8": "4.1.10", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } + }, "sdks/typescript-sdk": { "name": "@terminal49/sdk", "version": "0.3.1", diff --git a/package.json b/package.json index b79c8f9c..a42ead26 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "type": "module", "scripts": { - "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/cli", + "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/cli", "check": "npm run lint:api && npm run lint --workspaces && npm run type-check --workspaces", "format": "vp fmt --write api && npm run format --workspaces", "lint:api": "vp lint api && vp fmt --check api", diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md new file mode 100644 index 00000000..0b5b0a0e --- /dev/null +++ b/packages/searates-compat/README.md @@ -0,0 +1,101 @@ +# SeaRates ocean-tracking compatibility gateway + +This package exposes a small SeaRates-compatible HTTP surface backed only by +Terminal49's public JSON:API. It is a compatibility gateway for ocean container, +Bill of Lading (BOL), and booking tracking. It is not a clone of SeaRates' +rates, schedules, air, parcel, road, route-history, or Automatic Identification +System (AIS) products. + +## Endpoints + +- `GET /tracking` +- `GET /info/sealines` + +The Vercel deployment maps these paths to `api/tracking.ts` and +`api/info/sealines.ts`. `/info/terminals` is intentionally omitted because the +Terminal49 public API can fetch a known terminal but does not provide a +supported-terminals list. + +## Configure authentication + +Choose one of two modes: + +### Pass-through mode + +Leave `T49_SEARATES_API_TOKEN` unset. The gateway treats the SeaRates `api_key` +query parameter as a Terminal49 API key and sends it upstream as +`Authorization: Bearer `. + +### Service-token mode + +Set both values: + +```bash +T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY +``` + +Clients send `YOUR_GATEWAY_KEY` as `api_key`. The gateway compares it in +constant time and uses `T49_SEARATES_API_TOKEN` only for requests to the public +Terminal49 API. This is one shared deployment credential, not a multi-tenant +billing or key-management system. + +Optional settings: + +```bash +T49_API_BASE_URL=https://api.terminal49.com/v2 +T49_SEARATES_POLL_TIMEOUT_MS=4000 +T49_SEARATES_POLL_INTERVAL_MS=500 +``` + +## Point an existing client at the gateway + +Change the SeaRates base URL and keep the existing query parameters: + +```bash +curl "https://YOUR_GATEWAY.example.com/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" +``` + +The gateway accepts `type=CT`, `type=BL`, and `type=BK`, plus `force_update`, +`route`, and `ais`. `force_update=true` requests a Terminal49 container refresh +when a tracked container already exists. The compatibility response always +includes SeaRates' route summary. Detailed route geometry and AIS pins are not +implemented. + +Fetch the carrier dictionary with: + +```bash +curl "https://YOUR_GATEWAY.example.com/info/sealines?api_key=YOUR_GATEWAY_KEY" +``` + +In service-token mode, `/info/sealines` also works without `api_key`, matching +SeaRates' public dictionary behavior. Its rows are generated from Terminal49 +`GET /shipping_lines`; they are not a hardcoded sample. + +## Asynchronous tracking behavior + +Terminal49 creates tracking requests asynchronously. On a cache miss, the +gateway: + +1. creates or reuses a Terminal49 tracking request; +2. polls it for a short, bounded interval; +3. returns the full SeaRates envelope if the shipment becomes available; or +4. returns `status: "success"`, `message: "PENDING"`, `metadata.status: + "UNKNOWN"`, and empty data arrays. + +Retry the same `GET /tracking` request after a pending response. The gateway +reuses the existing tracking request instead of creating another one. Terminal49 +failure reasons are translated to SeaRates-style messages such as +`WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and +`SEALINE_HASNT_PROVIDE_INFO`. + +## Compatibility limits + +- Timestamps are rendered in SeaRates' `YYYY-MM-DD HH:MM:SS` shape but remain + UTC because Terminal49 stores canonical event timestamps in UTC. +- SeaRates quota counters and cache expiration have no Terminal49 equivalent, + so those fields are `null`. +- Equipment ISO codes are reconstructed for common dry, reefer, open-top, + flat-rack, hard-top, and tank combinations. Unknown combinations are `null`. +- Holds, fees, Last Free Day (LFD), and other Terminal49-only terminal + intelligence are deliberately excluded. diff --git a/packages/searates-compat/package.json b/packages/searates-compat/package.json new file mode 100644 index 00000000..85764d2f --- /dev/null +++ b/packages/searates-compat/package.json @@ -0,0 +1,25 @@ +{ + "name": "@terminal49/searates-compat", + "version": "0.1.0", + "private": true, + "description": "SeaRates ocean tracking compatibility gateway backed by the Terminal49 public API", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vp test --run", + "lint": "vp lint src && vp fmt --check src", + "format": "vp fmt --write src", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "@vitest/coverage-v8": "4.1.10", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } +} diff --git a/packages/searates-compat/src/__fixtures__/t49.ts b/packages/searates-compat/src/__fixtures__/t49.ts new file mode 100644 index 00000000..860b8371 --- /dev/null +++ b/packages/searates-compat/src/__fixtures__/t49.ts @@ -0,0 +1,139 @@ +import type { JsonApiDocument } from '../types.js'; + +export const shipmentFixture: JsonApiDocument = { + data: { + id: 'shipment-1', + type: 'shipment', + attributes: { + bill_of_lading_number: 'MEDUFR030802', + shipping_line_scac: 'MSCU', + shipping_line_name: 'Mediterranean Shipping Company', + port_of_lading_locode: 'FRLEH', + port_of_discharge_locode: 'USNYC', + pol_atd_at: '2026-08-01T10:00:00Z', + pod_eta_at: '2026-08-20T14:00:00Z', + line_tracking_last_succeeded_at: '2026-08-10T12:30:00Z', + pod_vessel_name: 'EXAMPLE VESSEL', + pod_vessel_imo: '9811000', + }, + relationships: { + containers: { data: [{ id: 'container-1', type: 'container' }] }, + port_of_lading: { data: { id: 'port-pol', type: 'port' } }, + port_of_discharge: { data: { id: 'port-pod', type: 'port' } }, + }, + }, + included: [ + { + id: 'container-1', + type: 'container', + attributes: { + number: 'MSCU1234567', + equipment_type: 'dry', + equipment_length: 40, + equipment_height: 'high_cube', + current_status: 'on_ship', + }, + relationships: { + shipment: { data: { id: 'shipment-1', type: 'shipment' } }, + }, + }, + { + id: 'port-pol', + type: 'port', + attributes: { + name: 'Le Havre', + code: 'FRLEH', + country_code: 'FR', + time_zone: 'Europe/Paris', + latitude: 49.49, + longitude: 0.1, + }, + }, + { + id: 'port-pod', + type: 'port', + attributes: { + name: 'New York / New Jersey', + code: 'USNYC', + country_code: 'US', + time_zone: 'America/New_York', + latitude: 40.67, + longitude: -74.04, + }, + }, + ], +}; + +export const eventsFixture: JsonApiDocument = { + data: [ + { + id: 'event-1', + type: 'transport_event', + attributes: { + event: 'container.transport.full_in', + timestamp: '2026-07-31T08:00:00Z', + voyage_number: null, + data_source: 'shipping_line', + }, + relationships: { + location: { data: { id: 'port-pol', type: 'port' } }, + terminal: { data: null }, + vessel: { data: null }, + }, + }, + { + id: 'event-2', + type: 'transport_event', + attributes: { + event: 'container.transport.vessel_departed', + timestamp: '2026-08-01T10:00:00Z', + voyage_number: '421A', + data_source: 'shipping_line', + }, + relationships: { + location: { data: { id: 'port-pol', type: 'port' } }, + terminal: { data: null }, + vessel: { data: { id: 'vessel-1', type: 'vessel' } }, + }, + }, + ], + included: [ + { + id: 'port-pol', + type: 'port', + attributes: { + name: 'Le Havre', + code: 'FRLEH', + country_code: 'FR', + time_zone: 'Europe/Paris', + }, + }, + { + id: 'vessel-1', + type: 'vessel', + attributes: { + name: 'EXAMPLE VESSEL', + imo: '9811000', + mmsi: '353136000', + }, + }, + ], +}; + +export const shippingLinesFixture: JsonApiDocument = { + data: [ + { + id: 'line-1', + type: 'shipping_line', + attributes: { + name: 'Mediterranean Shipping Company', + short_name: 'MSC', + scac: 'MSCU', + alternative_scacs: ['MEDU'], + bill_of_lading_tracking_support: true, + booking_number_tracking_support: true, + container_number_tracking_support: true, + }, + }, + ], +}; diff --git a/packages/searates-compat/src/client.test.ts b/packages/searates-compat/src/client.test.ts new file mode 100644 index 00000000..b3d205fb --- /dev/null +++ b/packages/searates-compat/src/client.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { Terminal49PublicClient } from './client.js'; +import type { TrackingType } from './types.js'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/vnd.api+json' }, + }); +} + +describe('tracking request shaping', () => { + it.each([ + ['CT', 'container'], + ['BL', 'bill_of_lading'], + ['BK', 'booking_number'], + ] as const)('maps %s to T49 request_type %s', async (type, expected) => { + const requests: Array<{ init?: RequestInit; url: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + requests.push({ init, url }); + if (url.includes('/tracking_requests?')) { + return jsonResponse({ data: [] }); + } + return jsonResponse( + { + data: { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + relationships: { tracked_object: { data: null } }, + }, + }, + 201, + ); + }; + const client = new Terminal49PublicClient({ + apiToken: 'test-token', + baseUrl: 'https://api.example.test/v2', + fetchImpl, + pollTimeoutMs: 0, + }); + + await client.resolveTrackingRequest({ + number: 'EXAMPLE123', + scac: 'MSCU', + type: type as TrackingType, + }); + + const create = requests.find((request) => request.init?.method === 'POST'); + expect(JSON.parse(String(create?.init?.body))).toMatchObject({ + data: { + type: 'tracking_request', + attributes: { + request_number: 'EXAMPLE123', + request_type: expected, + scac: 'MSCU', + }, + }, + }); + expect(create?.init?.headers).toMatchObject({ + Authorization: 'Bearer test-token', + }); + }); + + it('uses asynchronous carrier detection when sealine is omitted', async () => { + const bodies: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).includes('/tracking_requests?')) { + return jsonResponse({ data: [] }); + } + bodies.push(String(init?.body)); + return jsonResponse( + { + data: { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + }, + }, + 201, + ); + }; + const client = new Terminal49PublicClient({ + apiToken: 'test-token', + fetchImpl, + pollTimeoutMs: 0, + }); + + await client.resolveTrackingRequest({ + number: 'MEDUFR030802', + type: 'BL', + }); + + expect(JSON.parse(bodies[0])).toMatchObject({ + data: { attributes: { auto_detect_vocc_scac: true } }, + }); + }); +}); diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts new file mode 100644 index 00000000..f8f0c8d3 --- /dev/null +++ b/packages/searates-compat/src/client.ts @@ -0,0 +1,228 @@ +import type { + JsonApiDocument, + JsonApiResource, + Terminal49TrackingType, + TrackingType, +} from './types.js'; + +export class Terminal49ApiError extends Error { + readonly status: number; + readonly document: JsonApiDocument | null; + + constructor(status: number, document: JsonApiDocument | null) { + const detail = document?.errors?.[0]?.detail; + super(detail || `Terminal49 API returned HTTP ${status}`); + this.name = 'Terminal49ApiError'; + this.status = status; + this.document = document; + } +} + +export interface Terminal49ClientConfig { + apiToken: string; + baseUrl?: string; + fetchImpl?: typeof fetch; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +function normalizeToken(token: string): string { + return token.trim().replace(/^(Bearer|Token)\s+/i, ''); +} + +function resourceArray(document: JsonApiDocument): JsonApiResource[] { + return Array.isArray(document.data) ? document.data : []; +} + +function trackedObjectId(resource: JsonApiResource): string | null { + const tracked = resource.relationships?.tracked_object?.data; + return tracked && !Array.isArray(tracked) && tracked.type === 'shipment' + ? tracked.id + : null; +} + +function trackingType(type: TrackingType): Terminal49TrackingType { + switch (type) { + case 'CT': + return 'container'; + case 'BL': + return 'bill_of_lading'; + case 'BK': + return 'booking_number'; + default: { + const exhaustive: never = type; + return exhaustive; + } + } +} + +export class Terminal49PublicClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly pollIntervalMs: number; + private readonly pollTimeoutMs: number; + private readonly token: string; + + constructor(config: Terminal49ClientConfig) { + this.token = normalizeToken(config.apiToken); + this.baseUrl = (config.baseUrl || 'https://api.terminal49.com/v2').replace( + /\/+$/, + '', + ); + this.fetchImpl = config.fetchImpl || fetch; + this.pollIntervalMs = config.pollIntervalMs ?? 500; + this.pollTimeoutMs = config.pollTimeoutMs ?? 4_000; + } + + async shippingLines(): Promise { + return this.request('/shipping_lines'); + } + + async findShipment( + number: string, + type?: TrackingType, + ): Promise { + if (type === 'CT' || (!type && /^[A-Z]{4}\d{7}$/.test(number))) { + const containers = await this.request( + `/containers?filter[number]=${encodeURIComponent(number)}&include=shipment&page[size]=1`, + ); + const container = resourceArray(containers)[0]; + const shipmentReference = container?.relationships?.shipment?.data; + if ( + shipmentReference && + !Array.isArray(shipmentReference) && + shipmentReference.type === 'shipment' + ) { + return this.shipment(shipmentReference.id); + } + if (type === 'CT') return null; + } + + const shipments = await this.request( + `/shipments?number=${encodeURIComponent(number)}&include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal&page[size]=1`, + ); + const shipment = resourceArray(shipments)[0]; + return shipment ? this.shipment(shipment.id) : null; + } + + async shipment(id: string): Promise { + return this.request( + `/shipments/${encodeURIComponent(id)}?include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal`, + ); + } + + async transportEvents(containerId: string): Promise { + return this.request( + `/containers/${encodeURIComponent(containerId)}/transport_events?include=location,terminal,vessel`, + ); + } + + async refreshContainer(containerId: string): Promise { + await this.request( + `/containers/${encodeURIComponent(containerId)}/refresh`, + { + method: 'PATCH', + }, + ); + } + + async resolveTrackingRequest(input: { + number: string; + scac?: string; + type: TrackingType; + }): Promise< + | { failedReason: string; state: 'failed' } + | { state: 'pending' } + | { shipmentId: string; state: 'created' } + > { + const existing = await this.trackingRequests(input.number, input.scac); + let requestResource = resourceArray(existing)[0]; + + if (!requestResource) { + const attributes: Record = { + request_number: input.number, + request_type: trackingType(input.type), + }; + if (input.scac) { + attributes.scac = input.scac; + } else { + attributes.auto_detect_vocc_scac = true; + } + const created = await this.request('/tracking_requests', { + body: JSON.stringify({ + data: { type: 'tracking_request', attributes }, + }), + headers: { 'Content-Type': 'application/vnd.api+json' }, + method: 'POST', + }); + requestResource = Array.isArray(created.data) + ? created.data[0] + : (created.data ?? undefined); + } + + if (!requestResource) return { state: 'pending' }; + + const deadline = Date.now() + this.pollTimeoutMs; + while (true) { + const status = String(requestResource.attributes?.status || 'pending'); + const shipmentId = trackedObjectId(requestResource); + if (shipmentId) return { shipmentId, state: 'created' }; + if (status === 'failed') { + return { + failedReason: String( + requestResource.attributes?.failed_reason || 'not_found', + ), + state: 'failed', + }; + } + if (Date.now() >= deadline) return { state: 'pending' }; + await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs)); + const next = await this.request( + `/tracking_requests/${encodeURIComponent(requestResource.id)}?include=tracked_object`, + ); + requestResource = Array.isArray(next.data) + ? next.data[0] + : (next.data ?? requestResource); + } + } + + private async trackingRequests( + number: string, + scac?: string, + ): Promise { + const params = new URLSearchParams({ + 'filter[request_number]': number, + include: 'tracked_object', + 'page[size]': '1', + }); + if (scac) params.set('filter[scac]', scac); + return this.request(`/tracking_requests?${params.toString()}`); + } + + private async request( + path: string, + init: RequestInit = {}, + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: 'application/vnd.api+json', + Authorization: `Bearer ${this.token}`, + ...init.headers, + }, + }); + let document: JsonApiDocument | null = null; + try { + document = (await response.json()) as JsonApiDocument; + } catch { + document = null; + } + if (!response.ok) { + throw new Terminal49ApiError(response.status, document); + } + if (!document) { + throw new Terminal49ApiError(response.status, null); + } + return document; + } +} diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts new file mode 100644 index 00000000..291aaa94 --- /dev/null +++ b/packages/searates-compat/src/http.ts @@ -0,0 +1,137 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; +import type { TrackingQuery, TrackingType } from './types.js'; + +type RequestLike = { + method?: string; + url?: string; +} & IncomingMessage; + +type ResponseLike = { + status(code: number): ResponseLike; + json(payload: unknown): void; + setHeader(name: string, value: string): void; +} & ServerResponse; + +function first(params: URLSearchParams, key: string): string | undefined { + const value = params.get(key)?.trim(); + return value || undefined; +} + +function booleanParam(params: URLSearchParams, key: string): boolean { + return ['1', 'true', 'yes'].includes( + (params.get(key) || '').trim().toLowerCase(), + ); +} + +function trackingType(value: string | undefined): TrackingType | undefined { + const normalized = value?.toUpperCase(); + return normalized === 'CT' || normalized === 'BL' || normalized === 'BK' + ? normalized + : undefined; +} + +function gatewayConfig(): GatewayConfig { + const pollTimeout = Number(process.env.T49_SEARATES_POLL_TIMEOUT_MS); + const pollInterval = Number(process.env.T49_SEARATES_POLL_INTERVAL_MS); + return { + apiBaseUrl: process.env.T49_API_BASE_URL, + clientSecret: process.env.T49_SEARATES_CLIENT_SECRET, + pollIntervalMs: + Number.isFinite(pollInterval) && pollInterval >= 0 + ? pollInterval + : undefined, + pollTimeoutMs: + Number.isFinite(pollTimeout) && pollTimeout >= 0 + ? pollTimeout + : undefined, + serviceApiToken: process.env.T49_SEARATES_API_TOKEN, + }; +} + +function setHeaders(response: ResponseLike): void { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + response.setHeader('Cache-Control', 'no-store'); +} + +function requestParams(request: RequestLike): URLSearchParams { + return new URL(request.url || '/', 'https://compat.invalid').searchParams; +} + +export function createTrackingHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return async ( + request: RequestLike, + response: ResponseLike, + ): Promise => { + setHeaders(response); + if (request.method === 'OPTIONS') { + response.status(204).json(null); + return; + } + if (request.method !== 'GET') { + response.status(405).json({ + status: 'error', + message: 'METHOD_NOT_ALLOWED', + data: null, + }); + return; + } + + const params = requestParams(request); + const number = (first(params, 'number') || '') + .replace(/\s+/g, '') + .toUpperCase(); + const sealine = first(params, 'sealine')?.toUpperCase(); + const rawType = first(params, 'type'); + if (rawType && !trackingType(rawType)) { + response.status(200).json({ + status: 'error', + message: 'WRONG_TYPE', + data: null, + }); + return; + } + const query: TrackingQuery = { + ais: booleanParam(params, 'ais'), + forceUpdate: booleanParam(params, 'force_update'), + number, + route: booleanParam(params, 'route'), + sealine, + type: trackingType(rawType), + }; + response + .status(200) + .json(await gateway.tracking(first(params, 'api_key'), query)); + }; +} + +export function createShippingLinesHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return async ( + request: RequestLike, + response: ResponseLike, + ): Promise => { + setHeaders(response); + if (request.method === 'OPTIONS') { + response.status(204).json(null); + return; + } + if (request.method !== 'GET') { + response.status(405).json({ + status: 'error', + message: 'METHOD_NOT_ALLOWED', + data: null, + }); + return; + } + response + .status(200) + .json( + await gateway.shippingLines(first(requestParams(request), 'api_key')), + ); + }; +} diff --git a/packages/searates-compat/src/index.ts b/packages/searates-compat/src/index.ts new file mode 100644 index 00000000..63f5878a --- /dev/null +++ b/packages/searates-compat/src/index.ts @@ -0,0 +1,22 @@ +export { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +export { createShippingLinesHandler, createTrackingHandler } from './http.js'; +export { + mapEvent, + mapShippingLines, + mapTrackingPayload, + pendingEnvelope, +} from './mapping.js'; +export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; +export type { + JsonApiDocument, + JsonApiResource, + SeaRatesEnvelope, + SeaRatesEvent, + TrackingPayload, + TrackingQuery, + TrackingType, +} from './types.js'; diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts new file mode 100644 index 00000000..56bc180b --- /dev/null +++ b/packages/searates-compat/src/mapping.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + eventsFixture, + shipmentFixture, + shippingLinesFixture, +} from './__fixtures__/t49.js'; +import { mapEvent, mapShippingLines, mapTrackingPayload } from './mapping.js'; +import type { JsonApiResource, TrackingPayload } from './types.js'; + +describe('SeaRates response mapping', () => { + it('matches the documented tracking envelope and event fields', () => { + if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { + throw new Error('Shipment fixture must contain one resource'); + } + const payload: TrackingPayload = { + eventsByContainerId: new Map([['container-1', eventsFixture]]), + shipment: shipmentFixture.data, + included: shipmentFixture.included || [], + requestedNumber: 'MEDUFR030802', + requestedType: 'BL', + }; + + const result = mapTrackingPayload(payload); + expect(result.status).toBe('success'); + expect(result.message).toBe('OK'); + expect(result.data).toMatchObject({ + metadata: { + type: 'BL', + number: 'MEDUFR030802', + sealine: 'MSCU', + status: 'IN_TRANSIT', + }, + locations: [ + { id: 1, locode: 'FRLEH' }, + { id: 2, locode: 'USNYC' }, + ], + route: { + pol: { location: 1, actual: true }, + pod: { location: 2, actual: false }, + }, + containers: [ + { + number: 'MSCU1234567', + iso_code: '45G1', + size_type: "40' High Cube Dry", + events: [ + { + event_code: 'GTIN', + status: 'CGI', + actual: true, + transport_type: 'TRUCK', + }, + { + event_code: 'DEPA', + status: 'VDL', + actual: true, + transport_type: 'VESSEL', + voyage: '421A', + }, + ], + }, + ], + }); + }); + + it.each([ + ['container.transport.empty_out', 'GTOT', 'CEP'], + ['container.transport.full_in', 'GTIN', 'CGI'], + ['container.transport.vessel_loaded', 'LOAD', 'CLL'], + ['container.transport.vessel_departed', 'DEPA', 'VDL'], + ['container.transport.transshipment_arrived', 'ARRI', 'VAT'], + ['container.transport.transshipment_discharged', 'DISC', 'CDT'], + ['container.transport.transshipment_loaded', 'LOAD', 'CLT'], + ['container.transport.transshipment_departed', 'DEPA', 'VDT'], + ['container.transport.vessel_arrived', 'ARRI', 'VAD'], + ['container.transport.full_out', 'GTOT', 'CGO'], + ['container.transport.delivered', 'DLVY', 'CDC'], + ['container.transport.empty_in', 'GTIN', 'CER'], + ['container.transport.rail_departed', 'DEPA', 'LTS'], + ])('maps %s to %s / %s', (event, code, status) => { + const resource: JsonApiResource = { + id: 'event', + type: 'transport_event', + attributes: { event, timestamp: '2026-08-01T10:00:00Z' }, + }; + expect( + mapEvent(resource, 1, { + facilities: new Map(), + locations: new Map(), + vessels: new Map(), + }), + ).toMatchObject({ event_code: code, status }); + }); + + it('maps every public T49 shipping line instead of using a sample list', () => { + expect(mapShippingLines(shippingLinesFixture)).toEqual({ + status: 'success', + message: 'OK', + data: [ + { + name: 'Mediterranean Shipping Company', + active: true, + active_types: { + ct: true, + bl: true, + bk: true, + bl_ct: false, + bk_ct: false, + }, + maintenance: false, + scac_codes: ['MSCU', 'MEDU'], + prefixes: ['MSC', 'MED'], + }, + ], + }); + }); +}); diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts new file mode 100644 index 00000000..7b7b2c64 --- /dev/null +++ b/packages/searates-compat/src/mapping.ts @@ -0,0 +1,538 @@ +import type { + JsonApiDocument, + JsonApiResource, + JsonObject, + SeaRatesEnvelope, + SeaRatesEvent, + TrackingPayload, + TrackingType, +} from './types.js'; + +interface EventMapping { + code: string; + description: string; + eventType: 'EQUIPMENT' | 'TRANSPORT'; + status: string; + transport: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; +} + +const EVENT_MAPPINGS: Record = { + 'container.transport.empty_out': { + code: 'GTOT', + description: 'Empty Picked-up at Depot', + eventType: 'EQUIPMENT', + status: 'CEP', + transport: 'TRUCK', + }, + 'container.transport.full_in': { + code: 'GTIN', + description: 'Gate in at Port terminal', + eventType: 'EQUIPMENT', + status: 'CGI', + transport: 'TRUCK', + }, + 'container.transport.vessel_loaded': { + code: 'LOAD', + description: 'Loaded on board', + eventType: 'EQUIPMENT', + status: 'CLL', + transport: 'VESSEL', + }, + 'container.transport.vessel_departed': { + code: 'DEPA', + description: 'Vessel Departure', + eventType: 'TRANSPORT', + status: 'VDL', + transport: 'VESSEL', + }, + 'container.transport.vessel_arrived': { + code: 'ARRI', + description: 'Vessel Arrival', + eventType: 'TRANSPORT', + status: 'VAD', + transport: 'VESSEL', + }, + 'container.transport.vessel_discharged': { + code: 'DISC', + description: 'Discharged from vessel', + eventType: 'EQUIPMENT', + status: 'CDD', + transport: 'VESSEL', + }, + 'container.transport.transshipment_arrived': { + code: 'ARRI', + description: 'Vessel Arrival at transshipment port', + eventType: 'TRANSPORT', + status: 'VAT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_discharged': { + code: 'DISC', + description: 'Discharged in transshipment', + eventType: 'EQUIPMENT', + status: 'CDT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_loaded': { + code: 'LOAD', + description: 'Loaded at transshipment port', + eventType: 'EQUIPMENT', + status: 'CLT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_departed': { + code: 'DEPA', + description: 'Vessel Departure from transshipment port', + eventType: 'TRANSPORT', + status: 'VDT', + transport: 'VESSEL', + }, + 'container.transport.full_out': { + code: 'GTOT', + description: 'Gate out from final port', + eventType: 'EQUIPMENT', + status: 'CGO', + transport: 'TRUCK', + }, + 'container.transport.delivered': { + code: 'DLVY', + description: 'Container delivered to consignee', + eventType: 'EQUIPMENT', + status: 'CDC', + transport: 'TRUCK', + }, + 'container.transport.empty_in': { + code: 'GTIN', + description: 'Empty container returned to depot', + eventType: 'EQUIPMENT', + status: 'CER', + transport: 'TRUCK', + }, + 'container.transport.rail_loaded': { + code: 'LOAD', + description: 'Loaded on rail', + eventType: 'EQUIPMENT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_departed': { + code: 'DEPA', + description: 'Rail departure', + eventType: 'TRANSPORT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_arrived': { + code: 'ARRI', + description: 'Rail arrival', + eventType: 'TRANSPORT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_unloaded': { + code: 'DISC', + description: 'Discharged from rail', + eventType: 'EQUIPMENT', + status: 'LTS', + transport: 'RAIL', + }, +}; + +function attrs(resource: JsonApiResource): JsonObject { + return resource.attributes || {}; +} + +function relatedId( + resource: JsonApiResource, + relationship: string, +): string | null { + const data = resource.relationships?.[relationship]?.data; + return data && !Array.isArray(data) ? data.id : null; +} + +function formatDate(value: unknown): string | null { + if (typeof value !== 'string' || !value) return null; + return value.replace('T', ' ').replace(/(?:\.\d+)?Z$/, ''); +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function resourceIndex( + resources: JsonApiResource[], +): Map { + return new Map(resources.map((resource) => [resource.id, resource])); +} + +function equipment(attributes: JsonObject): { + isoCode: string | null; + sizeType: string | null; +} { + const length = numberValue(attributes.equipment_length); + const height = stringValue(attributes.equipment_height); + const type = stringValue(attributes.equipment_type); + if (!length || !height || !type) return { isoCode: null, sizeType: null }; + + const first = length === 20 ? '2' : length === 40 ? '4' : 'L'; + const second = height === 'high_cube' ? '5' : '2'; + const typeCode: Record = { + dry: 'G1', + flat_rack: 'P1', + hard_top: 'U1', + open_top: 'U1', + reefer: 'R1', + tank: 'T1', + }; + const label: Record = { + dry: 'Dry', + flat_rack: 'Flat Rack', + hard_top: 'Hard Top', + open_top: 'Open Top', + reefer: 'Reefer', + tank: 'Tank', + }; + const heightLabel = height === 'high_cube' ? ' High Cube' : ''; + return { + isoCode: typeCode[type] ? `${first}${second}${typeCode[type]}` : null, + sizeType: `${length}'${heightLabel} ${label[type] || type}`, + }; +} + +function seaRatesStatus(value: unknown): string { + if (typeof value !== 'string') return 'UNKNOWN'; + if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { + return 'COMPLETED'; + } + if ( + [ + 'available', + 'awaiting_inland_transfer', + 'in_transit', + 'not_available', + 'on_ship', + ].includes(value) + ) { + return 'IN_TRANSIT'; + } + return 'UNKNOWN'; +} + +function defaultType(number: string, type?: TrackingType): TrackingType { + return type || (/^[A-Z]{4}\d{7}$/.test(number) ? 'CT' : 'BL'); +} + +function collectResources(payload: TrackingPayload): JsonApiResource[] { + const resources = [payload.shipment, ...payload.included]; + for (const document of payload.eventsByContainerId.values()) { + if (Array.isArray(document.data)) resources.push(...document.data); + resources.push(...(document.included || [])); + } + return resources; +} + +export function mapEvent( + resource: JsonApiResource, + orderId: number, + ids: { + facilities: Map; + locations: Map; + vessels: Map; + }, +): SeaRatesEvent | null { + const attributes = attrs(resource); + const eventName = stringValue(attributes.event); + if (!eventName) return null; + const normalizedName = eventName.replace('.estimated.', '.'); + const mapping = EVENT_MAPPINGS[normalizedName]; + if (!mapping) return null; + const estimated = eventName.includes('.estimated.'); + const locationId = relatedId(resource, 'location'); + const facilityId = relatedId(resource, 'terminal'); + const vesselId = relatedId(resource, 'vessel'); + return { + actual: !estimated, + date: formatDate(attributes.timestamp), + description: mapping.description, + event_type: mapping.eventType, + event_code: mapping.code, + status: mapping.status, + facility: facilityId ? (ids.facilities.get(facilityId) ?? null) : null, + is_additional_event: false, + is_date_from_sealine: attributes.data_source === 'shipping_line', + location: locationId ? (ids.locations.get(locationId) ?? null) : null, + order_id: orderId, + transport_type: mapping.transport, + type: mapping.transport === 'VESSEL' ? 'sea' : 'land', + vessel: vesselId ? (ids.vessels.get(vesselId) ?? null) : null, + voyage: stringValue(attributes.voyage_number), + }; +} + +export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { + const resources = collectResources(payload); + const byId = resourceIndex(resources); + const locationResources = resources.filter((resource) => + ['metro_area', 'port'].includes(resource.type), + ); + const facilityResources = resources.filter((resource) => + ['rail_terminal', 'terminal'].includes(resource.type), + ); + const vesselResources = resources.filter( + (resource) => resource.type === 'vessel', + ); + const locations = new Map( + locationResources.map((resource, index) => [resource.id, index + 1]), + ); + const facilities = new Map( + facilityResources.map((resource, index) => [resource.id, index + 1]), + ); + const vessels = new Map( + vesselResources.map((resource, index) => [resource.id, index + 1]), + ); + const shipmentAttributes = attrs(payload.shipment); + const containerResources = payload.included.filter( + (resource) => resource.type === 'container', + ); + const containerStatuses = containerResources.map((resource) => + seaRatesStatus(attrs(resource).current_status), + ); + const overallStatus = containerStatuses.includes('IN_TRANSIT') + ? 'IN_TRANSIT' + : containerStatuses.includes('COMPLETED') + ? 'COMPLETED' + : 'UNKNOWN'; + + const locationList = locationResources.map((resource) => { + const attributes = attrs(resource); + return { + id: locations.get(resource.id) || 0, + name: stringValue(attributes.name), + state: stringValue(attributes.state_abbr), + country: null, + country_code: stringValue(attributes.country_code), + locode: stringValue(attributes.code), + lat: numberValue(attributes.latitude), + lng: numberValue(attributes.longitude), + timezone: stringValue(attributes.time_zone), + }; + }); + + const facilityList = facilityResources.map((resource) => { + const attributes = attrs(resource); + const port = byId.get(relatedId(resource, 'port') || ''); + const portAttributes = port ? attrs(port) : {}; + return { + id: facilities.get(resource.id) || 0, + name: stringValue(attributes.name), + country_code: stringValue(portAttributes.country_code), + locode: stringValue(portAttributes.code), + bic_code: stringValue( + attributes.bic_facility_code || attributes.bic_code, + ), + smdg_code: stringValue(attributes.smdg_code), + lat: null, + lng: null, + }; + }); + + const vesselList = vesselResources.map((resource) => { + const attributes = attrs(resource); + return { + id: vessels.get(resource.id) || 0, + name: stringValue(attributes.name), + imo: stringValue(attributes.imo), + call_sign: null, + mmsi: stringValue(attributes.mmsi), + flag: null, + }; + }); + + if ( + vesselList.length === 0 && + stringValue(shipmentAttributes.pod_vessel_name) + ) { + vesselList.push({ + id: 1, + name: stringValue(shipmentAttributes.pod_vessel_name), + imo: stringValue(shipmentAttributes.pod_vessel_imo), + call_sign: null, + mmsi: null, + flag: null, + }); + } + + const containers = containerResources.map((resource) => { + const attributes = attrs(resource); + const eventDocument = payload.eventsByContainerId.get(resource.id); + const eventResources = Array.isArray(eventDocument?.data) + ? eventDocument.data + : []; + const events = eventResources + .map((event, index) => + mapEvent(event, index + 1, { facilities, locations, vessels }), + ) + .filter((event): event is SeaRatesEvent => event !== null) + .sort((left, right) => (left.date || '').localeCompare(right.date || '')) + .map((event, index) => ({ ...event, order_id: index + 1 })); + const equipmentDetails = equipment(attributes); + return { + number: stringValue(attributes.number), + iso_code: equipmentDetails.isoCode, + size_type: equipmentDetails.sizeType, + status: seaRatesStatus(attributes.current_status), + is_status_from_sealine: true, + events_mirrored: false, + events, + }; + }); + + const polId = relatedId(payload.shipment, 'port_of_lading'); + const podId = relatedId(payload.shipment, 'port_of_discharge'); + const destinationId = relatedId(payload.shipment, 'destination'); + + return { + status: 'success', + message: 'OK', + data: { + metadata: { + type: payload.requestedType, + number: + stringValue(shipmentAttributes.bill_of_lading_number) || + payload.requestedNumber, + sealine: stringValue(shipmentAttributes.shipping_line_scac), + sealine_name: stringValue(shipmentAttributes.shipping_line_name), + status: overallStatus, + is_status_from_sealine: true, + from_cache: true, + updated_at: formatDate( + shipmentAttributes.line_tracking_last_succeeded_at, + ), + cache_expires: null, + api_calls: null, + unique_shipments: null, + }, + locations: locationList, + facilities: facilityList, + route: { + prepol: { + location: polId ? (locations.get(polId) ?? null) : null, + date: formatDate( + shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, + ), + actual: Boolean(shipmentAttributes.pol_atd_at), + }, + pol: { + location: polId ? (locations.get(polId) ?? null) : null, + date: formatDate( + shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, + ), + actual: Boolean(shipmentAttributes.pol_atd_at), + }, + pod: { + location: podId ? (locations.get(podId) ?? null) : null, + date: formatDate( + shipmentAttributes.pod_ata_at || shipmentAttributes.pod_eta_at, + ), + actual: Boolean(shipmentAttributes.pod_ata_at), + predictive_eta: null, + }, + postpod: { + location: destinationId + ? (locations.get(destinationId) ?? null) + : null, + date: formatDate( + shipmentAttributes.destination_ata_at || + shipmentAttributes.destination_eta_at, + ), + actual: shipmentAttributes.destination_ata_at + ? true + : shipmentAttributes.destination_eta_at + ? false + : null, + }, + }, + vessels: vesselList, + containers, + }, + }; +} + +export function pendingEnvelope( + number: string, + type?: TrackingType, + sealine?: string, +): SeaRatesEnvelope { + return { + status: 'success', + message: 'PENDING', + data: { + metadata: { + type: defaultType(number, type), + number, + sealine: sealine || null, + sealine_name: null, + status: 'UNKNOWN', + is_status_from_sealine: false, + from_cache: false, + updated_at: null, + cache_expires: null, + api_calls: null, + unique_shipments: null, + }, + locations: [], + facilities: [], + route: { + prepol: { location: null, date: null, actual: null }, + pol: { location: null, date: null, actual: null }, + pod: { + location: null, + date: null, + actual: null, + predictive_eta: null, + }, + postpod: { location: null, date: null, actual: null }, + }, + vessels: [], + containers: [], + }, + }; +} + +export function mapShippingLines(document: JsonApiDocument): SeaRatesEnvelope { + const resources = Array.isArray(document.data) ? document.data : []; + return { + status: 'success', + message: 'OK', + data: resources.map((resource) => { + const attributes = attrs(resource); + const primary = stringValue(attributes.scac); + const alternatives = Array.isArray(attributes.alternative_scacs) + ? attributes.alternative_scacs.filter( + (value): value is string => typeof value === 'string', + ) + : []; + const scacCodes = primary + ? [primary, ...alternatives.filter((value) => value !== primary)] + : alternatives; + return { + name: stringValue(attributes.name), + active: true, + active_types: { + ct: attributes.container_number_tracking_support === true, + bl: attributes.bill_of_lading_tracking_support === true, + bk: attributes.booking_number_tracking_support === true, + bl_ct: false, + bk_ct: false, + }, + maintenance: false, + scac_codes: scacCodes, + prefixes: scacCodes.map((scac) => scac.slice(0, 3)), + }; + }), + }; +} diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts new file mode 100644 index 00000000..5a4abbf6 --- /dev/null +++ b/packages/searates-compat/src/service.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + eventsFixture, + shipmentFixture, + shippingLinesFixture, +} from './__fixtures__/t49.js'; +import { SeaRatesCompatibilityGateway } from './service.js'; +import type { TrackingQuery } from './types.js'; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/vnd.api+json' }, + }); +} + +const query: TrackingQuery = { + ais: false, + forceUpdate: false, + number: 'MEDUFR030802', + route: false, + sealine: 'MSCU', + type: 'BL', +}; + +describe('SeaRates compatibility gateway', () => { + it('returns the documented contract from fixture-backed public API calls', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = String(input); + if (url.includes('/shipments?')) { + return response({ + data: [shipmentFixture.data], + included: shipmentFixture.included, + }); + } + if (url.includes('/shipments/shipment-1')) { + return response(shipmentFixture); + } + if (url.includes('/transport_events')) return response(eventsFixture); + throw new Error(`Unexpected fixture request: ${url}`); + }; + const gateway = new SeaRatesCompatibilityGateway({ + apiBaseUrl: 'https://api.example.test/v2', + fetchImpl, + }); + + const result = await gateway.tracking('pass-through-key', query); + + expect(result).toMatchObject({ + status: 'success', + message: 'OK', + data: { + metadata: { number: 'MEDUFR030802', type: 'BL' }, + locations: expect.any(Array), + facilities: expect.any(Array), + route: expect.any(Object), + vessels: expect.any(Array), + containers: [{ events: expect.any(Array) }], + }, + }); + }); + + it('returns SeaRates errors for missing and bad api_key values', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + clientSecret: 'gateway-key', + serviceApiToken: 'service-token', + }); + await expect(gateway.tracking(undefined, query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_REQUIRED', + data: null, + }); + await expect(gateway.tracking('wrong-key', query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_WRONG', + data: null, + }); + }); + + it('maps an upstream authentication rejection to API_KEY_WRONG', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + fetchImpl: async () => + response( + { + errors: [{ status: '401', title: 'Unauthorized' }], + data: null, + }, + 401, + ), + }); + await expect(gateway.tracking('bad-t49-key', query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_WRONG', + data: null, + }); + }); + + it('serves the sealines dictionary from /shipping_lines', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + serviceApiToken: 'service-token', + fetchImpl: async (input) => { + expect(String(input)).toEndWith('/shipping_lines'); + return response(shippingLinesFixture); + }, + }); + const result = await gateway.shippingLines(); + expect(result).toMatchObject({ + status: 'success', + message: 'OK', + data: [{ scac_codes: ['MSCU', 'MEDU'] }], + }); + }); +}); diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts new file mode 100644 index 00000000..d29a24da --- /dev/null +++ b/packages/searates-compat/src/service.ts @@ -0,0 +1,211 @@ +import { timingSafeEqual } from 'node:crypto'; +import { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +import { + mapShippingLines, + mapTrackingPayload, + pendingEnvelope, +} from './mapping.js'; +import type { + JsonApiDocument, + JsonApiResource, + SeaRatesEnvelope, + TrackingPayload, + TrackingQuery, + TrackingType, +} from './types.js'; + +export interface GatewayConfig { + apiBaseUrl?: string; + clientSecret?: string; + fetchImpl?: typeof fetch; + pollIntervalMs?: number; + pollTimeoutMs?: number; + serviceApiToken?: string; +} + +function errorEnvelope(message: string): SeaRatesEnvelope { + return { status: 'error', message, data: null }; +} + +function secureEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} + +function inferType(number: string, explicit?: TrackingType): TrackingType { + return explicit || (/^[A-Z]{4}\d{7}$/.test(number) ? 'CT' : 'BL'); +} + +function shipmentFrom(document: JsonApiDocument): JsonApiResource | null { + if (!document.data || Array.isArray(document.data)) return null; + return document.data.type === 'shipment' ? document.data : null; +} + +function containerResources(document: JsonApiDocument): JsonApiResource[] { + return (document.included || []).filter( + (resource) => resource.type === 'container', + ); +} + +function upstreamErrorMessage(error: Terminal49ApiError): string { + if (error.status === 401) return 'API_KEY_WRONG'; + if (error.status === 403) return 'API_KEY_ACCESS_DENIED'; + if (error.status === 429) return 'API_KEY_RATE_LIMIT'; + if (error.status === 422) { + const detail = error.document?.errors?.[0]?.detail || ''; + if (/scac|shipping line/i.test(detail)) return 'WRONG_SEALINE'; + return 'WRONG_NUMBER'; + } + return error.status >= 500 ? 'SEALINE_NO_RESPONSE' : 'WRONG_PARAMETERS'; +} + +export class SeaRatesCompatibilityGateway { + private readonly config: GatewayConfig; + + constructor(config: GatewayConfig = {}) { + this.config = config; + } + + async tracking( + apiKey: string | undefined, + query: TrackingQuery, + ): Promise { + if (!apiKey) return errorEnvelope('API_KEY_REQUIRED'); + if (!query.number) return errorEnvelope('WRONG_NUMBER'); + + let client: Terminal49PublicClient; + try { + client = this.client(apiKey, false); + } catch { + return errorEnvelope('API_KEY_WRONG'); + } + + const type = inferType(query.number, query.type); + try { + let shipmentDocument = await client.findShipment(query.number, type); + if (!shipmentDocument) { + const resolution = await client.resolveTrackingRequest({ + number: query.number, + scac: + query.sealine && query.sealine !== 'AUTO' + ? query.sealine + : undefined, + type, + }); + if (resolution.state === 'failed') { + const message = + resolution.failedReason === 'scac_auto_detect_failed' + ? 'AUTO_CANT_DETECT_SEALINE' + : resolution.failedReason === 'invalid_number' + ? 'WRONG_NUMBER' + : 'SEALINE_HASNT_PROVIDE_INFO'; + return errorEnvelope(message); + } + if (resolution.state === 'pending') { + return pendingEnvelope(query.number, type, query.sealine); + } + shipmentDocument = await client.shipment(resolution.shipmentId); + } + + const shipment = shipmentFrom(shipmentDocument); + if (!shipment) return pendingEnvelope(query.number, type, query.sealine); + let containers = containerResources(shipmentDocument); + + if (query.forceUpdate && containers.length > 0) { + await Promise.all( + containers.map((container) => client.refreshContainer(container.id)), + ); + shipmentDocument = await client.shipment(shipment.id); + containers = containerResources(shipmentDocument); + } + + const eventsByContainerId = new Map(); + await Promise.all( + containers.map(async (container) => { + eventsByContainerId.set( + container.id, + await client.transportEvents(container.id), + ); + }), + ); + const payload: TrackingPayload = { + eventsByContainerId, + included: shipmentDocument.included || [], + requestedNumber: query.number, + requestedType: type, + shipment, + }; + return mapTrackingPayload(payload); + } catch (error) { + return errorEnvelope( + error instanceof Terminal49ApiError + ? upstreamErrorMessage(error) + : 'SEALINE_NO_RESPONSE', + ); + } + } + + async shippingLines(apiKey?: string): Promise { + let client: Terminal49PublicClient; + try { + client = this.client(apiKey, true); + } catch { + return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'API_KEY_REQUIRED'); + } + try { + return mapShippingLines(await client.shippingLines()); + } catch (error) { + return errorEnvelope( + error instanceof Terminal49ApiError + ? upstreamErrorMessage(error) + : 'SEALINE_NO_RESPONSE', + ); + } + } + + private client( + apiKey: string | undefined, + allowServiceTokenWithoutKey: boolean, + ): Terminal49PublicClient { + const serviceToken = this.config.serviceApiToken?.trim(); + let token: string; + if (serviceToken) { + if ( + !allowServiceTokenWithoutKey && + (!apiKey || + !this.config.clientSecret || + !secureEqual(apiKey, this.config.clientSecret)) + ) { + throw new Error('Invalid compatibility gateway key'); + } + if ( + apiKey && + this.config.clientSecret && + !secureEqual(apiKey, this.config.clientSecret) + ) { + throw new Error('Invalid compatibility gateway key'); + } + token = serviceToken; + } else { + if (!apiKey) throw new Error('API key is required'); + token = apiKey; + } + + const clientConfig: Terminal49ClientConfig = { + apiToken: token, + baseUrl: this.config.apiBaseUrl, + fetchImpl: this.config.fetchImpl, + pollIntervalMs: this.config.pollIntervalMs, + pollTimeoutMs: this.config.pollTimeoutMs, + }; + return new Terminal49PublicClient(clientConfig); + } +} diff --git a/packages/searates-compat/src/types.ts b/packages/searates-compat/src/types.ts new file mode 100644 index 00000000..99ceecb5 --- /dev/null +++ b/packages/searates-compat/src/types.ts @@ -0,0 +1,79 @@ +export type JsonObject = { [key: string]: JsonValue }; +export type JsonValue = + | JsonObject + | JsonValue[] + | boolean + | null + | number + | string; + +export interface ResourceIdentifier { + id: string; + type: string; +} + +export interface JsonApiResource extends ResourceIdentifier { + attributes?: JsonObject; + relationships?: Record< + string, + { data?: ResourceIdentifier | ResourceIdentifier[] | null } + >; +} + +export interface JsonApiDocument { + data: JsonApiResource | JsonApiResource[] | null; + included?: JsonApiResource[]; + errors?: Array<{ + code?: string; + detail?: string; + status?: string; + title?: string; + }>; +} + +export type TrackingType = 'BL' | 'BK' | 'CT'; +export type Terminal49TrackingType = + | 'bill_of_lading' + | 'booking_number' + | 'container'; + +export interface TrackingQuery { + ais: boolean; + forceUpdate: boolean; + number: string; + route: boolean; + sealine?: string; + type?: TrackingType; +} + +export interface SeaRatesEvent { + actual: boolean; + date: string | null; + description: string; + event_code: string; + event_type: 'EQUIPMENT' | 'TRANSPORT'; + facility: number | null; + is_additional_event: boolean; + is_date_from_sealine: boolean; + location: number | null; + order_id: number; + status: string; + transport_type: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; + type: 'land' | 'sea'; + vessel: number | null; + voyage: string | null; +} + +export interface SeaRatesEnvelope { + status: 'error' | 'success'; + message: string; + data: JsonValue; +} + +export interface TrackingPayload { + eventsByContainerId: Map; + shipment: JsonApiResource; + included: JsonApiResource[]; + requestedNumber: string; + requestedType: TrackingType; +} diff --git a/packages/searates-compat/tsconfig.json b/packages/searates-compat/tsconfig.json new file mode 100644 index 00000000..188b2bfa --- /dev/null +++ b/packages/searates-compat/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "lib": ["ES2022"], + "moduleResolution": "NodeNext", + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index ddfc83f2..749aa930 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,16 @@ "isolatedModules": true, "noEmit": true }, - "include": ["api/**/*", "packages/mcp/src/**/*"], - "exclude": ["node_modules", "packages/mcp/node_modules", "packages/mcp/dist"] + "include": [ + "api/**/*", + "packages/mcp/src/**/*", + "packages/searates-compat/src/**/*" + ], + "exclude": [ + "node_modules", + "packages/mcp/node_modules", + "packages/mcp/dist", + "packages/searates-compat/node_modules", + "packages/searates-compat/dist" + ] } diff --git a/vercel.json b/vercel.json index fde83b03..31d55c59 100644 --- a/vercel.json +++ b/vercel.json @@ -2,11 +2,17 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, "installCommand": "npm ci", - "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp", + "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat", "functions": { "api/mcp.ts": { "maxDuration": 30 }, + "api/tracking.ts": { + "maxDuration": 30 + }, + "api/info/sealines.ts": { + "maxDuration": 15 + }, "api/oauth-protected-resource.ts": { "maxDuration": 10 }, @@ -22,6 +28,14 @@ "source": "/.well-known/openai-apps-challenge", "destination": "/api/openai-apps-challenge" }, + { + "source": "/tracking", + "destination": "/api/tracking" + }, + { + "source": "/info/sealines", + "destination": "/api/info/sealines" + }, { "source": "/.well-known/oauth-protected-resource", "destination": "/api/oauth-protected-resource" From 59437ca68a720f53eed095cef8d210cbb44513e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:13:45 +0000 Subject: [PATCH 02/12] fix: stabilize compatibility mappings and tests Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/client.ts | 3 ++- packages/searates-compat/src/mapping.ts | 10 +++++++++- packages/searates-compat/src/service.test.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts index f8f0c8d3..6db454a5 100644 --- a/packages/searates-compat/src/client.ts +++ b/packages/searates-compat/src/client.ts @@ -136,7 +136,8 @@ export class Terminal49PublicClient { | { shipmentId: string; state: 'created' } > { const existing = await this.trackingRequests(input.number, input.scac); - let requestResource = resourceArray(existing)[0]; + let requestResource: JsonApiResource | undefined = + resourceArray(existing)[0]; if (!requestResource) { const attributes: Record = { diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index 7b7b2c64..3fb7093c 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -232,7 +232,15 @@ function collectResources(payload: TrackingPayload): JsonApiResource[] { if (Array.isArray(document.data)) resources.push(...document.data); resources.push(...(document.included || [])); } - return resources; + const unique = new Map(); + for (const resource of resources) { + const key = `${resource.type}:${resource.id}`; + const existing = unique.get(key); + if (!existing || Object.keys(attrs(resource)).length > 0) { + unique.set(key, resource); + } + } + return [...unique.values()]; } export function mapEvent( diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 5a4abbf6..8cd22cfc 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -99,7 +99,7 @@ describe('SeaRates compatibility gateway', () => { const gateway = new SeaRatesCompatibilityGateway({ serviceApiToken: 'service-token', fetchImpl: async (input) => { - expect(String(input)).toEndWith('/shipping_lines'); + expect(String(input).endsWith('/shipping_lines')).toBe(true); return response(shippingLinesFixture); }, }); From e86e8d07f010c10fa2d40e57a4b8ac810b2341db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:14:35 +0000 Subject: [PATCH 03/12] test: cover SeaRates HTTP query contract Co-authored-by: Akshay Dodeja --- packages/searates-compat/package.json | 2 +- packages/searates-compat/src/http.test.ts | 100 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/searates-compat/src/http.test.ts diff --git a/packages/searates-compat/package.json b/packages/searates-compat/package.json index 85764d2f..9dd0a1a3 100644 --- a/packages/searates-compat/package.json +++ b/packages/searates-compat/package.json @@ -8,7 +8,7 @@ "types": "./dist/index.d.ts", "scripts": { "build": "tsc", - "test": "vp test --run", + "test": "vp test --run src", "lint": "vp lint src && vp fmt --check src", "format": "vp fmt --write src", "type-check": "tsc --noEmit" diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts new file mode 100644 index 00000000..2744533a --- /dev/null +++ b/packages/searates-compat/src/http.test.ts @@ -0,0 +1,100 @@ +import type { IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vite-plus/test'; +import { createTrackingHandler } from './http.js'; +import { SeaRatesCompatibilityGateway } from './service.js'; +import type { SeaRatesEnvelope, TrackingQuery, TrackingType } from './types.js'; + +class CapturingGateway extends SeaRatesCompatibilityGateway { + apiKey?: string; + query?: TrackingQuery; + + override async tracking( + apiKey: string | undefined, + query: TrackingQuery, + ): Promise { + this.apiKey = apiKey; + this.query = query; + return { status: 'success', message: 'OK', data: { containers: [] } }; + } +} + +function request(url: string): IncomingMessage { + // SAFETY: The handler reads only method and url from this test double. + return { method: 'GET', url } as IncomingMessage; +} + +function response(): { + body?: unknown; + response: Parameters>[1]; + status?: number; +} { + const state: { body?: unknown; status?: number } = {}; + const responseDouble = { + setHeader: () => undefined, + status(code: number) { + state.status = code; + return responseDouble; + }, + json(payload: unknown) { + state.body = payload; + }, + }; + // SAFETY: The handler uses only setHeader, status, and json on this test double. + return { + get body() { + return state.body; + }, + get status() { + return state.status; + }, + response: responseDouble as unknown as Parameters< + ReturnType + >[1], + }; +} + +describe('GET /tracking contract', () => { + it('parses the SeaRates query and returns its JSON envelope', async () => { + const gateway = new CapturingGateway(); + const handler = createTrackingHandler(gateway); + const output = response(); + + await handler( + request( + '/tracking?api_key=gateway-key&number=mscu1234567&type=CT&sealine=mscu&force_update=true&route=1&ais=yes', + ), + output.response, + ); + + expect(output.status).toBe(200); + expect(output.body).toEqual({ + status: 'success', + message: 'OK', + data: { containers: [] }, + }); + expect(gateway.apiKey).toBe('gateway-key'); + expect(gateway.query).toEqual({ + ais: true, + forceUpdate: true, + number: 'MSCU1234567', + route: true, + sealine: 'MSCU', + type: 'CT' satisfies TrackingType, + }); + }); + + it('returns a SeaRates-style WRONG_TYPE envelope', async () => { + const handler = createTrackingHandler(new CapturingGateway()); + const output = response(); + await handler( + request('/tracking?api_key=gateway-key&number=EXAMPLE&type=AIR'), + output.response, + ); + expect(output.status).toBe(200); + expect(output.body).toEqual({ + status: 'error', + message: 'WRONG_TYPE', + data: null, + }); + }); +}); From 3714ed32d092772dce027e513772116510876bc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:17:08 +0000 Subject: [PATCH 04/12] fix: align empty and auth responses with SeaRates Co-authored-by: Akshay Dodeja --- packages/searates-compat/README.md | 14 ++++--- packages/searates-compat/src/http.test.ts | 2 +- packages/searates-compat/src/http.ts | 6 +-- packages/searates-compat/src/index.ts | 2 +- packages/searates-compat/src/mapping.test.ts | 3 +- packages/searates-compat/src/mapping.ts | 15 +++---- packages/searates-compat/src/service.test.ts | 42 ++++++++++++++++++-- packages/searates-compat/src/service.ts | 28 +++++++------ 8 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md index 0b5b0a0e..7c96c507 100644 --- a/packages/searates-compat/README.md +++ b/packages/searates-compat/README.md @@ -80,12 +80,14 @@ gateway: 1. creates or reuses a Terminal49 tracking request; 2. polls it for a short, bounded interval; 3. returns the full SeaRates envelope if the shipment becomes available; or -4. returns `status: "success"`, `message: "PENDING"`, `metadata.status: - "UNKNOWN"`, and empty data arrays. - -Retry the same `GET /tracking` request after a pending response. The gateway -reuses the existing tracking request instead of creating another one. Terminal49 -failure reasons are translated to SeaRates-style messages such as +4. returns SeaRates' successful empty-data outcome: + `status: "success"`, `message: "SEALINE_HASNT_PROVIDE_INFO"`, + `metadata.status: "UNKNOWN"`, and empty data arrays. + +SeaRates has no documented pending response, so the gateway does not invent one. +Retry the same `GET /tracking` request after the empty-data outcome. The gateway +reuses the existing Terminal49 tracking request instead of creating another one. +Terminal49 failure reasons are translated to SeaRates-style messages such as `WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and `SEALINE_HASNT_PROVIDE_INFO`. diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts index 2744533a..1d556d98 100644 --- a/packages/searates-compat/src/http.test.ts +++ b/packages/searates-compat/src/http.test.ts @@ -94,7 +94,7 @@ describe('GET /tracking contract', () => { expect(output.body).toEqual({ status: 'error', message: 'WRONG_TYPE', - data: null, + data: {}, }); }); }); diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts index 291aaa94..405709b1 100644 --- a/packages/searates-compat/src/http.ts +++ b/packages/searates-compat/src/http.ts @@ -75,7 +75,7 @@ export function createTrackingHandler( response.status(405).json({ status: 'error', message: 'METHOD_NOT_ALLOWED', - data: null, + data: {}, }); return; } @@ -90,7 +90,7 @@ export function createTrackingHandler( response.status(200).json({ status: 'error', message: 'WRONG_TYPE', - data: null, + data: {}, }); return; } @@ -124,7 +124,7 @@ export function createShippingLinesHandler( response.status(405).json({ status: 'error', message: 'METHOD_NOT_ALLOWED', - data: null, + data: {}, }); return; } diff --git a/packages/searates-compat/src/index.ts b/packages/searates-compat/src/index.ts index 63f5878a..115768c1 100644 --- a/packages/searates-compat/src/index.ts +++ b/packages/searates-compat/src/index.ts @@ -5,10 +5,10 @@ export { } from './client.js'; export { createShippingLinesHandler, createTrackingHandler } from './http.js'; export { + emptyTrackingEnvelope, mapEvent, mapShippingLines, mapTrackingPayload, - pendingEnvelope, } from './mapping.js'; export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; export type { diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 56bc180b..400d9667 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -64,7 +64,7 @@ describe('SeaRates response mapping', () => { }); it.each([ - ['container.transport.empty_out', 'GTOT', 'CEP'], + ['container.transport.empty_out', 'PICK', 'CPS'], ['container.transport.full_in', 'GTIN', 'CGI'], ['container.transport.vessel_loaded', 'LOAD', 'CLL'], ['container.transport.vessel_departed', 'DEPA', 'VDL'], @@ -99,6 +99,7 @@ describe('SeaRates response mapping', () => { data: [ { name: 'Mediterranean Shipping Company', + short_name: 'MSC', active: true, active_types: { ct: true, diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index 3fb7093c..deb5c93a 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -18,10 +18,10 @@ interface EventMapping { const EVENT_MAPPINGS: Record = { 'container.transport.empty_out': { - code: 'GTOT', + code: 'PICK', description: 'Empty Picked-up at Depot', eventType: 'EQUIPMENT', - status: 'CEP', + status: 'CPS', transport: 'TRUCK', }, 'container.transport.full_in': { @@ -206,7 +206,7 @@ function equipment(attributes: JsonObject): { function seaRatesStatus(value: unknown): string { if (typeof value !== 'string') return 'UNKNOWN'; if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { - return 'COMPLETED'; + return 'DELIVERED'; } if ( [ @@ -311,8 +311,8 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { ); const overallStatus = containerStatuses.includes('IN_TRANSIT') ? 'IN_TRANSIT' - : containerStatuses.includes('COMPLETED') - ? 'COMPLETED' + : containerStatuses.includes('DELIVERED') + ? 'DELIVERED' : 'UNKNOWN'; const locationList = locationResources.map((resource) => { @@ -470,14 +470,14 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { }; } -export function pendingEnvelope( +export function emptyTrackingEnvelope( number: string, type?: TrackingType, sealine?: string, ): SeaRatesEnvelope { return { status: 'success', - message: 'PENDING', + message: 'SEALINE_HASNT_PROVIDE_INFO', data: { metadata: { type: defaultType(number, type), @@ -529,6 +529,7 @@ export function mapShippingLines(document: JsonApiDocument): SeaRatesEnvelope { : alternatives; return { name: stringValue(attributes.name), + short_name: stringValue(attributes.short_name), active: true, active_types: { ct: attributes.container_number_tracking_support === true, diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 8cd22cfc..dc8718aa 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -67,13 +67,13 @@ describe('SeaRates compatibility gateway', () => { }); await expect(gateway.tracking(undefined, query)).resolves.toEqual({ status: 'error', - message: 'API_KEY_REQUIRED', - data: null, + message: 'WRONG_PARAMETERS', + data: {}, }); await expect(gateway.tracking('wrong-key', query)).resolves.toEqual({ status: 'error', message: 'API_KEY_WRONG', - data: null, + data: {}, }); }); @@ -91,7 +91,41 @@ describe('SeaRates compatibility gateway', () => { await expect(gateway.tracking('bad-t49-key', query)).resolves.toEqual({ status: 'error', message: 'API_KEY_WRONG', - data: null, + data: {}, + }); + }); + + it('uses SeaRates empty-data semantics while T49 is still pending', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + pollTimeoutMs: 0, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes('/shipments?')) return response({ data: [] }); + if (url.includes('/tracking_requests?')) { + return response({ + data: [ + { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + relationships: { tracked_object: { data: null } }, + }, + ], + }); + } + throw new Error(`Unexpected fixture request: ${url}`); + }, + }); + + await expect( + gateway.tracking('pass-through-key', query), + ).resolves.toMatchObject({ + status: 'success', + message: 'SEALINE_HASNT_PROVIDE_INFO', + data: { + metadata: { status: 'UNKNOWN' }, + containers: [], + }, }); }); diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts index d29a24da..911bab25 100644 --- a/packages/searates-compat/src/service.ts +++ b/packages/searates-compat/src/service.ts @@ -5,9 +5,9 @@ import { type Terminal49ClientConfig, } from './client.js'; import { + emptyTrackingEnvelope, mapShippingLines, mapTrackingPayload, - pendingEnvelope, } from './mapping.js'; import type { JsonApiDocument, @@ -28,7 +28,7 @@ export interface GatewayConfig { } function errorEnvelope(message: string): SeaRatesEnvelope { - return { status: 'error', message, data: null }; + return { status: 'error', message, data: {} }; } function secureEqual(left: string, right: string): boolean { @@ -78,7 +78,7 @@ export class SeaRatesCompatibilityGateway { apiKey: string | undefined, query: TrackingQuery, ): Promise { - if (!apiKey) return errorEnvelope('API_KEY_REQUIRED'); + if (!apiKey) return errorEnvelope('WRONG_PARAMETERS'); if (!query.number) return errorEnvelope('WRONG_NUMBER'); let client: Terminal49PublicClient; @@ -101,22 +101,24 @@ export class SeaRatesCompatibilityGateway { type, }); if (resolution.state === 'failed') { - const message = - resolution.failedReason === 'scac_auto_detect_failed' - ? 'AUTO_CANT_DETECT_SEALINE' - : resolution.failedReason === 'invalid_number' - ? 'WRONG_NUMBER' - : 'SEALINE_HASNT_PROVIDE_INFO'; - return errorEnvelope(message); + if (resolution.failedReason === 'scac_auto_detect_failed') { + return errorEnvelope('AUTO_CANT_DETECT_SEALINE'); + } + if (resolution.failedReason === 'invalid_number') { + return errorEnvelope('WRONG_NUMBER'); + } + return emptyTrackingEnvelope(query.number, type, query.sealine); } if (resolution.state === 'pending') { - return pendingEnvelope(query.number, type, query.sealine); + return emptyTrackingEnvelope(query.number, type, query.sealine); } shipmentDocument = await client.shipment(resolution.shipmentId); } const shipment = shipmentFrom(shipmentDocument); - if (!shipment) return pendingEnvelope(query.number, type, query.sealine); + if (!shipment) { + return emptyTrackingEnvelope(query.number, type, query.sealine); + } let containers = containerResources(shipmentDocument); if (query.forceUpdate && containers.length > 0) { @@ -158,7 +160,7 @@ export class SeaRatesCompatibilityGateway { try { client = this.client(apiKey, true); } catch { - return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'API_KEY_REQUIRED'); + return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'WRONG_PARAMETERS'); } try { return mapShippingLines(await client.shippingLines()); From 7ac6d71df51c47232a5709758c6e0a3257ee4f51 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 20:11:51 +0000 Subject: [PATCH 05/12] refactor: isolate migration APIs in dedicated Vercel app Co-authored-by: Akshay Dodeja --- .env.local.example | 10 ------ .github/workflows/ci.yml | 28 +++++++++++++++ api/info/sealines.ts | 3 -- api/tracking.ts | 3 -- apps/migrate/.env.example | 11 ++++++ apps/migrate/api/info/sealines.ts | 3 ++ apps/migrate/api/tracking.ts | 3 ++ apps/migrate/package.json | 25 ++++++++++++++ apps/migrate/tsconfig.json | 17 +++++++++ apps/migrate/vercel.json | 24 +++++++++++++ package-lock.json | 20 +++++++++++ package.json | 3 +- packages/searates-compat/README.md | 55 +++++++++++++++++++++++++----- vercel.json | 16 +-------- 14 files changed, 181 insertions(+), 40 deletions(-) delete mode 100644 api/info/sealines.ts delete mode 100644 api/tracking.ts create mode 100644 apps/migrate/.env.example create mode 100644 apps/migrate/api/info/sealines.ts create mode 100644 apps/migrate/api/tracking.ts create mode 100644 apps/migrate/package.json create mode 100644 apps/migrate/tsconfig.json create mode 100644 apps/migrate/vercel.json diff --git a/.env.local.example b/.env.local.example index 47347932..0121f08b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,13 +22,3 @@ T49_API_BASE_URL=http://localhost:3000/v2 # POSTHOG_PROJECT_API_KEY=phc_... # POSTHOG_HOST=https://f.terminal49.com # POSTHOG_DEBUG=true - -# --- SeaRates ocean-tracking compatibility gateway --- -# Pass-through mode (default): leave these unset and send a Terminal49 API key -# in the SeaRates-compatible `api_key` query parameter. -# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`, and -# the gateway uses T49_SEARATES_API_TOKEN only for public T49 API requests. -# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY -# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY -# T49_SEARATES_POLL_TIMEOUT_MS=4000 -# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 286b069c..39b3fcb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,3 +268,31 @@ jobs: echo "Vercel preview changed while the smoke test was running" exit 1 fi + + migrate: + runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: package-lock.json + - name: Install workspace dependencies + run: npm ci + - name: Build compatibility package + run: npm run build --workspace @terminal49/searates-compat + - name: Build migrate application + run: npm run build --workspace @terminal49/migrate-app + - name: Type-check compatibility package and migrate application + run: | + npm run type-check --workspace @terminal49/searates-compat + npm run type-check --workspace @terminal49/migrate-app + - name: Test compatibility package and migrate configuration + run: | + npm run test --workspace @terminal49/searates-compat + npm run test --workspace @terminal49/migrate-app + - name: Check compatibility package and migrate handlers + run: | + npm run lint --workspace @terminal49/searates-compat + npm run lint --workspace @terminal49/migrate-app diff --git a/api/info/sealines.ts b/api/info/sealines.ts deleted file mode 100644 index 08db7046..00000000 --- a/api/info/sealines.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createShippingLinesHandler } from '../../packages/searates-compat/src/http.js'; - -export default createShippingLinesHandler(); diff --git a/api/tracking.ts b/api/tracking.ts deleted file mode 100644 index fba878cb..00000000 --- a/api/tracking.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createTrackingHandler } from '../packages/searates-compat/src/http.js'; - -export default createTrackingHandler(); diff --git a/apps/migrate/.env.example b/apps/migrate/.env.example new file mode 100644 index 00000000..8a9c6175 --- /dev/null +++ b/apps/migrate/.env.example @@ -0,0 +1,11 @@ +# Pass-through mode is the default: leave service-token variables unset and +# send a Terminal49 API key in the SeaRates-compatible `api_key` query. +T49_API_BASE_URL=https://api.terminal49.com/v2 + +# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`. +# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY + +# Optional bounded polling settings. +# T49_SEARATES_POLL_TIMEOUT_MS=4000 +# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/apps/migrate/api/info/sealines.ts b/apps/migrate/api/info/sealines.ts new file mode 100644 index 00000000..0cfd5a54 --- /dev/null +++ b/apps/migrate/api/info/sealines.ts @@ -0,0 +1,3 @@ +import { createShippingLinesHandler } from '@terminal49/searates-compat'; + +export default createShippingLinesHandler(); diff --git a/apps/migrate/api/tracking.ts b/apps/migrate/api/tracking.ts new file mode 100644 index 00000000..2641d683 --- /dev/null +++ b/apps/migrate/api/tracking.ts @@ -0,0 +1,3 @@ +import { createTrackingHandler } from '@terminal49/searates-compat'; + +export default createTrackingHandler(); diff --git a/apps/migrate/package.json b/apps/migrate/package.json new file mode 100644 index 00000000..aea88473 --- /dev/null +++ b/apps/migrate/package.json @@ -0,0 +1,25 @@ +{ + "name": "@terminal49/migrate-app", + "version": "0.1.0", + "private": true, + "description": "Vercel application for vendor compatibility APIs", + "type": "module", + "scripts": { + "build": "tsc --noEmit", + "test": "node -e \"JSON.parse(require('fs').readFileSync('vercel.json', 'utf8'))\"", + "lint": "vp lint api && vp fmt --check api", + "format": "vp fmt --write api", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@terminal49/searates-compat": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } +} diff --git a/apps/migrate/tsconfig.json b/apps/migrate/tsconfig.json new file mode 100644 index 00000000..20ce48fe --- /dev/null +++ b/apps/migrate/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "lib": ["ES2022"], + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["api/**/*"], + "exclude": ["node_modules"] +} diff --git a/apps/migrate/vercel.json b/apps/migrate/vercel.json new file mode 100644 index 00000000..79c6d42c --- /dev/null +++ b/apps/migrate/vercel.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "version": 2, + "installCommand": "cd ../.. && npm ci", + "buildCommand": "cd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app", + "functions": { + "api/tracking.ts": { + "maxDuration": 30 + }, + "api/info/sealines.ts": { + "maxDuration": 15 + } + }, + "rewrites": [ + { + "source": "/searates-api/tracking", + "destination": "/api/tracking" + }, + { + "source": "/searates-api/info/sealines", + "destination": "/api/info/sealines" + } + ] +} diff --git a/package-lock.json b/package-lock.json index bde4642f..16f6a38a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "terminal49-api", "version": "1.0.0", "workspaces": [ + "apps/*", "packages/*", "sdks/*" ], @@ -26,6 +27,21 @@ "node": "24.x" } }, + "apps/migrate": { + "name": "@terminal49/migrate-app", + "version": "0.1.0", + "dependencies": { + "@terminal49/searates-compat": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", @@ -5366,6 +5382,10 @@ "resolved": "packages/mcp", "link": true }, + "node_modules/@terminal49/migrate-app": { + "resolved": "apps/migrate", + "link": true + }, "node_modules/@terminal49/sdk": { "resolved": "sdks/typescript-sdk", "link": true diff --git a/package.json b/package.json index a42ead26..88a63102 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "private": true, "description": "Terminal49 API with MCP Server", "workspaces": [ + "apps/*", "packages/*", "sdks/*" ], "type": "module", "scripts": { - "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/cli", + "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app && npm run build --workspace @terminal49/cli", "check": "npm run lint:api && npm run lint --workspaces && npm run type-check --workspaces", "format": "vp fmt --write api && npm run format --workspaces", "lint:api": "vp lint api && vp fmt --check api", diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md index 7c96c507..eb3b4669 100644 --- a/packages/searates-compat/README.md +++ b/packages/searates-compat/README.md @@ -8,13 +8,23 @@ System (AIS) products. ## Endpoints -- `GET /tracking` -- `GET /info/sealines` +- `GET /searates-api/tracking` +- `GET /searates-api/info/sealines` -The Vercel deployment maps these paths to `api/tracking.ts` and -`api/info/sealines.ts`. `/info/terminals` is intentionally omitted because the -Terminal49 public API can fetch a known terminal but does not provide a -supported-terminals list. +The dedicated migrate Vercel application in `apps/migrate` maps these paths to +its tracking and sealines handlers. The vendor-prefixed layout leaves room for +future compatibility APIs without placing them on the MCP application. +`/info/terminals` is intentionally omitted because the Terminal49 public API can +fetch a known terminal but does not provide a supported-terminals list. + +The intended production URLs are: + +- `https://migrate.terminal49.com/searates-api/tracking` +- `https://migrate.terminal49.com/searates-api/info/sealines` + +The custom domain is not live yet. Until DNS and the production domain are +configured, deployments use their Vercel preview hostname with the same +`/searates-api/...` paths. ## Configure authentication @@ -53,7 +63,7 @@ T49_SEARATES_POLL_INTERVAL_MS=500 Change the SeaRates base URL and keep the existing query parameters: ```bash -curl "https://YOUR_GATEWAY.example.com/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" +curl "https://migrate.terminal49.com/searates-api/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" ``` The gateway accepts `type=CT`, `type=BL`, and `type=BK`, plus `force_update`, @@ -65,7 +75,7 @@ implemented. Fetch the carrier dictionary with: ```bash -curl "https://YOUR_GATEWAY.example.com/info/sealines?api_key=YOUR_GATEWAY_KEY" +curl "https://migrate.terminal49.com/searates-api/info/sealines?api_key=YOUR_GATEWAY_KEY" ``` In service-token mode, `/info/sealines` also works without `api_key`, matching @@ -101,3 +111,32 @@ Terminal49 failure reasons are translated to SeaRates-style messages such as flat-rack, hard-top, and tank combinations. Unknown combinations are `null`. - Holds, fees, Last Free Day (LFD), and other Terminal49-only terminal intelligence are deliberately excluded. + +## Create the dedicated Vercel project + +Create a second Vercel project in the Terminal49 team and import this same +repository. This is a dashboard setup step; CI does not create or configure the +project. + +Use these project settings: + +| Setting | Value | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Root Directory | `apps/migrate` | +| Include source files outside the Root Directory | Enabled | +| Framework Preset | Other | +| Install Command | `cd ../.. && npm ci` | +| Build Command | `cd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app` | +| Node.js Version | 24 | + +The outside-root source setting is required because the app consumes the +`@terminal49/searates-compat` workspace from `packages/searates-compat`. + +Configure either pass-through mode or the service-token environment variables +described above in the new project. Do not copy them into the MCP Vercel +project. + +After the project has a successful production deployment and DNS is ready, add +`migrate.terminal49.com` under the project's production domains. Vercel will +show the DNS record that must be added; do not assume the domain is active until +Vercel verifies it. diff --git a/vercel.json b/vercel.json index 31d55c59..fde83b03 100644 --- a/vercel.json +++ b/vercel.json @@ -2,17 +2,11 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, "installCommand": "npm ci", - "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat", + "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp", "functions": { "api/mcp.ts": { "maxDuration": 30 }, - "api/tracking.ts": { - "maxDuration": 30 - }, - "api/info/sealines.ts": { - "maxDuration": 15 - }, "api/oauth-protected-resource.ts": { "maxDuration": 10 }, @@ -28,14 +22,6 @@ "source": "/.well-known/openai-apps-challenge", "destination": "/api/openai-apps-challenge" }, - { - "source": "/tracking", - "destination": "/api/tracking" - }, - { - "source": "/info/sealines", - "destination": "/api/info/sealines" - }, { "source": "/.well-known/oauth-protected-resource", "destination": "/api/oauth-protected-resource" From b7a7342ce3a29eec8087836ce7feda698e0762f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:51:31 +0000 Subject: [PATCH 06/12] fix: map SeaRates milestones from ordered timelines Co-authored-by: Akshay Dodeja --- apps/migrate/.env.example | 1 + apps/migrate/api/container.ts | 3 + apps/migrate/api/reference.ts | 3 + apps/migrate/vercel.json | 14 + packages/searates-compat/README.md | 20 +- packages/searates-compat/src/client.test.ts | 61 +- packages/searates-compat/src/client.ts | 93 +- packages/searates-compat/src/http.test.ts | 45 +- packages/searates-compat/src/http.ts | 41 +- packages/searates-compat/src/index.ts | 12 +- packages/searates-compat/src/mapping.test.ts | 460 +++++++-- packages/searates-compat/src/mapping.ts | 985 +++++++++++++------ packages/searates-compat/src/service.test.ts | 76 +- packages/searates-compat/src/service.ts | 28 +- packages/searates-compat/src/types.ts | 24 +- 15 files changed, 1425 insertions(+), 441 deletions(-) create mode 100644 apps/migrate/api/container.ts create mode 100644 apps/migrate/api/reference.ts diff --git a/apps/migrate/.env.example b/apps/migrate/.env.example index 8a9c6175..8ef02608 100644 --- a/apps/migrate/.env.example +++ b/apps/migrate/.env.example @@ -9,3 +9,4 @@ T49_API_BASE_URL=https://api.terminal49.com/v2 # Optional bounded polling settings. # T49_SEARATES_POLL_TIMEOUT_MS=4000 # T49_SEARATES_POLL_INTERVAL_MS=500 +# T49_SEARATES_REQUEST_TIMEOUT_MS=10000 diff --git a/apps/migrate/api/container.ts b/apps/migrate/api/container.ts new file mode 100644 index 00000000..ee08addf --- /dev/null +++ b/apps/migrate/api/container.ts @@ -0,0 +1,3 @@ +import { createContainerHandler } from '@terminal49/searates-compat'; + +export default createContainerHandler(); diff --git a/apps/migrate/api/reference.ts b/apps/migrate/api/reference.ts new file mode 100644 index 00000000..3ecade96 --- /dev/null +++ b/apps/migrate/api/reference.ts @@ -0,0 +1,3 @@ +import { createReferenceHandler } from '@terminal49/searates-compat'; + +export default createReferenceHandler(); diff --git a/apps/migrate/vercel.json b/apps/migrate/vercel.json index 79c6d42c..70806552 100644 --- a/apps/migrate/vercel.json +++ b/apps/migrate/vercel.json @@ -7,6 +7,12 @@ "api/tracking.ts": { "maxDuration": 30 }, + "api/container.ts": { + "maxDuration": 30 + }, + "api/reference.ts": { + "maxDuration": 30 + }, "api/info/sealines.ts": { "maxDuration": 15 } @@ -16,6 +22,14 @@ "source": "/searates-api/tracking", "destination": "/api/tracking" }, + { + "source": "/searates-api/container", + "destination": "/api/container" + }, + { + "source": "/searates-api/reference", + "destination": "/api/reference" + }, { "source": "/searates-api/info/sealines", "destination": "/api/info/sealines" diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md index eb3b4669..4aeff31f 100644 --- a/packages/searates-compat/README.md +++ b/packages/searates-compat/README.md @@ -9,6 +9,8 @@ System (AIS) products. ## Endpoints - `GET /searates-api/tracking` +- `GET /searates-api/container` (deprecated SeaRates singular-container shape) +- `GET /searates-api/reference` (deprecated SeaRates BL/BK shape) - `GET /searates-api/info/sealines` The dedicated migrate Vercel application in `apps/migrate` maps these paths to @@ -20,6 +22,8 @@ fetch a known terminal but does not provide a supported-terminals list. The intended production URLs are: - `https://migrate.terminal49.com/searates-api/tracking` +- `https://migrate.terminal49.com/searates-api/container` +- `https://migrate.terminal49.com/searates-api/reference` - `https://migrate.terminal49.com/searates-api/info/sealines` The custom domain is not live yet. Until DNS and the production domain are @@ -34,7 +38,7 @@ Choose one of two modes: Leave `T49_SEARATES_API_TOKEN` unset. The gateway treats the SeaRates `api_key` query parameter as a Terminal49 API key and sends it upstream as -`Authorization: Bearer `. +`Authorization: Token `. ### Service-token mode @@ -90,16 +94,14 @@ gateway: 1. creates or reuses a Terminal49 tracking request; 2. polls it for a short, bounded interval; 3. returns the full SeaRates envelope if the shipment becomes available; or -4. returns SeaRates' successful empty-data outcome: - `status: "success"`, `message: "SEALINE_HASNT_PROVIDE_INFO"`, - `metadata.status: "UNKNOWN"`, and empty data arrays. +4. returns SeaRates' no-data error: + `status: "error"`, `message: "NO_TRACKING_INFO"`, and `data: {}`. SeaRates has no documented pending response, so the gateway does not invent one. -Retry the same `GET /tracking` request after the empty-data outcome. The gateway -reuses the existing Terminal49 tracking request instead of creating another one. -Terminal49 failure reasons are translated to SeaRates-style messages such as -`WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and -`SEALINE_HASNT_PROVIDE_INFO`. +Retry the same `GET /tracking` request after `NO_TRACKING_INFO`. The gateway +reuses a matching active Terminal49 tracking request instead of creating another +one. Terminal49 failure reasons are translated to SeaRates-style messages such +as `WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and `NO_TRACKING_INFO`. ## Compatibility limits diff --git a/packages/searates-compat/src/client.test.ts b/packages/searates-compat/src/client.test.ts index b3d205fb..ee94c493 100644 --- a/packages/searates-compat/src/client.test.ts +++ b/packages/searates-compat/src/client.test.ts @@ -59,10 +59,69 @@ describe('tracking request shaping', () => { }, }); expect(create?.init?.headers).toMatchObject({ - Authorization: 'Bearer test-token', + Authorization: 'Token test-token', }); }); + it('reuses only an active request with the matching request type', async () => { + const requests: Array<{ init?: RequestInit; url: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + requests.push({ init, url }); + return jsonResponse({ + data: [ + { + id: 'failed-bl', + type: 'tracking_request', + attributes: { + request_type: 'bill_of_lading', + status: 'failed', + updated_at: '2026-08-03T00:00:00Z', + }, + }, + { + id: 'tracked-booking', + type: 'tracking_request', + attributes: { + request_type: 'booking_number', + status: 'created', + updated_at: '2026-08-02T00:00:00Z', + }, + relationships: { + tracked_object: { + data: { id: 'wrong-shipment', type: 'shipment' }, + }, + }, + }, + { + id: 'pending-bl', + type: 'tracking_request', + attributes: { + request_type: 'bill_of_lading', + status: 'pending', + updated_at: '2026-08-01T00:00:00Z', + }, + }, + ], + }); + }; + const client = new Terminal49PublicClient({ + apiToken: 'test-token', + fetchImpl, + pollTimeoutMs: 0, + }); + + await expect( + client.resolveTrackingRequest({ + number: 'MEDUFR030802', + type: 'BL', + }), + ).resolves.toEqual({ state: 'pending' }); + expect(requests.some((request) => request.init?.method === 'POST')).toBe( + false, + ); + }); + it('uses asynchronous carrier detection when sealine is omitted', async () => { const bodies: string[] = []; const fetchImpl: typeof fetch = async (input, init) => { diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts index 6db454a5..6750d20b 100644 --- a/packages/searates-compat/src/client.ts +++ b/packages/searates-compat/src/client.ts @@ -24,6 +24,7 @@ export interface Terminal49ClientConfig { fetchImpl?: typeof fetch; pollIntervalMs?: number; pollTimeoutMs?: number; + requestTimeoutMs?: number; } function normalizeToken(token: string): string { @@ -41,6 +42,57 @@ function trackedObjectId(resource: JsonApiResource): string | null { : null; } +function resourceTimestamp(resource: JsonApiResource): number { + const value = + resource.attributes?.updated_at || resource.attributes?.created_at; + return typeof value === 'string' ? Date.parse(value) || 0 : 0; +} + +function selectTrackingRequest( + resources: JsonApiResource[], + requestType: Terminal49TrackingType, +): JsonApiResource | undefined { + return resources + .filter( + (resource) => + resource.attributes?.request_type === requestType && + resource.attributes?.status !== 'failed', + ) + .sort((left, right) => { + const leftTracked = trackedObjectId(left) ? 1 : 0; + const rightTracked = trackedObjectId(right) ? 1 : 0; + return ( + rightTracked - leftTracked || + resourceTimestamp(right) - resourceTimestamp(left) + ); + })[0]; +} + +function freshnessSignature(document: JsonApiDocument): string { + const resources = [ + ...(Array.isArray(document.data) + ? document.data + : document.data + ? [document.data] + : []), + ...(document.included || []), + ]; + return resources + .filter((resource) => ['container', 'shipment'].includes(resource.type)) + .map((resource) => { + const attributes = resource.attributes || {}; + return [ + resource.id, + attributes.line_tracking_last_succeeded_at, + attributes.pod_last_tracking_request_at, + attributes.shipment_last_tracking_request_at, + attributes.terminal_checked_at, + ].join(':'); + }) + .sort() + .join('|'); +} + function trackingType(type: TrackingType): Terminal49TrackingType { switch (type) { case 'CT': @@ -61,6 +113,7 @@ export class Terminal49PublicClient { private readonly fetchImpl: typeof fetch; private readonly pollIntervalMs: number; private readonly pollTimeoutMs: number; + private readonly requestTimeoutMs: number; private readonly token: string; constructor(config: Terminal49ClientConfig) { @@ -72,6 +125,7 @@ export class Terminal49PublicClient { this.fetchImpl = config.fetchImpl || fetch; this.pollIntervalMs = config.pollIntervalMs ?? 500; this.pollTimeoutMs = config.pollTimeoutMs ?? 4_000; + this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000; } async shippingLines(): Promise { @@ -105,9 +159,11 @@ export class Terminal49PublicClient { return shipment ? this.shipment(shipment.id) : null; } - async shipment(id: string): Promise { + async shipment(id: string, timeoutMs?: number): Promise { return this.request( `/shipments/${encodeURIComponent(id)}?include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal`, + {}, + timeoutMs, ); } @@ -126,6 +182,23 @@ export class Terminal49PublicClient { ); } + async waitForShipmentUpdate( + shipmentId: string, + previous: JsonApiDocument, + ): Promise { + const baseline = freshnessSignature(previous); + const deadline = Date.now() + this.pollTimeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs)); + const current = await this.shipment( + shipmentId, + Math.min(this.requestTimeoutMs, Math.max(1, deadline - Date.now())), + ); + if (freshnessSignature(current) !== baseline) return current; + } + return null; + } + async resolveTrackingRequest(input: { number: string; scac?: string; @@ -136,8 +209,10 @@ export class Terminal49PublicClient { | { shipmentId: string; state: 'created' } > { const existing = await this.trackingRequests(input.number, input.scac); - let requestResource: JsonApiResource | undefined = - resourceArray(existing)[0]; + let requestResource = selectTrackingRequest( + resourceArray(existing), + trackingType(input.type), + ); if (!requestResource) { const attributes: Record = { @@ -178,8 +253,11 @@ export class Terminal49PublicClient { } if (Date.now() >= deadline) return { state: 'pending' }; await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs)); + const remainingMs = Math.max(1, deadline - Date.now()); const next = await this.request( `/tracking_requests/${encodeURIComponent(requestResource.id)}?include=tracked_object`, + {}, + Math.min(this.requestTimeoutMs, remainingMs), ); requestResource = Array.isArray(next.data) ? next.data[0] @@ -194,7 +272,7 @@ export class Terminal49PublicClient { const params = new URLSearchParams({ 'filter[request_number]': number, include: 'tracked_object', - 'page[size]': '1', + 'page[size]': '30', }); if (scac) params.set('filter[scac]', scac); return this.request(`/tracking_requests?${params.toString()}`); @@ -203,12 +281,17 @@ export class Terminal49PublicClient { private async request( path: string, init: RequestInit = {}, + timeoutMs = this.requestTimeoutMs, ): Promise { + const timeoutSignal = AbortSignal.timeout(Math.max(1, timeoutMs)); const response = await this.fetchImpl(`${this.baseUrl}${path}`, { ...init, + signal: init.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal, headers: { Accept: 'application/vnd.api+json', - Authorization: `Bearer ${this.token}`, + Authorization: `Token ${this.token}`, ...init.headers, }, }); diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts index 1d556d98..6f987176 100644 --- a/packages/searates-compat/src/http.test.ts +++ b/packages/searates-compat/src/http.test.ts @@ -1,6 +1,10 @@ import type { IncomingMessage } from 'node:http'; import { describe, expect, it } from 'vite-plus/test'; -import { createTrackingHandler } from './http.js'; +import { + createContainerHandler, + createReferenceHandler, + createTrackingHandler, +} from './http.js'; import { SeaRatesCompatibilityGateway } from './service.js'; import type { SeaRatesEnvelope, TrackingQuery, TrackingType } from './types.js'; @@ -83,6 +87,45 @@ describe('GET /tracking contract', () => { }); }); + it('forces CT and returns singular data.container on /container', async () => { + const gateway = new CapturingGateway(); + const handler = createContainerHandler(gateway); + const output = response(); + await handler( + request('/container?api_key=gateway-key&number=MSCU1234567&type=BL'), + output.response, + ); + + expect(gateway.query?.type).toBe('CT'); + expect(output.body).toEqual({ + status: 'success', + message: 'OK', + data: { container: null }, + }); + }); + + it('allows BL/BK but rejects CT on /reference', async () => { + const gateway = new CapturingGateway(); + const handler = createReferenceHandler(gateway); + const booking = response(); + await handler( + request('/reference?api_key=gateway-key&number=BOOKING1&type=BK'), + booking.response, + ); + expect(gateway.query?.type).toBe('BK'); + + const container = response(); + await handler( + request('/reference?api_key=gateway-key&number=MSCU1234567&type=CT'), + container.response, + ); + expect(container.body).toEqual({ + status: 'error', + message: 'WRONG_TYPE', + data: {}, + }); + }); + it('returns a SeaRates-style WRONG_TYPE envelope', async () => { const handler = createTrackingHandler(new CapturingGateway()); const output = response(); diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts index 405709b1..a91933d6 100644 --- a/packages/searates-compat/src/http.ts +++ b/packages/searates-compat/src/http.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { toContainerEnvelope } from './mapping.js'; import { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; import type { TrackingQuery, TrackingType } from './types.js'; @@ -34,6 +35,7 @@ function trackingType(value: string | undefined): TrackingType | undefined { function gatewayConfig(): GatewayConfig { const pollTimeout = Number(process.env.T49_SEARATES_POLL_TIMEOUT_MS); const pollInterval = Number(process.env.T49_SEARATES_POLL_INTERVAL_MS); + const requestTimeout = Number(process.env.T49_SEARATES_REQUEST_TIMEOUT_MS); return { apiBaseUrl: process.env.T49_API_BASE_URL, clientSecret: process.env.T49_SEARATES_CLIENT_SECRET, @@ -45,6 +47,10 @@ function gatewayConfig(): GatewayConfig { Number.isFinite(pollTimeout) && pollTimeout >= 0 ? pollTimeout : undefined, + requestTimeoutMs: + Number.isFinite(requestTimeout) && requestTimeout > 0 + ? requestTimeout + : undefined, serviceApiToken: process.env.T49_SEARATES_API_TOKEN, }; } @@ -61,6 +67,11 @@ function requestParams(request: RequestLike): URLSearchParams { export function createTrackingHandler( gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), + options: { + allowedTypes?: readonly TrackingType[]; + forcedType?: TrackingType; + singularContainer?: boolean; + } = {}, ) { return async ( request: RequestLike, @@ -86,7 +97,13 @@ export function createTrackingHandler( .toUpperCase(); const sealine = first(params, 'sealine')?.toUpperCase(); const rawType = first(params, 'type'); - if (rawType && !trackingType(rawType)) { + const parsedType = options.forcedType || trackingType(rawType); + if ( + (!options.forcedType && rawType && !parsedType) || + (parsedType && + options.allowedTypes && + !options.allowedTypes.includes(parsedType)) + ) { response.status(200).json({ status: 'error', message: 'WRONG_TYPE', @@ -100,14 +117,32 @@ export function createTrackingHandler( number, route: booleanParam(params, 'route'), sealine, - type: trackingType(rawType), + type: parsedType, }; + const envelope = await gateway.tracking(first(params, 'api_key'), query); response .status(200) - .json(await gateway.tracking(first(params, 'api_key'), query)); + .json( + options.singularContainer ? toContainerEnvelope(envelope) : envelope, + ); }; } +export function createContainerHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return createTrackingHandler(gateway, { + forcedType: 'CT', + singularContainer: true, + }); +} + +export function createReferenceHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return createTrackingHandler(gateway, { allowedTypes: ['BL', 'BK'] }); +} + export function createShippingLinesHandler( gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), ) { diff --git a/packages/searates-compat/src/index.ts b/packages/searates-compat/src/index.ts index 115768c1..ea384280 100644 --- a/packages/searates-compat/src/index.ts +++ b/packages/searates-compat/src/index.ts @@ -3,12 +3,17 @@ export { Terminal49PublicClient, type Terminal49ClientConfig, } from './client.js'; -export { createShippingLinesHandler, createTrackingHandler } from './http.js'; export { - emptyTrackingEnvelope, - mapEvent, + createContainerHandler, + createReferenceHandler, + createShippingLinesHandler, + createTrackingHandler, +} from './http.js'; +export { mapShippingLines, mapTrackingPayload, + noTrackingInfoEnvelope, + toContainerEnvelope, } from './mapping.js'; export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; export type { @@ -16,6 +21,7 @@ export type { JsonApiResource, SeaRatesEnvelope, SeaRatesEvent, + SeaRatesEventCode, TrackingPayload, TrackingQuery, TrackingType, diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 400d9667..3cda8c63 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -1,97 +1,395 @@ import { describe, expect, it } from 'vite-plus/test'; -import { - eventsFixture, - shipmentFixture, - shippingLinesFixture, -} from './__fixtures__/t49.js'; -import { mapEvent, mapShippingLines, mapTrackingPayload } from './mapping.js'; -import type { JsonApiResource, TrackingPayload } from './types.js'; - -describe('SeaRates response mapping', () => { - it('matches the documented tracking envelope and event fields', () => { - if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { - throw new Error('Shipment fixture must contain one resource'); +import { shipmentFixture, shippingLinesFixture } from './__fixtures__/t49.js'; +import { mapShippingLines, mapTrackingPayload } from './mapping.js'; +import type { + JsonApiResource, + SeaRatesEventCode, + TrackingPayload, + TrackingType, +} from './types.js'; + +const CLOSED_CODES = new Set([ + 'ARRI', + 'CONF', + 'CUSI', + 'CUSR', + 'DEPA', + 'DISC', + 'GTIN', + 'GTOT', + 'INSP', + 'ISSU', + 'LOAD', + 'PICK', + 'RECE', + 'RELS', + 'STRP', + 'STUF', +]); + +function event( + id: string, + name: string, + timestamp: string, + location = 'port-pol', +): JsonApiResource { + const sea = /vessel|transshipment|feeder/.test(name); + return { + id, + type: 'transport_event', + attributes: { + event: name, + timestamp, + timezone: location === 'port-pol' ? 'Europe/Paris' : 'America/New_York', + voyage_number: sea ? `V-${id}` : null, + }, + relationships: { + location: { data: { id: location, type: 'port' } }, + terminal: { data: null }, + vessel: sea + ? { data: { id: 'vessel-1', type: 'vessel' } } + : { data: null }, + }, + }; +} + +function payload( + eventResources: JsonApiResource[], + currentStatus = 'on_ship', + requestedType: TrackingType = 'BL', + requestedNumber = 'MEDUFR030802', + equipmentType = 'dry', +): TrackingPayload { + if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { + throw new Error('Shipment fixture must contain one resource'); + } + const included = (shipmentFixture.included || []).map((resource) => + resource.type === 'container' + ? { + ...resource, + attributes: { + ...resource.attributes, + current_status: currentStatus, + equipment_type: equipmentType, + }, + } + : resource, + ); + return { + eventsByContainerId: new Map([ + [ + 'container-1', + { + data: eventResources, + included: [ + { + id: 'vessel-1', + type: 'vessel', + attributes: { + name: 'EXAMPLE VESSEL', + imo: '9811000', + }, + }, + ], + }, + ], + ]), + shipment: shipmentFixture.data, + included, + requestedNumber, + requestedType, + }; +} + +function responseData(result: ReturnType) { + if ( + !result.data || + typeof result.data !== 'object' || + Array.isArray(result.data) + ) { + throw new Error('Expected SeaRates response data object'); + } + return result.data; +} + +function eventsFrom(result: ReturnType) { + const data = responseData(result); + const containers = data.containers; + if (!Array.isArray(containers) || !containers[0]) { + throw new Error('Expected one mapped container'); + } + const container = containers[0]; + if ( + !container || + typeof container !== 'object' || + Array.isArray(container) || + !Array.isArray(container.events) + ) { + throw new Error('Expected mapped event array'); + } + return container.events; +} + +describe('SeaRates positional event mapping', () => { + it('maps first and later sea loads to CLL and CLT', () => { + const result = mapTrackingPayload( + payload([ + event( + 'load-1', + 'container.transport.vessel_loaded', + '2026-08-01T10:00:00Z', + ), + event( + 'load-2', + 'container.transport.transshipment_loaded', + '2026-08-05T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect( + eventsFrom(result) + .filter((item) => item.event_code === 'LOAD') + .map((item) => item.status), + ).toEqual(['CLL', 'CLT']); + }); + + it('maps hub and last sea arrivals to VAT and VAD by order', () => { + const result = mapTrackingPayload( + payload([ + event( + 'depart-1', + 'container.transport.vessel_departed', + '2026-08-01T10:00:00Z', + ), + event( + 'arrive-1', + 'container.transport.transshipment_arrived', + '2026-08-05T10:00:00Z', + 'port-pod', + ), + event( + 'depart-2', + 'container.transport.transshipment_departed', + '2026-08-06T10:00:00Z', + 'port-pod', + ), + event( + 'arrive-2', + 'container.transport.vessel_arrived', + '2026-08-10T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect( + eventsFrom(result) + .filter((item) => item.event_code === 'ARRI') + .map((item) => item.status), + ).toEqual(['VAT', 'VAD']); + }); + + it('maps hub and final discharges to CDT and CDD by onward sailing', () => { + const result = mapTrackingPayload( + payload([ + event( + 'disc-1', + 'container.transport.transshipment_discharged', + '2026-08-05T10:00:00Z', + 'port-pod', + ), + event( + 'load-2', + 'container.transport.transshipment_loaded', + '2026-08-06T10:00:00Z', + 'port-pod', + ), + event( + 'depart-2', + 'container.transport.transshipment_departed', + '2026-08-07T10:00:00Z', + 'port-pod', + ), + event( + 'disc-2', + 'container.transport.vessel_discharged', + '2026-08-10T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect( + eventsFrom(result) + .filter((item) => item.event_code === 'DISC') + .map((item) => item.status), + ).toEqual(['CDT', 'CDD']); + }); + + it('uses cargo state for empty and laden gate-out milestones', () => { + const result = mapTrackingPayload( + payload([ + event( + 'empty-out', + 'container.transport.empty_out', + '2026-07-30T10:00:00Z', + ), + event( + 'disc', + 'container.transport.vessel_discharged', + '2026-08-10T10:00:00Z', + 'port-pod', + ), + event( + 'full-out', + 'container.transport.full_out', + '2026-08-11T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect( + eventsFrom(result) + .filter((item) => item.event_code === 'GTOT') + .map((item) => item.status), + ).toEqual(['CEP', 'CGO']); + }); + + it('keeps availability and delivered rows without inventing event codes', () => { + const result = mapTrackingPayload( + payload([ + event( + 'available', + 'container.transport.available', + '2026-08-10T10:00:00Z', + 'port-pod', + ), + event( + 'delivered', + 'container.transport.delivered', + '2026-08-11T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect(eventsFrom(result)).toMatchObject([ + { event_code: null, status: 'UNKN' }, + { event_code: null, status: 'CDC' }, + ]); + expect( + eventsFrom(result).every( + (item) => item.event_code === null || CLOSED_CODES.has(item.event_code), + ), + ).toBe(true); + }); + + it('keeps inland events as LTS with no vessel reference', () => { + const result = mapTrackingPayload( + payload([ + event( + 'rail-load', + 'container.transport.rail_loaded', + '2026-08-11T10:00:00Z', + 'port-pod', + ), + event( + 'rail-depart', + 'container.transport.rail_departed', + '2026-08-11T12:00:00Z', + 'port-pod', + ), + ]), + ); + expect(eventsFrom(result)).toMatchObject([ + { status: 'LTS', transport_type: 'RAIL', type: 'land', vessel: null }, + { status: 'LTS', transport_type: 'RAIL', type: 'land', vessel: null }, + ]); + }); + + it('anchors prepol on an earlier gate instead of cloning pol', () => { + const result = mapTrackingPayload( + payload([ + event('gate', 'container.transport.full_in', '2026-07-30T10:00:00Z'), + event( + 'load', + 'container.transport.vessel_loaded', + '2026-07-31T10:00:00Z', + ), + event( + 'depart', + 'container.transport.vessel_departed', + '2026-08-01T10:00:00Z', + ), + ]), + ); + expect(responseData(result).route).toMatchObject({ + prepol: { date: '2026-07-30 12:00:00' }, + pol: { date: '2026-08-01 12:00:00' }, + }); + }); + + it('keeps picked_up and active rail states in transit', () => { + for (const status of [ + 'picked_up', + 'grounded', + 'on_rail', + 'off_dock', + 'dropped', + 'loaded', + ]) { + const result = mapTrackingPayload(payload([], status)); + expect(responseData(result).metadata).toMatchObject({ + status: 'IN_TRANSIT', + }); } - const payload: TrackingPayload = { - eventsByContainerId: new Map([['container-1', eventsFixture]]), - shipment: shipmentFixture.data, - included: shipmentFixture.included || [], - requestedNumber: 'MEDUFR030802', - requestedType: 'BL', - }; - - const result = mapTrackingPayload(payload); - expect(result.status).toBe('success'); - expect(result.message).toBe('OK'); - expect(result.data).toMatchObject({ + }); + + it('echoes the requested CT number and maps public equipment enum values', () => { + const result = mapTrackingPayload( + payload([], 'new', 'CT', 'MSCU1234567', 'open top'), + ); + expect(responseData(result)).toMatchObject({ metadata: { - type: 'BL', - number: 'MEDUFR030802', - sealine: 'MSCU', - status: 'IN_TRANSIT', - }, - locations: [ - { id: 1, locode: 'FRLEH' }, - { id: 2, locode: 'USNYC' }, - ], - route: { - pol: { location: 1, actual: true }, - pod: { location: 2, actual: false }, + type: 'CT', + number: 'MSCU1234567', + status: 'PLANNED', }, containers: [ { - number: 'MSCU1234567', - iso_code: '45G1', - size_type: "40' High Cube Dry", - events: [ - { - event_code: 'GTIN', - status: 'CGI', - actual: true, - transport_type: 'TRUCK', - }, - { - event_code: 'DEPA', - status: 'VDL', - actual: true, - transport_type: 'VESSEL', - voyage: '421A', - }, - ], + iso_code: '45U1', + size_type: "40' High Cube Open Top", }, ], }); }); - it.each([ - ['container.transport.empty_out', 'PICK', 'CPS'], - ['container.transport.full_in', 'GTIN', 'CGI'], - ['container.transport.vessel_loaded', 'LOAD', 'CLL'], - ['container.transport.vessel_departed', 'DEPA', 'VDL'], - ['container.transport.transshipment_arrived', 'ARRI', 'VAT'], - ['container.transport.transshipment_discharged', 'DISC', 'CDT'], - ['container.transport.transshipment_loaded', 'LOAD', 'CLT'], - ['container.transport.transshipment_departed', 'DEPA', 'VDT'], - ['container.transport.vessel_arrived', 'ARRI', 'VAD'], - ['container.transport.full_out', 'GTOT', 'CGO'], - ['container.transport.delivered', 'DLVY', 'CDC'], - ['container.transport.empty_in', 'GTIN', 'CER'], - ['container.transport.rail_departed', 'DEPA', 'LTS'], - ])('maps %s to %s / %s', (event, code, status) => { - const resource: JsonApiResource = { - id: 'event', - type: 'transport_event', - attributes: { event, timestamp: '2026-08-01T10:00:00Z' }, - }; - expect( - mapEvent(resource, 1, { - facilities: new Map(), - locations: new Map(), - vessels: new Map(), - }), - ).toMatchObject({ event_code: code, status }); + it('formats offset timestamps in the official SeaRates date shape', () => { + const result = mapTrackingPayload( + payload([ + { + ...event( + 'depart', + 'container.transport.vessel_departed', + '2026-08-01T10:00:00-07:00', + ), + attributes: { + event: 'container.transport.vessel_departed', + timestamp: '2026-08-01T10:00:00-07:00', + }, + relationships: { + location: { data: null }, + terminal: { data: null }, + vessel: { data: { id: 'vessel-1', type: 'vessel' } }, + }, + }, + ]), + ); + expect(eventsFrom(result)[0]).toMatchObject({ + date: '2026-08-01 10:00:00', + }); }); +}); +describe('SeaRates shipping line mapping', () => { it('maps every public T49 shipping line instead of using a sample list', () => { expect(mapShippingLines(shippingLinesFixture)).toEqual({ status: 'success', diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index deb5c93a..a8ae2d1d 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -4,139 +4,45 @@ import type { JsonObject, SeaRatesEnvelope, SeaRatesEvent, + SeaRatesEventCode, TrackingPayload, - TrackingType, } from './types.js'; -interface EventMapping { - code: string; +type Conveyance = 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; + +interface EventIds { + facilities: Map; + locations: Map; + locationsByLocode: Map; + vessels: Map; +} + +interface EventDraft { + actual: boolean; + code: SeaRatesEventCode | null; description: string; - eventType: 'EQUIPMENT' | 'TRANSPORT'; + eventType: 'EQUIPMENT' | 'TRANSPORT' | null; + explicitConveyance: boolean; + facility: number | null; + instant: number | null; + instantKey: string; + location: number | null; + name: string; + order: number; status: string; - transport: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; + transport: Conveyance | null; + type: 'land' | 'sea'; + vessel: number | null; + voyage: string | null; + date: string | null; } -const EVENT_MAPPINGS: Record = { - 'container.transport.empty_out': { - code: 'PICK', - description: 'Empty Picked-up at Depot', - eventType: 'EQUIPMENT', - status: 'CPS', - transport: 'TRUCK', - }, - 'container.transport.full_in': { - code: 'GTIN', - description: 'Gate in at Port terminal', - eventType: 'EQUIPMENT', - status: 'CGI', - transport: 'TRUCK', - }, - 'container.transport.vessel_loaded': { - code: 'LOAD', - description: 'Loaded on board', - eventType: 'EQUIPMENT', - status: 'CLL', - transport: 'VESSEL', - }, - 'container.transport.vessel_departed': { - code: 'DEPA', - description: 'Vessel Departure', - eventType: 'TRANSPORT', - status: 'VDL', - transport: 'VESSEL', - }, - 'container.transport.vessel_arrived': { - code: 'ARRI', - description: 'Vessel Arrival', - eventType: 'TRANSPORT', - status: 'VAD', - transport: 'VESSEL', - }, - 'container.transport.vessel_discharged': { - code: 'DISC', - description: 'Discharged from vessel', - eventType: 'EQUIPMENT', - status: 'CDD', - transport: 'VESSEL', - }, - 'container.transport.transshipment_arrived': { - code: 'ARRI', - description: 'Vessel Arrival at transshipment port', - eventType: 'TRANSPORT', - status: 'VAT', - transport: 'VESSEL', - }, - 'container.transport.transshipment_discharged': { - code: 'DISC', - description: 'Discharged in transshipment', - eventType: 'EQUIPMENT', - status: 'CDT', - transport: 'VESSEL', - }, - 'container.transport.transshipment_loaded': { - code: 'LOAD', - description: 'Loaded at transshipment port', - eventType: 'EQUIPMENT', - status: 'CLT', - transport: 'VESSEL', - }, - 'container.transport.transshipment_departed': { - code: 'DEPA', - description: 'Vessel Departure from transshipment port', - eventType: 'TRANSPORT', - status: 'VDT', - transport: 'VESSEL', - }, - 'container.transport.full_out': { - code: 'GTOT', - description: 'Gate out from final port', - eventType: 'EQUIPMENT', - status: 'CGO', - transport: 'TRUCK', - }, - 'container.transport.delivered': { - code: 'DLVY', - description: 'Container delivered to consignee', - eventType: 'EQUIPMENT', - status: 'CDC', - transport: 'TRUCK', - }, - 'container.transport.empty_in': { - code: 'GTIN', - description: 'Empty container returned to depot', - eventType: 'EQUIPMENT', - status: 'CER', - transport: 'TRUCK', - }, - 'container.transport.rail_loaded': { - code: 'LOAD', - description: 'Loaded on rail', - eventType: 'EQUIPMENT', - status: 'LTS', - transport: 'RAIL', - }, - 'container.transport.rail_departed': { - code: 'DEPA', - description: 'Rail departure', - eventType: 'TRANSPORT', - status: 'LTS', - transport: 'RAIL', - }, - 'container.transport.rail_arrived': { - code: 'ARRI', - description: 'Rail arrival', - eventType: 'TRANSPORT', - status: 'LTS', - transport: 'RAIL', - }, - 'container.transport.rail_unloaded': { - code: 'DISC', - description: 'Discharged from rail', - eventType: 'EQUIPMENT', - status: 'LTS', - transport: 'RAIL', - }, -}; +const SEA_OPERATION_CODES = new Set([ + 'ARRI', + 'DEPA', + 'DISC', + 'LOAD', +]); function attrs(resource: JsonApiResource): JsonObject { return resource.attributes || {}; @@ -150,11 +56,6 @@ function relatedId( return data && !Array.isArray(data) ? data.id : null; } -function formatDate(value: unknown): string | null { - if (typeof value !== 'string' || !value) return null; - return value.replace('T', ' ').replace(/(?:\.\d+)?Z$/, ''); -} - function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } @@ -163,124 +64,550 @@ function numberValue(value: unknown): number | null { return typeof value === 'number' ? value : null; } +function formatParts(date: Date, timeZone: string): string | null { + try { + const parts = new Intl.DateTimeFormat('en-CA', { + day: '2-digit', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: '2-digit', + second: '2-digit', + timeZone, + year: 'numeric', + }).formatToParts(date); + const values = new Map(parts.map((part) => [part.type, part.value])); + return `${values.get('year')}-${values.get('month')}-${values.get('day')} ${values.get('hour')}:${values.get('minute')}:${values.get('second')}`; + } catch { + return null; + } +} + +function formatDate(value: unknown, timeZone?: string | null): string | null { + if (typeof value !== 'string' || !value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/); + return match ? `${match[1]} ${match[2]}` : null; + } + if (timeZone) { + const local = formatParts(parsed, timeZone); + if (local) return local; + } + const offsetMatch = value.match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?[+-]\d{2}:\d{2}$/, + ); + if (offsetMatch) return `${offsetMatch[1]} ${offsetMatch[2]}`; + return parsed.toISOString().slice(0, 19).replace('T', ' '); +} + +function timestamp(value: unknown): { instant: number | null; key: string } { + if (typeof value !== 'string' || !value) { + return { instant: null, key: '' }; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) + ? { instant: null, key: value } + : { instant: parsed, key: String(parsed) }; +} + function resourceIndex( resources: JsonApiResource[], ): Map { return new Map(resources.map((resource) => [resource.id, resource])); } +function collectResources(payload: TrackingPayload): JsonApiResource[] { + const resources = [payload.shipment, ...payload.included]; + for (const document of payload.eventsByContainerId.values()) { + if (Array.isArray(document.data)) resources.push(...document.data); + resources.push(...(document.included || [])); + } + const unique = new Map(); + for (const resource of resources) { + const key = `${resource.type}:${resource.id}`; + const existing = unique.get(key); + if (!existing || Object.keys(attrs(resource)).length > 0) { + unique.set(key, resource); + } + } + return [...unique.values()]; +} + function equipment(attributes: JsonObject): { isoCode: string | null; sizeType: string | null; } { const length = numberValue(attributes.equipment_length); const height = stringValue(attributes.equipment_height); - const type = stringValue(attributes.equipment_type); + const type = stringValue(attributes.equipment_type) + ?.toLowerCase() + .replaceAll('_', ' '); if (!length || !height || !type) return { isoCode: null, sizeType: null }; - const first = length === 20 ? '2' : length === 40 ? '4' : 'L'; - const second = height === 'high_cube' ? '5' : '2'; - const typeCode: Record = { - dry: 'G1', - flat_rack: 'P1', - hard_top: 'U1', - open_top: 'U1', - reefer: 'R1', - tank: 'T1', - }; - const label: Record = { - dry: 'Dry', - flat_rack: 'Flat Rack', - hard_top: 'Hard Top', - open_top: 'Open Top', - reefer: 'Reefer', - tank: 'Tank', - }; + const lengthCode = new Map([ + [10, '1'], + [20, '2'], + [40, '4'], + [45, 'L'], + ]).get(length); + const typeCode = new Map([ + ['dry', 'G1'], + ['flat rack', 'P1'], + ['open top', 'U1'], + ['reefer', 'R1'], + ['tank', 'T1'], + ]).get(type); + const label = new Map([ + ['bulk', 'Bulk'], + ['dry', 'Dry'], + ['flat rack', 'Flat Rack'], + ['open top', 'Open Top'], + ['reefer', 'Reefer'], + ['tank', 'Tank'], + ]).get(type); + const heightCode = height === 'high_cube' ? '5' : '2'; const heightLabel = height === 'high_cube' ? ' High Cube' : ''; return { - isoCode: typeCode[type] ? `${first}${second}${typeCode[type]}` : null, - sizeType: `${length}'${heightLabel} ${label[type] || type}`, + isoCode: + lengthCode && typeCode ? `${lengthCode}${heightCode}${typeCode}` : null, + sizeType: label ? `${length}'${heightLabel} ${label}` : null, }; } -function seaRatesStatus(value: unknown): string { - if (typeof value !== 'string') return 'UNKNOWN'; - if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { - return 'DELIVERED'; - } +function seaRatesStatus(value: unknown, events: EventDraft[]): string { + const normalized = + typeof value === 'string' ? value.toLowerCase().replaceAll(' ', '_') : ''; + if (['delivered', 'empty_returned'].includes(normalized)) return 'DELIVERED'; if ( [ 'available', 'awaiting_inland_transfer', + 'departed', + 'discharged', + 'dropped', + 'grounded', + 'hold', 'in_transit', + 'loaded', 'not_available', + 'off_dock', + 'on_rail', 'on_ship', - ].includes(value) + 'picked_up', + ].includes(normalized) + ) { + return 'IN_TRANSIT'; + } + if (events.some((event) => ['CDC', 'CER'].includes(event.status))) { + return 'DELIVERED'; + } + if ( + events.some((event) => + [ + 'CDD', + 'CDT', + 'CGI', + 'CGO', + 'CLL', + 'CLT', + 'LTS', + 'VAD', + 'VAT', + 'VDL', + 'VDT', + ].includes(event.status), + ) ) { return 'IN_TRANSIT'; } - return 'UNKNOWN'; + if (['booked', 'created', 'new', 'planned'].includes(normalized)) { + return 'PLANNED'; + } + return events.length > 0 ? 'PLANNED' : 'UNKNOWN'; } -function defaultType(number: string, type?: TrackingType): TrackingType { - return type || (/^[A-Z]{4}\d{7}$/.test(number) ? 'CT' : 'BL'); +function eventDescription(name: string, attributes: JsonObject): string { + const supplied = + stringValue(attributes.description) || + stringValue(attributes.original_event); + if (supplied) return supplied; + const label = name.split('.').at(-1)?.replaceAll('_', ' ') || 'Unknown event'; + return label.replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function collectResources(payload: TrackingPayload): JsonApiResource[] { - const resources = [payload.shipment, ...payload.included]; - for (const document of payload.eventsByContainerId.values()) { - if (Array.isArray(document.data)) resources.push(...document.data); - resources.push(...(document.included || [])); +function classifyCode( + name: string, + description: string, +): SeaRatesEventCode | null { + const text = `${name} ${description}`.toLowerCase(); + if (/\b(customs).*(release|released)\b/.test(text)) return 'CUSR'; + if (/\b(customs).*(inspect|inspection)\b/.test(text)) return 'CUSI'; + if (/\b(stuff|stuffing|stuffed)\b/.test(text)) return 'STUF'; + if (/\b(strip|stripping|stripped)\b/.test(text)) return 'STRP'; + if (/\b(receive|received)\b/.test(text)) return 'RECE'; + if (/\b(confirm|confirmed|booking_confirmed)\b/.test(text)) return 'CONF'; + if (/\b(issue|issued)\b/.test(text)) return 'ISSU'; + if (/\b(inspect|inspected)\b/.test(text)) return 'INSP'; + if (/\b(release|released)\b/.test(text)) return 'RELS'; + if (/\b(pickup|picked up|picked_up)\b/.test(text)) return 'PICK'; + if (/\b(full_out|empty_out|gate out|gate_out)\b/.test(text)) return 'GTOT'; + if (/\b(full_in|empty_in|gate in|gate_in|drop)\b/.test(text)) return 'GTIN'; + if (/\b(discharg|unload)\w*\b/.test(text)) return 'DISC'; + if (/\b(load|loaded)\w*\b/.test(text)) return 'LOAD'; + if (/\b(depart|departure)\w*\b/.test(text)) return 'DEPA'; + if (/\b(arriv|arrival)\w*\b/.test(text)) return 'ARRI'; + return null; +} + +function conveyance( + resource: JsonApiResource, + name: string, + description: string, + code: SeaRatesEventCode | null, +): { explicit: boolean; transport: Conveyance | null } { + const attributes = attrs(resource); + const supplied = + stringValue(attributes.transport_type) || stringValue(attributes.mode); + const text = `${supplied || ''} ${name} ${description}`.toLowerCase(); + if (/\b(rail|train)\b/.test(text)) { + return { + explicit: Boolean(supplied) || /rail/.test(name), + transport: 'RAIL', + }; } - const unique = new Map(); - for (const resource of resources) { - const key = `${resource.type}:${resource.id}`; - const existing = unique.get(key); - if (!existing || Object.keys(attrs(resource)).length > 0) { - unique.set(key, resource); - } + if (/\b(barge|feeder|waterway)\b/.test(text)) { + return { explicit: true, transport: 'BARGE' }; } - return [...unique.values()]; + if (/\b(truck|road)\b/.test(text)) { + return { explicit: Boolean(supplied), transport: 'TRUCK' }; + } + if (/\binland\b/.test(text)) { + return { explicit: true, transport: 'TRUCK' }; + } + if ( + /\b(vessel|ocean|ship|transshipment)\b/.test(text) || + relatedId(resource, 'vessel') || + (code && SEA_OPERATION_CODES.has(code)) + ) { + return { explicit: Boolean(supplied), transport: 'VESSEL' }; + } + return { explicit: false, transport: 'TRUCK' }; } -export function mapEvent( +function eventLocation( resource: JsonApiResource, - orderId: number, - ids: { - facilities: Map; - locations: Map; - vessels: Map; - }, -): SeaRatesEvent | null { - const attributes = attrs(resource); - const eventName = stringValue(attributes.event); - if (!eventName) return null; - const normalizedName = eventName.replace('.estimated.', '.'); - const mapping = EVENT_MAPPINGS[normalizedName]; - if (!mapping) return null; - const estimated = eventName.includes('.estimated.'); - const locationId = relatedId(resource, 'location'); - const facilityId = relatedId(resource, 'terminal'); - const vesselId = relatedId(resource, 'vessel'); + ids: EventIds, +): number | null { + const relationshipId = relatedId(resource, 'location'); + if (relationshipId) return ids.locations.get(relationshipId) ?? null; + const locode = stringValue(attrs(resource).location_locode); + return locode ? (ids.locationsByLocode.get(locode) ?? null) : null; +} + +function eventTimeZone( + resource: JsonApiResource, + locationByNumber: Map, + location: number | null, +): string | null { + return ( + stringValue(attrs(resource).timezone) || + (location + ? stringValue( + attrs(locationByNumber.get(location) || { id: '', type: '' }) + .time_zone, + ) + : null) + ); +} + +function draftEvents( + resources: JsonApiResource[], + ids: EventIds, + locationByNumber: Map, +): EventDraft[] { + return resources + .map((resource, order): EventDraft | null => { + const attributes = attrs(resource); + const rawName = stringValue(attributes.event); + if (!rawName) return null; + const name = rawName.replace('.estimated.', '.'); + const description = eventDescription(name, attributes); + const code = classifyCode(name, description); + const isDelay = /\b(delay|delayed|transshipment delay)\b/i.test( + `${name} ${description}`, + ); + const mode = isDelay + ? { explicit: false, transport: null } + : conveyance(resource, name, description, code); + const location = eventLocation(resource, ids); + const eventTimestamp = attributes.timestamp; + const parsed = timestamp(eventTimestamp); + const isLand = mode.transport === 'RAIL' || mode.transport === 'TRUCK'; + return { + actual: + !rawName.includes('.estimated.') && attributes.estimated !== true, + code, + date: formatDate( + eventTimestamp, + eventTimeZone(resource, locationByNumber, location), + ), + description, + eventType: isDelay + ? null + : code === 'ARRI' || code === 'DEPA' + ? 'TRANSPORT' + : 'EQUIPMENT', + explicitConveyance: mode.explicit, + facility: relatedId(resource, 'terminal') + ? (ids.facilities.get(relatedId(resource, 'terminal') || '') ?? null) + : null, + instant: parsed.instant, + instantKey: parsed.key, + location, + name, + order, + status: isDelay ? 'TSD' : 'UNKN', + transport: mode.transport, + type: isDelay || !isLand ? 'sea' : 'land', + vessel: + isLand || !relatedId(resource, 'vessel') + ? null + : (ids.vessels.get(relatedId(resource, 'vessel') || '') ?? null), + voyage: isLand ? null : stringValue(attributes.voyage_number), + }; + }) + .filter((event): event is EventDraft => event !== null) + .sort((left, right) => { + if (left.instant === null && right.instant === null) { + return left.order - right.order; + } + if (left.instant === null) return 1; + if (right.instant === null) return -1; + return left.instant - right.instant || left.order - right.order; + }); +} + +function isSea(event: EventDraft): boolean { + return event.transport === 'VESSEL' || event.transport === 'BARGE'; +} + +function assignStatuses(events: EventDraft[]): void { + const seaLoads = events.filter( + (event) => event.code === 'LOAD' && isSea(event), + ); + const seaDepartures = events.filter( + (event) => event.code === 'DEPA' && isSea(event), + ); + const firstSeaBoundary = [...seaLoads, ...seaDepartures].sort( + (left, right) => (left.instant ?? Infinity) - (right.instant ?? Infinity), + )[0]; + const lastSeaArrival = [...events] + .reverse() + .find((event) => event.code === 'ARRI' && isSea(event)); + const finalSeaDischarge = [...events] + .reverse() + .find( + (event) => + event.code === 'DISC' && + isSea(event) && + !/transshipment/.test(event.name) && + !events.some( + (later) => + later.order !== event.order && + (later.instant ?? -Infinity) > (event.instant ?? -Infinity) && + isSea(later) && + (later.code === 'LOAD' || later.code === 'DEPA'), + ), + ); + + for (const event of events) { + if (event.status === 'TSD') continue; + const text = `${event.name} ${event.description}`.toLowerCase(); + const isExplicitInland = + event.transport === 'RAIL' || + (event.transport === 'TRUCK' && event.explicitConveyance); + const afterPod = + finalSeaDischarge?.instant !== null && + finalSeaDischarge?.instant !== undefined && + event.instant !== null && + event.instant >= finalSeaDischarge.instant; + const empty = /\bempty\b/.test(text); + + if ( + isExplicitInland && + event.code && + ['ARRI', 'DEPA', 'DISC', 'GTIN', 'GTOT', 'LOAD', 'PICK'].includes( + event.code, + ) + ) { + event.status = 'LTS'; + } else if (event.name.endsWith('.delivered')) { + event.status = 'CDC'; + } else if (event.code === 'LOAD') { + event.status = + event === seaLoads[0] ? 'CLL' : isSea(event) ? 'CLT' : 'LTS'; + } else if (event.code === 'DEPA') { + event.status = + event === seaDepartures[0] ? 'VDL' : isSea(event) ? 'VDT' : 'LTS'; + } else if (event.code === 'ARRI') { + const laterSailing = events.some( + (later) => + later.instant !== null && + event.instant !== null && + later.instant > event.instant && + isSea(later) && + (later.code === 'LOAD' || later.code === 'DEPA'), + ); + event.status = + !isSea(event) || + (firstSeaBoundary?.instant !== null && + event.instant !== null && + firstSeaBoundary?.instant !== undefined && + event.instant < firstSeaBoundary.instant) + ? 'LTS' + : event === lastSeaArrival && !laterSailing + ? 'VAD' + : 'VAT'; + } else if (event.code === 'DISC') { + const laterSailing = events.some( + (later) => + later.instant !== null && + event.instant !== null && + later.instant > event.instant && + isSea(later) && + (later.code === 'LOAD' || later.code === 'DEPA'), + ); + event.status = !isSea(event) + ? 'LTS' + : event !== finalSeaDischarge || + laterSailing || + /transshipment/.test(event.name) + ? 'CDT' + : 'CDD'; + } else if (event.code === 'GTOT') { + event.status = + empty || event.name.endsWith('.empty_out') + ? 'CEP' + : afterPod || event.name.endsWith('.full_out') + ? 'CGO' + : firstSeaBoundary && + event.instant !== null && + firstSeaBoundary.instant !== null && + event.instant < firstSeaBoundary.instant + ? 'CEP' + : 'UNKN'; + } else if (event.code === 'GTIN') { + event.status = + empty || event.name.endsWith('.empty_in') + ? 'CER' + : event.name.endsWith('.full_in') && !afterPod + ? 'CGI' + : afterPod + ? 'CER' + : 'UNKN'; + } else if (event.code === 'PICK') { + event.status = isExplicitInland + ? 'LTS' + : afterPod + ? 'CGO' + : empty && /merchant haul/.test(text) + ? 'CEP' + : 'CPS'; + } else { + event.status = 'UNKN'; + } + } +} + +function samePlace(left: EventDraft, right: EventDraft): boolean { + const leftPlace = left.location ?? left.facility; + const rightPlace = right.location ?? right.facility; + return leftPlace === rightPlace || leftPlace === null || rightPlace === null; +} + +function deduplicate(events: EventDraft[]): EventDraft[] { + const kept: EventDraft[] = []; + for (const event of events) { + if (event.status === 'LTS' || event.status === 'UNKN') { + kept.push(event); + continue; + } + const duplicateIndex = kept.findIndex( + (candidate) => + event.instantKey.length > 0 && + candidate.instantKey === event.instantKey && + candidate.status === event.status && + samePlace(candidate, event), + ); + if (duplicateIndex < 0) { + kept.push(event); + continue; + } + const existing = kept[duplicateIndex]; + const eventLocated = event.location !== null || event.facility !== null; + const existingLocated = + existing.location !== null || existing.facility !== null; + if ( + (eventLocated && !existingLocated) || + (eventLocated === existingLocated && event.actual && !existing.actual) || + (eventLocated === existingLocated && + event.actual === existing.actual && + event.order > existing.order) + ) { + kept[duplicateIndex] = event; + } + } + return kept.sort((left, right) => { + if (left.instant === null && right.instant === null) { + return left.order - right.order; + } + if (left.instant === null) return 1; + if (right.instant === null) return -1; + return left.instant - right.instant || left.order - right.order; + }); +} + +function publicEvent(event: EventDraft, order: number): SeaRatesEvent { return { - actual: !estimated, - date: formatDate(attributes.timestamp), - description: mapping.description, - event_type: mapping.eventType, - event_code: mapping.code, - status: mapping.status, - facility: facilityId ? (ids.facilities.get(facilityId) ?? null) : null, + actual: event.actual, + date: event.date, + description: event.description, + event_code: event.code, + event_type: event.eventType, + facility: event.facility, is_additional_event: false, - is_date_from_sealine: attributes.data_source === 'shipping_line', - location: locationId ? (ids.locations.get(locationId) ?? null) : null, - order_id: orderId, - transport_type: mapping.transport, - type: mapping.transport === 'VESSEL' ? 'sea' : 'land', - vessel: vesselId ? (ids.vessels.get(vesselId) ?? null) : null, - voyage: stringValue(attributes.voyage_number), + is_date_from_sealine: true, + location: event.location, + order_id: order, + status: event.status, + transport_type: event.transport, + type: event.type, + vessel: event.type === 'land' ? null : event.vessel, + voyage: event.type === 'land' ? null : event.voyage, }; } +function buildTimeline( + resources: JsonApiResource[], + ids: EventIds, + locationByNumber: Map, +): EventDraft[] { + const drafts = draftEvents(resources, ids, locationByNumber); + assignStatuses(drafts); + return deduplicate(drafts); +} + +function routePoint(event: EventDraft | undefined): { + actual: boolean | null; + date: string | null; + location: number | null; +} { + return event + ? { actual: event.actual, date: event.date, location: event.location } + : { actual: null, date: null, location: null }; +} + export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { const resources = collectResources(payload); const byId = resourceIndex(resources); @@ -296,24 +623,123 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { const locations = new Map( locationResources.map((resource, index) => [resource.id, index + 1]), ); + const locationsByLocode = new Map( + locationResources.flatMap((resource, index) => { + const code = stringValue(attrs(resource).code); + return code ? [[code, index + 1] as const] : []; + }), + ); + const locationByNumber = new Map( + locationResources.map((resource, index) => [index + 1, resource]), + ); const facilities = new Map( facilityResources.map((resource, index) => [resource.id, index + 1]), ); const vessels = new Map( vesselResources.map((resource, index) => [resource.id, index + 1]), ); + const ids: EventIds = { + facilities, + locations, + locationsByLocode, + vessels, + }; const shipmentAttributes = attrs(payload.shipment); const containerResources = payload.included.filter( (resource) => resource.type === 'container', ); - const containerStatuses = containerResources.map((resource) => - seaRatesStatus(attrs(resource).current_status), + const timelines = new Map(); + const statuses: string[] = []; + + const containers = containerResources.map((resource) => { + const attributes = attrs(resource); + const eventDocument = payload.eventsByContainerId.get(resource.id); + const eventResources = Array.isArray(eventDocument?.data) + ? eventDocument.data + : []; + const timeline = buildTimeline(eventResources, ids, locationByNumber); + timelines.set(resource.id, timeline); + const status = seaRatesStatus(attributes.current_status, timeline); + statuses.push(status); + const details = equipment(attributes); + return { + number: stringValue(attributes.number), + iso_code: details.isoCode, + size_type: details.sizeType, + status, + is_status_from_sealine: false, + events_mirrored: false, + events: timeline.map(publicEvent), + }; + }); + + const allEvents = [...timelines.values()] + .flat() + .sort( + (left, right) => (left.instant ?? Infinity) - (right.instant ?? Infinity), + ); + const polEvent = + allEvents.find( + (event) => + event.code === 'DEPA' && event.status === 'VDL' && isSea(event), + ) || + allEvents.find( + (event) => + event.code === 'LOAD' && event.status === 'CLL' && isSea(event), + ); + const podEvent = [...allEvents] + .reverse() + .find((event) => event.code === 'DISC' && event.status === 'CDD'); + const prepolEvent = allEvents.find( + (event) => + ['GTIN', 'GTOT', 'PICK'].includes(event.code || '') && + (polEvent?.instant === null || + polEvent?.instant === undefined || + (event.instant !== null && event.instant < polEvent.instant)), ); - const overallStatus = containerStatuses.includes('IN_TRANSIT') + const postpodEvent = [...allEvents] + .reverse() + .find( + (event) => + (['CDC', 'CER', 'CGO'].includes(event.status) || + ['GTIN', 'GTOT', 'PICK'].includes(event.code || '') || + /\.not_available$|\.available$/.test(event.name)) && + (podEvent?.instant === null || + podEvent?.instant === undefined || + (event.instant !== null && event.instant > podEvent.instant)), + ); + + const polLocationId = relatedId(payload.shipment, 'port_of_lading'); + const podLocationId = relatedId(payload.shipment, 'port_of_discharge'); + const fallbackPol = { + actual: Boolean(shipmentAttributes.pol_atd_at), + date: formatDate( + shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, + stringValue(shipmentAttributes.pol_timezone), + ), + location: polLocationId ? (locations.get(polLocationId) ?? null) : null, + }; + const fallbackPod = { + actual: Boolean(shipmentAttributes.pod_ata_at), + date: formatDate( + shipmentAttributes.pod_ata_at || shipmentAttributes.pod_eta_at, + stringValue(shipmentAttributes.pod_timezone), + ), + location: podLocationId ? (locations.get(podLocationId) ?? null) : null, + }; + const pol = polEvent ? routePoint(polEvent) : fallbackPol; + const pod = podEvent ? routePoint(podEvent) : fallbackPod; + const prepol = prepolEvent + ? routePoint(prepolEvent) + : { actual: null, date: null, location: pol.location }; + const postpod = postpodEvent ? routePoint(postpodEvent) : { ...pod }; + const metadataStatus = statuses.includes('IN_TRANSIT') ? 'IN_TRANSIT' - : containerStatuses.includes('DELIVERED') + : statuses.length > 0 && statuses.every((status) => status === 'DELIVERED') ? 'DELIVERED' - : 'UNKNOWN'; + : statuses.includes('PLANNED') + ? 'PLANNED' + : 'UNKNOWN'; const locationList = locationResources.map((resource) => { const attributes = attrs(resource); @@ -329,7 +755,6 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { timezone: stringValue(attributes.time_zone), }; }); - const facilityList = facilityResources.map((resource) => { const attributes = attrs(resource); const port = byId.get(relatedId(resource, 'port') || ''); @@ -347,7 +772,6 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { lng: null, }; }); - const vesselList = vesselResources.map((resource) => { const attributes = attrs(resource); return { @@ -359,7 +783,6 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { flag: null, }; }); - if ( vesselList.length === 0 && stringValue(shipmentAttributes.pod_vessel_name) @@ -374,48 +797,17 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { }); } - const containers = containerResources.map((resource) => { - const attributes = attrs(resource); - const eventDocument = payload.eventsByContainerId.get(resource.id); - const eventResources = Array.isArray(eventDocument?.data) - ? eventDocument.data - : []; - const events = eventResources - .map((event, index) => - mapEvent(event, index + 1, { facilities, locations, vessels }), - ) - .filter((event): event is SeaRatesEvent => event !== null) - .sort((left, right) => (left.date || '').localeCompare(right.date || '')) - .map((event, index) => ({ ...event, order_id: index + 1 })); - const equipmentDetails = equipment(attributes); - return { - number: stringValue(attributes.number), - iso_code: equipmentDetails.isoCode, - size_type: equipmentDetails.sizeType, - status: seaRatesStatus(attributes.current_status), - is_status_from_sealine: true, - events_mirrored: false, - events, - }; - }); - - const polId = relatedId(payload.shipment, 'port_of_lading'); - const podId = relatedId(payload.shipment, 'port_of_discharge'); - const destinationId = relatedId(payload.shipment, 'destination'); - return { status: 'success', message: 'OK', data: { metadata: { type: payload.requestedType, - number: - stringValue(shipmentAttributes.bill_of_lading_number) || - payload.requestedNumber, + number: payload.requestedNumber, sealine: stringValue(shipmentAttributes.shipping_line_scac), sealine_name: stringValue(shipmentAttributes.shipping_line_name), - status: overallStatus, - is_status_from_sealine: true, + status: metadataStatus, + is_status_from_sealine: false, from_cache: true, updated_at: formatDate( shipmentAttributes.line_tracking_last_succeeded_at, @@ -427,42 +819,16 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { locations: locationList, facilities: facilityList, route: { - prepol: { - location: polId ? (locations.get(polId) ?? null) : null, - date: formatDate( - shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, - ), - actual: Boolean(shipmentAttributes.pol_atd_at), - }, - pol: { - location: polId ? (locations.get(polId) ?? null) : null, - date: formatDate( - shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, - ), - actual: Boolean(shipmentAttributes.pol_atd_at), - }, + prepol, + pol, pod: { - location: podId ? (locations.get(podId) ?? null) : null, - date: formatDate( - shipmentAttributes.pod_ata_at || shipmentAttributes.pod_eta_at, + ...pod, + predictive_eta: formatDate( + shipmentAttributes.pod_eta_at, + stringValue(shipmentAttributes.pod_timezone), ), - actual: Boolean(shipmentAttributes.pod_ata_at), - predictive_eta: null, - }, - postpod: { - location: destinationId - ? (locations.get(destinationId) ?? null) - : null, - date: formatDate( - shipmentAttributes.destination_ata_at || - shipmentAttributes.destination_eta_at, - ), - actual: shipmentAttributes.destination_ata_at - ? true - : shipmentAttributes.destination_eta_at - ? false - : null, }, + postpod, }, vessels: vesselList, containers, @@ -470,45 +836,26 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { }; } -export function emptyTrackingEnvelope( - number: string, - type?: TrackingType, - sealine?: string, +export function noTrackingInfoEnvelope(): SeaRatesEnvelope { + return { status: 'error', message: 'NO_TRACKING_INFO', data: {} }; +} + +export function toContainerEnvelope( + envelope: SeaRatesEnvelope, ): SeaRatesEnvelope { - return { - status: 'success', - message: 'SEALINE_HASNT_PROVIDE_INFO', - data: { - metadata: { - type: defaultType(number, type), - number, - sealine: sealine || null, - sealine_name: null, - status: 'UNKNOWN', - is_status_from_sealine: false, - from_cache: false, - updated_at: null, - cache_expires: null, - api_calls: null, - unique_shipments: null, - }, - locations: [], - facilities: [], - route: { - prepol: { location: null, date: null, actual: null }, - pol: { location: null, date: null, actual: null }, - pod: { - location: null, - date: null, - actual: null, - predictive_eta: null, - }, - postpod: { location: null, date: null, actual: null }, - }, - vessels: [], - containers: [], - }, - }; + if ( + envelope.status !== 'success' || + !envelope.data || + typeof envelope.data !== 'object' || + Array.isArray(envelope.data) + ) { + return envelope; + } + const data = envelope.data; + const containers = Array.isArray(data.containers) ? data.containers : []; + const singular = { ...data, container: containers[0] ?? null }; + delete singular.containers; + return { ...envelope, data: singular }; } export function mapShippingLines(document: JsonApiDocument): SeaRatesEnvelope { diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index dc8718aa..377521ce 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -95,7 +95,7 @@ describe('SeaRates compatibility gateway', () => { }); }); - it('uses SeaRates empty-data semantics while T49 is still pending', async () => { + it('returns NO_TRACKING_INFO while T49 is still pending', async () => { const gateway = new SeaRatesCompatibilityGateway({ pollTimeoutMs: 0, fetchImpl: async (input) => { @@ -117,16 +117,76 @@ describe('SeaRates compatibility gateway', () => { }, }); + await expect(gateway.tracking('pass-through-key', query)).resolves.toEqual({ + status: 'error', + message: 'NO_TRACKING_INFO', + data: {}, + }); + }); + + it('does not exceed the ten-container force-refresh limit', async () => { + if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { + throw new Error('Shipment fixture must contain one resource'); + } + const included = Array.from({ length: 11 }, (_, index) => ({ + id: `container-${index}`, + type: 'container', + attributes: { number: `MSCU12345${String(index).padStart(2, '0')}` }, + })); + let refreshCalls = 0; + const document = { + data: shipmentFixture.data, + included, + }; + const gateway = new SeaRatesCompatibilityGateway({ + fetchImpl: async (input, init) => { + const url = String(input); + if (init?.method === 'PATCH') { + refreshCalls += 1; + return response({ data: null }); + } + if (url.includes('/shipments?')) { + return response({ data: [shipmentFixture.data] }); + } + return response(document); + }, + }); + await expect( - gateway.tracking('pass-through-key', query), - ).resolves.toMatchObject({ - status: 'success', - message: 'SEALINE_HASNT_PROVIDE_INFO', - data: { - metadata: { status: 'UNKNOWN' }, - containers: [], + gateway.tracking('pass-through-key', { ...query, forceUpdate: true }), + ).resolves.toEqual({ + status: 'error', + message: 'API_KEY_RATE_LIMIT', + data: {}, + }); + expect(refreshCalls).toBe(0); + }); + + it('does not serve the pre-refresh shipment when refresh stays unresolved', async () => { + let refreshCalls = 0; + const gateway = new SeaRatesCompatibilityGateway({ + pollTimeoutMs: 0, + fetchImpl: async (input, init) => { + const url = String(input); + if (init?.method === 'PATCH') { + refreshCalls += 1; + return response({ data: null }); + } + if (url.includes('/shipments?')) { + return response({ data: [shipmentFixture.data] }); + } + return response(shipmentFixture); }, }); + + await expect( + gateway.tracking('pass-through-key', { ...query, forceUpdate: true }), + ).resolves.toEqual({ + status: 'error', + message: 'NO_TRACKING_INFO', + data: {}, + }); + expect(refreshCalls).toBe(1); }); it('serves the sealines dictionary from /shipping_lines', async () => { diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts index 911bab25..7dfa10f8 100644 --- a/packages/searates-compat/src/service.ts +++ b/packages/searates-compat/src/service.ts @@ -5,9 +5,9 @@ import { type Terminal49ClientConfig, } from './client.js'; import { - emptyTrackingEnvelope, mapShippingLines, mapTrackingPayload, + noTrackingInfoEnvelope, } from './mapping.js'; import type { JsonApiDocument, @@ -24,6 +24,7 @@ export interface GatewayConfig { fetchImpl?: typeof fetch; pollIntervalMs?: number; pollTimeoutMs?: number; + requestTimeoutMs?: number; serviceApiToken?: string; } @@ -107,25 +108,35 @@ export class SeaRatesCompatibilityGateway { if (resolution.failedReason === 'invalid_number') { return errorEnvelope('WRONG_NUMBER'); } - return emptyTrackingEnvelope(query.number, type, query.sealine); + return noTrackingInfoEnvelope(); } if (resolution.state === 'pending') { - return emptyTrackingEnvelope(query.number, type, query.sealine); + return noTrackingInfoEnvelope(); } shipmentDocument = await client.shipment(resolution.shipmentId); } - const shipment = shipmentFrom(shipmentDocument); + let shipment = shipmentFrom(shipmentDocument); if (!shipment) { - return emptyTrackingEnvelope(query.number, type, query.sealine); + return noTrackingInfoEnvelope(); } let containers = containerResources(shipmentDocument); if (query.forceUpdate && containers.length > 0) { - await Promise.all( - containers.map((container) => client.refreshContainer(container.id)), + if (containers.length > 10) { + return errorEnvelope('API_KEY_RATE_LIMIT'); + } + for (const container of containers) { + await client.refreshContainer(container.id); + } + const refreshedDocument = await client.waitForShipmentUpdate( + shipment.id, + shipmentDocument, ); - shipmentDocument = await client.shipment(shipment.id); + if (!refreshedDocument) return noTrackingInfoEnvelope(); + shipmentDocument = refreshedDocument; + shipment = shipmentFrom(shipmentDocument); + if (!shipment) return noTrackingInfoEnvelope(); containers = containerResources(shipmentDocument); } @@ -207,6 +218,7 @@ export class SeaRatesCompatibilityGateway { fetchImpl: this.config.fetchImpl, pollIntervalMs: this.config.pollIntervalMs, pollTimeoutMs: this.config.pollTimeoutMs, + requestTimeoutMs: this.config.requestTimeoutMs, }; return new Terminal49PublicClient(clientConfig); } diff --git a/packages/searates-compat/src/types.ts b/packages/searates-compat/src/types.ts index 99ceecb5..35e085de 100644 --- a/packages/searates-compat/src/types.ts +++ b/packages/searates-compat/src/types.ts @@ -50,20 +50,38 @@ export interface SeaRatesEvent { actual: boolean; date: string | null; description: string; - event_code: string; - event_type: 'EQUIPMENT' | 'TRANSPORT'; + event_code: SeaRatesEventCode | null; + event_type: 'EQUIPMENT' | 'TRANSPORT' | null; facility: number | null; is_additional_event: boolean; is_date_from_sealine: boolean; location: number | null; order_id: number; status: string; - transport_type: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; + transport_type: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL' | null; type: 'land' | 'sea'; vessel: number | null; voyage: string | null; } +export type SeaRatesEventCode = + | 'ARRI' + | 'CONF' + | 'CUSI' + | 'CUSR' + | 'DEPA' + | 'DISC' + | 'GTIN' + | 'GTOT' + | 'INSP' + | 'ISSU' + | 'LOAD' + | 'PICK' + | 'RECE' + | 'RELS' + | 'STRP' + | 'STUF'; + export interface SeaRatesEnvelope { status: 'error' | 'success'; message: string; From a0d19e59bbde02ec8687f88cfe56075602e3d0eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:52:13 +0000 Subject: [PATCH 07/12] test: tighten mapper contract typing and pending fixture Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/mapping.test.ts | 9 +++++++-- packages/searates-compat/src/mapping.ts | 5 ++++- packages/searates-compat/src/service.test.ts | 5 ++++- packages/searates-compat/src/types.ts | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 3cda8c63..19401d25 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -3,6 +3,7 @@ import { shipmentFixture, shippingLinesFixture } from './__fixtures__/t49.js'; import { mapShippingLines, mapTrackingPayload } from './mapping.js'; import type { JsonApiResource, + SeaRatesEvent, SeaRatesEventCode, TrackingPayload, TrackingType, @@ -112,7 +113,9 @@ function responseData(result: ReturnType) { return result.data; } -function eventsFrom(result: ReturnType) { +function eventsFrom( + result: ReturnType, +): SeaRatesEvent[] { const data = responseData(result); const containers = data.containers; if (!Array.isArray(containers) || !containers[0]) { @@ -127,7 +130,9 @@ function eventsFrom(result: ReturnType) { ) { throw new Error('Expected mapped event array'); } - return container.events; + // SAFETY: Every event is produced by mapTrackingPayload and the shape above + // verifies that this value is the mapped event array. + return container.events as SeaRatesEvent[]; } describe('SeaRates positional event mapping', () => { diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index a8ae2d1d..78474fa6 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -853,7 +853,10 @@ export function toContainerEnvelope( } const data = envelope.data; const containers = Array.isArray(data.containers) ? data.containers : []; - const singular = { ...data, container: containers[0] ?? null }; + const singular: JsonObject = { + ...data, + container: containers[0] ?? null, + }; delete singular.containers; return { ...envelope, data: singular }; } diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 377521ce..b157f8ba 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -107,7 +107,10 @@ describe('SeaRates compatibility gateway', () => { { id: 'request-1', type: 'tracking_request', - attributes: { status: 'pending' }, + attributes: { + request_type: 'bill_of_lading', + status: 'pending', + }, relationships: { tracked_object: { data: null } }, }, ], diff --git a/packages/searates-compat/src/types.ts b/packages/searates-compat/src/types.ts index 35e085de..23da46d8 100644 --- a/packages/searates-compat/src/types.ts +++ b/packages/searates-compat/src/types.ts @@ -46,7 +46,7 @@ export interface TrackingQuery { type?: TrackingType; } -export interface SeaRatesEvent { +export interface SeaRatesEvent extends JsonObject { actual: boolean; date: string | null; description: string; From f1fbae2da3e1e6556d5218111be638917ed70ee0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:56:58 +0000 Subject: [PATCH 08/12] fix: handle duplicate and partial SeaRates timelines Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/client.ts | 26 +--- packages/searates-compat/src/http.test.ts | 7 + packages/searates-compat/src/http.ts | 12 +- packages/searates-compat/src/mapping.test.ts | 69 ++++++++++ packages/searates-compat/src/mapping.ts | 138 +++++++++++++++---- packages/searates-compat/src/service.test.ts | 22 ++- 6 files changed, 219 insertions(+), 55 deletions(-) diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts index 6750d20b..1d7797d8 100644 --- a/packages/searates-compat/src/client.ts +++ b/packages/searates-compat/src/client.ts @@ -69,28 +69,12 @@ function selectTrackingRequest( } function freshnessSignature(document: JsonApiDocument): string { - const resources = [ - ...(Array.isArray(document.data) + const shipment = Array.isArray(document.data) + ? document.data.find((resource) => resource.type === 'shipment') + : document.data?.type === 'shipment' ? document.data - : document.data - ? [document.data] - : []), - ...(document.included || []), - ]; - return resources - .filter((resource) => ['container', 'shipment'].includes(resource.type)) - .map((resource) => { - const attributes = resource.attributes || {}; - return [ - resource.id, - attributes.line_tracking_last_succeeded_at, - attributes.pod_last_tracking_request_at, - attributes.shipment_last_tracking_request_at, - attributes.terminal_checked_at, - ].join(':'); - }) - .sort() - .join('|'); + : undefined; + return String(shipment?.attributes?.line_tracking_last_succeeded_at || ''); } function trackingType(type: TrackingType): Terminal49TrackingType { diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts index 6f987176..91de4ae9 100644 --- a/packages/searates-compat/src/http.test.ts +++ b/packages/searates-compat/src/http.test.ts @@ -124,6 +124,13 @@ describe('GET /tracking contract', () => { message: 'WRONG_TYPE', data: {}, }); + + const omitted = response(); + await handler( + request('/reference?api_key=gateway-key&number=MSCU1234567'), + omitted.response, + ); + expect(gateway.query?.type).toBe('BL'); }); it('returns a SeaRates-style WRONG_TYPE envelope', async () => { diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts index a91933d6..3534e2a7 100644 --- a/packages/searates-compat/src/http.ts +++ b/packages/searates-compat/src/http.ts @@ -69,6 +69,7 @@ export function createTrackingHandler( gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), options: { allowedTypes?: readonly TrackingType[]; + defaultType?: TrackingType; forcedType?: TrackingType; singularContainer?: boolean; } = {}, @@ -97,9 +98,11 @@ export function createTrackingHandler( .toUpperCase(); const sealine = first(params, 'sealine')?.toUpperCase(); const rawType = first(params, 'type'); - const parsedType = options.forcedType || trackingType(rawType); + const requestedType = trackingType(rawType); + const parsedType = + options.forcedType || requestedType || options.defaultType; if ( - (!options.forcedType && rawType && !parsedType) || + (!options.forcedType && rawType && !requestedType) || (parsedType && options.allowedTypes && !options.allowedTypes.includes(parsedType)) @@ -140,7 +143,10 @@ export function createContainerHandler( export function createReferenceHandler( gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), ) { - return createTrackingHandler(gateway, { allowedTypes: ['BL', 'BK'] }); + return createTrackingHandler(gateway, { + allowedTypes: ['BL', 'BK'], + defaultType: 'BL', + }); } export function createShippingLinesHandler( diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 19401d25..00b08aa6 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -159,6 +159,57 @@ describe('SeaRates positional event mapping', () => { ).toEqual(['CLL', 'CLT']); }); + it('folds duplicate first loads before assigning ordinal milestones', () => { + const result = mapTrackingPayload( + payload([ + event( + 'load-copy-1', + 'container.transport.vessel_loaded', + '2026-08-01T10:00:00Z', + ), + event( + 'load-copy-2', + 'container.transport.vessel_loaded', + '2026-08-01T10:00:00Z', + ), + ]), + ); + expect( + eventsFrom(result).filter((item) => item.event_code === 'LOAD'), + ).toMatchObject([{ status: 'CLL' }]); + }); + + it('does not promote a hub load to origin when the timeline starts mid-journey', () => { + const result = mapTrackingPayload( + payload([ + event( + 'hub-arrival', + 'container.transport.transshipment_arrived', + '2026-08-04T10:00:00Z', + 'port-pod', + ), + event( + 'hub-discharge', + 'container.transport.transshipment_discharged', + '2026-08-04T12:00:00Z', + 'port-pod', + ), + event( + 'hub-load', + 'container.transport.transshipment_loaded', + '2026-08-05T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect( + eventsFrom(result).find((item) => item.event_code === 'LOAD'), + ).toMatchObject({ status: 'CLT' }); + expect(responseData(result).route).toMatchObject({ + pol: { date: '2026-08-01 12:00:00', location: 1 }, + }); + }); + it('maps hub and last sea arrivals to VAT and VAD by order', () => { const result = mapTrackingPayload( payload([ @@ -367,6 +418,24 @@ describe('SeaRates positional event mapping', () => { }); }); + it('selects the requested container rather than the first shipment sibling', () => { + const trackingPayload = payload([], 'picked_up', 'CT', 'MSCU1234567'); + trackingPayload.included = [ + { + id: 'sibling', + type: 'container', + attributes: { + number: 'TCLU7654321', + current_status: 'on_ship', + }, + }, + ...trackingPayload.included, + ]; + expect(responseData(mapTrackingPayload(trackingPayload))).toMatchObject({ + containers: [{ number: 'MSCU1234567' }], + }); + }); + it('formats offset timestamps in the official SeaRates date shape', () => { const result = mapTrackingPayload( payload([ diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index 78474fa6..d055e416 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -64,6 +64,12 @@ function numberValue(value: unknown): number | null { return typeof value === 'number' ? value : null; } +function normalizeNumber(value: unknown): string { + return typeof value === 'string' + ? value.replace(/\s+/g, '').toUpperCase() + : ''; +} + function formatParts(date: Date, timeZone: string): string | null { try { const parts = new Intl.DateTimeFormat('en-CA', { @@ -393,15 +399,33 @@ function isSea(event: EventDraft): boolean { } function assignStatuses(events: EventDraft[]): void { - const seaLoads = events.filter( - (event) => event.code === 'LOAD' && isSea(event), + const seaEvents = events.filter(isSea); + const originLoad = seaEvents.find( + (event, index) => + event.code === 'LOAD' && + !seaEvents + .slice(0, index) + .some((earlier) => + ['ARRI', 'DEPA', 'DISC', 'LOAD'].includes(earlier.code || ''), + ), ); - const seaDepartures = events.filter( - (event) => event.code === 'DEPA' && isSea(event), + const originDeparture = seaEvents.find( + (event, index) => + event.code === 'DEPA' && + !seaEvents + .slice(0, index) + .some((earlier) => + ['ARRI', 'DEPA', 'DISC'].includes(earlier.code || ''), + ) && + !seaEvents + .slice(0, index) + .some((earlier) => earlier.code === 'LOAD' && earlier !== originLoad), ); - const firstSeaBoundary = [...seaLoads, ...seaDepartures].sort( - (left, right) => (left.instant ?? Infinity) - (right.instant ?? Infinity), - )[0]; + const firstSeaBoundary = [originLoad, originDeparture] + .filter((event): event is EventDraft => Boolean(event)) + .sort( + (left, right) => (left.instant ?? Infinity) - (right.instant ?? Infinity), + )[0]; const lastSeaArrival = [...events] .reverse() .find((event) => event.code === 'ARRI' && isSea(event)); @@ -446,10 +470,10 @@ function assignStatuses(events: EventDraft[]): void { event.status = 'CDC'; } else if (event.code === 'LOAD') { event.status = - event === seaLoads[0] ? 'CLL' : isSea(event) ? 'CLT' : 'LTS'; + event === originLoad ? 'CLL' : isSea(event) ? 'CLT' : 'LTS'; } else if (event.code === 'DEPA') { event.status = - event === seaDepartures[0] ? 'VDL' : isSea(event) ? 'VDT' : 'LTS'; + event === originDeparture ? 'VDL' : isSea(event) ? 'VDT' : 'LTS'; } else if (event.code === 'ARRI') { const laterSailing = events.some( (later) => @@ -521,12 +545,72 @@ function assignStatuses(events: EventDraft[]): void { } function samePlace(left: EventDraft, right: EventDraft): boolean { - const leftPlace = left.location ?? left.facility; - const rightPlace = right.location ?? right.facility; - return leftPlace === rightPlace || leftPlace === null || rightPlace === null; + const leftLocated = left.location !== null || left.facility !== null; + const rightLocated = right.location !== null || right.facility !== null; + if (!leftLocated || !rightLocated) return true; + if ( + left.location !== null && + right.location !== null && + left.location !== right.location + ) { + return false; + } + if ( + left.facility !== null && + right.facility !== null && + left.facility !== right.facility + ) { + return false; + } + return ( + (left.location !== null && right.location !== null) || + (left.facility !== null && right.facility !== null) + ); +} + +function preferEvent(existing: EventDraft, event: EventDraft): EventDraft { + const eventLocated = event.location !== null || event.facility !== null; + const existingLocated = + existing.location !== null || existing.facility !== null; + if ( + (eventLocated && !existingLocated) || + (eventLocated === existingLocated && event.actual && !existing.actual) || + (eventLocated === existingLocated && + event.actual === existing.actual && + event.order > existing.order) + ) { + return event; + } + return existing; } -function deduplicate(events: EventDraft[]): EventDraft[] { +function deduplicateRaw(events: EventDraft[]): EventDraft[] { + const kept: EventDraft[] = []; + for (const event of events) { + const isExplicitLand = + event.transport === 'RAIL' || + (event.transport === 'TRUCK' && event.explicitConveyance); + if (!event.code || isExplicitLand || !event.instantKey) { + kept.push(event); + continue; + } + const duplicateIndex = kept.findIndex( + (candidate) => + candidate.code === event.code && + candidate.transport === event.transport && + candidate.instantKey === event.instantKey && + samePlace(candidate, event), + ); + if (duplicateIndex < 0) { + kept.push(event); + } else { + kept[duplicateIndex] = preferEvent(kept[duplicateIndex], event); + } + } + return kept; +} + +function deduplicateMilestones(events: EventDraft[]): EventDraft[] { const kept: EventDraft[] = []; for (const event of events) { if (event.status === 'LTS' || event.status === 'UNKN') { @@ -544,19 +628,7 @@ function deduplicate(events: EventDraft[]): EventDraft[] { kept.push(event); continue; } - const existing = kept[duplicateIndex]; - const eventLocated = event.location !== null || event.facility !== null; - const existingLocated = - existing.location !== null || existing.facility !== null; - if ( - (eventLocated && !existingLocated) || - (eventLocated === existingLocated && event.actual && !existing.actual) || - (eventLocated === existingLocated && - event.actual === existing.actual && - event.order > existing.order) - ) { - kept[duplicateIndex] = event; - } + kept[duplicateIndex] = preferEvent(kept[duplicateIndex], event); } return kept.sort((left, right) => { if (left.instant === null && right.instant === null) { @@ -593,9 +665,9 @@ function buildTimeline( ids: EventIds, locationByNumber: Map, ): EventDraft[] { - const drafts = draftEvents(resources, ids, locationByNumber); + const drafts = deduplicateRaw(draftEvents(resources, ids, locationByNumber)); assignStatuses(drafts); - return deduplicate(drafts); + return deduplicateMilestones(drafts); } function routePoint(event: EventDraft | undefined): { @@ -645,9 +717,17 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { vessels, }; const shipmentAttributes = attrs(payload.shipment); - const containerResources = payload.included.filter( + const allContainerResources = payload.included.filter( (resource) => resource.type === 'container', ); + const containerResources = + payload.requestedType === 'CT' + ? allContainerResources.filter( + (resource) => + normalizeNumber(attrs(resource).number) === + normalizeNumber(payload.requestedNumber), + ) + : allContainerResources; const timelines = new Map(); const statuses: string[] = []; diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index b157f8ba..289d8b18 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -167,18 +167,36 @@ describe('SeaRates compatibility gateway', () => { it('does not serve the pre-refresh shipment when refresh stays unresolved', async () => { let refreshCalls = 0; + let refreshAccepted = false; + const requestOnlyUpdate = { + ...shipmentFixture, + included: (shipmentFixture.included || []).map((resource) => + resource.type === 'container' + ? { + ...resource, + attributes: { + ...resource.attributes, + pod_last_tracking_request_at: '2026-08-20T12:00:00Z', + shipment_last_tracking_request_at: '2026-08-20T12:00:00Z', + }, + } + : resource, + ), + }; const gateway = new SeaRatesCompatibilityGateway({ - pollTimeoutMs: 0, + pollIntervalMs: 0, + pollTimeoutMs: 5, fetchImpl: async (input, init) => { const url = String(input); if (init?.method === 'PATCH') { refreshCalls += 1; + refreshAccepted = true; return response({ data: null }); } if (url.includes('/shipments?')) { return response({ data: [shipmentFixture.data] }); } - return response(shipmentFixture); + return response(refreshAccepted ? requestOnlyUpdate : shipmentFixture); }, }); From 9eb04f45bd6224518d33201f45f99011947d58ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:57:19 +0000 Subject: [PATCH 09/12] test: assert shipment fallback timestamp basis Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/mapping.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 00b08aa6..ab227e3c 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -206,7 +206,7 @@ describe('SeaRates positional event mapping', () => { eventsFrom(result).find((item) => item.event_code === 'LOAD'), ).toMatchObject({ status: 'CLT' }); expect(responseData(result).route).toMatchObject({ - pol: { date: '2026-08-01 12:00:00', location: 1 }, + pol: { date: '2026-08-01 10:00:00', location: 1 }, }); }); From 42e004248fd51bb3fad1785226612e6292ef3230 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 20:00:05 +0000 Subject: [PATCH 10/12] fix: isolate CT work and preserve explicit hub stages Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/mapping.test.ts | 54 +++++++++++++++++++ packages/searates-compat/src/mapping.ts | 40 ++++++++++++-- packages/searates-compat/src/service.test.ts | 57 ++++++++++++++++++++ packages/searates-compat/src/service.ts | 38 ++++++++++++- 4 files changed, 182 insertions(+), 7 deletions(-) diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index ab227e3c..783cdc9d 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -210,6 +210,36 @@ describe('SeaRates positional event mapping', () => { }); }); + it('keeps explicit transshipment events at hub milestones on truncated timelines', () => { + const result = mapTrackingPayload( + payload([ + event( + 'hub-load', + 'container.transport.transshipment_loaded', + '2026-08-05T10:00:00Z', + 'port-pod', + ), + event( + 'hub-depart', + 'container.transport.transshipment_departed', + '2026-08-06T10:00:00Z', + 'port-pod', + ), + event( + 'hub-arrive', + 'container.transport.transshipment_arrived', + '2026-08-07T10:00:00Z', + 'port-pod', + ), + ]), + ); + expect(eventsFrom(result).map((item) => item.status)).toEqual([ + 'CLT', + 'VDT', + 'VAT', + ]); + }); + it('maps hub and last sea arrivals to VAT and VAD by order', () => { const result = mapTrackingPayload( payload([ @@ -436,6 +466,30 @@ describe('SeaRates positional event mapping', () => { }); }); + it('merges sparse event includes without discarding richer port data', () => { + const trackingPayload = payload([ + event( + 'depart', + 'container.transport.vessel_departed', + '2026-08-01T10:00:00Z', + ), + ]); + const eventDocument = + trackingPayload.eventsByContainerId.get('container-1'); + if (!eventDocument) throw new Error('Expected event fixture document'); + eventDocument.included = [ + { + id: 'port-pol', + type: 'port', + attributes: { name: 'Le Havre' }, + }, + ...(eventDocument.included || []), + ]; + expect( + responseData(mapTrackingPayload(trackingPayload)).locations, + ).toMatchObject([{ locode: 'FRLEH', lat: 49.49, lng: 0.1 }]); + }); + it('formats offset timestamps in the official SeaRates date shape', () => { const result = mapTrackingPayload( payload([ diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index d055e416..dcbfb051 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -133,9 +133,22 @@ function collectResources(payload: TrackingPayload): JsonApiResource[] { for (const resource of resources) { const key = `${resource.type}:${resource.id}`; const existing = unique.get(key); - if (!existing || Object.keys(attrs(resource)).length > 0) { + if (!existing) { unique.set(key, resource); + continue; } + unique.set(key, { + ...existing, + ...resource, + attributes: { + ...existing.attributes, + ...resource.attributes, + }, + relationships: { + ...existing.relationships, + ...resource.relationships, + }, + }); } return [...unique.values()]; } @@ -403,6 +416,7 @@ function assignStatuses(events: EventDraft[]): void { const originLoad = seaEvents.find( (event, index) => event.code === 'LOAD' && + !/transshipment/.test(event.name) && !seaEvents .slice(0, index) .some((earlier) => @@ -412,6 +426,7 @@ function assignStatuses(events: EventDraft[]): void { const originDeparture = seaEvents.find( (event, index) => event.code === 'DEPA' && + !/transshipment/.test(event.name) && !seaEvents .slice(0, index) .some((earlier) => @@ -428,7 +443,12 @@ function assignStatuses(events: EventDraft[]): void { )[0]; const lastSeaArrival = [...events] .reverse() - .find((event) => event.code === 'ARRI' && isSea(event)); + .find( + (event) => + event.code === 'ARRI' && + isSea(event) && + !/transshipment/.test(event.name), + ); const finalSeaDischarge = [...events] .reverse() .find( @@ -470,10 +490,18 @@ function assignStatuses(events: EventDraft[]): void { event.status = 'CDC'; } else if (event.code === 'LOAD') { event.status = - event === originLoad ? 'CLL' : isSea(event) ? 'CLT' : 'LTS'; + event === originLoad && !/transshipment/.test(event.name) + ? 'CLL' + : isSea(event) + ? 'CLT' + : 'LTS'; } else if (event.code === 'DEPA') { event.status = - event === originDeparture ? 'VDL' : isSea(event) ? 'VDT' : 'LTS'; + event === originDeparture && !/transshipment/.test(event.name) + ? 'VDL' + : isSea(event) + ? 'VDT' + : 'LTS'; } else if (event.code === 'ARRI') { const laterSailing = events.some( (later) => @@ -490,7 +518,9 @@ function assignStatuses(events: EventDraft[]): void { firstSeaBoundary?.instant !== undefined && event.instant < firstSeaBoundary.instant) ? 'LTS' - : event === lastSeaArrival && !laterSailing + : event === lastSeaArrival && + !laterSailing && + !/transshipment/.test(event.name) ? 'VAD' : 'VAT'; } else if (event.code === 'DISC') { diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 289d8b18..1cd31f6e 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -165,6 +165,63 @@ describe('SeaRates compatibility gateway', () => { expect(refreshCalls).toBe(0); }); + it('fetches events only for the requested CT shipment member', async () => { + if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { + throw new Error('Shipment fixture must contain one resource'); + } + const sibling = { + id: 'container-sibling', + type: 'container', + attributes: { + number: 'TCLU7654321', + current_status: 'on_ship', + }, + }; + const shipmentDocument = { + ...shipmentFixture, + included: [sibling, ...(shipmentFixture.included || [])], + }; + const eventContainerIds: string[] = []; + const gateway = new SeaRatesCompatibilityGateway({ + fetchImpl: async (input) => { + const url = String(input); + if (url.includes('/containers?')) { + return response({ + data: [ + { + id: 'container-1', + type: 'container', + relationships: { + shipment: { + data: { id: 'shipment-1', type: 'shipment' }, + }, + }, + }, + ], + }); + } + if (url.includes('/shipments/shipment-1')) { + return response(shipmentDocument); + } + const eventMatch = url.match(/\/containers\/([^/]+)\/transport_events/); + if (eventMatch?.[1]) { + eventContainerIds.push(eventMatch[1]); + return response(eventsFixture); + } + throw new Error(`Unexpected fixture request: ${url}`); + }, + }); + + await expect( + gateway.tracking('pass-through-key', { + ...query, + number: 'MSCU1234567', + type: 'CT', + }), + ).resolves.toMatchObject({ status: 'success', message: 'OK' }); + expect(eventContainerIds).toEqual(['container-1']); + }); + it('does not serve the pre-refresh shipment when refresh stays unresolved', async () => { let refreshCalls = 0; let refreshAccepted = false; diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts index 7dfa10f8..6d40e371 100644 --- a/packages/searates-compat/src/service.ts +++ b/packages/searates-compat/src/service.ts @@ -56,6 +56,26 @@ function containerResources(document: JsonApiDocument): JsonApiResource[] { ); } +function normalizeNumber(value: unknown): string { + return typeof value === 'string' + ? value.replace(/\s+/g, '').toUpperCase() + : ''; +} + +function requestedContainers( + resources: JsonApiResource[], + type: TrackingType, + number: string, +): JsonApiResource[] { + return type === 'CT' + ? resources.filter( + (resource) => + normalizeNumber(resource.attributes?.number) === + normalizeNumber(number), + ) + : resources; +} + function upstreamErrorMessage(error: Terminal49ApiError): string { if (error.status === 401) return 'API_KEY_WRONG'; if (error.status === 403) return 'API_KEY_ACCESS_DENIED'; @@ -120,7 +140,14 @@ export class SeaRatesCompatibilityGateway { if (!shipment) { return noTrackingInfoEnvelope(); } - let containers = containerResources(shipmentDocument); + let containers = requestedContainers( + containerResources(shipmentDocument), + type, + query.number, + ); + if (type === 'CT' && containers.length === 0) { + return noTrackingInfoEnvelope(); + } if (query.forceUpdate && containers.length > 0) { if (containers.length > 10) { @@ -137,7 +164,14 @@ export class SeaRatesCompatibilityGateway { shipmentDocument = refreshedDocument; shipment = shipmentFrom(shipmentDocument); if (!shipment) return noTrackingInfoEnvelope(); - containers = containerResources(shipmentDocument); + containers = requestedContainers( + containerResources(shipmentDocument), + type, + query.number, + ); + if (type === 'CT' && containers.length === 0) { + return noTrackingInfoEnvelope(); + } } const eventsByContainerId = new Map(); From 8c2d50ca61675aa8f024f3c5b033f8d8a5644dfe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 20:00:29 +0000 Subject: [PATCH 11/12] test: match retained location among route ports Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/mapping.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 783cdc9d..d767f2bb 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -485,9 +485,15 @@ describe('SeaRates positional event mapping', () => { }, ...(eventDocument.included || []), ]; - expect( - responseData(mapTrackingPayload(trackingPayload)).locations, - ).toMatchObject([{ locode: 'FRLEH', lat: 49.49, lng: 0.1 }]); + expect(responseData(mapTrackingPayload(trackingPayload)).locations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + locode: 'FRLEH', + lat: 49.49, + lng: 0.1, + }), + ]), + ); }); it('formats offset timestamps in the official SeaRates date shape', () => { From 5d06966507f7766e8a4df79256f06f21c13a4fe7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 20:05:35 +0000 Subject: [PATCH 12/12] docs: document SeaRates compatibility gateway Co-authored-by: Akshay Dodeja --- docs/migrate/searates.mdx | 40 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/migrate/searates.mdx b/docs/migrate/searates.mdx index bccb017b..35b92464 100644 --- a/docs/migrate/searates.mdx +++ b/docs/migrate/searates.mdx @@ -6,7 +6,45 @@ description: "Map SeaRates tracking API fields, parameters, and errors to their If you have the SeaRates tracking API in production, this page maps it onto Terminal49 field by field, so you can cut over without reverse-engineering our schema. -There is no compatibility shim. You will change your request code and your response parsing. For most integrations that is an afternoon. +You can migrate in either of two ways: + +- **Keep the SeaRates wire format:** point your existing client at the Terminal49 SeaRates compatibility gateway. +- **Adopt the native Terminal49 API:** use the field mappings and webhook workflow in this guide. + +## Keep your existing SeaRates client + +The compatibility gateway accepts the SeaRates `api_key`, `number`, `sealine`, and `type` query parameters, then returns the SeaRates tracking envelope backed by Terminal49 data. + +Set your client's base URL to: + +```text +https://migrate.terminal49.com/searates-api +``` + +The gateway provides: + +- `GET /tracking` +- `GET /container` for the deprecated singular-container response +- `GET /reference` for the deprecated Bill of Lading (BOL) and booking response +- `GET /info/sealines` + +For example: + +```bash +curl "https://migrate.terminal49.com/searates-api/tracking\ +?api_key=YOUR_T49_API_KEY\ +&number=MRKU9465770\ +&sealine=MAEU\ +&type=CT" +``` + +The compatibility deployment is separate from `mcp.terminal49.com`. If the custom domain is not available in your environment yet, use the Vercel deployment hostname with the same `/searates-api` path. + + + The gateway covers ocean container, BOL, and booking tracking only. It does not implement SeaRates rates, schedules, air, parcel, road, AIS, route geometry, or history products. + + +Continue with the rest of this guide when you are ready to adopt Terminal49's native JSON:API and webhook model. ## Start in sixty seconds