Bump version to 2.1.8 in backend and frontend

This commit is contained in:
2026-01-23 10:19:51 +01:00
parent a2b9827279
commit b1e6f9a561
6 changed files with 659 additions and 607 deletions

View File

@@ -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",

View File

@@ -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'
})
}
}
)
}

View File

@@ -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'
})
}
}
}
)
}

View File

@@ -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'
}
})

View File

@@ -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": {

View File

@@ -1 +1 @@
2.1.4
2.1.8