feat: add VIP roster tracking and accountability reports

- Add isRosterOnly flag for VIPs who attend but don't need transportation
- Add VIP contact fields (phone, email) and emergency contact info
- Create Reports page under Admin menu with Accountability Roster
- Report shows all VIPs (active + roster-only) with contact/emergency info
- Export to CSV functionality for emergency preparedness
- VIP list filters roster-only by default with toggle to show
- VIP form includes collapsible contact/emergency section
- Fix first-user race condition with Serializable transaction
- Remove Traccar hardcoded default credentials
- Add feature flags endpoint for optional services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 09:16:32 +01:00
parent 934464bf8e
commit b35c14fddc
14 changed files with 791 additions and 93 deletions

View File

@@ -1,40 +0,0 @@
# ============================================
# Application Configuration
# ============================================
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# ============================================
# Database Configuration
# ============================================
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
# ============================================
# Redis Configuration (Optional)
# ============================================
REDIS_URL="redis://localhost:6379"
# ============================================
# Auth0 Configuration
# ============================================
# Get these from your Auth0 dashboard:
# 1. Create Application (Single Page Application)
# 2. Create API
# 3. Configure callback URLs: http://localhost:5173/callback
AUTH0_DOMAIN="dev-s855cy3bvjjbkljt.us.auth0.com"
AUTH0_AUDIENCE="https://vip-coordinator-api"
AUTH0_ISSUER="https://dev-s855cy3bvjjbkljt.us.auth0.com/"
# ============================================
# Flight Tracking API (Optional)
# ============================================
# Get API key from: https://aviationstack.com/
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
# ============================================
# AI Copilot Configuration (Optional)
# ============================================
# Get API key from: https://console.anthropic.com/
# Cost: ~$3 per million tokens
ANTHROPIC_API_KEY="your-anthropic-api-key"

View File

@@ -6,19 +6,19 @@ NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# ============================================
# Database Configuration
# Database Configuration (required)
# ============================================
# Port 5433 is used to avoid conflicts with local PostgreSQL
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
# ============================================
# Redis Configuration (Optional)
# Redis Configuration (required)
# ============================================
# Port 6380 is used to avoid conflicts with local Redis
REDIS_URL="redis://localhost:6380"
# ============================================
# Auth0 Configuration
# Auth0 Configuration (required)
# ============================================
# Get these from your Auth0 dashboard:
# 1. Create Application (Single Page Application)
@@ -29,6 +29,16 @@ AUTH0_AUDIENCE="https://your-api-identifier"
AUTH0_ISSUER="https://your-tenant.us.auth0.com/"
# ============================================
# Flight Tracking API (Optional)
# Optional Services
# ============================================
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
# Leave empty or remove to disable the feature.
# The app auto-detects which features are available.
# Flight tracking API (https://aviationstack.com/)
AVIATIONSTACK_API_KEY=
# AI Copilot (https://console.anthropic.com/)
ANTHROPIC_API_KEY=
# Signal webhook authentication (recommended in production)
SIGNAL_WEBHOOK_SECRET=

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "vips" ADD COLUMN "email" TEXT,
ADD COLUMN "emergencyContactName" TEXT,
ADD COLUMN "emergencyContactPhone" TEXT,
ADD COLUMN "isRosterOnly" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "phone" TEXT;

View File

@@ -52,6 +52,16 @@ model VIP {
venueTransport Boolean @default(false)
partySize Int @default(1) // Total people: VIP + entourage
notes String? @db.Text
// Roster-only flag: true = just tracking for accountability, not active coordination
isRosterOnly Boolean @default(false)
// Emergency contact info (for accountability reports)
phone String?
email String?
emergencyContactName String?
emergencyContactPhone String?
flights Flight[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -18,52 +18,56 @@ export class AuthService {
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
const picture = payload[`${namespace}/picture`] || payload.picture;
// Check if user exists
let user = await this.prisma.user.findUnique({
where: { auth0Id },
// Check if user exists (exclude soft-deleted users)
let user = await this.prisma.user.findFirst({
where: { auth0Id, deletedAt: null },
include: { driver: true },
});
if (!user) {
// Check if this is the first user (auto-approve as admin)
const approvedUserCount = await this.prisma.user.count({
where: { isApproved: true, deletedAt: null },
});
const isFirstUser = approvedUserCount === 0;
// Use serializable transaction to prevent race condition
// where two simultaneous registrations both become admin
user = await this.prisma.$transaction(async (tx) => {
const approvedUserCount = await tx.user.count({
where: { isApproved: true, deletedAt: null },
});
const isFirstUser = approvedUserCount === 0;
this.logger.log(
`Creating new user: ${email} (approvedUserCount: ${approvedUserCount}, isFirstUser: ${isFirstUser})`,
);
this.logger.log(
`Creating new user: ${email} (approvedUserCount: ${approvedUserCount}, isFirstUser: ${isFirstUser})`,
);
// Create new user
// First user is auto-approved as ADMINISTRATOR
// Subsequent users default to DRIVER and require approval
user = await this.prisma.user.create({
data: {
auth0Id,
email,
name,
picture,
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
isApproved: isFirstUser, // Auto-approve first user only
},
include: { driver: true },
});
// First user is auto-approved as ADMINISTRATOR
// Subsequent users default to DRIVER and require approval
const newUser = await tx.user.create({
data: {
auth0Id,
email,
name,
picture,
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
isApproved: isFirstUser,
},
include: { driver: true },
});
this.logger.log(
`User created: ${user.email} with role ${user.role} (approved: ${user.isApproved})`,
);
this.logger.log(
`User created: ${newUser.email} with role ${newUser.role} (approved: ${newUser.isApproved})`,
);
return newUser;
}, { isolationLevel: 'Serializable' });
}
return user;
}
/**
* Get current user profile
* Get current user profile (excludes soft-deleted users)
*/
async getCurrentUser(auth0Id: string) {
return this.prisma.user.findUnique({
where: { auth0Id },
return this.prisma.user.findFirst({
where: { auth0Id, deletedAt: null },
include: { driver: true },
});
}

View File

@@ -53,8 +53,8 @@ export class TraccarClientService implements OnModuleInit {
private client: AxiosInstance;
private readonly baseUrl: string;
private sessionCookie: string | null = null;
private adminUser: string = 'admin';
private adminPassword: string = 'admin';
private adminUser: string = '';
private adminPassword: string = '';
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('TRACCAR_API_URL') || 'http://localhost:8082';
@@ -86,6 +86,11 @@ export class TraccarClientService implements OnModuleInit {
* Authenticate with Traccar and get session cookie
*/
async authenticate(): Promise<boolean> {
if (!this.adminUser || !this.adminPassword) {
this.logger.warn('Traccar credentials not configured - skipping authentication');
return false;
}
try {
const response = await this.client.post(
'/api/session',

View File

@@ -12,6 +12,7 @@ import {
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileInterceptor } from '@nestjs/platform-express';
import { SettingsService } from './settings.service';
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
@@ -22,7 +23,24 @@ import { CanUpdate } from '../auth/decorators/check-ability.decorator';
@Controller('settings')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
constructor(
private readonly settingsService: SettingsService,
private readonly configService: ConfigService,
) {}
/**
* Feature flags - tells the frontend which optional services are configured.
* No ability decorator = any authenticated user can access.
*/
@Get('features')
getFeatureFlags() {
return {
copilot: !!this.configService.get('ANTHROPIC_API_KEY'),
flightTracking: !!this.configService.get('AVIATIONSTACK_API_KEY'),
signalMessaging: !!this.configService.get('SIGNAL_API_URL'),
gpsTracking: !!this.configService.get('TRACCAR_API_URL'),
};
}
@Get('pdf')
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)

View File

@@ -5,6 +5,7 @@ import {
IsBoolean,
IsDateString,
IsInt,
IsEmail,
Min,
} from 'class-validator';
import { Department, ArrivalMode } from '@prisma/client';
@@ -43,4 +44,27 @@ export class CreateVipDto {
@IsString()
@IsOptional()
notes?: string;
// Roster-only flag: true = just tracking for accountability, not active coordination
@IsBoolean()
@IsOptional()
isRosterOnly?: boolean;
// VIP contact info
@IsString()
@IsOptional()
phone?: string;
@IsEmail()
@IsOptional()
email?: string;
// Emergency contact info (for accountability reports)
@IsString()
@IsOptional()
emergencyContactName?: string;
@IsString()
@IsOptional()
emergencyContactPhone?: string;
}