@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.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Availability detection for terminal containers.
3
+ *
4
+ * Derives two business signals from the latest N4 `/unit` snapshot of a
5
+ * container, reusing the hold catalogue in `./holds` to decide what actually
6
+ * blocks a physical pickup:
7
+ *
8
+ * - **Empty-ready** — an empty container sitting in the yard with no
9
+ * movement-blocking holds is releasable for collection. Per Baltic Hub's
10
+ * Navis docs, `EMPTY PERMISSION` is applied to *every* empty regardless of
11
+ * direction and is cleared once the carrier's EDO/booking is provided; empties
12
+ * are handled under N4 category `Storage`, not `Export` — so this does not
13
+ * gate on category.
14
+ * - **Import holds-cleared** — an import container in the yard whose blocking
15
+ * holds have just been lifted (transition blocked → clear) has become
16
+ * collectable.
17
+ *
18
+ * Pure helpers — no DI, no persistence. The service owns the one-shot guard
19
+ * (job-level `emptyReadyAt` / `holdsClearedAt`).
20
+ */
21
+ import { resolveHolds } from './holds'
22
+ import { parseTerminalStops } from './stops'
23
+
24
+ /** N4 in-yard transit state (discharged, available on the terminal). */
25
+ const YARD_STATE = 'S40_YARD'
26
+
27
+ /** Minimal shape both a fetched event and a persisted `TerminalEvent` satisfy. */
28
+ export type AvailabilityInput = {
29
+ transitState?: string | null
30
+ impediments?: string[] | null
31
+ rawData?: Record<string, unknown> | null
32
+ }
33
+
34
+ /** Count holds that block physical movement / loading / pickup. */
35
+ export function blockingHoldCount(impediments: readonly string[] | null | undefined): number {
36
+ return resolveHolds(impediments ?? null).filter((h) => h.blocksMovement).length
37
+ }
38
+
39
+ /**
40
+ * Whether two raw impediment lists differ as sets (order- and duplicate-
41
+ * insensitive, whitespace-trimmed). An absent/null prior is treated as empty,
42
+ * so a first-seen container carrying holds counts as a change. Drives the
43
+ * `equipment.holds_updated` event (fires on every poll the hold set changes).
44
+ */
45
+ export function impedimentsChanged(
46
+ prior: readonly string[] | null | undefined,
47
+ current: readonly string[] | null | undefined,
48
+ ): boolean {
49
+ const norm = (l: readonly string[] | null | undefined): string[] =>
50
+ Array.from(new Set((l ?? []).map((c) => c.trim()).filter((c) => c !== ''))).sort()
51
+ const a = norm(prior)
52
+ const b = norm(current)
53
+ if (a.length !== b.length) return true
54
+ return a.some((code, i) => code !== b[i])
55
+ }
56
+
57
+ /** N4 `Category` column, normalized to import/export (null if absent/unknown). */
58
+ function category(e: AvailabilityInput): 'import' | 'export' | null {
59
+ const c = String(e.rawData?.['Category'] ?? '').trim().toLowerCase()
60
+ return c === 'import' || c === 'export' ? c : null
61
+ }
62
+
63
+ /**
64
+ * Whether the container is empty, per the N4 `Frght Kind` column. Baltic Hub
65
+ * reports empties as `Empty` (confirmed against a real /unit payload, 2026-07);
66
+ * `MTY` is kept as a defensive alias for other N4 terminals. Full containers
67
+ * report `FCL`/`LCL`.
68
+ */
69
+ function isEmpty(e: AvailabilityInput): boolean {
70
+ const kind = String(e.rawData?.['Frght Kind'] ?? '').trim().toUpperCase()
71
+ return kind === 'EMPTY' || kind === 'MTY'
72
+ }
73
+
74
+ function isInYard(e: AvailabilityInput): boolean {
75
+ return e.transitState === YARD_STATE
76
+ }
77
+
78
+ /**
79
+ * Whether the terminal has explicitly stopped road pickup for this container
80
+ * (Stop-Road). Only an *explicit* stop counts — an absent column (`null`, the
81
+ * pre-update world) or a `false` never blocks, keeping this purely additive.
82
+ *
83
+ * This gates only the **state-based** `isEmptyReady` below, where a road stop
84
+ * simply *delays* the one-shot until it lifts (the signal is re-evaluated every
85
+ * poll). The **transition-based** `isImportHoldsCleared` is deliberately NOT
86
+ * gated: it fires on the holds blocked→clear edge, so suppressing it during a
87
+ * road stop would lose the event entirely once the edge has passed, rather than
88
+ * defer it — the container's holds still genuinely cleared.
89
+ */
90
+ function isRoadPickupStopped(e: AvailabilityInput): boolean {
91
+ return parseTerminalStops(e.rawData).road === true
92
+ }
93
+
94
+ /**
95
+ * Empty ready for pickup: an empty container in the yard with no movement-
96
+ * blocking holds (its `EMPTY PERMISSION` and any others have cleared). State-
97
+ * based — evaluate on the latest snapshot. Direction-agnostic, since empties
98
+ * sit under N4 category `Storage` rather than `Export`.
99
+ */
100
+ export function isEmptyReady(e: AvailabilityInput): boolean {
101
+ return isEmpty(e) && isInYard(e) && blockingHoldCount(e.impediments) === 0 && !isRoadPickupStopped(e)
102
+ }
103
+
104
+ /**
105
+ * Import holds cleared: an import container in the yard whose blocking holds
106
+ * went from present → none this poll. Transition-based, so it never fires for a
107
+ * container that was never blocked.
108
+ */
109
+ export function isImportHoldsCleared(
110
+ e: AvailabilityInput,
111
+ priorImpediments: readonly string[] | null | undefined,
112
+ ): boolean {
113
+ return (
114
+ category(e) === 'import' &&
115
+ isInYard(e) &&
116
+ blockingHoldCount(priorImpediments) > 0 &&
117
+ blockingHoldCount(e.impediments) === 0
118
+ )
119
+ }
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { terminalLogger } from './logger'
3
+
4
+ type SchedulerLike = {
5
+ register: (registration: Record<string, unknown>) => Promise<void>
6
+ exists?: (scheduleId: string) => Promise<boolean>
7
+ }
8
+
9
+ type ResolveLike = { resolve: <T = unknown>(name: string) => T }
10
+
11
+ /**
12
+ * Deterministic RFC 4122 (v5-style) uuid derived from a stable seed, so the same
13
+ * (org, tenant) always maps to the same scheduled-job id. The scheduler upserts
14
+ * by id (idempotent `register`), and consumers validate with `z.uuid()`, so the
15
+ * version (nibble 13 = '5') and variant (nibble 17 = 8-b) bits must be set.
16
+ */
17
+ function deterministicUuid(seed: string): string {
18
+ const hex = createHash('sha1').update(seed).digest('hex').slice(0, 32).split('')
19
+ hex[12] = '5' // version 5
20
+ hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) // variant 10xx -> 8..b
21
+ const s = hex.join('')
22
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`
23
+ }
24
+
25
+ /**
26
+ * Ensure the periodic "poll all" schedule exists for an org/tenant — create-only.
27
+ *
28
+ * The schedule is seeded with the module defaults the first time only. Once it
29
+ * exists, this no-ops so user edits in the scheduler settings (cadence, enabled
30
+ * state) are the source of truth and are never overwritten — `setup.ts` and the
31
+ * config/job-save callers re-run this purely to self-heal orgs that predate the
32
+ * module. Stays a single row thanks to the deterministic id. No-ops gracefully
33
+ * when the optional scheduler module isn't installed.
34
+ */
35
+ export async function ensureTerminalPollSchedule(
36
+ container: ResolveLike,
37
+ scope: { organizationId: string; tenantId: string },
38
+ ): Promise<void> {
39
+ let scheduler: SchedulerLike
40
+ try {
41
+ scheduler = container.resolve<SchedulerLike>('schedulerService')
42
+ } catch {
43
+ return // scheduler module not installed
44
+ }
45
+ if (!scheduler?.register) return
46
+
47
+ const id = deterministicUuid(`terminal_tracking:poll_all:${scope.organizationId}:${scope.tenantId}`)
48
+
49
+ // Create-only: never overwrite an existing schedule, so UI cadence/enabled edits win.
50
+ // (`register` is a full upsert, so without this guard every config/job save would
51
+ // revert the row to the defaults below.)
52
+ try {
53
+ if (scheduler.exists && (await scheduler.exists(id))) return
54
+ } catch {
55
+ // Existence check is best-effort; fall through to register on failure.
56
+ }
57
+
58
+ try {
59
+ await scheduler.register({
60
+ id,
61
+ name: 'Terminal Tracking Poll',
62
+ description:
63
+ 'Polls all active terminal tracking jobs every 4 hours. Change the cadence in the scheduler settings.',
64
+ scopeType: 'organization',
65
+ organizationId: scope.organizationId,
66
+ tenantId: scope.tenantId,
67
+ scheduleType: 'cron',
68
+ scheduleValue: '0 */4 * * *',
69
+ timezone: 'UTC',
70
+ targetType: 'command',
71
+ targetCommand: 'terminal_tracking.tracking.poll_all',
72
+ targetPayload: { tenantId: scope.tenantId, organizationId: scope.organizationId },
73
+ sourceType: 'module',
74
+ sourceModule: 'terminal_tracking',
75
+ isEnabled: true,
76
+ })
77
+ } catch (err) {
78
+ terminalLogger.warn('Failed to ensure terminal poll schedule', {
79
+ organizationId: scope.organizationId,
80
+ message: err instanceof Error ? err.message : String(err),
81
+ })
82
+ }
83
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Unit-impediment ("hold") catalogue for Navis N4 terminals (Baltic Hub / BCT).
3
+ *
4
+ * N4 returns the `Unit Impediments` column as a comma-separated list of codes,
5
+ * each prefixed with `!` and delimited inconsistently — some use underscores
6
+ * (`!TECHNICAL_FULL_CONTAINER`), others spaces (`!CUSTOMS EXPORT PERMISSION`).
7
+ * This module normalizes a raw code and resolves it to a human explanation plus
8
+ * a severity, so the UI can render a coloured badge with an informative tooltip.
9
+ *
10
+ * Source of truth for the catalogue: Baltic Hub documentation
11
+ * https://baltichub.com/dla-klienta/navis
12
+ *
13
+ * Pure helper — no DI, no i18n, no React. English strings by convention; the
14
+ * consuming component owns localization/colour mapping.
15
+ */
16
+
17
+ /**
18
+ * How urgently a hold needs attention.
19
+ * - `critical`: blocks movement/load/pickup and typically needs a third party
20
+ * (customs office, terminal CS) to lift — cannot be self-served.
21
+ * - `warning`: routine, systematic permission that is auto- or self-removed in
22
+ * the normal flow (MRN update, VGM upload, line PIN) but currently blocks.
23
+ * - `info`: technical/administrative marker that does NOT block operations.
24
+ */
25
+ export type HoldSeverity = 'critical' | 'warning' | 'info'
26
+
27
+ /** Which container flow the hold belongs to. */
28
+ export type HoldCategory = 'import' | 'export' | 'general'
29
+
30
+ export type HoldDefinition = {
31
+ /** Canonical, normalized code (no `!`, spaced, upper-case). */
32
+ code: string
33
+ /** Short human label for the badge. */
34
+ label: string
35
+ category: HoldCategory
36
+ severity: HoldSeverity
37
+ /** Whether the hold blocks physical movement / loading / pickup. */
38
+ blocksMovement: boolean
39
+ /** What the hold means / why it is applied. */
40
+ description: string
41
+ /** How the hold is cleared and by whom. */
42
+ resolution: string
43
+ }
44
+
45
+ /** Resolved hold: a known definition, or an `unknown: true` fallback. */
46
+ export type HoldInfo = HoldDefinition & {
47
+ /** Original code as received from the terminal (with `!`, original spacing). */
48
+ rawCode: string
49
+ /** True when the code was not in the catalogue. */
50
+ unknown: boolean
51
+ /**
52
+ * Stable slug for i18n lookup, e.g. `dgd_hold`. The `label`/`description`/
53
+ * `resolution` fields above are English fallbacks; the consuming component
54
+ * resolves `terminal_tracking.holds.<i18nKey>.{label,description,resolution}`
55
+ * against the active locale, falling back to those English strings.
56
+ */
57
+ i18nKey: string
58
+ }
59
+
60
+ /** Derive the i18n slug for a normalized hold code (e.g. `DGD HOLD` → `dgd_hold`). */
61
+ export function holdI18nKey(normalizedCode: string): string {
62
+ return normalizedCode.toLowerCase().replace(/ /g, '_')
63
+ }
64
+
65
+ /** Sort/threshold rank: higher = more urgent. */
66
+ export const HOLD_SEVERITY_RANK: Record<HoldSeverity, number> = {
67
+ critical: 3,
68
+ warning: 2,
69
+ info: 1,
70
+ }
71
+
72
+ /**
73
+ * Normalize a raw N4 impediment code into its canonical lookup key: drop the
74
+ * leading `!`, trim, upper-case, and collapse runs of `_`/whitespace into a
75
+ * single space so `TECHNICAL_FULL_CONTAINER` and `TECHNICAL FULL CONTAINER`
76
+ * resolve to the same entry.
77
+ */
78
+ export function normalizeHoldCode(rawCode: string): string {
79
+ return rawCode
80
+ .replace(/^!/, '')
81
+ .replace(/[_\s]+/g, ' ')
82
+ .trim()
83
+ .toUpperCase()
84
+ }
85
+
86
+ function def(
87
+ code: string,
88
+ category: HoldCategory,
89
+ severity: HoldSeverity,
90
+ blocksMovement: boolean,
91
+ label: string,
92
+ description: string,
93
+ resolution: string,
94
+ ): HoldDefinition {
95
+ return { code, category, severity, blocksMovement, label, description, resolution }
96
+ }
97
+
98
+ /** Catalogue keyed by normalized code. */
99
+ const HOLD_CATALOGUE: Record<string, HoldDefinition> = {
100
+ // ── Import ────────────────────────────────────────────────────────────────
101
+ 'CFS APPOINTMENT': def(
102
+ 'CFS APPOINTMENT',
103
+ 'import',
104
+ 'warning',
105
+ true,
106
+ 'CFS appointment',
107
+ 'Applied while a CFS (warehouse) import order is being accepted.',
108
+ 'Cleared automatically once the order is completed and warehouse work is done.',
109
+ ),
110
+ 'CUSTOMS IMPORT PERMISSION': def(
111
+ 'CUSTOMS IMPORT PERMISSION',
112
+ 'import',
113
+ 'warning',
114
+ true,
115
+ 'Customs import permission',
116
+ 'Applied systematically to every import container pending customs release.',
117
+ 'Removed automatically after the MRN number is updated via the Polish PCS codes.',
118
+ ),
119
+ 'CUSTOMS IMPORT HOLD': def(
120
+ 'CUSTOMS IMPORT HOLD',
121
+ 'import',
122
+ 'critical',
123
+ true,
124
+ 'Customs import hold',
125
+ 'Placed manually by a customs officer for scanning, inspection or a clearance issue.',
126
+ 'Only a customs officer can lift it — contact the customs office.',
127
+ ),
128
+ 'LINE IMPORT PERMISSION': def(
129
+ 'LINE IMPORT PERMISSION',
130
+ 'import',
131
+ 'warning',
132
+ true,
133
+ 'Line import permission',
134
+ 'Applied systematically to every import container until the shipping line releases it.',
135
+ 'Removed once the line releases the container via its PIN number.',
136
+ ),
137
+ 'OOG HOLD': def(
138
+ 'OOG HOLD',
139
+ 'import',
140
+ 'critical',
141
+ true,
142
+ 'OOG hold',
143
+ 'Applied to out-of-gauge import containers that require special handling permits.',
144
+ 'Must be verified by Baltic Hub before pickup; the container cannot be notified for collection until then.',
145
+ ),
146
+
147
+ // ── Export ────────────────────────────────────────────────────────────────
148
+ 'CFS HOLD': def(
149
+ 'CFS HOLD',
150
+ 'export',
151
+ 'warning',
152
+ true,
153
+ 'CFS hold',
154
+ 'Applied while a CFS (warehouse) export order is being accepted.',
155
+ 'Cleared automatically once the order is completed and warehouse work is done.',
156
+ ),
157
+ 'CUSTOMS EXPORT PERMISSION': def(
158
+ 'CUSTOMS EXPORT PERMISSION',
159
+ 'export',
160
+ 'warning',
161
+ true,
162
+ 'Customs export permission',
163
+ 'Applied systematically to export containers pending customs release.',
164
+ 'Removed automatically after the MRN update; must be cleared before the customs-clearance deadline.',
165
+ ),
166
+ 'CUSTOMS EXPORT HOLD': def(
167
+ 'CUSTOMS EXPORT HOLD',
168
+ 'export',
169
+ 'critical',
170
+ true,
171
+ 'Customs export hold',
172
+ 'Placed manually by a customs officer for scanning, inspection or a clearance issue.',
173
+ 'Only a customs officer can lift it — contact the customs office.',
174
+ ),
175
+ 'UNIT VGM LOAD PERMISSION': def(
176
+ 'UNIT VGM LOAD PERMISSION',
177
+ 'export',
178
+ 'warning',
179
+ true,
180
+ 'VGM load permission',
181
+ 'Applied to every full export container that has no verified gross mass yet.',
182
+ 'Removed when the VGM weight is uploaded to Navis.',
183
+ ),
184
+ 'DGD HOLD': def(
185
+ 'DGD HOLD',
186
+ 'export',
187
+ 'critical',
188
+ true,
189
+ 'DGD hold',
190
+ 'Applied automatically to every export container carrying IMO (dangerous) cargo.',
191
+ 'Requires the DGD (dangerous-goods declaration) document to be submitted.',
192
+ ),
193
+ 'SCANNING HOLD': def(
194
+ 'SCANNING HOLD',
195
+ 'export',
196
+ 'critical',
197
+ true,
198
+ 'Scanning hold',
199
+ 'Placed manually by a customs officer; the container must be presented for scanning.',
200
+ 'Cleared by customs after scanning (DCT_CFS_SCANNING selection).',
201
+ ),
202
+
203
+ // ── General / technical ─────────────────────────────────────────────────────
204
+ 'EMPTY PERMISSION': def(
205
+ 'EMPTY PERMISSION',
206
+ 'general',
207
+ 'warning',
208
+ true,
209
+ 'Empty permission',
210
+ 'Applied systematically to every empty container pending a release reference.',
211
+ 'Removed once the EDO / booking number from the shipping line is provided.',
212
+ ),
213
+ 'MISSING AGENT PERMISSION': def(
214
+ 'MISSING AGENT PERMISSION',
215
+ 'general',
216
+ 'info',
217
+ false,
218
+ 'Missing agent permission',
219
+ 'Relates to an inactive PCS payment. Does not currently block ship loading or pickup.',
220
+ 'Removed by entering the shipping code in the Agent One field.',
221
+ ),
222
+ 'TECHNICAL FULL CONTAINER': def(
223
+ 'TECHNICAL FULL CONTAINER',
224
+ 'general',
225
+ 'info',
226
+ false,
227
+ 'Technical (full container)',
228
+ 'Applied to every full import/export container to support OCR character recognition. Does not block movement.',
229
+ 'No action required — informational only.',
230
+ ),
231
+ 'TECHNICAL LIVE REEFER': def(
232
+ 'TECHNICAL LIVE REEFER',
233
+ 'general',
234
+ 'info',
235
+ false,
236
+ 'Technical (live reefer)',
237
+ 'Applied to every full reefer container to support OCR. Does not block operations.',
238
+ 'No action required — informational only.',
239
+ ),
240
+ // Baltic Hub's Navis docs spell this "REFFER" (double F); keep both spellings
241
+ // so the real terminal code resolves to the non-blocking definition instead of
242
+ // the blocking unknown-hold fallback.
243
+ 'TECHNICAL LIVE REFFER': def(
244
+ 'TECHNICAL LIVE REFFER',
245
+ 'general',
246
+ 'info',
247
+ false,
248
+ 'Technical (live reefer)',
249
+ 'Applied to every full reefer container to support OCR. Does not block operations.',
250
+ 'No action required — informational only.',
251
+ ),
252
+ 'TERMINAL HOLD': def(
253
+ 'TERMINAL HOLD',
254
+ 'general',
255
+ 'critical',
256
+ true,
257
+ 'Terminal hold',
258
+ 'Applied for a container irregularity (incorrect labelling, bent container beams, etc.).',
259
+ 'Contact Baltic Hub Customer Service to resolve.',
260
+ ),
261
+ 'CZRM HOLD': def(
262
+ 'CZRM HOLD',
263
+ 'general',
264
+ 'critical',
265
+ true,
266
+ 'CZRM hold',
267
+ 'Applied by the customs office.',
268
+ 'Contact the customs office directly to have it lifted.',
269
+ ),
270
+ 'IZRX HOLD': def(
271
+ 'IZRX HOLD',
272
+ 'general',
273
+ 'critical',
274
+ true,
275
+ 'IZRX hold',
276
+ 'Applied by the customs office.',
277
+ 'Contact the customs office directly to have it lifted.',
278
+ ),
279
+ }
280
+
281
+ /**
282
+ * The canonical, normalized codes known to the catalogue, sorted by severity
283
+ * (most-urgent first) then alphabetically. Consumers that offer a constrained
284
+ * "pick a hold" UI (dropdown) or validate manual entry against the known set
285
+ * use this instead of hard-coding the list.
286
+ */
287
+ export const HOLD_CODES: string[] = Object.keys(HOLD_CATALOGUE).sort((a, b) => {
288
+ const bySeverity = HOLD_SEVERITY_RANK[HOLD_CATALOGUE[b].severity] - HOLD_SEVERITY_RANK[HOLD_CATALOGUE[a].severity]
289
+ return bySeverity !== 0 ? bySeverity : a.localeCompare(b)
290
+ })
291
+
292
+ /** True when `code` (normalized) is a known catalogue entry. */
293
+ export function isKnownHoldCode(code: string): boolean {
294
+ return Object.prototype.hasOwnProperty.call(HOLD_CATALOGUE, normalizeHoldCode(code))
295
+ }
296
+
297
+ /**
298
+ * Resolve a single raw impediment code to its explanation + severity. Unknown
299
+ * codes return a `warning`-severity fallback that echoes the cleaned code, so a
300
+ * never-before-seen hold still renders something meaningful and visible.
301
+ */
302
+ export function getHoldInfo(rawCode: string): HoldInfo {
303
+ const key = normalizeHoldCode(rawCode)
304
+ const found = HOLD_CATALOGUE[key]
305
+ if (found) {
306
+ return { ...found, rawCode, unknown: false, i18nKey: holdI18nKey(key) }
307
+ }
308
+ // Unknown code: surface the raw hold itself as the label (just drop the `!`
309
+ // marker) so an operator still sees exactly what the terminal reported.
310
+ const rawLabel = rawCode.replace(/^!/, '').trim()
311
+ return {
312
+ code: key,
313
+ label: rawLabel || 'Unknown hold',
314
+ category: 'general',
315
+ severity: 'warning',
316
+ blocksMovement: true,
317
+ description: 'Unrecognised terminal hold. Refer to the terminal for details.',
318
+ resolution: 'Contact the terminal to determine how this hold is cleared.',
319
+ rawCode,
320
+ unknown: true,
321
+ i18nKey: 'unknown',
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Resolve a list of raw impediment codes, sorted most-urgent first. Empty,
327
+ * null, or all-whitespace entries are dropped.
328
+ */
329
+ export function resolveHolds(rawCodes: readonly string[] | null | undefined): HoldInfo[] {
330
+ if (!rawCodes || rawCodes.length === 0) return []
331
+ return rawCodes
332
+ .filter((c) => c != null && c.trim() !== '')
333
+ .map(getHoldInfo)
334
+ .sort((a, b) => HOLD_SEVERITY_RANK[b.severity] - HOLD_SEVERITY_RANK[a.severity])
335
+ }
336
+
337
+ /** The highest severity among a list of raw codes, or `null` when there are none. */
338
+ export function highestHoldSeverity(
339
+ rawCodes: readonly string[] | null | undefined,
340
+ ): HoldSeverity | null {
341
+ const holds = resolveHolds(rawCodes)
342
+ if (holds.length === 0) return null
343
+ return holds[0].severity // already sorted most-urgent first
344
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Thin logging wrapper. Mirrors the shipment-tracking logger shape but stays
3
+ * dependency-light (falls back to console). The host app's logger can be wired
4
+ * in later via DI if needed.
5
+ */
6
+ const PREFIX = '[terminal-tracking]'
7
+
8
+ export const terminalLogger = {
9
+ debug(message: string, meta?: Record<string, unknown>) {
10
+ if (process.env.TERMINAL_TRACKING_DEBUG) console.debug(PREFIX, message, meta ?? '')
11
+ },
12
+ info(message: string, meta?: Record<string, unknown>) {
13
+ console.log(PREFIX, message, meta ?? '')
14
+ },
15
+ warn(message: string, meta?: Record<string, unknown>) {
16
+ console.warn(PREFIX, message, meta ?? '')
17
+ },
18
+ error(message: string, meta?: Record<string, unknown>) {
19
+ console.error(PREFIX, message, meta ?? '')
20
+ },
21
+ }
@@ -0,0 +1,52 @@
1
+ import { terminalLogger } from './logger'
2
+
3
+ export type RateLimitResult = {
4
+ allowed: boolean
5
+ retryAfterSeconds?: number
6
+ }
7
+
8
+ export type CacheService = {
9
+ get(key: string): Promise<string | null>
10
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>
11
+ }
12
+
13
+ /**
14
+ * Token-bucket rate limiter backed by a cache.
15
+ * Key: `tt:ratelimit:{tenantId}:{terminalCode}`
16
+ */
17
+ export async function checkRateLimit(
18
+ cache: CacheService,
19
+ tenantId: string,
20
+ terminalCode: string,
21
+ maxRequests: number,
22
+ windowSeconds: number,
23
+ ): Promise<RateLimitResult> {
24
+ const key = `tt:ratelimit:${tenantId}:${terminalCode}`
25
+ const now = Date.now()
26
+
27
+ const raw = await cache.get(key)
28
+ let tokens = maxRequests
29
+ let lastRefill = now
30
+
31
+ if (raw) {
32
+ try {
33
+ const state = JSON.parse(raw) as { tokens: number; lastRefill: number }
34
+ const elapsed = (now - state.lastRefill) / 1000
35
+ const refilled = Math.floor((elapsed / windowSeconds) * maxRequests)
36
+ tokens = Math.min(maxRequests, state.tokens + refilled)
37
+ lastRefill = refilled > 0 ? now : state.lastRefill
38
+ } catch {
39
+ // Corrupted cache entry, reset
40
+ }
41
+ }
42
+
43
+ if (tokens <= 0) {
44
+ const retryAfterSeconds = Math.ceil(windowSeconds / maxRequests)
45
+ terminalLogger.debug('Rate limited', { terminalCode, tenantId, retryAfterSeconds })
46
+ return { allowed: false, retryAfterSeconds }
47
+ }
48
+
49
+ tokens -= 1
50
+ await cache.set(key, JSON.stringify({ tokens, lastRefill }), windowSeconds * 2)
51
+ return { allowed: true }
52
+ }