@freighttech/terminal-tracking 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/generated/entities/terminal_config/index.js +53 -0
- package/dist/generated/entities/terminal_config/index.js.map +7 -0
- package/dist/generated/entities/terminal_event/index.js +59 -0
- package/dist/generated/entities/terminal_event/index.js.map +7 -0
- package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
- package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +26 -0
- package/dist/generated/entities.ids.generated.js.map +7 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/terminal_tracking/acl.js +14 -0
- package/dist/modules/terminal_tracking/acl.js.map +7 -0
- package/dist/modules/terminal_tracking/api/openapi.js +27 -0
- package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/utils.js +12 -0
- package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/ce.js +35 -0
- package/dist/modules/terminal_tracking/ce.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/index.js +4 -0
- package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
- package/dist/modules/terminal_tracking/data/entities.js +337 -0
- package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
- package/dist/modules/terminal_tracking/data/validators.js +98 -0
- package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
- package/dist/modules/terminal_tracking/di.js +39 -0
- package/dist/modules/terminal_tracking/di.js.map +7 -0
- package/dist/modules/terminal_tracking/encryption.js +14 -0
- package/dist/modules/terminal_tracking/encryption.js.map +7 -0
- package/dist/modules/terminal_tracking/events.js +45 -0
- package/dist/modules/terminal_tracking/events.js.map +7 -0
- package/dist/modules/terminal_tracking/i18n/de.json +175 -0
- package/dist/modules/terminal_tracking/i18n/en.json +178 -0
- package/dist/modules/terminal_tracking/i18n/es.json +175 -0
- package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
- package/dist/modules/terminal_tracking/index.js +15 -0
- package/dist/modules/terminal_tracking/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/availability.js +40 -0
- package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/holds.js +240 -0
- package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/logger.js +19 -0
- package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/stops.js +48 -0
- package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/totp.js +41 -0
- package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
- package/dist/modules/terminal_tracking/setup.js +23 -0
- package/dist/modules/terminal_tracking/setup.js.map +7 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
- package/generated/entities/terminal_config/index.ts +36 -0
- package/generated/entities/terminal_event/index.ts +39 -0
- package/generated/entities/terminal_tracking_job/index.ts +28 -0
- package/generated/entities/terminal_vessel_visit/index.ts +30 -0
- package/generated/entities.ids.generated.ts +52 -0
- package/package.json +80 -0
- package/src/index.ts +44 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
- package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
- package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
- package/src/modules/terminal_tracking/acl.ts +10 -0
- package/src/modules/terminal_tracking/api/openapi.ts +28 -0
- package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
- package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
- package/src/modules/terminal_tracking/api/utils.ts +10 -0
- package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
- package/src/modules/terminal_tracking/ce.ts +31 -0
- package/src/modules/terminal_tracking/commands/index.ts +3 -0
- package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
- package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
- package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
- package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
- package/src/modules/terminal_tracking/data/entities.ts +386 -0
- package/src/modules/terminal_tracking/data/validators.ts +113 -0
- package/src/modules/terminal_tracking/di.ts +46 -0
- package/src/modules/terminal_tracking/encryption.ts +12 -0
- package/src/modules/terminal_tracking/events.ts +48 -0
- package/src/modules/terminal_tracking/i18n/de.json +175 -0
- package/src/modules/terminal_tracking/i18n/en.json +178 -0
- package/src/modules/terminal_tracking/i18n/es.json +175 -0
- package/src/modules/terminal_tracking/i18n/pl.json +206 -0
- package/src/modules/terminal_tracking/index.ts +13 -0
- package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
- package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
- package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
- package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
- package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
- package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
- package/src/modules/terminal_tracking/lib/availability.ts +119 -0
- package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
- package/src/modules/terminal_tracking/lib/holds.ts +344 -0
- package/src/modules/terminal_tracking/lib/logger.ts +21 -0
- package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
- package/src/modules/terminal_tracking/lib/stops.ts +124 -0
- package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
- package/src/modules/terminal_tracking/lib/totp.ts +63 -0
- package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
- package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
- package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
- package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
- package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
- package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
- package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
- package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
- package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
- package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
- package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
- package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
- package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
- package/src/modules/terminal_tracking/setup.ts +24 -0
- package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
|
|
2
|
+
import type { GctToken } from '../types'
|
|
3
|
+
import { n4Request } from '../../n4/http'
|
|
4
|
+
import { generateTotp } from '../../../totp'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GCT token acquisition + process-level cache.
|
|
8
|
+
*
|
|
9
|
+
* GCT mints a token from a plain `GET /gctapi/Auth/{company}/{login}/{password}`
|
|
10
|
+
* (credentials in the path — see the security note below), returning an ISO
|
|
11
|
+
* `expires` instant. The token is then replayed in the `Authorization` header on
|
|
12
|
+
* every data call. This mirrors `../../n4/auth/ropc.ts` in shape (cache keyed by
|
|
13
|
+
* principal, 30s safety margin, invalidate-on-401) but not in wire protocol.
|
|
14
|
+
*
|
|
15
|
+
* LIFECYCLE: a cached token is reused until 30s before `expires`. Once it lapses
|
|
16
|
+
* we first try to REFRESH it via the keep-alive endpoint (`GET /gctapi/ping`,
|
|
17
|
+
* current token in the header) — cheap, no 2FA code — and only RE-AUTHENTICATE
|
|
18
|
+
* (mint a new token with a fresh authenticator code) when the refresh fails.
|
|
19
|
+
*
|
|
20
|
+
* AUTH: GCT's login expects an authenticator one-time code where a password would
|
|
21
|
+
* normally go. We store the base32 authenticator *secret* (the seed behind the
|
|
22
|
+
* enrollment QR) in `auth_config` and derive the current 6-digit TOTP code at
|
|
23
|
+
* mint time — never a static password. A legacy static `loginPassword` is still
|
|
24
|
+
* honoured as a fallback so pre-existing configs don't hard-break.
|
|
25
|
+
*
|
|
26
|
+
* SECURITY: the credential (the derived code) is in the request URL, and the
|
|
27
|
+
* secret must never appear anywhere. We NEVER log the URL — the shared
|
|
28
|
+
* `n4Request` helper only logs the caller-supplied `label`, so we always pass an
|
|
29
|
+
* opaque label and never the URL, and errors below never echo it. Only the
|
|
30
|
+
* short-lived derived code ever reaches the URL; the seed does not.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type CachedToken = { token: string; expiresAt: number }
|
|
34
|
+
|
|
35
|
+
const tokenCache = new Map<string, CachedToken>()
|
|
36
|
+
|
|
37
|
+
export class GctAuthError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message)
|
|
40
|
+
this.name = 'GctAuthError'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type GctCredentials = { companyCode: string; loginName: string; totpSecret: string; legacyPassword: string }
|
|
45
|
+
|
|
46
|
+
function credentials(config: ResolvedTerminalConfig): GctCredentials {
|
|
47
|
+
const auth = (config.authConfig ?? {}) as Record<string, unknown>
|
|
48
|
+
const companyCode = String(auth.companyCode ?? '')
|
|
49
|
+
const loginName = String(auth.loginName ?? '')
|
|
50
|
+
const totpSecret = String(auth.totpSecret ?? '')
|
|
51
|
+
// Legacy fallback: pre-TOTP configs stored a static `loginPassword`.
|
|
52
|
+
const legacyPassword = String(auth.loginPassword ?? '')
|
|
53
|
+
if (!companyCode || !loginName || (!totpSecret && !legacyPassword)) {
|
|
54
|
+
throw new GctAuthError(
|
|
55
|
+
`GCT config ${config.terminalCode} is missing companyCode/loginName/totpSecret in auth_config`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return { companyCode, loginName, totpSecret, legacyPassword }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The value GCT expects in the password path segment: the current TOTP code
|
|
63
|
+
* derived from the authenticator secret, or a legacy static password when no
|
|
64
|
+
* secret is configured.
|
|
65
|
+
*/
|
|
66
|
+
function loginCode(creds: GctCredentials): string {
|
|
67
|
+
return creds.totpSecret ? generateTotp(creds.totpSecret) : creds.legacyPassword
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cacheKey(config: ResolvedTerminalConfig, creds: GctCredentials): string {
|
|
71
|
+
// Keyed by principal, NOT org/tenant: identical credentials mint the same
|
|
72
|
+
// token, so sharing one cache entry is correct. Persisted rows stay
|
|
73
|
+
// org-scoped independently of this cache.
|
|
74
|
+
return `${config.terminalCode}:${creds.companyCode}:${creds.loginName}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function joinUrl(base: string, path: string): string {
|
|
78
|
+
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Default GCT auth path; a config may override via `endpoints.auth`. */
|
|
82
|
+
const DEFAULT_AUTH_PATH = '/gctapi/Auth'
|
|
83
|
+
|
|
84
|
+
/** Default GCT token-refresh (keep-alive) path; override via `endpoints.refresh`. */
|
|
85
|
+
const DEFAULT_REFRESH_PATH = '/gctapi/ping'
|
|
86
|
+
|
|
87
|
+
function authUrl(config: ResolvedTerminalConfig, creds: GctCredentials, code: string): string {
|
|
88
|
+
const authPath =
|
|
89
|
+
(config.endpoints as { auth?: string } | undefined)?.auth?.trim() || DEFAULT_AUTH_PATH
|
|
90
|
+
const segments = [creds.companyCode, creds.loginName, code]
|
|
91
|
+
.map((s) => encodeURIComponent(s))
|
|
92
|
+
.join('/')
|
|
93
|
+
return joinUrl(config.baseUrl, `${authPath}/${segments}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshUrl(config: ResolvedTerminalConfig): string {
|
|
97
|
+
const refreshPath =
|
|
98
|
+
(config.endpoints as { refresh?: string } | undefined)?.refresh?.trim() || DEFAULT_REFRESH_PATH
|
|
99
|
+
return joinUrl(config.baseUrl, refreshPath)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Parse GCT's ISO `expires` into epoch ms; null when unparseable. */
|
|
103
|
+
function parseExpires(value: string | undefined): number | null {
|
|
104
|
+
if (!value) return null
|
|
105
|
+
const ms = Date.parse(value)
|
|
106
|
+
return Number.isNaN(ms) ? null : ms
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fall back to a conservative 15-minute TTL when `expires` is missing/bad. */
|
|
110
|
+
function tokenTtl(parsed: GctToken, now: number): number {
|
|
111
|
+
return parseExpires(parsed.expires) ?? now + 15 * 60 * 1000
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Full (re-)authentication: derive a fresh authenticator code (it rotates every
|
|
116
|
+
* 30s) and mint a new token from the path-GET Auth endpoint. Caches and returns
|
|
117
|
+
* the raw token. This spends one 2FA code, so it is the fallback path — callers
|
|
118
|
+
* try {@link refreshGctToken} first when they still hold a token.
|
|
119
|
+
*/
|
|
120
|
+
async function mintGctToken(
|
|
121
|
+
config: ResolvedTerminalConfig,
|
|
122
|
+
creds: GctCredentials,
|
|
123
|
+
key: string,
|
|
124
|
+
now: number,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const { status, ok, text } = await n4Request(
|
|
127
|
+
authUrl(config, creds, loginCode(creds)),
|
|
128
|
+
{ method: 'GET', headers: { Accept: 'application/json' } },
|
|
129
|
+
{ proxyUrl: config.proxyUrl, label: `${config.terminalCode} auth` },
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (!ok) {
|
|
133
|
+
// Do not include the URL (it carries the derived auth code); status only.
|
|
134
|
+
throw new GctAuthError(`GCT token request failed (${status}) for ${config.terminalCode}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let parsed: GctToken
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(text) as GctToken
|
|
140
|
+
} catch {
|
|
141
|
+
throw new GctAuthError(`GCT token response was not JSON for ${config.terminalCode}`)
|
|
142
|
+
}
|
|
143
|
+
if (!parsed.token) {
|
|
144
|
+
throw new GctAuthError(`No token in GCT auth response for ${config.terminalCode}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entry: CachedToken = { token: parsed.token, expiresAt: tokenTtl(parsed, now) }
|
|
148
|
+
tokenCache.set(key, entry)
|
|
149
|
+
return entry.token
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extend the current token via GCT's keep-alive endpoint
|
|
154
|
+
* (`GET /gctapi/ping`, current token replayed in the `Authorization` header).
|
|
155
|
+
* Returns the refreshed cache entry on success, or `null` on ANY failure —
|
|
156
|
+
* non-2xx, non-JSON, no token in the body, or a network error — so the caller
|
|
157
|
+
* falls back to a full re-authentication. Never throws (no 2FA code is spent).
|
|
158
|
+
*/
|
|
159
|
+
async function refreshGctToken(
|
|
160
|
+
config: ResolvedTerminalConfig,
|
|
161
|
+
token: string,
|
|
162
|
+
now: number,
|
|
163
|
+
): Promise<CachedToken | null> {
|
|
164
|
+
let res
|
|
165
|
+
try {
|
|
166
|
+
res = await n4Request(
|
|
167
|
+
refreshUrl(config),
|
|
168
|
+
{ method: 'GET', headers: { Accept: 'application/json', Authorization: token } },
|
|
169
|
+
{ proxyUrl: config.proxyUrl, label: `${config.terminalCode} refresh` },
|
|
170
|
+
)
|
|
171
|
+
} catch {
|
|
172
|
+
// Transient network failure exhausted its retries → re-authenticate.
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
if (!res.ok) return null
|
|
176
|
+
let parsed: GctToken
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(res.text) as GctToken
|
|
179
|
+
} catch {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
if (!parsed.token) return null
|
|
183
|
+
return { token: parsed.token, expiresAt: tokenTtl(parsed, now) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Acquire (and cache) a GCT token for the `Authorization` header. A cached token
|
|
188
|
+
* is reused until 30s before `expires`. Once it lapses, we first try to REFRESH
|
|
189
|
+
* it via the keep-alive endpoint (cheap — no 2FA code); only if that fails do we
|
|
190
|
+
* fully RE-AUTHENTICATE by minting a new token with a fresh authenticator code.
|
|
191
|
+
*/
|
|
192
|
+
export async function acquireGctToken(config: ResolvedTerminalConfig): Promise<string> {
|
|
193
|
+
const creds = credentials(config)
|
|
194
|
+
const key = cacheKey(config, creds)
|
|
195
|
+
const now = Date.now()
|
|
196
|
+
const cached = tokenCache.get(key)
|
|
197
|
+
if (cached && cached.expiresAt > now + 30_000) return cached.token
|
|
198
|
+
|
|
199
|
+
// (Near-)expired but a token is still in hand: try the keep-alive refresh
|
|
200
|
+
// before spending a fresh 2FA code. Any refresh failure → full re-auth.
|
|
201
|
+
if (cached) {
|
|
202
|
+
const refreshed = await refreshGctToken(config, cached.token, now)
|
|
203
|
+
if (refreshed) {
|
|
204
|
+
tokenCache.set(key, refreshed)
|
|
205
|
+
return refreshed.token
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return mintGctToken(config, creds, key, now)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Clear the cached token for a terminal (used after a 401). */
|
|
213
|
+
export function invalidateGctToken(config: ResolvedTerminalConfig): void {
|
|
214
|
+
try {
|
|
215
|
+
tokenCache.delete(cacheKey(config, credentials(config)))
|
|
216
|
+
} catch {
|
|
217
|
+
// Missing creds → nothing cached; ignore.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { GctContainer } from './types'
|
|
2
|
+
import { DEFAULT_TERMINAL_TIMEZONE, zonedNaiveToUtc } from '../zoned-time'
|
|
3
|
+
import type { TerminalFetchedEvent, ResolvedTerminalConfig } from '../../terminal-adapter'
|
|
4
|
+
import type { TerminalEventType, TerminalEventClassifierCode } from '../../../data/entities'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GCT returns a container *state snapshot*, not a timestamped milestone stream.
|
|
8
|
+
* We emit only the two milestones GCT actually timestamps — grounding (gate-in)
|
|
9
|
+
* and pickup (gate-out/departed) — rather than synthesizing discharge/load from
|
|
10
|
+
* a status code we cannot timestamp. The `eventCode`s (`GTIN`/`DEPA`) are the
|
|
11
|
+
* same the service maps to `equipment.gate_in` / `transport.departed`, so no
|
|
12
|
+
* adapter-specific wiring is needed downstream.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type MilestoneSpec = {
|
|
16
|
+
eventType: TerminalEventType
|
|
17
|
+
eventCode: string
|
|
18
|
+
classifier: TerminalEventClassifierCode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GATE_IN: MilestoneSpec = { eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT' }
|
|
22
|
+
const DEPARTED: MilestoneSpec = { eventType: 'TRANSPORT', eventCode: 'DEPA', classifier: 'ACT' }
|
|
23
|
+
|
|
24
|
+
/** The container's direction of travel, inferred from the GCT status prefix. */
|
|
25
|
+
export type GctCargoDirection = 'import' | 'export'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map `CntrStatus` (XF/EM/XM/IF/XI) to a direction: `X*` = export, `I*` = import.
|
|
29
|
+
* `EM` (empty, no purpose) and any unknown value yield null. Kept deliberately
|
|
30
|
+
* lenient — the vendor docs contradict on `IF` (full vs empty) and list an
|
|
31
|
+
* undocumented `XI`, but Phase 1 does not branch on the full/empty nuance.
|
|
32
|
+
*/
|
|
33
|
+
export function directionFromStatus(status: string | null | undefined): GctCargoDirection | null {
|
|
34
|
+
const s = (status ?? '').trim().toUpperCase()
|
|
35
|
+
if (!s) return null
|
|
36
|
+
if (s.startsWith('X')) return 'export'
|
|
37
|
+
if (s.startsWith('I')) return 'import'
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a GCT datetime string. GCT is inconsistent — some fields are ISO-8601
|
|
43
|
+
* with an explicit offset, others look like N4's "yyyy-mm-dd hh:mm"
|
|
44
|
+
* (terminal-local, no offset). A field that carries its own timezone is parsed
|
|
45
|
+
* as-is; a naive one is interpreted in the terminal's timezone (Europe/Warsaw
|
|
46
|
+
* by default). Returns null when unparseable or empty.
|
|
47
|
+
*/
|
|
48
|
+
export function parseGctDateTime(
|
|
49
|
+
value: string | null | undefined,
|
|
50
|
+
timeZone: string = DEFAULT_TERMINAL_TIMEZONE,
|
|
51
|
+
): Date | null {
|
|
52
|
+
if (!value) return null
|
|
53
|
+
const raw = value.trim()
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
// An explicit timezone (`Z` or `±hh:mm`) is authoritative — parse directly.
|
|
56
|
+
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
|
57
|
+
const ms = Date.parse(raw.replace(' ', 'T'))
|
|
58
|
+
return Number.isNaN(ms) ? null : new Date(ms)
|
|
59
|
+
}
|
|
60
|
+
// Otherwise it is terminal-local-without-offset: interpret it in `timeZone`.
|
|
61
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(raw)
|
|
62
|
+
if (!m) return null
|
|
63
|
+
const [, yyyy, mm, dd, hh, mi, ss] = m
|
|
64
|
+
return zonedNaiveToUtc(+yyyy, +mm, +dd, +hh, +mi, ss ? +ss : 0, timeZone)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildSourceEventId(terminalCode: string, ufvGkey: string, eventCode: string): string {
|
|
68
|
+
return `${terminalCode}:${ufvGkey}:${eventCode}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function baseEvent(
|
|
72
|
+
container: GctContainer,
|
|
73
|
+
config: ResolvedTerminalConfig,
|
|
74
|
+
ufvGkey: string,
|
|
75
|
+
spec: MilestoneSpec,
|
|
76
|
+
eventDateTime: Date,
|
|
77
|
+
): TerminalFetchedEvent {
|
|
78
|
+
const seals = (container.SealList ?? [])
|
|
79
|
+
.filter((n): n is string => typeof n === 'string' && n.trim() !== '')
|
|
80
|
+
.map((number) => ({ number, source: config.terminalCode }))
|
|
81
|
+
const holds = (container.HoldCodesList ?? []).filter(
|
|
82
|
+
(c): c is string => typeof c === 'string' && c.trim() !== '',
|
|
83
|
+
)
|
|
84
|
+
const direction = directionFromStatus(container.CntrStatus)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
source: 'terminal',
|
|
88
|
+
sourceEventId: buildSourceEventId(config.terminalCode, ufvGkey, spec.eventCode),
|
|
89
|
+
eventType: spec.eventType,
|
|
90
|
+
eventCode: spec.eventCode,
|
|
91
|
+
eventClassifierCode: spec.classifier,
|
|
92
|
+
eventDateTime,
|
|
93
|
+
containerNumber: container.CntrID,
|
|
94
|
+
ufvGkey,
|
|
95
|
+
transitState: container.CntrStatus ?? null,
|
|
96
|
+
visitState: null,
|
|
97
|
+
facilityCode: config.facilityCode ?? null,
|
|
98
|
+
facilityCodeListProvider: config.facilityCodeListProvider ?? null,
|
|
99
|
+
unlocode: config.unlocode ?? null,
|
|
100
|
+
visitRefIn: null,
|
|
101
|
+
visitRefOut: null,
|
|
102
|
+
// Vessel name/voyage are metadata only — GCT has no vessel-visit endpoint, so
|
|
103
|
+
// no schedule (ETA/ATA) enrichment is possible.
|
|
104
|
+
vesselName: container.VesselName ?? null,
|
|
105
|
+
voyageNumber: container.GCTVoyage ?? container.OwnerVoyage ?? null,
|
|
106
|
+
modeOfTransport: 'VESSEL',
|
|
107
|
+
seals: seals.length ? seals : null,
|
|
108
|
+
vgmWeightKg: typeof container.VGMWeight === 'number' ? container.VGMWeight : null,
|
|
109
|
+
impediments: holds.length ? holds : null,
|
|
110
|
+
loadedAt: null,
|
|
111
|
+
// Preserve the full snapshot plus a normalized direction for downstream use.
|
|
112
|
+
rawData: { ...container, Category: direction ?? null },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Turn one GCT container snapshot into zero, one, or two normalized events:
|
|
118
|
+
* a gate-in when `GroundingDateTime` is set, and a departed when
|
|
119
|
+
* `PickupDateTime` is set. A snapshot with neither timestamp yields no events
|
|
120
|
+
* (nothing has provably happened yet).
|
|
121
|
+
*/
|
|
122
|
+
export function mapContainerToEvents(
|
|
123
|
+
container: GctContainer,
|
|
124
|
+
config: ResolvedTerminalConfig,
|
|
125
|
+
): TerminalFetchedEvent[] {
|
|
126
|
+
if (!container.CntrID) return []
|
|
127
|
+
const ufvGkey = (container.VisitNo && container.VisitNo.trim()) || container.CntrID
|
|
128
|
+
|
|
129
|
+
const events: TerminalFetchedEvent[] = []
|
|
130
|
+
const grounded = parseGctDateTime(container.GroundingDateTime)
|
|
131
|
+
if (grounded) events.push(baseEvent(container, config, ufvGkey, GATE_IN, grounded))
|
|
132
|
+
const pickedUp = parseGctDateTime(container.PickupDateTime)
|
|
133
|
+
if (pickedUp) events.push(baseEvent(container, config, ufvGkey, DEPARTED, pickedUp))
|
|
134
|
+
|
|
135
|
+
return events
|
|
136
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response shapes for the GCT (Gdynia Container Terminal) API. GCT is a bespoke
|
|
3
|
+
* FastAPI JSON service (paths under `/gctapi/*`) — unrelated to Navis N4. Only
|
|
4
|
+
* the fields Phase 1 (read-only tracking) consumes are typed here; the full
|
|
5
|
+
* `Container` payload is preserved verbatim into `rawData`.
|
|
6
|
+
*
|
|
7
|
+
* Source: GCT DEV API OpenAPI 1.1.0 + Karta_kontenera_API.pdf.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** `GET /gctapi/Auth/{company}/{login}/{password}` → token envelope. */
|
|
11
|
+
export type GctToken = {
|
|
12
|
+
token: string
|
|
13
|
+
token_type: string
|
|
14
|
+
/** ISO-8601 instant the token is valid until. */
|
|
15
|
+
expires: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One container as returned by `POST /gctapi/GetContainerDetails`. Every field
|
|
20
|
+
* is optional in practice; GCT omits what it has no value for. `CntrID` is the
|
|
21
|
+
* only guaranteed member.
|
|
22
|
+
*/
|
|
23
|
+
export type GctContainer = {
|
|
24
|
+
CntrID: string
|
|
25
|
+
/** Per-visit terminal key — the dedup anchor (→ `ufvGkey`). */
|
|
26
|
+
VisitNo?: string | null
|
|
27
|
+
/** Container status: XF/EM/XM/IF/XI (export/import, full/empty). */
|
|
28
|
+
CntrStatus?: string | null
|
|
29
|
+
CntrSize?: string | null
|
|
30
|
+
CntrType?: string | null
|
|
31
|
+
/** Grounding (gate-in) instant — the container's arrival on the terminal. */
|
|
32
|
+
GroundingDateTime?: string | null
|
|
33
|
+
/** Pickup (gate-out) instant — the container left the terminal. */
|
|
34
|
+
PickupDateTime?: string | null
|
|
35
|
+
/** Impediment ("stopki") codes currently on the container. */
|
|
36
|
+
HoldCodesList?: string[] | null
|
|
37
|
+
/** Seals verified at GCT. */
|
|
38
|
+
SealList?: string[] | null
|
|
39
|
+
VGMWeight?: number | null
|
|
40
|
+
VesselName?: string | null
|
|
41
|
+
/** Voyage per GCT numbering. */
|
|
42
|
+
GCTVoyage?: string | null
|
|
43
|
+
/** Voyage per the line's own numbering. */
|
|
44
|
+
OwnerVoyage?: string | null
|
|
45
|
+
DischargePort?: string | null
|
|
46
|
+
FinalDestinationPort?: string | null
|
|
47
|
+
LoadingPort?: string | null
|
|
48
|
+
OriginPort?: string | null
|
|
49
|
+
Location?: string | null
|
|
50
|
+
// Remaining fields are preserved via the index signature into rawData.
|
|
51
|
+
[key: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `POST /gctapi/GetContainerDetails` response body. */
|
|
55
|
+
export type GctContainerDetailsResponse = {
|
|
56
|
+
Total?: number
|
|
57
|
+
Containers?: GctContainer[]
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TerminalRegistry } from '../../services/terminalRegistry'
|
|
2
|
+
import { N4TerminalAdapter } from './n4/adapter'
|
|
3
|
+
import { GctTerminalAdapter } from './gct/adapter'
|
|
4
|
+
import { BctTerminalAdapter } from './bct/adapter'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register all built-in terminal adapters. Adapters are keyed by `adapterType`;
|
|
8
|
+
* a single adapter serves every terminal of that type (N4 → Baltic Hub etc.,
|
|
9
|
+
* GCT → Gdynia Container Terminal, BCT → Bałtycki Terminal Kontenerowy via
|
|
10
|
+
* INCOS), which are configured as `TerminalConfig` rows, not hardcoded here.
|
|
11
|
+
*/
|
|
12
|
+
export function registerAllAdapters(registry: TerminalRegistry): void {
|
|
13
|
+
registry.register(new N4TerminalAdapter())
|
|
14
|
+
registry.register(new GctTerminalAdapter())
|
|
15
|
+
registry.register(new BctTerminalAdapter())
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// The adapter's fetches run through `n4Request`, which drives undici's `fetch`
|
|
4
|
+
// (same instance as the dispatcher). Mock the undici module, keeping the real
|
|
5
|
+
// ProxyAgent/Agent so `dispatcher` is built exactly as in production.
|
|
6
|
+
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }))
|
|
7
|
+
vi.mock('undici', async (orig) => {
|
|
8
|
+
const actual = await orig<typeof import('undici')>()
|
|
9
|
+
return { ...actual, fetch: fetchMock }
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
import { N4TerminalAdapter } from '../adapter'
|
|
13
|
+
import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
|
|
14
|
+
|
|
15
|
+
let counter = 0
|
|
16
|
+
function cfg(proxyUrl: string | null): ResolvedTerminalConfig {
|
|
17
|
+
// Unique terminalCode per config to bypass the process-level token cache.
|
|
18
|
+
return {
|
|
19
|
+
terminalCode: `proxytest-${counter++}`,
|
|
20
|
+
adapterType: 'n4',
|
|
21
|
+
displayName: 'x',
|
|
22
|
+
baseUrl: 'https://api2.baltichub.com/V1/',
|
|
23
|
+
proxyUrl,
|
|
24
|
+
endpoints: { unit: '/unit', vessel: '/VESSEL' },
|
|
25
|
+
authType: 'oauth2_password',
|
|
26
|
+
tokenUrl: 'https://token.example/token',
|
|
27
|
+
clientId: 'c',
|
|
28
|
+
authConfig: { username: 'u', password: 'p' },
|
|
29
|
+
rateLimitRequests: 200,
|
|
30
|
+
rateLimitWindowSeconds: 60,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stubFetch() {
|
|
35
|
+
fetchMock.mockImplementation(async (_url: string | URL, init?: any) => {
|
|
36
|
+
if (init?.method === 'POST') {
|
|
37
|
+
return new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 }), { status: 200 })
|
|
38
|
+
}
|
|
39
|
+
return new Response(
|
|
40
|
+
JSON.stringify({ 'query-response': { 'data-table': { columns: { column: [] }, rows: null } } }),
|
|
41
|
+
{ status: 200 },
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
return fetchMock
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => fetchMock.mockReset())
|
|
48
|
+
|
|
49
|
+
describe('N4 adapter proxy dispatcher', () => {
|
|
50
|
+
it('attaches a dispatcher to the API + token fetches when proxyUrl is set', async () => {
|
|
51
|
+
stubFetch()
|
|
52
|
+
await new N4TerminalAdapter().fetchVesselVisit({ visitRef: '26AAA1', config: cfg('http://127.0.0.1:8888') })
|
|
53
|
+
|
|
54
|
+
const vesselCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/VESSEL'))
|
|
55
|
+
const tokenCall = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'POST')
|
|
56
|
+
expect(vesselCall?.[1]?.dispatcher).toBeDefined()
|
|
57
|
+
expect(tokenCall?.[1]?.dispatcher).toBeDefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses no dispatcher when proxyUrl is null (direct)', async () => {
|
|
61
|
+
stubFetch()
|
|
62
|
+
await new N4TerminalAdapter().fetchVesselVisit({ visitRef: '26BBB2', config: cfg(null) })
|
|
63
|
+
|
|
64
|
+
const vesselCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/VESSEL'))
|
|
65
|
+
const tokenCall = fetchMock.mock.calls.find((c) => (c[1] as any)?.method === 'POST')
|
|
66
|
+
expect(vesselCall?.[1]?.dispatcher).toBeUndefined()
|
|
67
|
+
expect(tokenCall?.[1]?.dispatcher).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { parseUnitDataTable, type N4DataTableResponse } from '../parsers/data-table'
|
|
2
|
+
|
|
3
|
+
const COLUMNS = [
|
|
4
|
+
'Unit Nbr', 'T-State', 'V-State', 'Category', 'Line Op', 'Type ISO', 'Frght Kind',
|
|
5
|
+
'I/B Actual Visit', 'O/B Actual Visit', 'Time In', 'Time Out', 'Unit Impediments',
|
|
6
|
+
'CEN Number', 'Cmdy Weight', 'VGM Weight', 'Cargo Wt (kg)', 'Tare Wt', 'Weight (kg)',
|
|
7
|
+
'Seal Nbr1', 'Seal Nbr2', 'Seal Nbr3', 'Seal Nbr4', 'DSK Number', 'Loaded',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
// Real Baltic Hub /unit response shape for container GCXU5598460 (two UFV legs).
|
|
11
|
+
const REAL_RESPONSE: N4DataTableResponse = {
|
|
12
|
+
'query-response': {
|
|
13
|
+
'data-table': {
|
|
14
|
+
'@count': '2',
|
|
15
|
+
columns: { column: COLUMNS },
|
|
16
|
+
rows: {
|
|
17
|
+
row: [
|
|
18
|
+
{
|
|
19
|
+
'@primary-key': '11412344487',
|
|
20
|
+
field: ['GCXU5598460', 'Inbound', 'Active', 'Export', 'MAE', '45G1', 'FCL',
|
|
21
|
+
'GEN_TRUCK', '26WIKI624', null, null,
|
|
22
|
+
'!TECHNICAL_FULL_CONTAINER,!CUSTOMS EXPORT PERMISSION,!MISSING_AGENT_PERMISSION',
|
|
23
|
+
null, '24600', '28300.0', '24600.0', '3700.0', '28300.0',
|
|
24
|
+
null, null, null, null, null, null],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
'@primary-key': '11341499051',
|
|
28
|
+
field: ['GCXU5598460', 'Departed', 'Departed', 'Import', 'MAE', '45G1', 'FCL',
|
|
29
|
+
'26GIRO622', 'RST62379', '2026-05-27 23:41', '2026-06-03 01:58',
|
|
30
|
+
'!TECHNICAL_FULL_CONTAINER,!MISSING_AGENT_PERMISSION', '26PL32208D005FRKR0',
|
|
31
|
+
'6360', '10060.0', '6360.0', '3700.0', '10060.0',
|
|
32
|
+
'CN7842852', null, null, null, '26GIRO622 2026-05-26 19:39', '2026-06-03 01:47'],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('parseUnitDataTable', () => {
|
|
41
|
+
it('parses both UFV legs, mapping @primary-key to ufvGkey and fields by header name', () => {
|
|
42
|
+
const rows = parseUnitDataTable(REAL_RESPONSE)
|
|
43
|
+
expect(rows).toHaveLength(2)
|
|
44
|
+
|
|
45
|
+
const [exp, imp] = rows
|
|
46
|
+
// @primary-key -> ufvGkey (the N4 Ufv_Gkey)
|
|
47
|
+
expect(exp.ufvGkey).toBe('11412344487')
|
|
48
|
+
expect(imp.ufvGkey).toBe('11341499051')
|
|
49
|
+
|
|
50
|
+
// Positional field[] mapped by column header
|
|
51
|
+
expect(exp.unitNbr).toBe('GCXU5598460')
|
|
52
|
+
expect(exp.tState).toBe('Inbound')
|
|
53
|
+
expect(exp.category).toBe('Export')
|
|
54
|
+
expect(exp.ibActualVisit).toBe('GEN_TRUCK')
|
|
55
|
+
expect(exp.obActualVisit).toBe('26WIKI624')
|
|
56
|
+
expect(exp.vgmWeight).toBe(28300)
|
|
57
|
+
expect(exp.cargoWtKg).toBe(24600)
|
|
58
|
+
|
|
59
|
+
expect(imp.tState).toBe('Departed')
|
|
60
|
+
expect(imp.timeIn).toBe('2026-05-27 23:41')
|
|
61
|
+
expect(imp.timeOut).toBe('2026-06-03 01:58')
|
|
62
|
+
expect(imp.loaded).toBe('2026-06-03 01:47')
|
|
63
|
+
expect(imp.dskNumber).toBe('26GIRO622 2026-05-26 19:39')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('splits impediments and collects non-empty seals', () => {
|
|
67
|
+
const [exp, imp] = parseUnitDataTable(REAL_RESPONSE)
|
|
68
|
+
expect(exp.impediments).toEqual([
|
|
69
|
+
'!TECHNICAL_FULL_CONTAINER', '!CUSTOMS EXPORT PERMISSION', '!MISSING_AGENT_PERMISSION',
|
|
70
|
+
])
|
|
71
|
+
expect(exp.seals).toEqual([]) // all four seal columns null
|
|
72
|
+
expect(imp.seals).toEqual(['CN7842852']) // only Seal Nbr1 populated
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('preserves the full raw row (null for empty columns)', () => {
|
|
76
|
+
const [exp] = parseUnitDataTable(REAL_RESPONSE)
|
|
77
|
+
expect(exp.raw['Category']).toBe('Export')
|
|
78
|
+
expect(exp.raw['Time In']).toBeNull()
|
|
79
|
+
expect(Object.keys(exp.raw)).toHaveLength(COLUMNS.length)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('tolerates XML->JSON single-element collapsing (row/column as objects, not arrays)', () => {
|
|
83
|
+
const single: N4DataTableResponse = {
|
|
84
|
+
'query-response': {
|
|
85
|
+
'data-table': {
|
|
86
|
+
'@count': '1',
|
|
87
|
+
columns: { column: COLUMNS },
|
|
88
|
+
rows: {
|
|
89
|
+
row: {
|
|
90
|
+
'@primary-key': '999',
|
|
91
|
+
field: ['MSKU1', 'Yard', 'Active', 'Import', 'MSC', '22G1', 'FCL',
|
|
92
|
+
'V1', null, '2026-01-01 10:00', null, null, null, null, null, null, null, null,
|
|
93
|
+
null, null, null, null, null, null],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
const rows = parseUnitDataTable(single)
|
|
100
|
+
expect(rows).toHaveLength(1)
|
|
101
|
+
expect(rows[0].ufvGkey).toBe('999')
|
|
102
|
+
expect(rows[0].unitNbr).toBe('MSKU1')
|
|
103
|
+
expect(rows[0].tState).toBe('Yard')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('reports all-null stops for a pre-update payload lacking the STOP columns', () => {
|
|
107
|
+
const [exp, imp] = parseUnitDataTable(REAL_RESPONSE)
|
|
108
|
+
expect(exp.stops).toEqual({ vsl: null, road: null, rail: null })
|
|
109
|
+
expect(imp.stops).toEqual({ vsl: null, road: null, rail: null })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('parses the new Stop-Vsl / Stop-Road / Stop-Rail columns when present', () => {
|
|
113
|
+
const withStops: N4DataTableResponse = {
|
|
114
|
+
'query-response': {
|
|
115
|
+
'data-table': {
|
|
116
|
+
'@count': '1',
|
|
117
|
+
columns: { column: [...COLUMNS, 'Stop-Vsl', 'Stop-Road', 'Stop-Rail'] },
|
|
118
|
+
rows: {
|
|
119
|
+
row: {
|
|
120
|
+
'@primary-key': '555',
|
|
121
|
+
field: ['MSKU2', 'Yard', 'Active', 'Import', 'MSC', '22G1', 'FCL',
|
|
122
|
+
'V1', null, '2026-01-01 10:00', null, null, null, null, null, null, null, null,
|
|
123
|
+
null, null, null, null, null, null,
|
|
124
|
+
'N', 'Y', null],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
const [row] = parseUnitDataTable(withStops)
|
|
131
|
+
expect(row.stops).toEqual({ vsl: false, road: true, rail: false })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('parses a real post-update Baltic Hub payload (27 columns, Stop-* = "false")', () => {
|
|
135
|
+
// Verbatim shape from a real /unit response (container ECMU7554418, 2026-07):
|
|
136
|
+
// the STOP columns arrive in Vsl, Rail, Road order with string "false" values,
|
|
137
|
+
// and empties report Frght Kind "Empty" (not "MTY").
|
|
138
|
+
const REAL_WITH_STOPS: N4DataTableResponse = {
|
|
139
|
+
'query-response': {
|
|
140
|
+
'data-table': {
|
|
141
|
+
'@count': '2',
|
|
142
|
+
columns: {
|
|
143
|
+
column: [...COLUMNS, 'Stop-Vsl', 'Stop-Rail', 'Stop-Road'],
|
|
144
|
+
},
|
|
145
|
+
rows: {
|
|
146
|
+
row: [
|
|
147
|
+
{
|
|
148
|
+
'@primary-key': '11643960646',
|
|
149
|
+
field: ['ECMU7554418', 'Departed', 'Departed', 'Export', 'CMA', '45G1', 'Empty',
|
|
150
|
+
'BC1455XA', '26SPAI012', '2026-07-06 09:53', '2026-07-12 04:30', null, null, null,
|
|
151
|
+
null, '0.0', '4000.0', '4000.0', null, null, null, null, null, '2026-07-09 21:21',
|
|
152
|
+
'false', 'false', 'false'],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
'@primary-key': '11483997354',
|
|
156
|
+
field: ['ECMU7554418', 'Departed', 'Departed', 'Import', 'CMA', '45G1', 'FCL',
|
|
157
|
+
'26CCJA1FL1MA', 'BC1455XA', '2026-06-22 17:15', '2026-06-30 14:49',
|
|
158
|
+
'!TECHNICAL_FULL_CONTAINER,!MISSING_AGENT_PERMISSION', '26PL322080NSJAGEK7',
|
|
159
|
+
'18320', '22020.0', '18020.0', '4000.0', '22020.0', 'M7663322', null, null, null,
|
|
160
|
+
'26CCJA1FL1MA 2026-06-22 05:55', '2026-06-30 14:40', 'false', 'false', 'false'],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
const [mty, fcl] = parseUnitDataTable(REAL_WITH_STOPS)
|
|
168
|
+
expect(mty.raw['Frght Kind']).toBe('Empty')
|
|
169
|
+
expect(mty.stops).toEqual({ vsl: false, road: false, rail: false })
|
|
170
|
+
expect(fcl.stops).toEqual({ vsl: false, road: false, rail: false })
|
|
171
|
+
// Header order in the payload is Vsl, Rail, Road — name-indexed parsing is order-agnostic.
|
|
172
|
+
expect(mty.raw['Stop-Road']).toBe('false')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('returns [] for an empty (rows: null) or malformed envelope', () => {
|
|
176
|
+
expect(parseUnitDataTable({ 'query-response': { 'data-table': { '@count': '0', columns: { column: COLUMNS }, rows: null } } })).toEqual([])
|
|
177
|
+
expect(parseUnitDataTable({})).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
})
|