mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +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,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
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 { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
@@ -195,6 +197,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@BookingView()
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
@@ -205,6 +208,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("list-summary")
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
@@ -226,6 +230,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("queues/:queue")
|
||||
@BookingView()
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a dashboard queue",
|
||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||
@@ -956,12 +961,18 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/contract/sign")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||
async signContract(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@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 booking = await this.contractService.signContract(id, dto, {
|
||||
signerUserId: userId,
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
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 type { Response } from 'express';
|
||||
import {
|
||||
@@ -242,6 +244,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
|
||||
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterContractDto) {
|
||||
@@ -449,14 +452,25 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@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, {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
trainSchedulingService 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 { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
|
||||
/** 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 trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
@@ -1873,6 +1875,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.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. */
|
||||
|
||||
@@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -67,6 +67,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli
|
||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -272,9 +273,27 @@ export class TrainSchedulingService {
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
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) {
|
||||
// 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
|
||||
@@ -449,6 +468,7 @@ export class TrainSchedulingService {
|
||||
this.logger.log(
|
||||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
@@ -522,6 +542,7 @@ export class TrainSchedulingService {
|
||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
@@ -561,6 +582,9 @@ export class TrainSchedulingService {
|
||||
...windowRuleSnapshot(cfg),
|
||||
});
|
||||
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) {
|
||||
this.logger.log(
|
||||
@@ -764,6 +788,8 @@ export class TrainSchedulingService {
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2122,6 +2152,7 @@ export class TrainSchedulingService {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
void this.emitWindowState(scheduleId);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -2758,7 +2791,16 @@ export class TrainSchedulingService {
|
||||
take: 1,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3787,7 +3829,18 @@ export class TrainSchedulingService {
|
||||
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
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
s.scheduledDepartureDate > now,
|
||||
)
|
||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||
.filter((s) => {
|
||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||
|
||||
Reference in New Issue
Block a user