diff --git a/backend/package.json b/backend/package.json index 6a3b679..512f8a3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "edh-stats-backend", - "version": "2.1.2", + "version": "2.1.8", "description": "Backend API for EDH/Commander stats tracking application", "main": "src/server.js", "type": "module", diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 93cdc88..d53a189 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -24,7 +24,7 @@ const registerSchema = z.object({ .refine((val) => isNotReservedUsername(val), { message: 'This username is reserved and cannot be used' }), - + password: z .string('Password must be a string') .min(8, 'Password must be at least 8 characters') @@ -38,7 +38,7 @@ const registerSchema = z.object({ .refine((val) => /(?=.*\d)/.test(val), { message: 'Password must contain at least one number' }), - + email: z .string('Email must be a string') .email('Invalid email format') @@ -54,19 +54,20 @@ const loginSchema = z.object({ .string('Username is required') .min(1, 'Username is required') .transform((val) => val.toLowerCase().trim()), - - password: z - .string('Password is required') - .min(1, 'Password is required'), - - remember: z.boolean('Remember must be true or false').optional().default(false) + + password: z.string('Password is required').min(1, 'Password is required'), + + remember: z + .boolean('Remember must be true or false') + .optional() + .default(false) }) const changePasswordSchema = z.object({ currentPassword: z .string('Current password is required') .min(1, 'Current password is required'), - + newPassword: z .string('New password must be a string') .min(8, 'New password must be at least 8 characters') @@ -113,11 +114,17 @@ export default async function authRoutes(fastify, options) { const userRepo = new UserRepository() // Public endpoint to check if registration is allowed - fastify.get('/config', async (request, reply) => { - return { - allowRegistration: registrationConfig.allowRegistration + fastify.get( + '/config', + { + config: { rateLimit: { max: 10, timeWindow: '10 seconds' } } + }, + async (request, reply) => { + return { + allowRegistration: registrationConfig.allowRegistration + } } - }) + ) // Register new user fastify.post( @@ -126,32 +133,37 @@ export default async function authRoutes(fastify, options) { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } }, async (request, reply) => { - try { - // Check if registration is allowed - if (!registrationConfig.allowRegistration) { - return reply.code(403).send({ - error: 'Registration Disabled', - message: 'User registration is currently disabled' - }) - } + try { + // Check if registration is allowed + if (!registrationConfig.allowRegistration) { + return reply.code(403).send({ + error: 'Registration Disabled', + message: 'User registration is currently disabled' + }) + } - // Check if max user limit has been reached (only if allowRegistration is true and MAX_USERS is set) - if (registrationConfig.allowRegistration && registrationConfig.maxUsers) { - const userCount = await userRepo.countUsers() - if (userCount >= registrationConfig.maxUsers) { - return reply.code(403).send({ - error: 'Registration Disabled', - message: 'User registration limit has been reached' - }) - } - } + // Check if max user limit has been reached (only if allowRegistration is true and MAX_USERS is set) + if ( + registrationConfig.allowRegistration && + registrationConfig.maxUsers + ) { + const userCount = await userRepo.countUsers() + if (userCount >= registrationConfig.maxUsers) { + return reply.code(403).send({ + error: 'Registration Disabled', + message: 'User registration limit has been reached' + }) + } + } - // LAYER 1: Schema validation - const validatedData = registerSchema.parse(request.body) + // LAYER 1: Schema validation + const validatedData = registerSchema.parse(request.body) // LAYER 2: Business logic validation // Check username uniqueness - const existingUser = await userRepo.findByUsername(validatedData.username) + const existingUser = await userRepo.findByUsername( + validatedData.username + ) if (existingUser) { return reply.code(409).send({ error: 'Conflict', @@ -167,7 +179,9 @@ export default async function authRoutes(fastify, options) { return reply.code(409).send({ error: 'Conflict', message: 'Email already registered', - details: ['This email is already in use. Please use a different email.'] + details: [ + 'This email is already in use. Please use a different email.' + ] }) } } @@ -304,17 +318,17 @@ export default async function authRoutes(fastify, options) { } }, async (request, reply) => { - try { - await request.jwtVerify() + try { + await request.jwtVerify() - const user = await userRepo.findById(request.user.id) - if (!user) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'User not found' - }) - return - } + const user = await userRepo.findById(request.user.id) + if (!user) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'User not found' + }) + return + } // Generate new token const token = await reply.jwtSign( @@ -358,24 +372,24 @@ export default async function authRoutes(fastify, options) { ] }, async (request, reply) => { - try { - const user = await userRepo.findById(request.user.id) - if (!user) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } + try { + const user = await userRepo.findById(request.user.id) + if (!user) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } - reply.send({ - user: { - id: user.id, - username: user.username, - email: user.email, - createdAt: user.created_at - } - }) + reply.send({ + user: { + id: user.id, + username: user.username, + email: user.email, + createdAt: user.created_at + } + }) } catch (error) { fastify.log.error('Get profile error:', error) reply.code(500).send({ @@ -404,20 +418,23 @@ export default async function authRoutes(fastify, options) { ] }, async (request, reply) => { - try { - const validatedData = updateProfileSchema.parse(request.body) + try { + const validatedData = updateProfileSchema.parse(request.body) - const updated = await userRepo.updateProfile(request.user.id, validatedData) + const updated = await userRepo.updateProfile( + request.user.id, + validatedData + ) - if (!updated) { - reply.code(400).send({ - error: 'Update Failed', - message: 'No valid fields to update' - }) - return - } + if (!updated) { + reply.code(400).send({ + error: 'Update Failed', + message: 'No valid fields to update' + }) + return + } - const user = await userRepo.findById(request.user.id) + const user = await userRepo.findById(request.user.id) reply.send({ message: 'Profile updated successfully', @@ -470,31 +487,34 @@ export default async function authRoutes(fastify, options) { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, async (request, reply) => { - try { - const { newUsername } = updateUsernameSchema.parse(request.body) + try { + const { newUsername } = updateUsernameSchema.parse(request.body) - // Check if username is already taken - const existingUser = await userRepo.findByUsername(newUsername) - if (existingUser && existingUser.id !== request.user.id) { - reply.code(400).send({ - error: 'Username Taken', - message: 'Username is already taken' - }) - return - } + // Check if username is already taken + const existingUser = await userRepo.findByUsername(newUsername) + if (existingUser && existingUser.id !== request.user.id) { + reply.code(400).send({ + error: 'Username Taken', + message: 'Username is already taken' + }) + return + } - // Update username using repository method - const updated = await userRepo.updateUsername(request.user.id, newUsername) + // Update username using repository method + const updated = await userRepo.updateUsername( + request.user.id, + newUsername + ) - if (!updated) { - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to update username' - }) - return - } + if (!updated) { + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to update username' + }) + return + } - const user = await userRepo.findById(request.user.id) + const user = await userRepo.findById(request.user.id) reply.send({ message: 'Username updated successfully', @@ -522,54 +542,57 @@ export default async function authRoutes(fastify, options) { } ) - // Change password (POST - keep for backward compatibility) - fastify.post( - '/change-password', - { - preHandler: [ - async (request, reply) => { - try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - } - ], - config: { rateLimit: { max: 3, timeWindow: '1 hour' } } - }, - async (request, reply) => { - try { - const { currentPassword, newPassword } = changePasswordSchema.parse( - request.body - ) + // Change password (POST - keep for backward compatibility) + fastify.post( + '/change-password', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired token' + }) + } + } + ], + config: { rateLimit: { max: 3, timeWindow: '1 hour' } } + }, + async (request, reply) => { + try { + const { currentPassword, newPassword } = changePasswordSchema.parse( + request.body + ) - // Verify current password - const user = await userRepo.findByUsername(request.user.username) - if (!user) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } + // Verify current password + const user = await userRepo.findByUsername(request.user.username) + if (!user) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } - const isValidPassword = await userRepo.verifyPassword( - currentPassword, - user.password_hash - ) - if (!isValidPassword) { - reply.code(401).send({ - error: 'Authentication Failed', - message: 'Current password is incorrect' - }) - return - } + const isValidPassword = await userRepo.verifyPassword( + currentPassword, + user.password_hash + ) + if (!isValidPassword) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'Current password is incorrect' + }) + return + } - // Update password - const updated = await userRepo.updatePassword(request.user.id, newPassword) + // Update password + const updated = await userRepo.updatePassword( + request.user.id, + newPassword + ) if (!updated) { reply.code(500).send({ @@ -600,127 +623,130 @@ export default async function authRoutes(fastify, options) { } ) - // Change password (PUT) - fastify.put( - '/change-password', - { - preHandler: [ - async (request, reply) => { - try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - } - ], - config: { rateLimit: { max: 3, timeWindow: '1 hour' } } - }, - async (request, reply) => { - try { - const { currentPassword, newPassword } = changePasswordSchema.parse( - request.body - ) - - // Verify current password - const user = await userRepo.findByUsername(request.user.username) - if (!user) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } - - const isValidPassword = await userRepo.verifyPassword( - currentPassword, - user.password_hash - ) - if (!isValidPassword) { + // Change password (PUT) + fastify.put( + '/change-password', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { reply.code(401).send({ - error: 'Authentication Failed', - message: 'Current password is incorrect' + error: 'Unauthorized', + message: 'Invalid or expired token' }) - return } + } + ], + config: { rateLimit: { max: 3, timeWindow: '1 hour' } } + }, + async (request, reply) => { + try { + const { currentPassword, newPassword } = changePasswordSchema.parse( + request.body + ) - // Update password - const updated = await userRepo.updatePassword(request.user.id, newPassword) + // Verify current password + const user = await userRepo.findByUsername(request.user.username) + if (!user) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } - if (!updated) { - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to update password' - }) - return - } + const isValidPassword = await userRepo.verifyPassword( + currentPassword, + user.password_hash + ) + if (!isValidPassword) { + reply.code(401).send({ + error: 'Authentication Failed', + message: 'Current password is incorrect' + }) + return + } - reply.send({ - message: 'Password changed successfully' - }) - } catch (error) { - if (error instanceof z.ZodError) { - reply.code(400).send({ - error: 'Validation Error', - message: 'Invalid input data', - details: error.errors.map((e) => e.message) - }) - } else { - fastify.log.error('Change password error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to change password' - }) - } - } - } - ) + // Update password + const updated = await userRepo.updatePassword( + request.user.id, + newPassword + ) - // Delete account (DELETE /me) - fastify.delete( - '/me', - { - preHandler: [ - async (request, reply) => { - try { - await request.jwtVerify() - } catch (err) { - reply.code(401).send({ - error: 'Unauthorized', - message: 'Invalid or expired token' - }) - } - } - ], - config: { rateLimit: { max: 2, timeWindow: '1 hour' } } - }, - async (request, reply) => { - try { - const userId = request.user.id + if (!updated) { + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to update password' + }) + return + } - // Delete user account (cascades to commanders and games) - const deleted = await userRepo.deleteUser(userId) + reply.send({ + message: 'Password changed successfully' + }) + } catch (error) { + if (error instanceof z.ZodError) { + reply.code(400).send({ + error: 'Validation Error', + message: 'Invalid input data', + details: error.errors.map((e) => e.message) + }) + } else { + fastify.log.error('Change password error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to change password' + }) + } + } + } + ) - if (!deleted) { - reply.code(404).send({ - error: 'Not Found', - message: 'User not found' - }) - return - } + // Delete account (DELETE /me) + fastify.delete( + '/me', + { + preHandler: [ + async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ + error: 'Unauthorized', + message: 'Invalid or expired token' + }) + } + } + ], + config: { rateLimit: { max: 2, timeWindow: '1 hour' } } + }, + async (request, reply) => { + try { + const userId = request.user.id - reply.send({ - message: 'Account deleted successfully' - }) - } catch (error) { - fastify.log.error('Delete account error:', error) - reply.code(500).send({ - error: 'Internal Server Error', - message: 'Failed to delete account' - }) - } - } - ) + // Delete user account (cascades to commanders and games) + const deleted = await userRepo.deleteUser(userId) + + if (!deleted) { + reply.code(404).send({ + error: 'Not Found', + message: 'User not found' + }) + return + } + + reply.send({ + message: 'Account deleted successfully' + }) + } catch (error) { + fastify.log.error('Delete account error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to delete account' + }) + } + } + ) } diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index e684a1d..37647bc 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -18,30 +18,30 @@ const createGameSchema = z.object({ .refine((date) => validateDateRange(date), { message: 'Game date must be within the last year and not in the future' }), - + playerCount: z .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'), - + commanderId: z .number('Commander ID must be a number') .int('Commander ID must be a whole number') .positive('Commander ID must be positive') .max(2147483647, 'Invalid commander ID'), - + won: z.boolean('Won must be true or false'), - + rounds: z .number('Rounds must be a number') .int('Rounds must be a whole number') .min(1, 'Minimum 1 round') .max(50, 'Maximum 50 rounds'), - + startingPlayerWon: z.boolean('Starting player won must be true or false'), solRingTurnOneWon: z.boolean('Sol ring turn one won must be true or false'), - + notes: z .string('Notes must be a string') .max(1000, 'Notes limited to 1000 characters') @@ -62,32 +62,36 @@ const updateGameSchema = z.object({ message: 'Game date must be within the last year and not in the future' }) .optional(), - + commanderId: z .number('Commander ID must be a number') .int('Commander ID must be a whole number') .positive('Commander ID must be positive') .optional(), - + playerCount: z .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(), - + won: z.boolean('Won must be true or false').optional(), - + rounds: z .number('Rounds must be a number') .int('Rounds must be a whole number') .min(1, 'Minimum 1 round') .max(50, 'Maximum 50 rounds') .optional(), - - startingPlayerWon: z.boolean('Starting player won must be true or false').optional(), - solRingTurnOneWon: z.boolean('Sol ring turn one won must be true or false').optional(), - + + startingPlayerWon: z + .boolean('Starting player won must be true or false') + .optional(), + solRingTurnOneWon: z + .boolean('Sol ring turn one won must be true or false') + .optional(), + notes: z .string('Notes must be a string') .max(1000, 'Notes limited to 1000 characters') @@ -99,113 +103,111 @@ const updateGameSchema = z.object({ .nullable() }) -const gameQuerySchema = z.object({ - q: z - .string('Search query must be a string') - .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') - .int('Limit must be a whole number') - .min(1, 'Minimum 1 game per page') - .max(100, 'Maximum 100 games 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) -}).refine( - (data) => { - if (data.dateFrom && data.dateTo) { - return new Date(data.dateFrom) <= new Date(data.dateTo) +const gameQuerySchema = z + .object({ + q: z + .string('Search query must be a string') + .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') + .int('Limit must be a whole number') + .min(1, 'Minimum 1 game per page') + .max(100, 'Maximum 100 games 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) + }) + .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'] } - 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) +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'] } - return true - }, - { - message: 'dateFrom must be before or equal to dateTo', - path: ['dateFrom'] - } -) + ) export default async function gameRoutes(fastify, options) { // Initialize repositories @@ -216,7 +218,7 @@ export default async function gameRoutes(fastify, options) { fastify.get( '/', { - config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, + config: { rateLimit: { max: 30, timeWindow: '1 minute' } }, preHandler: [ async (request, reply) => { try { @@ -230,83 +232,97 @@ export default async function gameRoutes(fastify, options) { } ] }, - 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 + 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 - } - 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 - } - - // Fetch one extra game to check if there are more - let games = await gameRepo.getGamesByUserId(userId, limit + 1, offset, filters) - - // Check if there are more games beyond the limit - const hasMore = games.length > limit - - // Only return the requested limit - const gamesForResponse = games.slice(0, limit) - - // Transform database results to camelCase with commander info - const transformedGames = gamesForResponse.map((game) => ({ - id: game.id, - date: new Date(game.date).toLocaleDateString('en-US'), - playerCount: game.player_count, - commanderId: game.commander_id, - won: game.won, - rounds: game.rounds, - startingPlayerWon: game.starting_player_won, - solRingTurnOneWon: game.sol_ring_turn_one_won, - notes: game.notes || null, - commanderName: game.name, - commanderColors: game.colors || [], - userId: game.user_id, - createdAt: game.created_at, - updatedAt: game.updated_at - })) - - reply.send({ - games: transformedGames, - pagination: { - limit, - offset, - hasMore - } - }) - } 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' - }) - } + 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 + } + + // Fetch one extra game to check if there are more + let games = await gameRepo.getGamesByUserId( + userId, + limit + 1, + offset, + filters + ) + + // Check if there are more games beyond the limit + const hasMore = games.length > limit + + // Only return the requested limit + const gamesForResponse = games.slice(0, limit) + + // Transform database results to camelCase with commander info + const transformedGames = gamesForResponse.map((game) => ({ + id: game.id, + date: new Date(game.date).toLocaleDateString('en-US'), + playerCount: game.player_count, + commanderId: game.commander_id, + won: game.won, + rounds: game.rounds, + startingPlayerWon: game.starting_player_won, + solRingTurnOneWon: game.sol_ring_turn_one_won, + notes: game.notes || null, + commanderName: game.name, + commanderColors: game.colors || [], + userId: game.user_id, + createdAt: game.created_at, + updatedAt: game.updated_at + })) + + reply.send({ + games: transformedGames, + pagination: { + limit, + offset, + hasMore + } + }) + } 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' + }) + } + } + } ) // Get specific game @@ -327,23 +343,23 @@ export default async function gameRoutes(fastify, options) { ] }, async (request, reply) => { - try { - const { id } = request.params - const userId = request.user.id + 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'] - }) - } + // 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) + const game = await gameRepo.getGameById(gameId, userId) - if (!game) { + if (!game) { reply.code(404).send({ error: 'Not Found', message: 'Game not found' @@ -351,23 +367,23 @@ export default async function gameRoutes(fastify, options) { return } - reply.send({ - game: { - id: game.id, - date: new Date(game.date).toLocaleDateString('en-US'), - playerCount: game.player_count, - commanderId: game.commander_id, - won: game.won, - rounds: game.rounds, - startingPlayerWon: game.starting_player_won, - solRingTurnOneWon: game.sol_ring_turn_one_won, - notes: game.notes || null, - commanderName: game.commander_name, - commanderColors: game.commander_colors || [], - createdAt: game.created_at, - updatedAt: game.updated_at - } - }) + reply.send({ + game: { + id: game.id, + date: new Date(game.date).toLocaleDateString('en-US'), + playerCount: game.player_count, + commanderId: game.commander_id, + won: game.won, + rounds: game.rounds, + startingPlayerWon: game.starting_player_won, + solRingTurnOneWon: game.sol_ring_turn_one_won, + notes: game.notes || null, + commanderName: game.commander_name, + commanderColors: game.commander_colors || [], + createdAt: game.created_at, + updatedAt: game.updated_at + } + }) } catch (error) { fastify.log.error('Get game error:', error) reply.code(500).send({ @@ -382,7 +398,7 @@ export default async function gameRoutes(fastify, options) { fastify.post( '/', { - config: { rateLimit: { max: 3, timeWindow: '1 minute' } }, + config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, preHandler: [ async (request, reply) => { try { @@ -399,21 +415,23 @@ export default async function gameRoutes(fastify, options) { async (request, reply) => { try { const userId = request.user.id - + // 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'] - }) - } + // 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 = { @@ -429,7 +447,7 @@ export default async function gameRoutes(fastify, options) { } const game = await gameRepo.createGame(gameData) - + // Fetch the game with commander details const gameWithCommander = await gameRepo.getGameById(game.id, userId) @@ -488,52 +506,52 @@ export default async function gameRoutes(fastify, options) { ] }, async (request, reply) => { - try { - const { id } = request.params - const userId = request.user.id - - // Validate game ID parameter - const gameId = parseInt(id) - if (isNaN(gameId) || gameId <= 0) { + 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 game ID', - details: ['Game ID must be a positive integer'] + message: 'Invalid commander ID or commander not found', + details: ['Commander does not exist or does not belong to you'] }) } + } - const updateData = updateGameSchema.parse(request.body) + // Convert camelCase to snake_case for database + const gameData = {} + if (updateData.date !== undefined) gameData.date = updateData.date + if (updateData.commanderId !== undefined) + gameData.commander_id = updateData.commanderId + if (updateData.playerCount !== undefined) + gameData.player_count = updateData.playerCount + if (updateData.won !== undefined) gameData.won = updateData.won + if (updateData.rounds !== undefined) gameData.rounds = updateData.rounds + if (updateData.startingPlayerWon !== undefined) + gameData.starting_player_won = updateData.startingPlayerWon + if (updateData.solRingTurnOneWon !== undefined) + gameData.sol_ring_turn_one_won = updateData.solRingTurnOneWon + if (updateData.notes !== undefined) gameData.notes = updateData.notes - // 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 = {} - if (updateData.date !== undefined) gameData.date = updateData.date - if (updateData.commanderId !== undefined) - gameData.commander_id = updateData.commanderId - if (updateData.playerCount !== undefined) - gameData.player_count = updateData.playerCount - if (updateData.won !== undefined) gameData.won = updateData.won - if (updateData.rounds !== undefined) gameData.rounds = updateData.rounds - if (updateData.startingPlayerWon !== undefined) - gameData.starting_player_won = updateData.startingPlayerWon - if (updateData.solRingTurnOneWon !== undefined) - gameData.sol_ring_turn_one_won = updateData.solRingTurnOneWon - if (updateData.notes !== undefined) gameData.notes = updateData.notes - - const updated = await gameRepo.updateGame(gameId, userId, gameData) + const updated = await gameRepo.updateGame(gameId, userId, gameData) if (!updated) { reply.code(400).send({ @@ -543,26 +561,26 @@ export default async function gameRoutes(fastify, options) { return } - const game = await gameRepo.getGameById(gameId, userId) + const game = await gameRepo.getGameById(gameId, userId) - reply.send({ - message: 'Game updated successfully', - game: { - id: game.id, - date: new Date(game.date).toLocaleDateString('en-US'), - playerCount: game.player_count, - commanderId: game.commander_id, - won: game.won, - rounds: game.rounds, - startingPlayerWon: game.starting_player_won, - solRingTurnOneWon: game.sol_ring_turn_one_won, - notes: game.notes || null, - commanderName: game.commander_name, - commanderColors: game.commander_colors || [], - createdAt: game.created_at, - updatedAt: game.updated_at - } - }) + reply.send({ + message: 'Game updated successfully', + game: { + id: game.id, + date: new Date(game.date).toLocaleDateString('en-US'), + playerCount: game.player_count, + commanderId: game.commander_id, + won: game.won, + rounds: game.rounds, + startingPlayerWon: game.starting_player_won, + solRingTurnOneWon: game.sol_ring_turn_one_won, + notes: game.notes || null, + commanderName: game.commander_name, + commanderColors: game.commander_colors || [], + createdAt: game.created_at, + updatedAt: game.updated_at + } + }) } catch (error) { if (error instanceof z.ZodError) { reply.code(400).send({ @@ -600,23 +618,23 @@ export default async function gameRoutes(fastify, options) { ] }, async (request, reply) => { - try { - const { id } = request.params - const userId = request.user.id + 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'] - }) - } + // 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) + const deleted = await gameRepo.deleteGame(gameId, userId) - if (!deleted) { + if (!deleted) { reply.code(404).send({ error: 'Not Found', message: 'Game not found' @@ -632,8 +650,8 @@ export default async function gameRoutes(fastify, options) { reply.code(500).send({ error: 'Failed to delete game' }) - } } + } ) // Export games as JSON @@ -655,30 +673,38 @@ export default async function gameRoutes(fastify, options) { ] }, async (request, reply) => { - 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 + try { + const userId = request.user.id - const games = await gameRepo.exportGamesByUserId(userId, filters) - - // Generate filename with current date + // 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) + + // Generate filename with current date const today = new Date().toLocaleDateString('en-US').replace(/\//g, '_') const filename = `edh_games_${today}.json` - + // Set appropriate headers for file download reply.header('Content-Type', 'application/json') - reply.header('Content-Disposition', `attachment; filename="${filename}"`) - + reply.header( + 'Content-Disposition', + `attachment; filename="${filename}"` + ) + const exportData = { metadata: { exportDate: new Date().toISOString(), @@ -687,23 +713,23 @@ export default async function gameRoutes(fastify, options) { }, games: 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' - }) - } - } - } - ) + + 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' + }) + } + } + } + ) } diff --git a/backend/src/server.js b/backend/src/server.js index afabefe..46bea44 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -119,7 +119,7 @@ export default async function build(opts = {}) { app.get('/', async (request, reply) => { return { message: 'EDH/Commander Stats API', - version: '2.1.2', + version: '2.1.8', status: 'running' } }) diff --git a/frontend/package.json b/frontend/package.json index 2bdb020..bd87c89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "edh-stats-frontend", - "version": "2.1.2", + "version": "2.1.8", "description": "Frontend for EDH/Commander stats tracking application", "type": "module", "scripts": { diff --git a/frontend/public/version.txt b/frontend/public/version.txt index 7d2ed7c..ebf14b4 100644 --- a/frontend/public/version.txt +++ b/frontend/public/version.txt @@ -1 +1 @@ -2.1.4 +2.1.8