Major Enhancement: NestJS Migration + CASL Authorization + Error Handling
Some checks failed
CI/CD Pipeline / Backend Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled

Complete rewrite from Express to NestJS with enterprise-grade features:

## Backend Improvements
- Migrated from Express to NestJS 11.0.1 with TypeScript
- Implemented Prisma ORM 7.3.0 for type-safe database access
- Added CASL authorization system replacing role-based guards
- Created global exception filters with structured logging
- Implemented Auth0 JWT authentication with Passport.js
- Added vehicle management with conflict detection
- Enhanced event scheduling with driver/vehicle assignment
- Comprehensive error handling and logging

## Frontend Improvements
- Upgraded to React 19.2.0 with Vite 7.2.4
- Implemented CASL-based permission system
- Added AbilityContext for declarative permissions
- Created ErrorHandler utility for consistent error messages
- Enhanced API client with request/response logging
- Added War Room (Command Center) dashboard
- Created VIP Schedule view with complete itineraries
- Implemented Vehicle Management UI
- Added mock data generators for testing (288 events across 20 VIPs)

## New Features
- Vehicle fleet management (types, capacity, status tracking)
- Complete 3-day Jamboree schedule generation
- Individual VIP schedule pages with PDF export (planned)
- Real-time War Room dashboard with auto-refresh
- Permission-based navigation filtering
- First user auto-approval as administrator

## Documentation
- Created CASL_AUTHORIZATION.md (comprehensive guide)
- Created ERROR_HANDLING.md (error handling patterns)
- Updated CLAUDE.md with new architecture
- Added migration guides and best practices

## Technical Debt Resolved
- Removed custom authentication in favor of Auth0
- Replaced role checks with CASL abilities
- Standardized error responses across API
- Implemented proper TypeScript typing
- Added comprehensive logging

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 08:50:25 +01:00
parent 8ace1ab2c1
commit 868f7efc23
351 changed files with 44997 additions and 6276 deletions

View File

@@ -0,0 +1,90 @@
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { Role, User, VIP, Driver, ScheduleEvent, Flight, Vehicle } from '@prisma/client';
/**
* Define all possible actions in the system
*/
export enum Action {
Manage = 'manage', // Special: allows everything
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
Approve = 'approve', // Special: for user approval
UpdateStatus = 'update-status', // Special: for drivers to update event status
}
/**
* Define all subjects (resources) in the system
*/
export type Subjects =
| InferSubjects<typeof User | typeof VIP | typeof Driver | typeof ScheduleEvent | typeof Flight | typeof Vehicle>
| 'User'
| 'VIP'
| 'Driver'
| 'ScheduleEvent'
| 'Flight'
| 'Vehicle'
| 'all';
/**
* Define the AppAbility type
*/
export type AppAbility = PureAbility<[Action, Subjects]>;
@Injectable()
export class AbilityFactory {
/**
* Define abilities for a user based on their role
*/
defineAbilitiesFor(user: User): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
PureAbility as AbilityClass<AppAbility>,
);
// Define permissions based on role
if (user.role === Role.ADMINISTRATOR) {
// Administrators can do everything
can(Action.Manage, 'all');
} else if (user.role === Role.COORDINATOR) {
// Coordinators have full access except user management
can(Action.Read, 'all');
can(Action.Create, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
can(Action.Update, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
can(Action.Delete, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
// Cannot manage users
cannot(Action.Create, 'User');
cannot(Action.Update, 'User');
cannot(Action.Delete, 'User');
cannot(Action.Approve, 'User');
} else if (user.role === Role.DRIVER) {
// Drivers can only read most resources
can(Action.Read, ['VIP', 'Driver', 'ScheduleEvent', 'Vehicle']);
// Drivers can update status of their own events
can(Action.UpdateStatus, 'ScheduleEvent', { driverId: user.driver?.id });
// Cannot access flights
cannot(Action.Read, 'Flight');
// Cannot access users
cannot(Action.Read, 'User');
}
return build({
// Detect subject type from object
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
/**
* Check if user can perform action on subject
*/
canUserPerform(user: User, action: Action, subject: Subjects): boolean {
const ability = this.defineAbilitiesFor(user);
return ability.can(action, subject);
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { User } from '@prisma/client';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@CurrentUser() user: User) {
// Return user profile (password already excluded by Prisma)
return user;
}
}

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AbilityFactory } from './abilities/ability.factory';
@Module({
imports: [
HttpModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET') || 'development-secret-key',
signOptions: {
expiresIn: '7d',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, AbilityFactory],
exports: [AuthService, PassportModule, JwtModule, AbilityFactory],
})
export class AuthModule {}

View File

@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Role } from '@prisma/client';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(private prisma: PrismaService) {}
/**
* Validate and get/create user from Auth0 token payload
*/
async validateUser(payload: any) {
const namespace = 'https://vip-coordinator-api';
const auth0Id = payload.sub;
const email = payload[`${namespace}/email`] || payload.email || `${auth0Id}@auth0.local`;
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 },
include: { driver: true },
});
if (!user) {
// Check if this is the first user (auto-approve as admin)
const userCount = await this.prisma.user.count();
const isFirstUser = userCount === 0;
this.logger.log(
`Creating new user: ${email} (isFirstUser: ${isFirstUser})`,
);
// Create new user
user = await this.prisma.user.create({
data: {
auth0Id,
email,
name,
picture,
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
isApproved: isFirstUser, // Auto-approve first user
},
include: { driver: true },
});
this.logger.log(
`User created: ${user.email} with role ${user.role} (approved: ${user.isApproved})`,
);
}
return user;
}
/**
* Get current user profile
*/
async getCurrentUser(auth0Id: string) {
return this.prisma.user.findUnique({
where: { auth0Id },
include: { driver: true },
});
}
}

View File

@@ -0,0 +1,39 @@
import { SetMetadata } from '@nestjs/common';
import { Action, Subjects } from '../abilities/ability.factory';
import { CHECK_ABILITY, RequiredPermission } from '../guards/abilities.guard';
/**
* Decorator to check CASL abilities on a route
*
* @example
* @CheckAbilities({ action: Action.Create, subject: 'VIP' })
* async create(@Body() dto: CreateVIPDto) {
* return this.service.create(dto);
* }
*
* @example Multiple permissions (all must be satisfied)
* @CheckAbilities(
* { action: Action.Read, subject: 'VIP' },
* { action: Action.Update, subject: 'VIP' }
* )
*/
export const CheckAbilities = (...permissions: RequiredPermission[]) =>
SetMetadata(CHECK_ABILITY, permissions);
/**
* Helper functions for common permission checks
*/
export const CanCreate = (subject: Subjects) =>
CheckAbilities({ action: Action.Create, subject });
export const CanRead = (subject: Subjects) =>
CheckAbilities({ action: Action.Read, subject });
export const CanUpdate = (subject: Subjects) =>
CheckAbilities({ action: Action.Update, subject });
export const CanDelete = (subject: Subjects) =>
CheckAbilities({ action: Action.Delete, subject });
export const CanManage = (subject: Subjects) =>
CheckAbilities({ action: Action.Manage, subject });

View File

@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,64 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, Action, Subjects } from '../abilities/ability.factory';
/**
* Interface for required permissions
*/
export interface RequiredPermission {
action: Action;
subject: Subjects;
}
/**
* Metadata key for permissions
*/
export const CHECK_ABILITY = 'check_ability';
/**
* Guard that checks CASL abilities
*/
@Injectable()
export class AbilitiesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
) {}
async canActivate(context: ExecutionContext): boolean {
const requiredPermissions =
this.reflector.get<RequiredPermission[]>(
CHECK_ABILITY,
context.getHandler(),
) || [];
// If no permissions required, allow access
if (requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// User should be attached by JwtAuthGuard
if (!user) {
throw new ForbiddenException('User not authenticated');
}
// Build abilities for user
const ability = this.abilityFactory.defineAbilitiesFor(user);
// Check if user has all required permissions
const hasPermission = requiredPermissions.every((permission) =>
ability.can(permission.action, permission.subject),
);
if (!hasPermission) {
throw new ForbiddenException(
`User does not have required permissions`,
);
}
return true;
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,23 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.role === role);
}
}

View File

@@ -0,0 +1,75 @@
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { AuthService } from '../auth.service';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(JwtStrategy.name);
constructor(
private configService: ConfigService,
private authService: AuthService,
private httpService: HttpService,
) {
super({
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${configService.get('AUTH0_ISSUER')}.well-known/jwks.json`,
}),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
audience: configService.get('AUTH0_AUDIENCE'),
issuer: configService.get('AUTH0_ISSUER'),
algorithms: ['RS256'],
passReqToCallback: true, // We need the request to get the token
});
}
async validate(req: any, payload: any) {
// Extract token from Authorization header
const token = req.headers.authorization?.replace('Bearer ', '');
// Fetch user info from Auth0 /userinfo endpoint
try {
const userInfoUrl = `${this.configService.get('AUTH0_ISSUER')}userinfo`;
const response = await firstValueFrom(
this.httpService.get(userInfoUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
}),
);
// Merge userinfo data into payload
const userInfo = response.data;
payload.email = userInfo.email || payload.email;
payload.name = userInfo.name || payload.name;
payload.picture = userInfo.picture || payload.picture;
payload.email_verified = userInfo.email_verified;
} catch (error) {
this.logger.warn(`Failed to fetch user info: ${error.message}`);
// Continue with payload-only data (fallbacks will apply)
}
// Get or create user from Auth0 token
const user = await this.authService.validateUser(payload);
if (!user) {
throw new UnauthorizedException('User not found');
}
if (!user.isApproved) {
throw new UnauthorizedException('User account pending approval');
}
return user;
}
}