Enforce parameter validation and pagination in API

This commit is contained in:
2026-01-18 14:46:54 +01:00
parent 6d2345bee7
commit 23cfde1f78
6 changed files with 543 additions and 167 deletions

View File

@@ -37,6 +37,14 @@ export class CommanderRepository extends Repository {
sortBy = 'created_at',
sortOrder = 'DESC'
) {
// Validate pagination parameters
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new Error('Limit must be an integer between 1 and 50')
}
if (!Number.isInteger(offset) || offset < 0) {
throw new Error('Offset must be a non-negative integer')
}
// Whitelist allowed sort columns
const allowedSortColumns = [
'created_at',
@@ -74,7 +82,18 @@ export class CommanderRepository extends Repository {
/**
* Search commanders by name for a user
*/
async searchCommandersByName(userId, query, limit = 20) {
async searchCommandersByName(userId, query, limit = 20, offset = 0) {
// Validate parameters
if (typeof query !== 'string' || query.length === 0 || query.length > 100) {
throw new Error('Search query must be a non-empty string with max 100 characters')
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new Error('Limit must be an integer between 1 and 50')
}
if (!Number.isInteger(offset) || offset < 0) {
throw new Error('Offset must be a non-negative integer')
}
const searchQuery = `%${query}%`
const sql = `
@@ -93,16 +112,21 @@ export class CommanderRepository extends Repository {
FROM ${this.tableName} c
WHERE c.user_id = $1 AND c.name ILIKE $2
ORDER BY c.name ASC
LIMIT $3
LIMIT $3 OFFSET $4
`
return dbManager.all(sql, [userId, searchQuery, limit])
return dbManager.all(sql, [userId, searchQuery, limit, offset])
}
/**
* Get popular commanders for a user (with 5+ games)
*/
async getPopularCommandersByUserId(userId, limit = 10) {
// Validate limit parameter
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new Error('Limit must be an integer between 1 and 50')
}
const query = `
SELECT
c.id,
@@ -129,6 +153,11 @@ export class CommanderRepository extends Repository {
* Get commander statistics
*/
async getCommanderStats(commanderId, userId) {
// Validate parameters
if (!Number.isInteger(commanderId) || commanderId <= 0) {
throw new Error('Commander ID must be a positive integer')
}
const query = `
SELECT
c.id,

View File

@@ -43,6 +43,14 @@ export class GameRepository extends Repository {
* Get games for a user with filtering and pagination
*/
async getGamesByUserId(userId, limit = 50, offset = 0, filters = {}) {
// Validate pagination parameters
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('Limit must be an integer between 1 and 100')
}
if (!Number.isInteger(offset) || offset < 0) {
throw new Error('Offset must be a non-negative integer')
}
let query = `
SELECT
g.id,
@@ -66,38 +74,63 @@ export class GameRepository extends Repository {
const params = [userId]
let paramCount = 2
// Apply filters
// Apply filters with validation
if (filters.commander) {
if (typeof filters.commander !== 'string' || filters.commander.length > 100) {
throw new Error('Commander filter must be a string with max 100 characters')
}
query += ` AND cmdr.name ILIKE $${paramCount}`
params.push(`%${filters.commander}%`)
paramCount++
}
if (filters.playerCount) {
if (filters.playerCount !== undefined) {
if (!Number.isInteger(filters.playerCount) || filters.playerCount < 2 || filters.playerCount > 8) {
throw new Error('Player count must be an integer between 2 and 8')
}
query += ` AND g.player_count = $${paramCount}`
params.push(filters.playerCount)
paramCount++
}
if (filters.commanderId) {
if (filters.commanderId !== undefined) {
if (!Number.isInteger(filters.commanderId) || filters.commanderId <= 0) {
throw new Error('Commander ID must be a positive integer')
}
query += ` AND g.commander_id = $${paramCount}`
params.push(filters.commanderId)
paramCount++
}
if (filters.dateFrom) {
if (isNaN(Date.parse(filters.dateFrom))) {
throw new Error('dateFrom must be a valid date')
}
query += ` AND g.date >= $${paramCount}`
params.push(filters.dateFrom)
paramCount++
}
if (filters.dateTo) {
if (isNaN(Date.parse(filters.dateTo))) {
throw new Error('dateTo must be a valid date')
}
query += ` AND g.date <= $${paramCount}`
params.push(filters.dateTo)
paramCount++
}
// Validate date range if both provided
if (filters.dateFrom && filters.dateTo) {
if (new Date(filters.dateFrom) > new Date(filters.dateTo)) {
throw new Error('dateFrom must be before or equal to dateTo')
}
}
if (filters.won !== undefined) {
if (typeof filters.won !== 'boolean') {
throw new Error('Won filter must be a boolean')
}
query += ` AND g.won = $${paramCount}`
params.push(filters.won)
paramCount++
@@ -136,37 +169,68 @@ export class GameRepository extends Repository {
const params = [userId]
let paramCount = 2
// Apply filters
// Apply filters with validation
if (filters.commander) {
if (typeof filters.commander !== 'string' || filters.commander.length > 100) {
throw new Error('Commander filter must be a string with max 100 characters')
}
query += ` AND cmdr.name ILIKE $${paramCount}`
params.push(`%${filters.commander}%`)
paramCount++
}
if (filters.playerCount) {
if (filters.playerCount !== undefined) {
if (!Number.isInteger(filters.playerCount) || filters.playerCount < 2 || filters.playerCount > 8) {
throw new Error('Player count must be an integer between 2 and 8')
}
query += ` AND g.player_count = $${paramCount}`
params.push(filters.playerCount)
paramCount++
}
if (filters.commanderId) {
if (filters.commanderId !== undefined) {
if (!Number.isInteger(filters.commanderId) || filters.commanderId <= 0) {
throw new Error('Commander ID must be a positive integer')
}
query += ` AND g.commander_id = $${paramCount}`
params.push(filters.commanderId)
paramCount++
}
if (filters.dateFrom) {
if (isNaN(Date.parse(filters.dateFrom))) {
throw new Error('dateFrom must be a valid date')
}
query += ` AND g.date >= $${paramCount}`
params.push(filters.dateFrom)
paramCount++
}
if (filters.dateTo) {
if (isNaN(Date.parse(filters.dateTo))) {
throw new Error('dateTo must be a valid date')
}
query += ` AND g.date <= $${paramCount}`
params.push(filters.dateTo)
paramCount++
}
// Validate date range if both provided
if (filters.dateFrom && filters.dateTo) {
if (new Date(filters.dateFrom) > new Date(filters.dateTo)) {
throw new Error('dateFrom must be before or equal to dateTo')
}
}
if (filters.won !== undefined) {
if (typeof filters.won !== 'boolean') {
throw new Error('Won filter must be a boolean')
}
query += ` AND g.won = $${paramCount}`
params.push(filters.won)
paramCount++
}
query += ` ORDER BY g.date DESC`
return dbManager.all(query, params)
@@ -176,6 +240,11 @@ export class GameRepository extends Repository {
* Get game by ID with commander details
*/
async getGameById(gameId, userId) {
// Validate parameters
if (!Number.isInteger(parseInt(gameId)) || parseInt(gameId) <= 0) {
throw new Error('Game ID must be a positive integer')
}
const query = `
SELECT
g.id,
@@ -285,6 +354,14 @@ export class GameRepository extends Repository {
* Find game by date and commander (for duplicate checking)
*/
async findGameByDateAndCommander(userId, date, commanderId) {
// Validate parameters
if (isNaN(Date.parse(date))) {
throw new Error('Date must be a valid date')
}
if (!Number.isInteger(commanderId) || commanderId <= 0) {
throw new Error('Commander ID must be a positive integer')
}
try {
const result = await dbManager.query(
`
@@ -304,6 +381,11 @@ export class GameRepository extends Repository {
* Get game statistics for a commander
*/
async getCommanderGameStats(commanderId, userId) {
// Validate parameters
if (!Number.isInteger(commanderId) || commanderId <= 0) {
throw new Error('Commander ID must be a positive integer')
}
const query = `
SELECT
COUNT(*) as total_games,

View File

@@ -70,7 +70,31 @@ const commanderQuerySchema = z.object({
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander per page')
.max(50, 'Maximum 50 commanders per page')
.default(20)
.default(20),
offset: z
.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
.default(0),
sortBy: z
.enum(['created_at', 'updated_at', 'name', 'total_games'])
.default('created_at')
.optional(),
sortOrder: z
.enum(['ASC', 'DESC'])
.default('DESC')
.optional()
})
const popularCommandersQuerySchema = z.object({
limit: z
.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander')
.max(50, 'Maximum 50 commanders')
.default(10)
})
// Helper function to transform commander from DB format to API format
@@ -113,26 +137,38 @@ export default async function commanderRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { q, limit } = commanderQuerySchema.parse(request.query)
const { q, limit, offset, sortBy, sortOrder } = commanderQuerySchema.parse(request.query)
const userId = request.user.id
let commanders
if (q) {
commanders = await commanderRepo.searchCommandersByName(userId, q, limit)
commanders = await commanderRepo.searchCommandersByName(userId, q, limit, offset)
} else {
commanders = await commanderRepo.getCommandersByUserId(userId, limit)
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
}
reply.send({
commanders: commanders.map(transformCommander),
total: commanders.length
pagination: {
total: commanders.length,
limit,
offset
}
})
} catch (error) {
fastify.log.error('Get commanders error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch commanders'
})
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid query parameters',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Get commanders error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch commanders'
})
}
}
}
)
@@ -159,7 +195,17 @@ export default async function commanderRoutes(fastify, options) {
const { id } = request.params
const userId = request.user.id
const commander = await commanderRepo.findById(id)
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const commander = await commanderRepo.findById(commanderId)
if (!commander || commander.user_id !== userId) {
reply.code(404).send({
@@ -279,6 +325,17 @@ export default async function commanderRoutes(fastify, options) {
try {
const { id } = request.params
const userId = request.user.id
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const updateData = updateCommanderSchema.parse(request.body)
// Convert colors array to JSON if provided
@@ -287,7 +344,7 @@ export default async function commanderRoutes(fastify, options) {
updatePayload.colors = JSON.stringify(updatePayload.colors)
}
const updated = await commanderRepo.updateCommander(id, userId, updatePayload)
const updated = await commanderRepo.updateCommander(commanderId, userId, updatePayload)
if (!updated) {
reply.code(400).send({
@@ -297,12 +354,12 @@ export default async function commanderRoutes(fastify, options) {
return
}
const commander = await commanderRepo.findById(id)
const commander = await commanderRepo.findById(commanderId)
reply.send({
message: 'Commander updated successfully',
commander: transformCommander(commander)
})
reply.send({
message: 'Commander updated successfully',
commander: transformCommander(commander)
})
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = formatValidationErrors(error)
@@ -346,7 +403,17 @@ export default async function commanderRoutes(fastify, options) {
const { id } = request.params
const userId = request.user.id
const deleted = await commanderRepo.deleteCommander(id, userId)
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const deleted = await commanderRepo.deleteCommander(commanderId, userId)
if (!deleted) {
reply.code(404).send({
@@ -391,7 +458,17 @@ export default async function commanderRoutes(fastify, options) {
const { id } = request.params
const userId = request.user.id
const stats = await commanderRepo.getCommanderStats(id, userId)
// Validate commander ID parameter
const commanderId = parseInt(id)
if (isNaN(commanderId) || commanderId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID',
details: ['Commander ID must be a positive integer']
})
}
const stats = await commanderRepo.getCommanderStats(commanderId, userId)
reply.send({
stats: {
@@ -429,18 +506,31 @@ export default async function commanderRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { limit } = popularCommandersQuerySchema.parse(request.query)
const userId = request.user.id
const commanders = await commanderRepo.getPopularCommandersByUserId(userId)
const commanders = await commanderRepo.getPopularCommandersByUserId(userId, limit)
reply.send({
commanders: commanders.map(transformCommander)
commanders: commanders.map(transformCommander),
pagination: {
total: commanders.length,
limit
}
})
} catch (error) {
fastify.log.error('Get popular commanders error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch popular commanders'
})
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid query parameters',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Get popular commanders error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch popular commanders'
})
}
}
}
)

View File

@@ -105,6 +105,35 @@ const gameQuerySchema = z.object({
.min(1, 'Search query cannot be empty')
.max(50, 'Search query limited to 50 characters')
.optional(),
playerCount: z
.coerce
.number('Player count must be a number')
.int('Player count must be a whole number')
.min(2, 'Minimum 2 players required')
.max(8, 'Maximum 8 players allowed')
.optional(),
commanderId: z
.coerce
.number('Commander ID must be a number')
.int('Commander ID must be a whole number')
.positive('Commander ID must be positive')
.optional(),
dateFrom: z
.string('dateFrom must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid dateFrom format (use YYYY-MM-DD)'
})
.optional(),
dateTo: z
.string('dateTo must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid dateTo format (use YYYY-MM-DD)'
})
.optional(),
won: z
.enum(['true', 'false'])
.transform((val) => val === 'true')
.optional(),
limit: z
.coerce
.number('Limit must be a number')
@@ -118,7 +147,65 @@ const gameQuerySchema = z.object({
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
.default(0)
})
}).refine(
(data) => {
if (data.dateFrom && data.dateTo) {
return new Date(data.dateFrom) <= new Date(data.dateTo)
}
return true
},
{
message: 'dateFrom must be before or equal to dateTo',
path: ['dateFrom']
}
)
const exportGameQuerySchema = z.object({
commander: z
.string('Commander filter must be a string')
.max(100, 'Commander filter limited to 100 characters')
.optional(),
playerCount: z
.coerce
.number('Player count must be a number')
.int('Player count must be a whole number')
.min(2, 'Minimum 2 players required')
.max(8, 'Maximum 8 players allowed')
.optional(),
commanderId: z
.coerce
.number('Commander ID must be a number')
.int('Commander ID must be a whole number')
.positive('Commander ID must be positive')
.optional(),
dateFrom: z
.string('dateFrom must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid dateFrom format (use YYYY-MM-DD)'
})
.optional(),
dateTo: z
.string('dateTo must be a string')
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid dateTo format (use YYYY-MM-DD)'
})
.optional(),
won: z
.enum(['true', 'false'])
.transform((val) => val === 'true')
.optional()
}).refine(
(data) => {
if (data.dateFrom && data.dateTo) {
return new Date(data.dateFrom) <= new Date(data.dateTo)
}
return true
},
{
message: 'dateFrom must be before or equal to dateTo',
path: ['dateFrom']
}
)
export default async function gameRoutes(fastify, options) {
// Initialize repositories
@@ -143,17 +230,33 @@ export default async function gameRoutes(fastify, options) {
}
]
},
async (request, reply) => {
try {
const { q, limit, offset } = gameQuerySchema.parse(request.query)
const userId = request.user.id
async (request, reply) => {
try {
const validatedQuery = gameQuerySchema.parse(request.query)
const { q, limit, offset, playerCount, commanderId, dateFrom, dateTo, won } = validatedQuery
const userId = request.user.id
const filters = {}
if (q) {
filters.commander = q
}
const filters = {}
if (q) {
filters.commander = q
}
if (playerCount !== undefined) {
filters.playerCount = playerCount
}
if (commanderId !== undefined) {
filters.commanderId = commanderId
}
if (dateFrom !== undefined) {
filters.dateFrom = dateFrom
}
if (dateTo !== undefined) {
filters.dateTo = dateTo
}
if (won !== undefined) {
filters.won = won
}
let games = await gameRepo.getGamesByUserId(userId, limit, offset, filters)
let games = await gameRepo.getGamesByUserId(userId, limit, offset, filters)
// Transform database results to camelCase with commander info
const transformedGames = games.map((game) => ({
@@ -182,13 +285,21 @@ export default async function gameRoutes(fastify, options) {
offset
}
})
} catch (error) {
fastify.log.error('Get games error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch games'
})
}
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid query parameters',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Get games error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch games'
})
}
}
}
)
@@ -211,10 +322,20 @@ export default async function gameRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
const { id } = request.params
const userId = request.user.id
const game = await gameRepo.getGameById(id, userId)
// Validate game ID parameter
const gameId = parseInt(id)
if (isNaN(gameId) || gameId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid game ID',
details: ['Game ID must be a positive integer']
})
}
const game = await gameRepo.getGameById(gameId, userId)
if (!game) {
reply.code(404).send({
@@ -276,34 +397,17 @@ export default async function gameRoutes(fastify, options) {
// LAYER 1: Schema validation
const validatedData = createGameSchema.parse(request.body)
// LAYER 2: Business logic validation
// Check commander exists and belongs to user
const commander = await commanderRepo.findById(validatedData.commanderId)
if (!commander || commander.user_id !== userId) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID or commander not found',
details: ['Commander does not exist or does not belong to you']
})
}
// Check for duplicate games (same commander on same date)
const existingGame = await gameRepo.findGameByDateAndCommander(
userId,
validatedData.date,
validatedData.commanderId
)
if (existingGame) {
return reply.code(409).send({
error: 'Conflict',
message: 'Duplicate game detected',
details: [
`You already logged a game with ${commander.name} on ${validatedData.date}`
]
})
}
// LAYER 2: Business logic validation
// Check commander exists and belongs to user
const commander = await commanderRepo.findById(validatedData.commanderId)
if (!commander || commander.user_id !== userId) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID or commander not found',
details: ['Commander does not exist or does not belong to you']
})
}
// Convert camelCase to snake_case for database
const gameData = {
@@ -378,10 +482,35 @@ export default async function gameRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
const updateData = updateGameSchema.parse(request.body)
try {
const { id } = request.params
const userId = request.user.id
// Validate game ID parameter
const gameId = parseInt(id)
if (isNaN(gameId) || gameId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid game ID',
details: ['Game ID must be a positive integer']
})
}
const updateData = updateGameSchema.parse(request.body)
// LAYER 2: Business logic validation
// Check commander exists and belongs to user if updating commander
if (updateData.commanderId !== undefined) {
const commander = await commanderRepo.findById(updateData.commanderId)
if (!commander || commander.user_id !== userId) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid commander ID or commander not found',
details: ['Commander does not exist or does not belong to you']
})
}
}
// Convert camelCase to snake_case for database
const gameData = {}
@@ -398,7 +527,7 @@ export default async function gameRoutes(fastify, options) {
gameData.sol_ring_turn_one_won = updateData.solRingTurnOneWon
if (updateData.notes !== undefined) gameData.notes = updateData.notes
const updated = await gameRepo.updateGame(id, userId, gameData)
const updated = await gameRepo.updateGame(gameId, userId, gameData)
if (!updated) {
reply.code(400).send({
@@ -408,7 +537,7 @@ export default async function gameRoutes(fastify, options) {
return
}
const game = await gameRepo.getGameById(id, userId)
const game = await gameRepo.getGameById(gameId, userId)
reply.send({
message: 'Game updated successfully',
@@ -466,10 +595,20 @@ export default async function gameRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { id } = request.params
const userId = request.user.id
const { id } = request.params
const userId = request.user.id
const deleted = await gameRepo.deleteGame(id, userId)
// Validate game ID parameter
const gameId = parseInt(id)
if (isNaN(gameId) || gameId <= 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Invalid game ID',
details: ['Game ID must be a positive integer']
})
}
const deleted = await gameRepo.deleteGame(gameId, userId)
if (!deleted) {
reply.code(404).send({
@@ -510,18 +649,21 @@ export default async function gameRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const userId = request.user.id
const filters = {}
// Parse optional query filters
if (request.query.commander) filters.commander = request.query.commander
if (request.query.playerCount) filters.playerCount = parseInt(request.query.playerCount)
if (request.query.commanderId) filters.commanderId = parseInt(request.query.commanderId)
if (request.query.dateFrom) filters.dateFrom = request.query.dateFrom
if (request.query.dateTo) filters.dateTo = request.query.dateTo
try {
const userId = request.user.id
// Validate and parse export query parameters
const validatedQuery = exportGameQuerySchema.parse(request.query)
const filters = {}
if (validatedQuery.commander !== undefined) filters.commander = validatedQuery.commander
if (validatedQuery.playerCount !== undefined) filters.playerCount = validatedQuery.playerCount
if (validatedQuery.commanderId !== undefined) filters.commanderId = validatedQuery.commanderId
if (validatedQuery.dateFrom !== undefined) filters.dateFrom = validatedQuery.dateFrom
if (validatedQuery.dateTo !== undefined) filters.dateTo = validatedQuery.dateTo
if (validatedQuery.won !== undefined) filters.won = validatedQuery.won
const games = await gameRepo.exportGamesByUserId(userId, filters)
const games = await gameRepo.exportGamesByUserId(userId, filters)
// Generate filename with current date
const today = new Date().toLocaleDateString('en-US').replace(/\//g, '_')
@@ -540,14 +682,22 @@ export default async function gameRoutes(fastify, options) {
games: games
}
reply.send(exportData)
} catch (error) {
fastify.log.error('Export games error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to export games'
})
}
}
)
reply.send(exportData)
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid filter parameters',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Export games error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to export games'
})
}
}
}
)
}

View File

@@ -1,4 +1,22 @@
import { z } from 'zod'
import dbManager from '../config/database.js'
import { formatValidationErrors } from '../utils/validators.js'
const commanderStatsQuerySchema = z.object({
limit: z
.coerce
.number('Limit must be a number')
.int('Limit must be a whole number')
.min(1, 'Minimum 1 commander per page')
.max(100, 'Maximum 100 commanders per page')
.default(50),
offset: z
.coerce
.number('Offset must be a number')
.int('Offset must be a whole number')
.min(0, 'Offset cannot be negative')
.default(0)
})
export default async function statsRoutes(fastify, options) {
fastify.get(
@@ -18,36 +36,28 @@ export default async function statsRoutes(fastify, options) {
]
},
async (request, reply) => {
try {
const userId = request.user.id
try {
const userId = request.user.id
const stats = await dbManager.get(
`
SELECT
total_games,
win_rate,
total_commanders,
avg_rounds
FROM user_stats
WHERE user_id = $1
`,
[userId]
)
const stats = await dbManager.get(
`
SELECT
total_games,
win_rate,
total_commanders,
avg_rounds
FROM user_stats
WHERE user_id = $1
`,
[userId]
)
// Also query games directly to verify
const directGameCount = await dbManager.get(
`
SELECT COUNT(*) as count FROM games WHERE user_id = $1
`,
[userId]
)
reply.send({
totalGames: stats?.total_games || 0,
winRate: stats?.win_rate || 0,
totalCommanders: stats?.total_commanders || 0,
avgRounds: Math.round(stats?.avg_rounds || 0)
})
reply.send({
totalGames: stats?.total_games || 0,
winRate: stats?.win_rate || 0,
totalCommanders: stats?.total_commanders || 0,
avgRounds: Math.round(stats?.avg_rounds || 0)
})
} catch (error) {
fastify.log.error('Get stats overview error:', error)
reply.code(500).send({
@@ -77,6 +87,7 @@ export default async function statsRoutes(fastify, options) {
},
async (request, reply) => {
try {
const { limit, offset } = commanderStatsQuerySchema.parse(request.query)
const userId = request.user.id
// Get detailed commander stats with at least 5 games, sorted by total games then win rate
@@ -85,8 +96,9 @@ export default async function statsRoutes(fastify, options) {
SELECT * FROM commander_stats
WHERE user_id = $1 AND total_games >= 5
ORDER BY total_games DESC, win_rate DESC
LIMIT $2 OFFSET $3
`,
[userId]
[userId, limit, offset]
)
// Convert snake_case to camelCase
@@ -135,28 +147,41 @@ export default async function statsRoutes(fastify, options) {
[userId]
)
reply.send({
stats,
charts: {
playerCounts: {
labels: playerCountStats.map((s) => `${s.player_count} Players`),
data: playerCountStats.map((s) =>
Math.round((s.wins / s.total) * 100)
)
},
colors: {
labels: colorStats.map((s) => (Array.isArray(s.colors) ? s.colors.join('') : '')),
data: colorStats.map((s) => Math.round((s.wins / s.total) * 100))
}
}
})
} catch (error) {
fastify.log.error('Get commander stats error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch detailed stats'
})
}
}
)
reply.send({
stats,
pagination: {
total: stats.length,
limit,
offset
},
charts: {
playerCounts: {
labels: playerCountStats.map((s) => `${s.player_count} Players`),
data: playerCountStats.map((s) =>
Math.round((s.wins / s.total) * 100)
)
},
colors: {
labels: colorStats.map((s) => (Array.isArray(s.colors) ? s.colors.join('') : '')),
data: colorStats.map((s) => Math.round((s.wins / s.total) * 100))
}
}
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
error: 'Validation Error',
message: 'Invalid query parameters',
details: formatValidationErrors(error)
})
} else {
fastify.log.error('Get commander stats error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to fetch detailed stats'
})
}
}
}
)
}

View File

@@ -1 +1 @@
2.0.6
2.0.7