mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
enhance user management and booking features with permissions and real-time updates
This commit is contained in:
@@ -15,11 +15,13 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { BookingStaff, BookingView } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
@@ -195,6 +197,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
})
|
})
|
||||||
@@ -205,6 +208,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("list-summary")
|
@Get("list-summary")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||||
findListSummary(@Query() filter: FilterBookingDto) {
|
findListSummary(@Query() filter: FilterBookingDto) {
|
||||||
@@ -226,6 +230,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("queues/:queue")
|
@Get("queues/:queue")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a dashboard queue",
|
summary: "List bookings for a dashboard queue",
|
||||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||||
@@ -956,12 +961,18 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/contract/sign")
|
@Post(":id/contract/sign")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||||
async signContract(
|
async signContract(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
) {
|
) {
|
||||||
|
// Staff signature needs the sign permission; customer signs their own booking.
|
||||||
|
if (dto.role !== "CUSTOMER") {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
|
||||||
|
}
|
||||||
const userId = req.user?.id ?? req.user?.sub;
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
const booking = await this.contractService.signContract(id, dto, {
|
const booking = await this.contractService.signContract(id, dto, {
|
||||||
signerUserId: userId,
|
signerUserId: userId,
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
@@ -242,6 +244,7 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('list-summary')
|
@Get('list-summary')
|
||||||
|
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
|
||||||
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||||
@ApiOkResponse({ type: ContractListSummaryDto })
|
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||||
findListSummary(@Query() filter: FilterContractDto) {
|
findListSummary(@Query() filter: FilterContractDto) {
|
||||||
@@ -449,14 +452,25 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contract/sign')
|
@Post(':id/contract/sign')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
signContract(
|
signContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
// Each staff signing role maps to the permission that step already requires;
|
||||||
|
// customers sign their own contract with no permission key.
|
||||||
|
const signRolePermission: Record<string, string> = {
|
||||||
|
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
||||||
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
};
|
||||||
|
if (dto.role !== 'CUSTOMER') {
|
||||||
|
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||||
|
}
|
||||||
return this.transitionService.sign(id, dto, {
|
return this.transitionService.sign(id, dto, {
|
||||||
signerUserId: user?.id ?? user?.sub,
|
signerUserId: user?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
|||||||
expect(withEarly?.window?.label).toContain('08:00');
|
expect(withEarly?.window?.label).toContain('08:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: a schedule created INSIDE its own window day must open right away
|
||||||
|
// when the desk is open, and re-deriving after a settings change (close hour
|
||||||
|
// extended past "now", or lead pulled so the window day becomes today) must
|
||||||
|
// yield an immediate open — not tomorrow morning.
|
||||||
|
describe('computeImportWindowTimes — immediate open inside the window day', () => {
|
||||||
|
// 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
|
||||||
|
const now = new Date('2026-07-06T16:15:17.000Z');
|
||||||
|
// Departs Thu 9 Jul ~08:53 EAT
|
||||||
|
const departure = new Date('2026-07-09T05:53:00.000Z');
|
||||||
|
const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
|
||||||
|
|
||||||
|
it('desk 8–23, created 19:15 on the window day → opens NOW', () => {
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
|
||||||
|
expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
|
||||||
|
// Same call restampPendingWindows makes after the global-rules edit.
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
|
||||||
|
const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
|
||||||
|
const t = computeImportWindowTimes(
|
||||||
|
departsJul10,
|
||||||
|
{ ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
|
{ emitPhase: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly scheduler: SchedulerRegistry,
|
private readonly scheduler: SchedulerRegistry,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
@Optional() private readonly splitService?: BookingSplitService,
|
@Optional() private readonly splitService?: BookingSplitService,
|
||||||
@@ -1873,6 +1875,17 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
|
// Push the change (open / train full / closed) so portal home and GL cards
|
||||||
|
// flip in real time — FULL in particular happens outside the window tick
|
||||||
|
// (batch fill, staff mark-paid) and had no live signal before.
|
||||||
|
try {
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No wagon slots left for allocated + reserved bookings. */
|
/** No wagon slots left for allocated + reserved bookings. */
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
{
|
{
|
||||||
htmlToPdfBuffer: jest.fn(),
|
htmlToPdfBuffer: jest.fn(),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli
|
|||||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
@@ -272,9 +273,27 @@ export class TrainSchedulingService {
|
|||||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a schedule's current booking-window state over the socket so the
|
||||||
|
* portal home card and backoffice GL/batch views update in real time —
|
||||||
|
* used for lifecycle changes outside the window tick (create, cancel,
|
||||||
|
* finalize, restamp). A push failure must never break the mutation.
|
||||||
|
*/
|
||||||
|
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||||
@@ -449,6 +468,7 @@ export class TrainSchedulingService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
||||||
);
|
);
|
||||||
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -522,6 +542,7 @@ export class TrainSchedulingService {
|
|||||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
||||||
);
|
);
|
||||||
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -561,6 +582,9 @@ export class TrainSchedulingService {
|
|||||||
...windowRuleSnapshot(cfg),
|
...windowRuleSnapshot(cfg),
|
||||||
});
|
});
|
||||||
restamped += 1;
|
restamped += 1;
|
||||||
|
// New times take effect immediately on every card (the tick then opens
|
||||||
|
// the window within seconds if the re-derived open is already due).
|
||||||
|
void this.emitWindowState(s.id);
|
||||||
}
|
}
|
||||||
if (restamped > 0) {
|
if (restamped > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -764,6 +788,8 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const created = await this.getTrainScheduleById(createdScheduleId);
|
const created = await this.getTrainScheduleById(createdScheduleId);
|
||||||
|
// New window announced — portal home / GL cards pick it up immediately.
|
||||||
|
void this.emitWindowState(createdScheduleId);
|
||||||
return { ...created, warnings: scheduleWarnings };
|
return { ...created, warnings: scheduleWarnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,6 +1369,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Finalized — push so portal/GL cards reflect the new state instantly.
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1413,6 +1441,8 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2122,6 +2152,7 @@ export class TrainSchedulingService {
|
|||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
||||||
@@ -2462,6 +2493,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||||||
|
void this.emitWindowState(id);
|
||||||
return this.getTrainScheduleById(id);
|
return this.getTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2758,7 +2791,16 @@ export class TrainSchedulingService {
|
|||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// A read failure here silently downgrades every booking window to the
|
||||||
|
// hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings
|
||||||
|
// UI keeps showing the saved row — a maddening mismatch. The usual cause
|
||||||
|
// is a missing column (migrations not run on this database). Scream.
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to read train-scheduling global rules — booking windows are ` +
|
||||||
|
`running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` +
|
||||||
|
`Cause: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3787,7 +3829,18 @@ export class TrainSchedulingService {
|
|||||||
order: { scheduledDepartureDate: 'ASC' },
|
order: { scheduledDepartureDate: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A train that has already departed can never be booked, even if the window
|
||||||
|
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
|
||||||
|
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
|
||||||
|
// past-departure schedule never leaks into the portal day pool, the schedule
|
||||||
|
// calendar, or the ET GL create-booking gate.
|
||||||
|
const now = new Date();
|
||||||
return schedules
|
return schedules
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
s.scheduledDepartureDate > now,
|
||||||
|
)
|
||||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||||
.filter((s) => {
|
.filter((s) => {
|
||||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
|
Application,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationConfiguration,
|
OrganizationConfiguration,
|
||||||
Permission,
|
Permission,
|
||||||
Position,
|
|
||||||
PositionPermission,
|
|
||||||
PositionType,
|
|
||||||
Role,
|
|
||||||
RolePermission,
|
|
||||||
Unit,
|
Unit,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
|
||||||
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
|
||||||
import {
|
import {
|
||||||
EDR_FREIGHT_POSITIONS,
|
EDR_FREIGHT_APPLICATION,
|
||||||
EDR_FREIGHT_ROLES,
|
EDR_FREIGHT_PERMISSIONS,
|
||||||
type FreightSeedPosition,
|
|
||||||
type FreightSeedRole,
|
|
||||||
} from "./edr-freight.seed";
|
} from "./edr-freight.seed";
|
||||||
|
|
||||||
const EDR_UNIT_KEY = "edr_freight_hq";
|
const EDR_UNIT_KEY = "edr_freight_app";
|
||||||
const EDR_UNIT_NAME = { en: "EDR Freight HQ" };
|
const EDR_UNIT_NAME = { en: "EDR Freight App" };
|
||||||
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
|
|
||||||
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
|
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||||
@@ -51,22 +41,15 @@ export class EdrOrgSeeder {
|
|||||||
const organization = await this.ensureOrganization(manager);
|
const organization = await this.ensureOrganization(manager);
|
||||||
|
|
||||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
await this.ensureDefaultUnit(manager, organization.id);
|
||||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
|
||||||
await this.ensureSuperAdminPermissions(manager);
|
|
||||||
|
|
||||||
// Positions-as-roles: seed operational positions and grant their
|
const application = await this.ensureApplication(manager);
|
||||||
// permissions via PositionPermission (not Role/RolePermission).
|
await this.ensurePermissions(manager, application.id);
|
||||||
const unit = await this.ensureDefaultUnit(manager, organization.id);
|
|
||||||
const positionType = await this.ensureDefaultPositionType(manager, unit.id);
|
// Roles, positions and their permission links are intentionally NOT
|
||||||
await this.ensurePositions(
|
// seeded for now — only the application-scoped permission catalog,
|
||||||
manager,
|
// mirroring how the default IAM seed relates permissions to their
|
||||||
organization.id,
|
// application. Grants are assigned later through the IAM UI.
|
||||||
unit.id,
|
|
||||||
positionType.id,
|
|
||||||
EDR_FREIGHT_POSITIONS,
|
|
||||||
);
|
|
||||||
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||||
@@ -128,111 +111,6 @@ export class EdrOrgSeeder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) {
|
|
||||||
await manager.getRepository(Role).upsert(
|
|
||||||
seedRoles.map(({ key, name }) => ({ key, name })),
|
|
||||||
{
|
|
||||||
conflictPaths: { key: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureRolePermissions(
|
|
||||||
manager: EntityManager,
|
|
||||||
seedRoles: FreightSeedRole[],
|
|
||||||
) {
|
|
||||||
const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))];
|
|
||||||
|
|
||||||
if (!permissionKeys.length) {
|
|
||||||
this.logger.log("No EDR role permissions configured; skipping role-permission links");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleRepository = manager.getRepository(Role);
|
|
||||||
const rolePermissionRepository = manager.getRepository(RolePermission);
|
|
||||||
|
|
||||||
const roles = await roleRepository.find({
|
|
||||||
where: { key: In(seedRoles.map((role) => role.key)) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const seededPermissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(permissionKeys) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const roleByKey = new Map(roles.map((role) => [role.key, role]));
|
|
||||||
const permissionByKey = new Map(
|
|
||||||
seededPermissions.map((permission) => [permission.key, permission]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rolePermissions = seedRoles.flatMap((role) => {
|
|
||||||
const seededRole = roleByKey.get(role.key);
|
|
||||||
|
|
||||||
if (!seededRole) {
|
|
||||||
throw new Error(`missing_role:${role.key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return role.permissionKeys.map((permissionKey) => {
|
|
||||||
const seededPermission = permissionByKey.get(permissionKey);
|
|
||||||
|
|
||||||
if (!seededPermission) {
|
|
||||||
throw new Error(`missing_permission:${permissionKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
roleId: seededRole.id,
|
|
||||||
permissionId: seededPermission.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await rolePermissionRepository.upsert(rolePermissions, {
|
|
||||||
conflictPaths: { roleId: true, permissionId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureSuperAdminPermissions(manager: EntityManager) {
|
|
||||||
const role = await manager.getRepository(Role).findOne({
|
|
||||||
where: { key: ERoleKey.SUPER_ADMIN },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!permissions.length) {
|
|
||||||
this.logger.warn('No booking/rule-engine permissions found for super_admin');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.getRepository(RolePermission).upsert(
|
|
||||||
permissions.map((permission) => ({
|
|
||||||
roleId: role.id,
|
|
||||||
permissionId: permission.id,
|
|
||||||
})),
|
|
||||||
{ conflictPaths: { roleId: true, permissionId: true } },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureDefaultUnit(
|
private async ensureDefaultUnit(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -258,120 +136,51 @@ export class EdrOrgSeeder {
|
|||||||
return { id: unit.id };
|
return { id: unit.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureDefaultPositionType(
|
private async ensureApplication(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
unitId: string,
|
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const positionTypeRepository = manager.getRepository(PositionType);
|
const applicationRepository = manager.getRepository(Application);
|
||||||
|
|
||||||
// PositionType has no unique constraint on (key, unitId); find-then-insert.
|
const application = await applicationRepository.findOne({
|
||||||
let positionType = await positionTypeRepository.findOne({
|
where: { key: EDR_FREIGHT_APPLICATION.key },
|
||||||
where: { key: EDR_POSITION_TYPE_KEY, unitId },
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!positionType) {
|
if (!application?.id) {
|
||||||
const insertResult = await positionTypeRepository.insert({
|
const insertResult = await applicationRepository.insert({
|
||||||
key: EDR_POSITION_TYPE_KEY,
|
id: EDR_FREIGHT_APPLICATION.id,
|
||||||
name: EDR_POSITION_TYPE_NAME,
|
key: EDR_FREIGHT_APPLICATION.key,
|
||||||
isSystem: true,
|
name: { ...EDR_FREIGHT_APPLICATION.name },
|
||||||
unitId,
|
|
||||||
});
|
});
|
||||||
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
|
||||||
return { id: insertResult.identifiers[0]?.id as string };
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
|
||||||
return { id: positionType.id };
|
return { id: application.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePositions(
|
private async ensurePermissions(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
organizationId: string,
|
applicationId: string,
|
||||||
unitId: string,
|
|
||||||
positionTypeId: string,
|
|
||||||
seedPositions: FreightSeedPosition[],
|
|
||||||
) {
|
) {
|
||||||
await manager.getRepository(Position).upsert(
|
const permissionRepository = manager.getRepository(Permission);
|
||||||
seedPositions.map(({ key, name, rank }) => ({
|
|
||||||
key,
|
// Upsert by key so reruns are idempotent; applicationId ties every
|
||||||
name,
|
// permission to the EDR Freight application (also backfills rows that
|
||||||
rank,
|
// were previously seeded without the relation).
|
||||||
organizationId,
|
await permissionRepository.upsert(
|
||||||
unitId,
|
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
|
||||||
positionTypeId,
|
id: permission.id,
|
||||||
|
key: permission.key,
|
||||||
|
name: { ...permission.name },
|
||||||
|
applicationId,
|
||||||
})),
|
})),
|
||||||
{
|
{ conflictPaths: { key: true } },
|
||||||
conflictPaths: { key: true, unitId: true },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Ensured ${seedPositions.length} EDR positions '${seedPositions
|
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,
|
||||||
.map((position) => position.key)
|
|
||||||
.join("', '")}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensurePositionPermissions(
|
|
||||||
manager: EntityManager,
|
|
||||||
unitId: string,
|
|
||||||
seedPositions: FreightSeedPosition[],
|
|
||||||
) {
|
|
||||||
const permissionKeys = [
|
|
||||||
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!permissionKeys.length) {
|
|
||||||
this.logger.log(
|
|
||||||
"No EDR position permissions configured; skipping position-permission links",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positions = await manager.getRepository(Position).find({
|
|
||||||
where: { key: In(seedPositions.map((position) => position.key)), unitId },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const seededPermissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(permissionKeys) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const positionByKey = new Map(
|
|
||||||
positions.map((position) => [position.key, position]),
|
|
||||||
);
|
|
||||||
const permissionByKey = new Map(
|
|
||||||
seededPermissions.map((permission) => [permission.key, permission]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const positionPermissions = seedPositions.flatMap((position) => {
|
|
||||||
const seededPosition = positionByKey.get(position.key);
|
|
||||||
|
|
||||||
if (!seededPosition) {
|
|
||||||
throw new Error(`missing_position:${position.key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position.permissionKeys.map((permissionKey) => {
|
|
||||||
const seededPermission = permissionByKey.get(permissionKey);
|
|
||||||
|
|
||||||
if (!seededPermission) {
|
|
||||||
throw new Error(`missing_permission:${permissionKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
positionId: seededPosition.id as string,
|
|
||||||
permissionId: seededPermission.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
|
|
||||||
conflictPaths: { positionId: true, permissionId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured ${positionPermissions.length} EDR position-permission links`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ const DashboardShell = () => {
|
|||||||
return (
|
return (
|
||||||
<FreightDashboardLayout
|
<FreightDashboardLayout
|
||||||
sidebarSections={sidebarSections}
|
sidebarSections={sidebarSections}
|
||||||
|
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
|
||||||
|
// sidebar (or mobile burger) at all; the page renders full width.
|
||||||
|
hideSidebar={Boolean(glClearanceHome)}
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface FreightDashboardHeaderProps {
|
|||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
mobileOpened: boolean;
|
mobileOpened: boolean;
|
||||||
onToggleMobile: () => void;
|
onToggleMobile: () => void;
|
||||||
|
/** Hide the mobile burger when the shell has no sidebar to open. */
|
||||||
|
hideSidebarBurger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every header control is a consistent 36px frosted chip — same language as the
|
// Every header control is a consistent 36px frosted chip — same language as the
|
||||||
@@ -57,6 +59,7 @@ const FreightDashboardHeader = ({
|
|||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
mobileOpened,
|
mobileOpened,
|
||||||
onToggleMobile,
|
onToggleMobile,
|
||||||
|
hideSidebarBurger = false,
|
||||||
}: FreightDashboardHeaderProps) => {
|
}: FreightDashboardHeaderProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -88,13 +91,15 @@ const FreightDashboardHeader = ({
|
|||||||
{/* Left: burger (mobile) + search — the search now occupies the slot
|
{/* Left: burger (mobile) + search — the search now occupies the slot
|
||||||
the page title used to hold; each page owns its own title. */}
|
the page title used to hold; each page owns its own title. */}
|
||||||
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||||
<Burger
|
{!hideSidebarBurger && (
|
||||||
opened={mobileOpened}
|
<Burger
|
||||||
onClick={onToggleMobile}
|
opened={mobileOpened}
|
||||||
hiddenFrom="sm"
|
onClick={onToggleMobile}
|
||||||
size="sm"
|
hiddenFrom="sm"
|
||||||
aria-label="Toggle sidebar"
|
size="sm"
|
||||||
/>
|
aria-label="Toggle sidebar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group
|
<Group
|
||||||
gap={8}
|
gap={8}
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function getInitialTheme(): Theme {
|
|||||||
|
|
||||||
export interface FreightDashboardLayoutProps {
|
export interface FreightDashboardLayoutProps {
|
||||||
sidebarSections: SidebarSection[];
|
sidebarSections: SidebarSection[];
|
||||||
|
/** Render the shell with no navbar at all (used by GL clearance-only users). */
|
||||||
|
hideSidebar?: boolean;
|
||||||
activeHref?: string;
|
activeHref?: string;
|
||||||
onNavigate?: (href: string) => void;
|
onNavigate?: (href: string) => void;
|
||||||
headerRight?: ReactNode;
|
headerRight?: ReactNode;
|
||||||
@@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps {
|
|||||||
|
|
||||||
const FreightDashboardLayout = ({
|
const FreightDashboardLayout = ({
|
||||||
sidebarSections,
|
sidebarSections,
|
||||||
|
hideSidebar = false,
|
||||||
activeHref = "",
|
activeHref = "",
|
||||||
onNavigate,
|
onNavigate,
|
||||||
headerRight,
|
headerRight,
|
||||||
@@ -75,11 +78,17 @@ const FreightDashboardLayout = ({
|
|||||||
padding={0}
|
padding={0}
|
||||||
className="bg-edr-bg"
|
className="bg-edr-bg"
|
||||||
header={{ height: HEADER_HEIGHT }}
|
header={{ height: HEADER_HEIGHT }}
|
||||||
navbar={{
|
// When the sidebar is hidden the navbar slot is dropped entirely so Main
|
||||||
width: NAVBAR_WIDTH,
|
// spans the full viewport width (GL clearance-only users).
|
||||||
breakpoint: "sm",
|
navbar={
|
||||||
collapsed: { mobile: !mobileOpened },
|
hideSidebar
|
||||||
}}
|
? undefined
|
||||||
|
: {
|
||||||
|
width: NAVBAR_WIDTH,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !mobileOpened },
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FreightDashboardHeader
|
<FreightDashboardHeader
|
||||||
pageMeta={pageMeta}
|
pageMeta={pageMeta}
|
||||||
@@ -93,14 +102,17 @@ const FreightDashboardLayout = ({
|
|||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
mobileOpened={mobileOpened}
|
mobileOpened={mobileOpened}
|
||||||
onToggleMobile={toggleMobile}
|
onToggleMobile={toggleMobile}
|
||||||
|
hideSidebarBurger={hideSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FreightSidebar
|
{!hideSidebar && (
|
||||||
sections={sidebarSections}
|
<FreightSidebar
|
||||||
activeHref={activeHref}
|
sections={sidebarSections}
|
||||||
onNavigate={navigate}
|
activeHref={activeHref}
|
||||||
onClose={closeMobile}
|
onNavigate={navigate}
|
||||||
/>
|
onClose={closeMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
|
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
|
||||||
|
|||||||
Reference in New Issue
Block a user