Enforce parameter validation and pagination in API
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.0.6
|
||||
2.0.7
|
||||
|
||||
Reference in New Issue
Block a user