enhance user management and booking features with permissions and real-time updates

This commit is contained in:
Marshal
2026-07-06 17:15:22 +00:00
parent d3de4a6abd
commit 8bd00ea78a
11 changed files with 214 additions and 254 deletions

View File

@@ -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,

View File

@@ -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,
}); });
} }

View File

@@ -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 823, 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 817, 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());
});
});

View File

@@ -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,
); );
}); });

View File

@@ -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. */

View File

@@ -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 = [

View File

@@ -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 817, 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 (817). 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

View File

@@ -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`,
); );
} }
} }

View File

@@ -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

View File

@@ -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"

View File

@@ -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