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/apps/migrate/.env.example b/apps/migrate/.env.example new file mode 100644 index 00000000..8ef02608 --- /dev/null +++ b/apps/migrate/.env.example @@ -0,0 +1,12 @@ +# 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 +# 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/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/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/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..70806552 --- /dev/null +++ b/apps/migrate/vercel.json @@ -0,0 +1,38 @@ +{ + "$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/container.ts": { + "maxDuration": 30 + }, + "api/reference.ts": { + "maxDuration": 30 + }, + "api/info/sealines.ts": { + "maxDuration": 15 + } + }, + "rewrites": [ + { + "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/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 diff --git a/package-lock.json b/package-lock.json index d0d920f6..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,10 +5382,18 @@ "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 }, + "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 +18578,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..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/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 new file mode 100644 index 00000000..4aeff31f --- /dev/null +++ b/packages/searates-compat/README.md @@ -0,0 +1,144 @@ +# 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 /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 +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/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 +configured, deployments use their Vercel preview hostname with the same +`/searates-api/...` paths. + +## 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: Token `. + +### 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://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`, +`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://migrate.terminal49.com/searates-api/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 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 `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 + +- 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. + +## 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/packages/searates-compat/package.json b/packages/searates-compat/package.json new file mode 100644 index 00000000..9dd0a1a3 --- /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 src", + "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..ee94c493 --- /dev/null +++ b/packages/searates-compat/src/client.test.ts @@ -0,0 +1,158 @@ +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: '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) => { + 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..1d7797d8 --- /dev/null +++ b/packages/searates-compat/src/client.ts @@ -0,0 +1,296 @@ +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; + requestTimeoutMs?: 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 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 shipment = Array.isArray(document.data) + ? document.data.find((resource) => resource.type === 'shipment') + : document.data?.type === 'shipment' + ? document.data + : undefined; + return String(shipment?.attributes?.line_tracking_last_succeeded_at || ''); +} + +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 requestTimeoutMs: 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; + this.requestTimeoutMs = config.requestTimeoutMs ?? 10_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, timeoutMs?: number): Promise { + return this.request( + `/shipments/${encodeURIComponent(id)}?include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal`, + {}, + timeoutMs, + ); + } + + 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 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; + type: TrackingType; + }): Promise< + | { failedReason: string; state: 'failed' } + | { state: 'pending' } + | { shipmentId: string; state: 'created' } + > { + const existing = await this.trackingRequests(input.number, input.scac); + let requestResource = selectTrackingRequest( + resourceArray(existing), + trackingType(input.type), + ); + + 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 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] + : (next.data ?? requestResource); + } + } + + private async trackingRequests( + number: string, + scac?: string, + ): Promise { + const params = new URLSearchParams({ + 'filter[request_number]': number, + include: 'tracked_object', + 'page[size]': '30', + }); + if (scac) params.set('filter[scac]', scac); + return this.request(`/tracking_requests?${params.toString()}`); + } + + 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: `Token ${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.test.ts b/packages/searates-compat/src/http.test.ts new file mode 100644 index 00000000..91de4ae9 --- /dev/null +++ b/packages/searates-compat/src/http.test.ts @@ -0,0 +1,150 @@ +import type { IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vite-plus/test'; +import { + createContainerHandler, + createReferenceHandler, + 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('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: {}, + }); + + 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 () => { + 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: {}, + }); + }); +}); diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts new file mode 100644 index 00000000..3534e2a7 --- /dev/null +++ b/packages/searates-compat/src/http.ts @@ -0,0 +1,178 @@ +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'; + +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); + 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, + pollIntervalMs: + Number.isFinite(pollInterval) && pollInterval >= 0 + ? pollInterval + : undefined, + pollTimeoutMs: + Number.isFinite(pollTimeout) && pollTimeout >= 0 + ? pollTimeout + : undefined, + requestTimeoutMs: + Number.isFinite(requestTimeout) && requestTimeout > 0 + ? requestTimeout + : 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()), + options: { + allowedTypes?: readonly TrackingType[]; + defaultType?: TrackingType; + forcedType?: TrackingType; + singularContainer?: boolean; + } = {}, +) { + 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: {}, + }); + 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'); + const requestedType = trackingType(rawType); + const parsedType = + options.forcedType || requestedType || options.defaultType; + if ( + (!options.forcedType && rawType && !requestedType) || + (parsedType && + options.allowedTypes && + !options.allowedTypes.includes(parsedType)) + ) { + response.status(200).json({ + status: 'error', + message: 'WRONG_TYPE', + data: {}, + }); + return; + } + const query: TrackingQuery = { + ais: booleanParam(params, 'ais'), + forceUpdate: booleanParam(params, 'force_update'), + number, + route: booleanParam(params, 'route'), + sealine, + type: parsedType, + }; + const envelope = await gateway.tracking(first(params, 'api_key'), query); + response + .status(200) + .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'], + defaultType: 'BL', + }); +} + +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: {}, + }); + 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..ea384280 --- /dev/null +++ b/packages/searates-compat/src/index.ts @@ -0,0 +1,28 @@ +export { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +export { + createContainerHandler, + createReferenceHandler, + createShippingLinesHandler, + createTrackingHandler, +} from './http.js'; +export { + mapShippingLines, + mapTrackingPayload, + noTrackingInfoEnvelope, + toContainerEnvelope, +} from './mapping.js'; +export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; +export type { + JsonApiDocument, + JsonApiResource, + SeaRatesEnvelope, + SeaRatesEvent, + SeaRatesEventCode, + 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..d767f2bb --- /dev/null +++ b/packages/searates-compat/src/mapping.test.ts @@ -0,0 +1,550 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { shipmentFixture, shippingLinesFixture } from './__fixtures__/t49.js'; +import { mapShippingLines, mapTrackingPayload } from './mapping.js'; +import type { + JsonApiResource, + SeaRatesEvent, + 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, +): SeaRatesEvent[] { + 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'); + } + // 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', () => { + 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('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 10:00:00', location: 1 }, + }); + }); + + 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([ + 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', + }); + } + }); + + 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: 'CT', + number: 'MSCU1234567', + status: 'PLANNED', + }, + containers: [ + { + iso_code: '45U1', + size_type: "40' High Cube Open Top", + }, + ], + }); + }); + + 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('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).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + locode: 'FRLEH', + lat: 49.49, + lng: 0.1, + }), + ]), + ); + }); + + 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', + message: 'OK', + data: [ + { + name: 'Mediterranean Shipping Company', + short_name: 'MSC', + 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..dcbfb051 --- /dev/null +++ b/packages/searates-compat/src/mapping.ts @@ -0,0 +1,1007 @@ +import type { + JsonApiDocument, + JsonApiResource, + JsonObject, + SeaRatesEnvelope, + SeaRatesEvent, + SeaRatesEventCode, + TrackingPayload, +} from './types.js'; + +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' | null; + explicitConveyance: boolean; + facility: number | null; + instant: number | null; + instantKey: string; + location: number | null; + name: string; + order: number; + status: string; + transport: Conveyance | null; + type: 'land' | 'sea'; + vessel: number | null; + voyage: string | null; + date: string | null; +} + +const SEA_OPERATION_CODES = new Set([ + 'ARRI', + 'DEPA', + 'DISC', + 'LOAD', +]); + +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 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 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', { + 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) { + unique.set(key, resource); + continue; + } + unique.set(key, { + ...existing, + ...resource, + attributes: { + ...existing.attributes, + ...resource.attributes, + }, + relationships: { + ...existing.relationships, + ...resource.relationships, + }, + }); + } + 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) + ?.toLowerCase() + .replaceAll('_', ' '); + if (!length || !height || !type) return { isoCode: null, sizeType: null }; + + 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: + lengthCode && typeCode ? `${lengthCode}${heightCode}${typeCode}` : null, + sizeType: label ? `${length}'${heightLabel} ${label}` : null, + }; +} + +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', + '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'; + } + if (['booked', 'created', 'new', 'planned'].includes(normalized)) { + return 'PLANNED'; + } + return events.length > 0 ? 'PLANNED' : 'UNKNOWN'; +} + +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 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', + }; + } + if (/\b(barge|feeder|waterway)\b/.test(text)) { + return { explicit: true, transport: 'BARGE' }; + } + 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' }; +} + +function eventLocation( + resource: JsonApiResource, + 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 seaEvents = events.filter(isSea); + const originLoad = seaEvents.find( + (event, index) => + event.code === 'LOAD' && + !/transshipment/.test(event.name) && + !seaEvents + .slice(0, index) + .some((earlier) => + ['ARRI', 'DEPA', 'DISC', 'LOAD'].includes(earlier.code || ''), + ), + ); + const originDeparture = seaEvents.find( + (event, index) => + event.code === 'DEPA' && + !/transshipment/.test(event.name) && + !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 = [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) && + !/transshipment/.test(event.name), + ); + 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 === originLoad && !/transshipment/.test(event.name) + ? 'CLL' + : isSea(event) + ? 'CLT' + : 'LTS'; + } else if (event.code === 'DEPA') { + event.status = + event === originDeparture && !/transshipment/.test(event.name) + ? '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 && + !/transshipment/.test(event.name) + ? '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 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 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') { + 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; + } + kept[duplicateIndex] = preferEvent(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: 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: 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 = deduplicateRaw(draftEvents(resources, ids, locationByNumber)); + assignStatuses(drafts); + return deduplicateMilestones(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); + 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 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 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[] = []; + + 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 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' + : statuses.length > 0 && statuses.every((status) => status === 'DELIVERED') + ? 'DELIVERED' + : statuses.includes('PLANNED') + ? 'PLANNED' + : '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, + }); + } + + return { + status: 'success', + message: 'OK', + data: { + metadata: { + type: payload.requestedType, + number: payload.requestedNumber, + sealine: stringValue(shipmentAttributes.shipping_line_scac), + sealine_name: stringValue(shipmentAttributes.shipping_line_name), + status: metadataStatus, + is_status_from_sealine: false, + 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, + pol, + pod: { + ...pod, + predictive_eta: formatDate( + shipmentAttributes.pod_eta_at, + stringValue(shipmentAttributes.pod_timezone), + ), + }, + postpod, + }, + vessels: vesselList, + containers, + }, + }; +} + +export function noTrackingInfoEnvelope(): SeaRatesEnvelope { + return { status: 'error', message: 'NO_TRACKING_INFO', data: {} }; +} + +export function toContainerEnvelope( + envelope: SeaRatesEnvelope, +): SeaRatesEnvelope { + 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: JsonObject = { + ...data, + container: containers[0] ?? null, + }; + delete singular.containers; + return { ...envelope, data: singular }; +} + +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), + short_name: stringValue(attributes.short_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..1cd31f6e --- /dev/null +++ b/packages/searates-compat/src/service.test.ts @@ -0,0 +1,285 @@ +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: 'WRONG_PARAMETERS', + data: {}, + }); + await expect(gateway.tracking('wrong-key', query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_WRONG', + data: {}, + }); + }); + + 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: {}, + }); + }); + + it('returns NO_TRACKING_INFO 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: { + request_type: 'bill_of_lading', + status: 'pending', + }, + relationships: { tracked_object: { data: null } }, + }, + ], + }); + } + throw new Error(`Unexpected fixture request: ${url}`); + }, + }); + + 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, forceUpdate: true }), + ).resolves.toEqual({ + status: 'error', + message: 'API_KEY_RATE_LIMIT', + data: {}, + }); + 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; + 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({ + 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(refreshAccepted ? requestOnlyUpdate : 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 () => { + const gateway = new SeaRatesCompatibilityGateway({ + serviceApiToken: 'service-token', + fetchImpl: async (input) => { + expect(String(input).endsWith('/shipping_lines')).toBe(true); + 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..6d40e371 --- /dev/null +++ b/packages/searates-compat/src/service.ts @@ -0,0 +1,259 @@ +import { timingSafeEqual } from 'node:crypto'; +import { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +import { + mapShippingLines, + mapTrackingPayload, + noTrackingInfoEnvelope, +} 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; + requestTimeoutMs?: number; + serviceApiToken?: string; +} + +function errorEnvelope(message: string): SeaRatesEnvelope { + return { status: 'error', message, data: {} }; +} + +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 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'; + 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('WRONG_PARAMETERS'); + 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') { + if (resolution.failedReason === 'scac_auto_detect_failed') { + return errorEnvelope('AUTO_CANT_DETECT_SEALINE'); + } + if (resolution.failedReason === 'invalid_number') { + return errorEnvelope('WRONG_NUMBER'); + } + return noTrackingInfoEnvelope(); + } + if (resolution.state === 'pending') { + return noTrackingInfoEnvelope(); + } + shipmentDocument = await client.shipment(resolution.shipmentId); + } + + let shipment = shipmentFrom(shipmentDocument); + if (!shipment) { + return noTrackingInfoEnvelope(); + } + 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) { + return errorEnvelope('API_KEY_RATE_LIMIT'); + } + for (const container of containers) { + await client.refreshContainer(container.id); + } + const refreshedDocument = await client.waitForShipmentUpdate( + shipment.id, + shipmentDocument, + ); + if (!refreshedDocument) return noTrackingInfoEnvelope(); + shipmentDocument = refreshedDocument; + shipment = shipmentFrom(shipmentDocument); + if (!shipment) return noTrackingInfoEnvelope(); + containers = requestedContainers( + containerResources(shipmentDocument), + type, + query.number, + ); + if (type === 'CT' && containers.length === 0) { + return noTrackingInfoEnvelope(); + } + } + + 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' : 'WRONG_PARAMETERS'); + } + 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, + requestTimeoutMs: this.config.requestTimeoutMs, + }; + 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..23da46d8 --- /dev/null +++ b/packages/searates-compat/src/types.ts @@ -0,0 +1,97 @@ +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 extends JsonObject { + actual: boolean; + date: string | null; + description: string; + 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' | 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; + 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" + ] }