The customers:* keys were seeded and present in the backoffice constants but
enforced nowhere except reset-password. Customer CRUD sat behind the coarse
edr_freight_app:admin umbrella, and every company read endpoint was unguarded.
Two routes could not be gated on the route alone, because the authority they
need depends on the request BODY, not the path:
- PATCH /companies/:id carries `status` (UpdateCompanyDto extends
PartialType(CreateCompanyDto)), so it both edits fields and blacklists.
- PATCH /company-profiles/:profileId/status is approve, reject, suspend and
blacklist on one route.
Both now take a one-of route guard and assert per-status against a shared
STATUS_PERM map: approving/reactivating needs customers:verify, suspending or
blacklisting needs customers:deactivate. PATCH /companies/:id additionally
requires customers:update when any non-status field is present, so a caller
holding only deactivate cannot rename a company. The backoffice mirrors the
same map so no button is offered that the server would reject.
GET /companies/:companyId/documents is left authenticated-only with the split
in the handler: it is dual-audience. The portal reads its own documents during
onboarding, and the contract-request detail page (gated on contracts:view)
reads the applicant's. Gating it on customers:view alone would have 403'd
customers on their own documents and blanked the contract reviewer's panel.
The two by-company customer-view reads take a one-of guard for the same reason
— otherwise a staffer granted only customers:view gets a detail page whose tabs
403 individually.
Frontend: the customers routes were sidebar-filtered but not wrapped in
RequirePermission, so direct URL navigation rendered them for anyone.
Verified: freight-api type-check clean; backoffice type-check unchanged from
HEAD (pre-existing errors only); 25 tests pass across the companies and
freight-permission suites. Not exercised against a running API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Copy permissions from" listed every position type in every
organization, because the dropdown read an unfiltered GET
/position-types. IAM exposes no organization-scoped route and carries no
organizationId on a position type, so the list is now narrowed
client-side to the built-in (isSystem) types plus those whose unit
belongs to the selected organization, with the type being edited
excluded.
Also in position management:
- Invalidate every position-type cache key root after a mutation. React
Query matches prefixes element by element, so ["position-type"] never
reached ["position-types-common", ...] and the department pickers kept
serving a stale list. invalidatePositionTypeQueries() covers all three
roots and is shared by the hook and the form.
- Drop getByOrganizationId and getCommonTypesByOrganizationId. Both
issued the same requests as their unit counterparts and had no callers.
- Surface errors that were being swallowed. Three mutations had empty
onError handlers, hiding IAM's 403 for built-in position types, and
CreatePositionForm's bare catch discarded the reason for every failure.
- Move organization and unit into the zod schema so they validate with
translated messages and inline errors instead of an ad-hoc toast, and
keep submit disabled through the permission-assignment call that
follows the save.
- Report the two outcomes the form used to hide: a save that succeeded
while permission assignment failed, and clearing every permission,
which assign-seconds-for-first cannot express.
- Fix the list page's loading and error states, which rendered the
"Add User" string as a spinner, ignored the unit-scoped query, and
left the export button stuck after a failed download.
- Halve PermissionSearch's requests. It fetched 50 rows, read the total
off the response and immediately refetched, and it re-filtered results
on the undebounced term, blanking the list while typing.
Remove the three record toggles. They never worked: IAM's
PositionTypeConfiguration holds only { id, organizationId,
positionTypeId, timeframe } in every published build, canAssignRecord
and canCreateBankRecord exist nowhere in the package, and the global
ValidationPipe runs with forbidNonWhitelisted, so every write was a 400.
The reads were broken too, passing a positionTypeId to a route that
filters on organizationId. A TODO records where the real flag lives:
PositionConfiguration.canReceiveRecord, keyed by positionId.
Delete ActionsColumn.tsx, which had no references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Creating a department under a unit required picking a position type,
which is not always known at that point. Sub-department creation is
unchanged and still requires one.
PositionPayload.positionTypeId becomes optional and the field is omitted
from the request body when unset rather than sent as an empty string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Export interchange document — a generation error after Djibouti unloa…
and
Intervals were definable only via SQL and completion couldn't record an
odometer, so KM-based auto-scheduling never fired. Add
/maintenance/intervals endpoints + per-vehicle intervals UI, odometer on
the completion dialog, service_item to distinguish oil vs tire
intervals, full due board (all items per vehicle), no-dup completion
guard and dual km+date thresholds on one schedule.
Portal: truck assignment — template download next to Bulk Upload
Portal: truck assignment — template download next to Bulk Upload
The External Truck Assignment card only exposed the Excel template from inside the Bulk Upload modal. A "Download Template" button now sits beside "Bulk Upload" in the card header, matching the container-import pattern in the booking form, so customers can grab the template without opening the modal first.
The External Truck Assignment card only exposed the Excel template from inside the Bulk Upload modal. A Download Template button now sits beside Bulk Upload in the card header, matching the container-import pattern in the booking form, so customers can grab the template without opening the modal first.
createUniqueConstraint/createForeignKey used bare table names, which
resolve against the default schema (public) while the table was created
in freight — the migration failed on boot and rolled back every start.
Rewritten as idempotent schema-qualified SQL (unique index replaces the
constraint, FK inline on the column).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store the signer's saved-signature URL on booking_handovers (new
signature_image_url column) when a handover is signed — per-truck or
booking-level — so signed handover documents can render the actual
signature, matching the contract-signing flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add maintenance intervals configuration to track maintenance by kilometers
driven. When a maintenance is marked COMPLETED, automatically calculate and
schedule the next maintenance based on interval + current odometer reading.
Features:
- MaintenanceInterval entity: stores KM/day intervals per vehicle & type
- scheduleNextMaintenance(): creates next SCHEDULED item after completion
- nextDueKm field: tracks when next maintenance is due (in kilometers)
- getDueBoard() queries already support KM-based tracking
Maintenance now "marches forward" based on distance driven, not just dates.
Each vehicle type can have different intervals (e.g., oil every 10k km, tires 50k km).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Implement full train import journey with six container bookings filling a 54-wagon train.
- Create tests for split offer and rebooking scenarios, handling payment expiry and waiting list promotions.
- Add tests for handling waiting bookings expiration when the train is full.
- Implement tests for reopening booking windows after expired reservations.
- Seed database with necessary corridor data for import flows, including yards, container types, locomotives, and rates.