Date: March 2, 2026 | Framework: Fastify + TypeScript + Prisma ORM + PostgreSQL
Your API is moderately homogeneous with good foundational patterns, but has several critical gaps in data integrity, validation consistency, and error handling. The architecture is solid, but implementation varies significantly across modules.
Overall Health Score: 6.5/10
- β Strengths: Standardized response format, good error classifications, transaction usage
β οΈ Warnings: Incomplete input validation, missing DTOs/schemas, inconsistent error handling- π΄ Critical: Authorization gaps, missing business logic validation, race conditions in some endpoints
routes β handler functions β controller (class-based) β service β database
Assessment: CONSISTENT
- Routes properly delegate to handlers
- Handlers parse input and call controllers
- Controllers orchestrate business logic via services
- Services handle database operations
Issues Found:
- Auth module: Mixes concerns - auth.ts does both middleware + user lookup
- Leagues/Drafts: Use class-based controllers (β good)
- Notifications/Users: Use function-based handlers (inconsistent with leagues/drafts)
- No clear input validation layer - scattered across controllers/services
All responses follow consistent format:
// Success
{ success: true, data: T }
// Error
{ success: false, error: { code, message, details? } }Coverage:
- β
8/8 modules use
sendSuccess()/sendError()fromapi-response.ts - β
Standard error codes defined in
ErrorCodeenum - β HTTP status codes properly mapped
Issues Found:
- Some error responses bypass the standard (auth middleware returns raw
{ error: string }) - Not all error codes are used consistently across modules
- Missing specific error codes for:
- Draft-specific errors (timeout, unavailable driver, etc.)
- Scoring errors (calculation failures)
- Payment/subscription errors
Used Codes:
- UNAUTHORIZED, INVALID_TOKEN, TOKEN_EXPIRED β
- FORBIDDEN, VALIDATION_ERROR β
- NOT_FOUND variants β
- ALREADY_EXISTS, DUPLICATE_ENTRY β
- BUSINESS_RULE_VIOLATION β
Missing Codes:
RATE_LIMITED
DRAFT_WINDOW_CLOSED
DRIVER_ALREADY_PICKED
LEAGUE_FULL_ERROR
MISSING_REQUIRED_FIELDS
INVALID_STATE_TRANSITION
CONCURRENT_MODIFICATION
DATA_INTEGRITY_ERROR
// β WRONG - Doesn't use sendError()
reply.status(401).send({ error: 'No token provided' });
// β
SHOULD BE
sendError(reply, ApiError.unauthorized('No token provided'));Impact: Inconsistent error format from auth failures, breaks client parsing
// β BAD - No error codes
throw new Error('Username already taken');
throw new Error('League name already exists for this season');
// β
GOOD - Specific error codes
throw ApiError.conflict('Username already taken');Impact: Controllers don't know severity level, can't set correct HTTP status
await this.prisma.$transaction(async (tx) => {
// No error context added to transaction operations
// If tx fails, no details about which league/race failed
});Module-by-Module Assessment:
| Module | DTOs | Zod Schemas | Manual Validation | Score |
|---|---|---|---|---|
| Auth | β login.dto.ts | β Full coverage | β Password regex | 9/10 |
| Users | β No DTO | β No schemas | 4/10 | |
| Leagues | β No DTO | β No schemas | 4/10 | |
| Drafts | β No DTO | β No schemas | β Missing | 2/10 |
| Scoring | β No DTO | β No schemas | β None | 1/10 |
| Notifications | β No DTO | β No schemas | β Incomplete | 2/10 |
| F1Data | β Unknown | β Unknown | β Unknown | ? |
| Admin | β No DTO | 3/10 |
[scoring.controller.ts#1-100]
// β NO VALIDATION
async getRaceScores(
request: FastifyRequest<{ Params: { id: string; round: string } }>,
reply: FastifyReply
): Promise<void> {
const { id: leagueId, round } = request.params;
// round could be: "abc", "-5", "1000", null
// Only parseInt() is used - no validation!
const race = await this.prisma.race.findFirst({
where: {
seasonId: league.seasonId,
round: parseInt(round, 10), // β Could be NaN
},
});
}Required fixes:
// β
SHOULD BE
const roundSchema = z.object({
id: z.string().cuid(),
round: z.coerce.number().int().min(1).max(25),
});
const { id: leagueId, round } = roundSchema.parse(request.params);[leagues.service.ts#30-50]
async createLeague(userId: string, input: CreateLeagueInput) {
// input has NO type safety - CreateLeagueInput is just interface
// No actual runtime validation that:
// - name is string (not null/array/object)
// - scoringType is valid enum
// - maxPlayers is actually a number
// - seasonId exists
}[drafts.service.ts#961]
async submitPick({ draftWindowId, leagueMemberId, driverId }: SubmitPickInput) {
// β No validation that:
// - draftWindowId format is valid
// - leagueMemberId format is valid
// - driverId format is valid
// - User making request owns the leagueMemberId
}- β JWT tokens with expiry
- β Refresh token rotation
- β Account lockout on failed attempts (5 attempts, 15 min lockout)
- β Inconsistent auth middleware usage
- β Missing authorization checks in several endpoints
[auth.routes.ts#1-110]
// β
PROTECTED (auth middleware)
fastify.get('/me', { preHandler: authenticate }, getMeHandler);
// β PUBLIC (should be protected!)
fastify.get('/leagues/:id', getLeagueHandler);
// But what about /leagues/:id/members (could leak private league data)?
// β Inconsistent
fastify.post('/me/password', { preHandler: authenticate }, ...);
fastify.post('/logout', logoutHandler); // No auth check!No authorization checks for:
- Who can update draft order? (only commissioner)
- Who can enter race results? (only admin)
- Who can set system settings? (only super_admin)
- Who can flag commissioner issues? (only league members)
Example: [leagues.controller.ts#400+]
async updateDraftOrder(request: FastifyRequest, reply: FastifyReply) {
const user = getAuthenticatedUser(request);
const { leagueId } = request.params;
// β NO CHECK: Is user the commissioner?
// Any authenticated user could update ANY league's draft order!
const league = await this.leaguesService.updateDraftOrder(leagueId, body);
}[users.service.ts#150+]
async changePassword(userId: string, dto: ChangePasswordDto) {
// β
Good: Verifies current password
const isValid = await verifyPassword(dto.currentPassword, user.passwordHash);
// β
Good: Invalidates all refresh tokens
await prisma.$transaction([
prisma.user.update({ ... }),
prisma.refreshToken.deleteMany({ where: { userId } }),
]);
}
// But what about:
async requestEmailChange(userId: string, dto: EmailChangeRequestDto) {
// β οΈ Verifies password twice - inefficient
// β οΈ Doesn't check if user can actually change email (throttled? verified?)
// β οΈ No audit log
}β Good:
- Foreign key constraints with cascading deletes
- Unique constraints on (seasonId, round) for races
- Unique constraint on (leagueId, userId) for league members
- Unique constraint on (draftWindowId, leagueMemberId, round) for draft picks
- Soft deletes via
leftAttimestamp on LeagueMember
β Missing:
- No constraint preventing user from joining same league twice during race conditions
- No constraint on duplicate email addresses during concurrent email changes
- Missing indexes on frequently queried fields (e.g., User.id in notifications)
Current Statistics:
- Used in: 9 files
- Auth: 1 transaction (logout)
- Users: 2 transactions (password change, email verification)
- Leagues: 2 transactions (join league, leave league)
- Drafts: 1 transaction (submit pick)
- Scoring: 2 transactions (race scores, standings)
[auth.service.ts#469]
// β
CORRECT: Transaction ensures both happen or neither
await prisma.$transaction([
prisma.user.update({ ... passwordHash ... }),
prisma.refreshToken.deleteMany({ where: { userId } }), // Invalidate all sessions
]);[users.service.ts#259]
// β
CORRECT: Prevents race condition
await prisma.$transaction([
prisma.emailChangeRequest.updateMany({ cancelledAt: new Date() }),
prisma.emailChangeRequest.create({ ... newRequest ... }),
]);[leagues.service.ts#737]
// β οΈ INCOMPLETE TRANSACTION
const [member] = await this.prisma.$transaction([
prisma.leagueMember.create({ ... }),
// Missing: Update league memberCount? Check if league is full?
// No validation that league isn't full!
]);[scoring.service.ts#226]
// β SILENT FAILURES
await this.prisma.$transaction(async (tx) => {
for (const league of leagues) {
const scores = await calculateScores(...);
// If this fails for one league, entire transaction fails
// No granular error handling or logging
}
});- β All files use TypeScript
- β Auth module has full DTO coverage (login.dto.ts)
- β 7/8 modules missing input validation DDOs
- β Response types are interfaces, not Zod schemas
[leagues.controller.ts#20]
// β No validation of request body shape
const body = request.body as CreateLeagueInput;
// Could be:
// { name: null, scoringType: "invalid", maxPlayers: [] }
// No runtime validation!Should be:
const createLeagueSchema = z.object({
name: z.string().min(3).max(80),
seasonId: z.string().cuid(),
scoringType: z.enum(['fia_official', 'linear_20', 'proprietary']).optional(),
draftType: z.enum(['snake', 'regular']).optional(),
// ... etc
});
const input = createLeagueSchema.parse(request.body);[leagues.types.ts]
// Interface - no validation
export interface LeagueResponse {
id: string;
name: string;
// ...
}
// vs Auth
export type LoginResponse = z.infer<typeof loginResponseSchema>; // β
Zod-validated| Area | Auth | Users | Leagues | Drafts | Scoring |
|---|---|---|---|---|---|
| Controller Pattern | Functions | Functions | Classes | Classes | Classes |
| DTO/Validation | Zod β | None β | None β | None β | None β |
| Error Handling | Good β | Inconsistent |
Inconsistent |
Minimal β | Minimal β |
| Transactions | Some |
Some |
Some |
Some |
Some |
| Rate Limiting | Yes β | Yes β | No β | No β | No β |
| WebSocket Support | No | No | No | Yes β | No |
| Logging | Good β | Good β | Good β | Good β | Good β |
// Leagues - CLASS BASED
export class LeaguesController {
constructor(private leaguesService: LeaguesService) {}
async createLeague(request, reply) { ... }
}
// Auth - FUNCTION BASED
export async function registerHandler(request, reply) { ... }
// This requires different import patterns and makes testing harder// Admin lists users with pagination
{ page, limit, search, role, status }
// But drafts list without pagination
getLeagueDraftWindows(leagueId) // Returns all, no limit!
// And notifications use inconsistent param names
{ page, limit, unreadOnly, type }Same scenario, different errors:
// Leagues
throw new Error('League name already exists for this season');
// Drafts (if it had validation)
throw new Error('Draft already exists for this race');
// Should use consistent pattern:
throw ApiError.conflict('League already exists for season');[drafts.service.ts (not shown)]
// β UNSAFE: Multiple steps in sequence
async submitPick(draftWindowId, leagueMemberId, driverId) {
// Step 1: Check if draft is open
const draftWindow = await prisma.draftWindow.findUnique(...);
if (draftWindow.status !== 'open') throw Error(...);
// Step 2: Check if driver is available (β οΈ BETWEEN 1 & 2, ANOTHER USER PICKED IT!)
// Step 3: Create draft pick
await prisma.draftPick.create({ driverId });
}
// Result: Two users can pick the same driverFix: Use transaction with proper locking
await prisma.$transaction(async (tx) => {
const [draftWindow, existingPick] = await Promise.all([
tx.draftWindow.findUnique({ where: { id: draftWindowId } }),
tx.draftPick.findFirst({ where: { driverId, draftWindowId } }),
]);
if (existingPick) throw Error('Driver already picked');
await tx.draftPick.create(...);
});[leagues.service.ts#750+]
async joinLeague(leagueId, userId) {
const league = await this.prisma.league.findUnique({
include: { members: { where: { leftAt: null } } }
});
// β UNSAFE: Between check and create, another user could join!
if (league.members.length >= league.maxPlayers) {
throw Error('League is full');
}
// β If this fails, transaction should prevent
await this.prisma.leagueMember.create({ ... });
}// β User can request email change, then:
// 1. Another tab initiates email change again
// 2. Verification tokens overlap
// 3. Unclear which change succeeds// Scoring - no validation of sorts
GET /races/:round/scores
// - round could be "abc", "-1", "999"
// Leagues - no validation of team name
POST /leagues/:id/join
{ teamName: "..." }
// - teamName could be "", very long, contain injected code
// Drafts - no validation of pick order
POST /drafts/:id/picks
{ driverId: "..." }
// - driverId could be from different season// If user is already in league, what happens?
POST /leagues/:id/join
// β No check: user.leagueMemberships.find(m => m.leagueId === id && !m.leftAt)
// If draft window is open but user isn't in league, what happens?
POST /drafts/:id/pick
// β No check: Is leagueMemberId actually from this league?
// If picking driver from different season?
POST /drafts/:id/pick
// β No check: Does driverId belong to season of this league?
// If two picks submitted simultaneously?
POST /drafts/:id/pick
// β No lock mechanism - both could succeed- β No audit log when league created/deleted
- β No audit log when user joins/leaves league
- β
β οΈ Audit log table exists but rarely populated - β Auth logs account lockouts
- β Fastify logger used in handlers
- β No structured logging (no request IDs for tracing)
- β No performance metrics
- β No error rate metrics
// Create centralized audit logger
async function auditLog(userId, action, entityType, entityId, changes?) {
await prisma.auditLog.create({
data: { userId, action, entityType, entityId, changes }
});
}
// Use in all mutations
async joinLeague(userId, leagueId) {
const member = await prisma.leagueMember.create(...);
await auditLog(userId, 'JOIN_LEAGUE', 'League', leagueId, { teamName });
}GET /health β
OK (health check)
GET /leagues β
OK (public leagues only)
GET /leagues/:id β οΈ Needs visibility check
GET /leagues/:id/members β οΈ Leaks member list
GET /join/:inviteToken β
OK (token-based)
POST /join/:inviteToken β
OK (requires auth)
GET /users/vapid-public-key β
OK (public key)
POST /auth/login β
Proper auth
POST /auth/register β
Rate limited (5/min)
POST /auth/refresh β οΈ No CSRF token
POST /auth/logout β οΈ No auth check applied!
GET /users/me β
Auth required
PATCH /users/me β
Rate limited
POST /leagues β
Auth required
GET /admin/stats β οΈ No auth check visible
GET /admin/users β οΈ No role check
POST /admin/scoring/calculate/:raceId β οΈ No role check
-
Add input validation with Zod to all endpoints
- Create DTOs for all CreateX/UpdateX operations
- Auto-validate in request handlers
-
Fix auth middleware - Apply to logout and admin endpoints
- Add role-based authorization checks
- Create
@authorization('super_admin')decorator pattern
-
Fix race conditions in draft picks and league joins
- Wrap critical sections in transactions
- Add optimistic locking if needed
-
Add comprehensive business logic validation
- User already in league?
- Driver from correct season?
- Draft window actually open?
- Standardize controller patterns - Choose class-based OR function-based
- Add missing error codes for domain-specific errors
- Complete transaction coverage in critical paths
- Add pagination to all list endpoints
- Implement audit logging for all mutations
- Add correlation IDs for request tracing
- Create comprehensive API documentation
- Add request/response validation tests
- Add concurrent operation tests
- Implement graceful degradation for external APIs
- Add metrics and alerting
BEFORE (Current) β
// leaguesController.ts
async createLeague(request, reply) {
try {
const user = getAuthenticatedUser(request);
const body = request.body as CreateLeagueInput; // No validation!
const league = await this.leaguesService.createLeague(user.id, body);
sendSuccess(reply, league, 201);
} catch (error) {
sendError(reply, error); // Generic error
}
}
// leaguesService.ts
async createLeague(userId, input) {
if (input.name.length < 3 || input.name.length > 80) {
throw new Error('Invalid name'); // No error code
}
// ... manual validation scattered
}AFTER (Fixed) β
// leagues.dto.ts
export const createLeagueSchema = z.object({
name: z.string().min(3).max(80),
seasonId: z.string().cuid(),
scoringType: z.enum(['fia_official', 'linear_20', 'proprietary']).optional(),
maxPlayers: z.number().int().min(2).max(11).optional(),
// ... all fields with constraints
});
export type CreateLeagueInput = z.infer<typeof createLeagueSchema>;
// leaguesController.ts
async createLeague(request: FastifyRequest, reply: FastifyReply) {
try {
const user = getAuthenticatedUser(request);
const input = createLeagueSchema.parse(request.body); // Validated
const league = await this.leaguesService.createLeague(user.id, input);
sendSuccess(reply, league, 201);
} catch (error) {
if (error instanceof z.ZodError) {
sendError(reply, ApiError.validationError(error.errors));
} else {
sendError(reply, error);
}
}
}
// leaguesService.ts
async createLeague(userId: string, input: CreateLeagueInput) {
// No re-validation needed - input is guaranteed valid
// Just business logic
const season = await this.prisma.season.findUniqueOrThrow({
where: { id: input.seasonId },
}).catch(() => {
throw ApiError.notFound('Season');
});
const existingLeague = await this.prisma.league.findUnique({
where: { seasonId_name: { seasonId: input.seasonId, name: input.name } },
});
if (existingLeague) {
throw ApiError.conflict('League name already exists for this season');
}
// Create with transaction to ensure atomicity
return await this.prisma.$transaction(async (tx) => {
const league = await tx.league.create({ data: { ... } });
const member = await tx.leagueMember.create({
data: { leagueId: league.id, userId, teamName: defaultTeamName },
});
return transformLeagueResponse(league, 1, true);
});
}BEFORE (Current) β
async submitPick(request, reply) {
try {
const user = getAuthenticatedUser(request);
const { draftId } = request.params;
const { driverId } = request.body;
// No validation of driverId format
// No check if user owns the draft pick slot
const pick = await this.draftsService.submitPick(draftId, driverId);
sendSuccess(reply, pick);
} catch (error) {
sendError(reply, error);
}
}AFTER (Fixed) β
// drafts.dto.ts
export const submitPickSchema = z.object({
draftWindowId: z.string().cuid(),
leagueMemberId: z.string().cuid(),
driverId: z.string().cuid(),
});
export type SubmitPickInput = z.infer<typeof submitPickSchema>;
async submitPick(request: FastifyRequest, reply: FastifyReply) {
try {
const user = getAuthenticatedUser(request);
const input = submitPickSchema.parse({
...request.body,
draftWindowId: request.params.draftId,
});
// Verify user owns this league member record
const leagueMember = await this.prisma.leagueMember.findUnique({
where: { id: input.leagueMemberId },
});
if (leagueMember?.userId !== user.id) {
throw ApiError.forbidden('You cannot submit picks for this league');
}
const pick = await this.draftsService.submitPick(input);
sendSuccess(reply, pick);
} catch (error) {
if (error instanceof z.ZodError) {
sendError(reply, ApiError.validationError(error.errors));
} else {
sendError(reply, error);
}
}
}
// drafts.service.ts
async submitPick(input: SubmitPickInput) {
// Use transaction to prevent race condition
return await this.prisma.$transaction(async (tx) => {
// Fetch with lock
const [draftWindow, existingPick, driver] = await Promise.all([
tx.draftWindow.findUniqueOrThrow({
where: { id: input.draftWindowId },
}),
tx.draftPick.findFirst({
where: {
draftWindowId: input.draftWindowId,
driverId: input.driverId,
},
}),
tx.driver.findUniqueOrThrow({
where: { id: input.driverId },
}),
]);
// Validate state
if (draftWindow.status !== 'open') {
throw ApiError.badRequest('Draft window is not open', {
status: draftWindow.status,
});
}
if (existingPick) {
throw ApiError.conflict('Driver already picked in this round', {
pickedBy: existingPick.leagueMemberId,
});
}
// Check if league member's turn
const currentTurn = await this.getCurrentTurn(input.draftWindowId);
if (currentTurn.leagueMemberId !== input.leagueMemberId) {
throw ApiError.badRequest('Not your turn');
}
// Create pick
const pick = await tx.draftPick.create({
data: {
draftWindowId: input.draftWindowId,
leagueMemberId: input.leagueMemberId,
driverId: input.driverId,
round: currentTurn.round,
pickOrder: currentTurn.pickOrder,
submittedAt: new Date(),
},
});
return pick;
});
}| Category | Status | Score | Notes |
|---|---|---|---|
| Architecture | β Good | 8/10 | Clear layering, consistent patterns |
| Response Format | β Good | 9/10 | Standardized structure, minor gaps |
| Error Handling | π‘ Fair | 6/10 | Some inconsistencies, missing codes |
| Input Validation | π΄ Poor | 3/10 | Only auth module has Zod schemas |
| Authentication | π‘ Fair | 6/10 | Good tokens, missing auth checks |
| Authorization | π΄ Poor | 2/10 | Minimal role/permission checks |
| Data Integrity | π‘ Fair | 6/10 | Uses transactions, missing in some paths |
| Type Safety | π‘ Fair | 5/10 | TS everywhere, but interfaces not validated |
| Concurrency | π΄ Poor | 3/10 | Race conditions in draft/league ops |
| Consistency | π‘ Fair | 5/10 | Patterns vary across modules |
- Add Zod validation DTOs to all endpoints
- Apply auth middleware to all protected routes
- Fix draft picks race condition with transaction
- Fix league joins with member count check
- Standardize controller patterns across modules
- Add authorization checks to admin endpoints
- Implement audit logging for mutations
- Add missing error codes and centralize error factory
- Add comprehensive API documentation
- Add integration tests for concurrent operations
- Implement request correlation IDs
- Add metrics/monitoring
Generated: March 2, 2026
Reviewed By: API Audit Tool
Next Review: After fixes applied