Both apps wrote a cookie named `current-position-id` but stored
different ids in it — freight the `employeePositionId`, Smart Office the
`position.id`. On a shared domain each login overwrote the other's desk
selection, and the loser silently fell back to the first position.
Freight now uses `freight-current-position-id` through a small helper
that reads the old name once, so a session live across the deploy keeps
its desk, and clears it on every write.
Also mounts the position switcher in the freight dashboard header. It
only existed under /performance-management, so on every other page a
two-desk user had no way to switch and was stuck on whatever
`useAuthUser` defaulted to. It now hides below two positions rather than
showing a one-option dropdown to the single-desk majority.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staff given a post in Smart Office and another in freight only ever
loaded one of them. Two causes, both in how the IAM guard collapses the
login snapshot:
- `x-current-position-id` is read two ways inside one function: the
employee row is matched on `position.id`, the position on
`employeePositionId`. Freight sends the latter, Smart Office the
former, so whichever value arrives one lookup matches nothing and
falls back to `positions[0]`. FreightJwtGuard now matches both fields.
- IAM keeps one employee row per organization, and EDR and EDR Freight
are separate organizations, so a user holding a post in each owns two
rows. Only the active row reached `collectPermissionKeys`, so the
freight post's permissions disappeared whenever the other row won the
active slot. `employee.positions` now unions every row, which is what
the util already does for the array shape.
`delegatedPositions` stays scoped to the active row on purpose: yard
scope widens on it, and someone standing in on another organization's
row is not this desk's stand-in.
/auth/me now returns every employee row, active row first, so the
position picker can offer a desk that is not on the active row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DiffRow rendered onboarding's initial field values as '— → value',
implying a prior state that never existed. Show the value alone when
there is no real before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Provisioning created nothing on dev. Synapse answers a burst of writes with
429 + retry_after_ms, and a reconcile is nothing but a burst of writes, so the
first throttled m.space.child PUT threw and took the whole run with it. On the
sign-in path ChatSsoService catches that by design, so every employee got a
working sign-in into an Element with no rooms in it. fetchWithRetry now sits
behind all three request wrappers and honours the delay Synapse asks for,
capped at 5 attempts so a wedged homeserver still fails rather than hangs.
Two ways the reconcile could destroy state, both now blocked:
- Zero position holders meant "remove everyone": every member kicked from
every room, then every account deactivated. It never means that - it means
the IAM query failed, the org/unit keys drifted, or a migration is
mid-flight. reconcile() aborts, and syncMembership() refuses to empty a
populated room, as a per-room backstop.
- Deactivation could not be undone here. Reactivation wants a password and
password_config.enabled is false, and room memberships do not come back.
Departed accounts are locked instead - same access block, one PUT to
reverse - and ensureUser lifts the lock when someone returns in IAM.
Also: join the space itself, not only the rooms inside it, or Element leaves
every dept room loose in Home and never shows the space at all. And drop the
bridge's per-type routing - it pointed at a hardcoded dept-operation alias
while the reconcile derives dept-${positionKey} from IAM, so bridged
notifications went to a room the bridge created and nobody was in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildExportLoadListHtml's wagon table (origin export doc, Marshalling
2/3, and the intercity fallback) had no station columns — only the
import doc did. Adds Departure Station / Arrival Station per row,
mirroring the import doc's existing pattern: a whole-route wagon reads
the schedule's own origin/destination, a leg-slot wagon reads its own
boardYardId/alightYardId instead (resolved via a new yardLabelById
opt, computed once per document from board+alight yard ids across the
trainSet).
Also fixes two pre-existing off-by-one colspans on the EMPTY row and
the 'No wagons on this train set' placeholder, now that the real
column count changed with the two new ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Marshalling 2's wagon list was computed from intercityOnBoardView's
CURRENT state (boardYardId == null || hasLoaded), which can't tell
'coupled at THIS stop' from 'coupled at a LATER stop that has, by
generation time, also already happened' — both read LOADED once the
trip has moved on. Reprinting Marshalling 2 (Dire Dawa) after
Marshalling 3's stop (Adama) had already run leaked Adama's 5 coupled
wagons into Dire Dawa's document (54 wagons became 59), reproduced
live on S-2026-00073.
marshallingDocumentAt now corrects the on-board list against the
adjustment log: a leg-slot wagon belongs on a stop's document only if
it has a logged ADD at or before that stop's own timestamp. Extracted
as wagonsAsOfStop so it's unit-testable without mocking the DB.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leg-slot wagons planned to couple at a LATER stop were being counted
into the Wagons/Allocations/Total containers tiles on the ORIGIN
marshalling document (Import Load List, export load list) — inflating
the departing count from 54 to 64 on a real train, plus a 'TO BE
LOADED AT X' / 'TO LOAD AT X' row and a separate 'To load en route'
tally to work around it.
Filters those slots out of buildImportLoadListHtml / buildExportLoadListHtml
entirely instead: a wagon not part of the departing consist gets no
row and no count on this document, full stop. Its own coupling shows
up on THAT stop's own numbered marshalling document once it actually
happens (intercityOnBoardView already filtered correctly there,
unaffected). Deletes the now-dead pendingBoardYardLabelBySlot special-
casing, loadsHere guards, and the 'to load en route' tile.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.
The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.
That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.
The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
Optional IAM account per agent — nullable, nothing backfilled, so
roster-only rows keep working. Staff invite existing ones; new ones get
an account when an email is supplied. Activation reuses the shipping-line
link path. SMS reachability widens to Djibouti.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Surfaces the physical stack/slot model in the staff app.
- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
configured-vs-built-vs-occupied counts, and stack create/delete plus
block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
fillable level of each stack so the form cannot suggest a position
the API will refuse
- move modal warns when a container is buried, lists the blockers, and
disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
both are exits from a stack
Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.
- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
Nullable freight_type on the warehouse, null meaning it takes both.
No backfill: every existing warehouse is unrestricted today and
writing a value would narrow allocation behind the operator's back.
Reuses FREIGHT_TYPES from the booking entity rather than a third copy
of the same two values.
Yard and zone soft-delete, refused with 409 while a yard still has
zones or a zone still holds inventory. warehouse_zones:delete was
missing from the catalog — the role presets spread every zone key, so
its absence crashes FreightPositionsSeeder at boot; a migration seeds
it everywhere.
Clicking a zone opens its contents as a datatable. Container identity
comes from booking_container_units for booked cargo and from
containers for backlog registrations; bulk cargo keeps its row with no
container number rather than disappearing from the zone.
Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
The import template was a link inside the bulk upload modal, so getting the
template meant opening a dialog that asks for the file you do not have yet.
It becomes a Download Template button next to Bulk Upload; the modal keeps
only its one-line explainer.
The returns table renders one list holding both booking-linked and standalone
empty returns, but a booking-linked row showed the literal string Associated
in place of its reference, and nothing showed the owning company.
listEmptyReturns becomes a raw projection joining freight.bookings and
freight.companies, so each row carries bookingReference and a companyName that
falls back to the booking's company when none was typed on the return itself.
Return date was collected as a bare date input, storing every return at 00:00.
All three entry points — booking-linked, standalone and the bulk default — now
use datetime-local seeded from local time rather than UTC, and the column
renders date and time.
freight.companies allows duplicate names, and Mantine v9 throws on duplicate
Autocomplete option values, taking down the whole Container Returns page with
a render error.
Dedupes the option list by trimmed name. resolveId now returns an id only
when exactly one company carries the name — an ambiguous name resolves to
nothing, so the return keeps the typed company name rather than silently
attaching to whichever duplicate happened to come first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend now generates a separate marshalling document per corridor stop
where the consist actually coupled/uncoupled/switched something
(marshallingStops/marshallingDocumentAt), instead of one 'current
position' doc. Wires that into the backoffice:
- TrainScheduleV2DetailPage: the single Intercity Marshalling menu item
becomes one item per stop with a logged change, falling back to the
old single item when nothing has happened yet.
- TrainScheduleTrackPage: same fallback/menu treatment on its own
marshalling button.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Empties already sitting in an EDR yard but never entered in the system had
to be typed one at a time. Adds a bulk path: parse the sheet in the browser
(all-or-nothing, row-numbered errors), preview it, then POST one batch.
The server rejects the batch if any container already has a non-COMPLETED
return, so re-uploading the same sheet cannot duplicate boxes. No interchange
notification fires — these are historical rows, not a live handover.
Company is an Autocomplete over registered customers that also accepts a
typed name, since a backfilled box may belong to a company that is not a
customer yet. Exact name match sets customer_id; the name always lands in the
new empty_container_returns.company_name.
Also fixes the single Record Return modal, which collected Yard and Zone and
then dropped them before the API call, and did not invalidate the returns
list after a standalone return.
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.
Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
filter on the list and the export.
Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.
requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.