Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8171db0985 |
@@ -41,6 +41,17 @@ CREATE TABLE IF NOT EXISTS games (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Add inactive column for archiving commanders (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'commanders' AND column_name = 'inactive'
|
||||
) THEN
|
||||
ALTER TABLE commanders ADD COLUMN inactive BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Performance indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_commanders_user_id ON commanders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id);
|
||||
@@ -48,6 +59,7 @@ CREATE INDEX IF NOT EXISTS idx_games_commander_id ON games(commander_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_date ON games(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_user_commander ON games(user_id, commander_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_user_date ON games(user_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_commanders_user_inactive ON commanders(user_id, inactive);
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_timestamp()
|
||||
@@ -83,18 +95,18 @@ CREATE VIEW user_stats AS
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id) as total_commanders,
|
||||
(SELECT COUNT(*) FROM games WHERE user_id = u.id) as total_games,
|
||||
(SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE) as total_wins,
|
||||
(SELECT COUNT(DISTINCT id) FROM commanders WHERE user_id = u.id AND inactive = FALSE) as total_commanders,
|
||||
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as total_games,
|
||||
(SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE) as total_wins,
|
||||
ROUND(
|
||||
CASE
|
||||
WHEN (SELECT COUNT(*) FROM games WHERE user_id = u.id) > 0
|
||||
THEN ((SELECT COUNT(*) FROM games WHERE user_id = u.id AND won = TRUE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games WHERE user_id = u.id))
|
||||
WHEN (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) > 0
|
||||
THEN ((SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND g.won = TRUE AND c.inactive = FALSE)::NUMERIC * 100.0 / (SELECT COUNT(*) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE))
|
||||
ELSE 0
|
||||
END, 2
|
||||
) as win_rate,
|
||||
(SELECT AVG(rounds) FROM games WHERE user_id = u.id) as avg_rounds,
|
||||
(SELECT MAX(date) FROM games WHERE user_id = u.id) as last_game_date
|
||||
(SELECT AVG(g.rounds) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as avg_rounds,
|
||||
(SELECT MAX(g.date) FROM games g JOIN commanders c ON g.commander_id = c.id WHERE g.user_id = u.id AND c.inactive = FALSE) as last_game_date
|
||||
FROM users u
|
||||
GROUP BY u.id, u.username;
|
||||
|
||||
@@ -105,6 +117,7 @@ SELECT
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.inactive,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
|
||||
ROUND(
|
||||
@@ -118,4 +131,5 @@ SELECT
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND starting_player_won = TRUE) as starting_player_wins,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND sol_ring_turn_one_won = TRUE) as sol_ring_wins,
|
||||
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
|
||||
FROM commanders c;
|
||||
FROM commanders c
|
||||
WHERE c.inactive = FALSE;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class CommanderRepository extends Repository {
|
||||
`
|
||||
INSERT INTO ${this.tableName} (name, colors, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, colors, user_id, created_at, updated_at
|
||||
RETURNING id, name, colors, user_id, inactive, created_at, updated_at
|
||||
`,
|
||||
[name, colors, userId]
|
||||
)
|
||||
@@ -63,6 +63,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.inactive,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -102,6 +103,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.inactive,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -133,6 +135,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.inactive,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -141,7 +144,7 @@ export class CommanderRepository extends Repository {
|
||||
(SELECT ROUND(AVG(rounds)::NUMERIC, 2) FROM games WHERE commander_id = c.id) as avg_rounds,
|
||||
(SELECT MAX(date) FROM games WHERE commander_id = c.id) as last_played
|
||||
FROM ${this.tableName} c
|
||||
WHERE c.user_id = $1 AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
|
||||
WHERE c.user_id = $1 AND c.inactive = FALSE AND (SELECT COUNT(*) FROM games WHERE commander_id = c.id) >= 5
|
||||
ORDER BY win_rate DESC, c.name ASC
|
||||
LIMIT $2
|
||||
`
|
||||
@@ -163,6 +166,7 @@ export class CommanderRepository extends Repository {
|
||||
c.id,
|
||||
c.name,
|
||||
c.colors,
|
||||
c.inactive,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE) as total_wins,
|
||||
ROUND((SELECT COUNT(*) FROM games WHERE commander_id = c.id AND won = TRUE)::NUMERIC * 100.0 / NULLIF((SELECT COUNT(*) FROM games WHERE commander_id = c.id), 0), 2) as win_rate,
|
||||
@@ -248,6 +252,24 @@ export class CommanderRepository extends Repository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a commander's inactive status
|
||||
*/
|
||||
async toggleInactive(commanderId, userId, inactive) {
|
||||
// Verify ownership
|
||||
const existing = await this.findById(commanderId)
|
||||
if (!existing || existing.user_id !== userId) {
|
||||
throw new Error('Commander not found or access denied')
|
||||
}
|
||||
|
||||
const result = await dbManager.query(
|
||||
`UPDATE ${this.tableName} SET inactive = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3 RETURNING *`,
|
||||
[inactive, commanderId, userId]
|
||||
)
|
||||
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a commander
|
||||
*/
|
||||
|
||||
@@ -16,11 +16,13 @@ const createCommanderSchema = z.object({
|
||||
.refine((val) => /^[a-zA-Z0-9\s,.\'-]+$/.test(val), {
|
||||
message: 'Commander name contains invalid characters'
|
||||
}),
|
||||
|
||||
|
||||
colors: z
|
||||
.array(
|
||||
z.enum(['W', 'U', 'B', 'R', 'G'], {
|
||||
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
|
||||
errorMap: () => ({
|
||||
message: 'Invalid color (must be W, U, B, R, or G)'
|
||||
})
|
||||
}),
|
||||
{
|
||||
errorMap: () => ({ message: 'Colors must be an array' })
|
||||
@@ -43,11 +45,13 @@ const updateCommanderSchema = z.object({
|
||||
message: 'Commander name contains invalid characters'
|
||||
})
|
||||
.optional(),
|
||||
|
||||
|
||||
colors: z
|
||||
.array(
|
||||
z.enum(['W', 'U', 'B', 'R', 'G'], {
|
||||
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
|
||||
errorMap: () => ({
|
||||
message: 'Invalid color (must be W, U, B, R, or G)'
|
||||
})
|
||||
})
|
||||
)
|
||||
.min(1, 'Select at least one color')
|
||||
@@ -64,15 +68,13 @@ const commanderQuerySchema = z.object({
|
||||
.min(1, 'Search query cannot be empty')
|
||||
.max(50, 'Search query limited to 50 characters')
|
||||
.optional(),
|
||||
limit: z
|
||||
.coerce
|
||||
limit: z.coerce
|
||||
.number('Limit must be a number')
|
||||
.int('Limit must be a whole number')
|
||||
.min(1, 'Minimum 1 commander per page')
|
||||
.max(50, 'Maximum 50 commanders per page')
|
||||
.default(20),
|
||||
offset: z
|
||||
.coerce
|
||||
offset: z.coerce
|
||||
.number('Offset must be a number')
|
||||
.int('Offset must be a whole number')
|
||||
.min(0, 'Offset cannot be negative')
|
||||
@@ -81,15 +83,15 @@ const commanderQuerySchema = z.object({
|
||||
.enum(['created_at', 'updated_at', 'name', 'total_games'])
|
||||
.default('created_at')
|
||||
.optional(),
|
||||
sortOrder: z
|
||||
.enum(['ASC', 'DESC'])
|
||||
.default('DESC')
|
||||
.optional()
|
||||
sortOrder: z.enum(['ASC', 'DESC']).default('DESC').optional()
|
||||
})
|
||||
|
||||
const toggleInactiveSchema = z.object({
|
||||
inactive: z.boolean()
|
||||
})
|
||||
|
||||
const popularCommandersQuerySchema = z.object({
|
||||
limit: z
|
||||
.coerce
|
||||
limit: z.coerce
|
||||
.number('Limit must be a number')
|
||||
.int('Limit must be a whole number')
|
||||
.min(1, 'Minimum 1 commander')
|
||||
@@ -104,6 +106,7 @@ function transformCommander(cmd) {
|
||||
name: cmd.name,
|
||||
colors: cmd.colors || [],
|
||||
userId: cmd.user_id,
|
||||
inactive: cmd.inactive || false,
|
||||
totalGames: parseInt(cmd.total_games) || 0,
|
||||
totalWins: parseInt(cmd.total_wins) || 0,
|
||||
winRate: cmd.win_rate ? parseFloat(cmd.win_rate) : 0,
|
||||
@@ -137,15 +140,27 @@ export default async function commanderRoutes(fastify, options) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { q, limit, offset, sortBy, sortOrder } = commanderQuerySchema.parse(request.query)
|
||||
const userId = request.user.id
|
||||
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, offset)
|
||||
} else {
|
||||
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
|
||||
}
|
||||
let commanders
|
||||
if (q) {
|
||||
commanders = await commanderRepo.searchCommandersByName(
|
||||
userId,
|
||||
q,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
} else {
|
||||
commanders = await commanderRepo.getCommandersByUserId(
|
||||
userId,
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
reply.send({
|
||||
commanders: commanders.map(transformCommander),
|
||||
@@ -191,36 +206,36 @@ export default async function commanderRoutes(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 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']
|
||||
})
|
||||
}
|
||||
// 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)
|
||||
const commander = await commanderRepo.findById(commanderId)
|
||||
|
||||
if (!commander || commander.user_id !== userId) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!commander || commander.user_id !== userId) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reply.send({
|
||||
commander: {
|
||||
...commander,
|
||||
colors: commander.colors || []
|
||||
}
|
||||
})
|
||||
reply.send({
|
||||
commander: {
|
||||
...commander,
|
||||
colors: commander.colors || []
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
fastify.log.error('Get commander error:', error)
|
||||
reply.code(500).send({
|
||||
@@ -250,36 +265,41 @@ export default async function commanderRoutes(fastify, options) {
|
||||
]
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const userId = request.user.id
|
||||
|
||||
// LAYER 1: Schema validation
|
||||
const validatedData = createCommanderSchema.parse(request.body)
|
||||
try {
|
||||
const userId = request.user.id
|
||||
|
||||
// LAYER 2: Business logic validation
|
||||
// Check if user has reached max commander limit (100)
|
||||
const commanderCount = await commanderRepo.countCommandersByUserId(userId)
|
||||
if (commanderCount >= 100) {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'Commander limit reached',
|
||||
details: ['You have reached the maximum of 100 commanders. Delete some to add more.']
|
||||
})
|
||||
}
|
||||
// LAYER 1: Schema validation
|
||||
const validatedData = createCommanderSchema.parse(request.body)
|
||||
|
||||
// Check for duplicate commander name (case-insensitive)
|
||||
const existing = await commanderRepo.findByNameAndUserId(
|
||||
validatedData.name.toLowerCase(),
|
||||
userId
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'Commander already exists',
|
||||
details: [`You already have a commander named "${validatedData.name}"`]
|
||||
})
|
||||
}
|
||||
// LAYER 2: Business logic validation
|
||||
// Check if user has reached max commander limit (100)
|
||||
const commanderCount =
|
||||
await commanderRepo.countCommandersByUserId(userId)
|
||||
if (commanderCount >= 100) {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'Commander limit reached',
|
||||
details: [
|
||||
'You have reached the maximum of 100 commanders. Delete some to add more.'
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Check for duplicate commander name (case-insensitive)
|
||||
const existing = await commanderRepo.findByNameAndUserId(
|
||||
validatedData.name.toLowerCase(),
|
||||
userId
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'Commander already exists',
|
||||
details: [
|
||||
`You already have a commander named "${validatedData.name}"`
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Convert colors array to JSON string for storage
|
||||
const colorsJson = JSON.stringify(validatedData.colors)
|
||||
@@ -289,27 +309,27 @@ export default async function commanderRoutes(fastify, options) {
|
||||
colorsJson
|
||||
)
|
||||
|
||||
reply.code(201).send({
|
||||
message: 'Commander created successfully',
|
||||
commander: transformCommander(commander)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedErrors = formatValidationErrors(error)
|
||||
const firstError = formattedErrors[0]?.message || 'Invalid input data'
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: firstError,
|
||||
details: formattedErrors
|
||||
})
|
||||
} else {
|
||||
fastify.log.error('Create commander error:', error)
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to create commander'
|
||||
})
|
||||
}
|
||||
}
|
||||
reply.code(201).send({
|
||||
message: 'Commander created successfully',
|
||||
commander: transformCommander(commander)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedErrors = formatValidationErrors(error)
|
||||
const firstError = formattedErrors[0]?.message || 'Invalid input data'
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: firstError,
|
||||
details: formattedErrors
|
||||
})
|
||||
} else {
|
||||
fastify.log.error('Create commander error:', error)
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to create commander'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -332,61 +352,133 @@ export default async function commanderRoutes(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 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']
|
||||
})
|
||||
}
|
||||
// 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)
|
||||
const updateData = updateCommanderSchema.parse(request.body)
|
||||
|
||||
// Convert colors array to JSON if provided
|
||||
const updatePayload = { ...updateData }
|
||||
if (updatePayload.colors) {
|
||||
updatePayload.colors = JSON.stringify(updatePayload.colors)
|
||||
}
|
||||
// Convert colors array to JSON if provided
|
||||
const updatePayload = { ...updateData }
|
||||
if (updatePayload.colors) {
|
||||
updatePayload.colors = JSON.stringify(updatePayload.colors)
|
||||
}
|
||||
|
||||
const updated = await commanderRepo.updateCommander(commanderId, userId, updatePayload)
|
||||
const updated = await commanderRepo.updateCommander(
|
||||
commanderId,
|
||||
userId,
|
||||
updatePayload
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
reply.code(400).send({
|
||||
error: 'Update Failed',
|
||||
message: 'No valid fields to update or commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!updated) {
|
||||
reply.code(400).send({
|
||||
error: 'Update Failed',
|
||||
message: 'No valid fields to update or commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const commander = await commanderRepo.findById(commanderId)
|
||||
const commander = await commanderRepo.findById(commanderId)
|
||||
|
||||
reply.send({
|
||||
message: 'Commander updated successfully',
|
||||
commander: transformCommander(commander)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedErrors = formatValidationErrors(error)
|
||||
const firstError = formattedErrors[0]?.message || 'Invalid input data'
|
||||
reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: firstError,
|
||||
details: formattedErrors
|
||||
})
|
||||
} else {
|
||||
fastify.log.error('Update commander error:', error.message || error)
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to update commander'
|
||||
})
|
||||
}
|
||||
}
|
||||
reply.send({
|
||||
message: 'Commander updated successfully',
|
||||
commander: transformCommander(commander)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedErrors = formatValidationErrors(error)
|
||||
const firstError = formattedErrors[0]?.message || 'Invalid input data'
|
||||
reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: firstError,
|
||||
details: formattedErrors
|
||||
})
|
||||
} else {
|
||||
fastify.log.error('Update commander error:', error.message || error)
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to update commander'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Toggle commander inactive status
|
||||
fastify.patch(
|
||||
'/:id/inactive',
|
||||
{
|
||||
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
||||
preHandler: [
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired token'
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params
|
||||
const userId = request.user.id
|
||||
|
||||
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 { inactive } = toggleInactiveSchema.parse(request.body)
|
||||
const updated = await commanderRepo.toggleInactive(
|
||||
commanderId,
|
||||
userId,
|
||||
inactive
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Commander not found'
|
||||
})
|
||||
}
|
||||
|
||||
reply.send({
|
||||
message: `Commander ${inactive ? 'deactivated' : 'reactivated'} successfully`,
|
||||
commander: transformCommander(updated)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: 'Invalid request body',
|
||||
details: formatValidationErrors(error)
|
||||
})
|
||||
} else {
|
||||
fastify.log.error('Toggle inactive error:', error)
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to update commander status'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -409,29 +501,29 @@ export default async function commanderRoutes(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 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']
|
||||
})
|
||||
}
|
||||
// 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)
|
||||
const deleted = await commanderRepo.deleteCommander(commanderId, userId)
|
||||
|
||||
if (!deleted) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!deleted) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Commander not found'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reply.send({
|
||||
message: 'Commander deleted successfully'
|
||||
@@ -464,29 +556,29 @@ export default async function commanderRoutes(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 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']
|
||||
})
|
||||
}
|
||||
// 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)
|
||||
const stats = await commanderRepo.getCommanderStats(commanderId, userId)
|
||||
|
||||
reply.send({
|
||||
stats: {
|
||||
...stats,
|
||||
win_rate: Math.round(stats.win_rate || 0),
|
||||
avg_rounds: Math.round(stats.avg_rounds || 0)
|
||||
}
|
||||
})
|
||||
reply.send({
|
||||
stats: {
|
||||
...stats,
|
||||
win_rate: Math.round(stats.win_rate || 0),
|
||||
avg_rounds: Math.round(stats.avg_rounds || 0)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
fastify.log.error('Get commander stats error:', error)
|
||||
reply.code(500).send({
|
||||
@@ -515,18 +607,21 @@ 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, limit)
|
||||
try {
|
||||
const { limit } = popularCommandersQuerySchema.parse(request.query)
|
||||
const userId = request.user.id
|
||||
const commanders = await commanderRepo.getPopularCommandersByUserId(
|
||||
userId,
|
||||
limit
|
||||
)
|
||||
|
||||
reply.send({
|
||||
commanders: commanders.map(transformCommander),
|
||||
pagination: {
|
||||
total: commanders.length,
|
||||
limit
|
||||
}
|
||||
})
|
||||
reply.send({
|
||||
commanders: commanders.map(transformCommander),
|
||||
pagination: {
|
||||
total: commanders.length,
|
||||
limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
|
||||
@@ -118,13 +118,14 @@ export default async function statsRoutes(fastify, options) {
|
||||
const playerCountStats = await dbManager.all(
|
||||
`
|
||||
SELECT
|
||||
player_count,
|
||||
g.player_count,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN won = TRUE THEN 1 ELSE 0 END) as wins
|
||||
FROM games
|
||||
WHERE user_id = $1
|
||||
GROUP BY player_count
|
||||
ORDER BY player_count
|
||||
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
|
||||
FROM games g
|
||||
JOIN commanders c ON g.commander_id = c.id
|
||||
WHERE g.user_id = $1 AND c.inactive = FALSE
|
||||
GROUP BY g.player_count
|
||||
ORDER BY g.player_count
|
||||
`,
|
||||
[userId]
|
||||
)
|
||||
@@ -139,7 +140,7 @@ export default async function statsRoutes(fastify, options) {
|
||||
SUM(CASE WHEN g.won = TRUE THEN 1 ELSE 0 END) as wins
|
||||
FROM games g
|
||||
JOIN commanders c ON g.commander_id = c.id
|
||||
WHERE g.user_id = $1
|
||||
WHERE g.user_id = $1 AND c.inactive = FALSE
|
||||
GROUP BY c.colors
|
||||
`,
|
||||
[userId]
|
||||
|
||||
@@ -230,14 +230,17 @@
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<template x-for="commander in commanders" :key="commander.id">
|
||||
<div class="card hover:shadow-lg transition-shadow">
|
||||
<div class="card hover:shadow-lg transition-shadow" :class="{ 'opacity-60': commander.inactive }">
|
||||
<!-- Commander Header -->
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900"
|
||||
x-text="commander.name"
|
||||
></h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900"
|
||||
x-text="commander.name"
|
||||
></h3>
|
||||
<span x-show="commander.inactive" class="px-2 py-0.5 rounded text-xs font-bold bg-gray-200 text-gray-600">INACTIVE</span>
|
||||
</div>
|
||||
<div class="flex space-x-1 mt-1">
|
||||
<template x-for="color in commander.colors" :key="color">
|
||||
<div
|
||||
@@ -477,6 +480,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inactive Toggle -->
|
||||
<div class="mb-6 flex items-center justify-between p-3 rounded-lg" :class="editingCommander.inactive ? 'bg-gray-100' : 'bg-gray-50'">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">Commander Status</p>
|
||||
<p class="text-xs text-gray-500" x-text="editingCommander.inactive ? 'This commander is excluded from stats and dropdowns.' : 'This commander is active and included in stats.'"></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleInactive(editingCommander)"
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
||||
:class="editingCommander.inactive ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'"
|
||||
x-text="editingCommander.inactive ? 'Reactivate' : 'Deactivate'"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
<!-- Top Commanders -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Top Commanders</h2>
|
||||
<h2 class="text-xl font-semibold">Top Played Commanders</h2>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-edh-accent hover:text-edh-primary"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
content="Log and track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full" x-data="gameManager()">
|
||||
@@ -36,9 +36,9 @@
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
|
||||
<div class="flex space-x-4">
|
||||
<button @click="exportGames()" class="btn btn-secondary">
|
||||
<!--<button @click="exportGames()" class="btn btn-secondary">
|
||||
Export JSON
|
||||
</button>
|
||||
</button>-->
|
||||
<button @click="showLogForm = !showLogForm" class="btn btn-primary">
|
||||
<span x-show="!showLogForm">Log New Game</span>
|
||||
<span x-show="showLogForm">Cancel</span>
|
||||
@@ -79,10 +79,10 @@
|
||||
required
|
||||
>
|
||||
<option value="">Select a commander...</option>
|
||||
<template x-for="commander in commanders" :key="commander.id">
|
||||
<template x-for="commander in commanders.filter(c => !c.inactive || (editingGame && editingGame.commanderId == c.id))" :key="commander.id">
|
||||
<option
|
||||
:value="commander.id"
|
||||
x-text="commander.name"
|
||||
x-text="commander.name + (commander.inactive ? ' (inactive)' : '')"
|
||||
></option>
|
||||
</template>
|
||||
</select>
|
||||
@@ -171,16 +171,16 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="col-span-1 md:col-span-2 w-full">
|
||||
<label class="form-label">Game Notes</label>
|
||||
<textarea
|
||||
x-model="formData.notes"
|
||||
rows="5"
|
||||
class="form-textarea w-full"
|
||||
placeholder="Any memorable moments, combos, or reasons for winning/losing..."
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Notes -->
|
||||
<div class="col-span-1 md:col-span-2 w-full">
|
||||
<label class="form-label">Game Notes</label>
|
||||
<textarea
|
||||
x-model="formData.notes"
|
||||
rows="5"
|
||||
class="form-textarea w-full"
|
||||
placeholder="Any memorable moments, combos, or reasons for winning/losing..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
@@ -337,94 +337,98 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Load More -->
|
||||
<div x-show="pagination.hasMore" class="flex justify-center pt-8">
|
||||
<button
|
||||
@click="loadMore()"
|
||||
:disabled="pagination.isLoadingMore"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': pagination.isLoadingMore }"
|
||||
class="btn btn-secondary flex justify-center items-center space-x-2"
|
||||
<!-- Load More -->
|
||||
<div x-show="pagination.hasMore" class="flex justify-center pt-8">
|
||||
<button
|
||||
@click="loadMore()"
|
||||
:disabled="pagination.isLoadingMore"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': pagination.isLoadingMore }"
|
||||
class="btn btn-secondary flex justify-center items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
x-show="!pagination.isLoadingMore"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg
|
||||
x-show="!pagination.isLoadingMore"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="pagination.isLoadingMore"
|
||||
class="animate-spin h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="pagination.isLoadingMore"
|
||||
class="animate-spin h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div
|
||||
x-show="deleteConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="deleteConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this game record? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="deleteConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete()"
|
||||
:disabled="deleteConfirm.deleting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!deleteConfirm.deleting">Delete Game</span>
|
||||
<span x-show="deleteConfirm.deleting">Deleting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div
|
||||
x-show="deleteConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="deleteConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this game record? This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="deleteConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete()"
|
||||
:disabled="deleteConfirm.deleting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!deleteConfirm.deleting">Delete Game</span>
|
||||
<span x-show="deleteConfirm.deleting">Deleting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts --> <script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/games.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/games.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -230,6 +230,40 @@ function commanderManager() {
|
||||
}
|
||||
},
|
||||
|
||||
async toggleInactive(commander) {
|
||||
const newInactive = !commander.inactive
|
||||
commander.inactive = newInactive
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch(`/api/commanders/${commander.id}/inactive`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ inactive: newInactive })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const index = this.commanders.findIndex((c) => c.id === commander.id)
|
||||
if (index !== -1) {
|
||||
this.commanders.splice(index, 1, data.commander)
|
||||
}
|
||||
} else {
|
||||
commander.inactive = !newInactive
|
||||
this.serverError = 'Failed to update commander status'
|
||||
}
|
||||
} catch (error) {
|
||||
commander.inactive = !newInactive
|
||||
console.error('Toggle inactive error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCommander(commander) {
|
||||
if (!confirm(`Are you sure you want to delete "${commander.name}"?`))
|
||||
return
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.8
|
||||
2.1.9
|
||||
|
||||
Reference in New Issue
Block a user