diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index efdf4f691..33730b5ae 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3020,6 +3020,7 @@ export class BookingBatchService implements OnModuleInit { : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, + scheduledDate: schedule.scheduledDepartureDate, status: restoredStatus, // A paid booking still hunting for a wagon keeps its flag through the // move — it only clears when wagons are actually assigned. diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts new file mode 100644 index 000000000..8823098ab --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts @@ -0,0 +1,64 @@ +import { applyDirectionScope, scopedDirections } from './trade-scope.util'; + +/** + * The scope decides what a restricted user may see, so the cases that matter + * are the ones where a wrong answer widens access: an unrestricted fallback + * where a restriction was configured, or an out-of-scope explicit filter + * being honoured instead of denied. + */ +describe('scopedDirections', () => { + it('leaves an unrestricted user unfiltered', () => { + expect(scopedDirections(null)).toBeNull(); + }); + + it('honours an explicit filter for an unrestricted user', () => { + expect(scopedDirections(null, 'EXPORT')).toEqual(['EXPORT']); + }); + + it('falls back to the full scope when no filter is requested', () => { + expect(scopedDirections(['EXPORT'])).toEqual(['EXPORT']); + }); + + it('narrows to the intersection when the filter is in scope', () => { + expect(scopedDirections(['IMPORT', 'EXPORT'], 'EXPORT')).toEqual(['EXPORT']); + }); + + it('denies an out-of-scope filter instead of widening access', () => { + expect(scopedDirections(['EXPORT'], 'IMPORT')).toEqual([]); + }); +}); + +describe('applyDirectionScope', () => { + const makeQb = () => { + const calls: { sql: string; params?: object }[] = []; + const qb = { + calls, + andWhere(sql: string, params?: object) { + calls.push({ sql, params }); + return qb; + }, + }; + return qb; + }; + + it('does not touch the query when unrestricted', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', null); + expect(qb.calls).toHaveLength(0); + }); + + it('matches nothing on an empty scope rather than everything', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', []); + expect(qb.calls[0].sql).toBe('1 = 0'); + }); + + it('filters to the allowed directions', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', ['EXPORT']); + expect(qb.calls[0].sql).toContain('booking.trade_direction IN'); + expect(qb.calls[0].params).toEqual({ + scopeDirs_booking_trade_direction: ['EXPORT'], + }); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx index 2c447d72d..842f11064 100644 --- a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx @@ -2,10 +2,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { - useAllExternalUsers, - userTypeEnum, -} from "@/super-admin/hooks/useExternalUsers"; import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS, @@ -39,9 +35,9 @@ export default function TradeAccessPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({ - userType: userTypeEnum.employee, - take: 3000, + const { data: usersResponse, isLoading: usersLoading } = useQuery({ + queryKey: ["staff-users", "employees"], + queryFn: userTradeAccessService.employees, }); const { data: configs, isLoading: configsLoading } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts index 8931762d5..3570e004d 100644 --- a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts @@ -25,7 +25,27 @@ export interface MyTradeAccess { directions: TradeDirection[]; } +export interface StaffUser { + id: string; + /** Localized jsonb on iam.users — not a plain string. */ + name: { en?: string; am?: string } | null; + username: string; + email: string | null; +} + export const userTradeAccessService = { + /** + * Employees to assign scopes to. Served by the freight API rather than IAM's + * `/users/filter`, which 400s on its own pagination params (its @Query() DTO + * omits skip/take/orderBy while the global whitelist pipe rejects them). + */ + employees: async (): Promise<{ items: StaffUser[] }> => + ( + await client.get("/staff/users", { + params: { userType: "employee", pageSize: 100 }, + }) + ).data, + /** All configured per-user scopes (admin only). */ list: async (): Promise => (await client.get("/user-trade-access")).data,