feat: add party size tracking and master event linking

Add partySize field to VIP model (default 1) to track total people
traveling with each VIP including entourage/handlers/spouses. Vehicle
capacity checks now sum party sizes instead of just counting VIPs.

Add masterEventId self-reference to ScheduleEvent for linking transport
legs to shared itinerary items (events, meetings, meals). When creating
a transport event, users can link it to a shared activity and VIPs
auto-populate from the linked event.

Changes:
- Schema: partySize on VIP, masterEventId on ScheduleEvent
- Backend: party-size-aware capacity checks, master/child event includes
- VIP Form: party size input with helper text
- Event Form: party-size capacity display, master event selector
- Event List: party size in capacity and VIP names, master event badges
- Command Center: all VIP names shown with party size indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 23:40:44 +01:00
parent 714cac5d10
commit 8e8bbad3fc
10 changed files with 214 additions and 29 deletions

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "vips" ADD COLUMN "partySize" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "schedule_events" ADD COLUMN "masterEventId" TEXT;
-- AddForeignKey
ALTER TABLE "schedule_events" ADD CONSTRAINT "schedule_events_masterEventId_fkey" FOREIGN KEY ("masterEventId") REFERENCES "schedule_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -50,6 +50,7 @@ model VIP {
expectedArrival DateTime? // For self-driving arrivals
airportPickup Boolean @default(false)
venueTransport Boolean @default(false)
partySize Int @default(1) // Total people: VIP + entourage
notes String? @db.Text
flights Flight[]
createdAt DateTime @default(now())
@@ -197,6 +198,11 @@ model ScheduleEvent {
vehicleId String?
vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
// Master/child event hierarchy (shared activity → transport legs)
masterEventId String?
masterEvent ScheduleEvent? @relation("EventHierarchy", fields: [masterEventId], references: [id], onDelete: SetNull)
childEvents ScheduleEvent[] @relation("EventHierarchy")
// Metadata
notes String? @db.Text

View File

@@ -55,4 +55,8 @@ export class CreateEventDto {
@IsUUID()
@IsOptional()
vehicleId?: string;
@IsUUID()
@IsOptional()
masterEventId?: string;
}

View File

@@ -34,7 +34,7 @@ export class EventsService {
if (createEventDto.vehicleId && createEventDto.vipIds) {
await this.checkVehicleCapacity(
createEventDto.vehicleId,
createEventDto.vipIds.length,
createEventDto.vipIds,
);
}
@@ -71,6 +71,13 @@ export class EventsService {
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
});
@@ -83,6 +90,13 @@ export class EventsService {
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
orderBy: { startTime: 'asc' },
});
@@ -96,6 +110,13 @@ export class EventsService {
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
});
@@ -125,12 +146,10 @@ export class EventsService {
// Check vehicle capacity if vehicle or VIPs are being updated
const vehicleId = updateEventDto.vehicleId || event.vehicleId;
const vipCount = updateEventDto.vipIds
? updateEventDto.vipIds.length
: event.vipIds.length;
const vipIds = updateEventDto.vipIds || event.vipIds;
if (vehicleId && vipCount > 0 && !updateEventDto.forceAssign) {
await this.checkVehicleCapacity(vehicleId, vipCount);
if (vehicleId && vipIds.length > 0 && !updateEventDto.forceAssign) {
await this.checkVehicleCapacity(vehicleId, vipIds);
}
// Check for conflicts if driver or times are being updated (unless forceAssign is true)
@@ -190,6 +209,13 @@ export class EventsService {
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
});
@@ -209,6 +235,13 @@ export class EventsService {
include: {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
where: { deletedAt: null },
select: { id: true, title: true, type: true },
},
},
});
@@ -233,9 +266,9 @@ export class EventsService {
}
/**
* Check vehicle capacity
* Check vehicle capacity using sum of VIP party sizes
*/
private async checkVehicleCapacity(vehicleId: string, vipCount: number) {
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null },
});
@@ -244,14 +277,20 @@ export class EventsService {
throw new NotFoundException('Vehicle not found');
}
if (vipCount > vehicle.seatCapacity) {
const vips = await this.prisma.vIP.findMany({
where: { id: { in: vipIds }, deletedAt: null },
select: { partySize: true },
});
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
if (totalPeople > vehicle.seatCapacity) {
this.logger.warn(
`Vehicle capacity exceeded: ${vipCount} VIPs > ${vehicle.seatCapacity} seats`,
`Vehicle capacity exceeded: ${totalPeople} people > ${vehicle.seatCapacity} seats`,
);
throw new BadRequestException({
message: `Vehicle capacity exceeded: ${vipCount} VIPs require more than ${vehicle.seatCapacity} available seats`,
message: `Vehicle capacity exceeded: ${totalPeople} people require more than ${vehicle.seatCapacity} available seats`,
capacity: vehicle.seatCapacity,
requested: vipCount,
requested: totalPeople,
exceeded: true,
});
}

View File

@@ -4,6 +4,8 @@ import {
IsOptional,
IsBoolean,
IsDateString,
IsInt,
Min,
} from 'class-validator';
import { Department, ArrivalMode } from '@prisma/client';
@@ -33,6 +35,11 @@ export class CreateVipDto {
@IsOptional()
venueTransport?: boolean;
@IsInt()
@IsOptional()
@Min(1)
partySize?: number;
@IsString()
@IsOptional()
notes?: string;