From 2f0485d7c5ad38ec3314b8dad50cf38f3c09458a Mon Sep 17 00:00:00 2001 From: Michael Skrynski Date: Fri, 16 Jan 2026 14:55:25 +0100 Subject: [PATCH] Finish JSON export feature and simplify JWT config --- TODO.md | 2 +- backend/src/config/jwt.js | 7 +-- backend/src/models/Game.js | 87 ++++++++++++++++++++++++++++++++++++- backend/src/routes/games.js | 60 +++++++++++++++++++++++++ frontend/public/games.html | 13 ++++-- frontend/public/js/games.js | 38 ++++++++++++++++ 6 files changed, 195 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index f431a62..262637d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ ## frontend/public/games.html -[ ] export logs to JSON via a button in the top right corner, the file should have the current date in the name i.e: edh_games_16_01_2026.json +[x] export logs to JSON via a button in the top right corner, the file should have the current date in the name i.e: edh_games_16_01_2026.json ## frontend/public/round-counter.html diff --git a/backend/src/config/jwt.js b/backend/src/config/jwt.js index 24d152a..796f972 100644 --- a/backend/src/config/jwt.js +++ b/backend/src/config/jwt.js @@ -10,12 +10,7 @@ const rootDir = resolve(__dirname, '../../..') config({ path: resolve(rootDir, '.env') }) export const jwtConfig = { - secret: process.env.JWT_SECRET || 'fallback-secret-for-development', - algorithm: 'HS512', - expiresIn: '24h', - refreshExpiresIn: '7d', - issuer: 'edh-stats', - audience: 'edh-stats-users' + secret: process.env.JWT_SECRET || 'fallback-secret-for-development' } export const corsConfig = { diff --git a/backend/src/models/Game.js b/backend/src/models/Game.js index 0666871..fb6c028 100644 --- a/backend/src/models/Game.js +++ b/backend/src/models/Game.js @@ -94,25 +94,35 @@ class Game { ORDER BY g.date DESC ` + const params = [userId] + if (filters.commander) { + params.push(`%${filters.commander}%`) + } + if (filters.playerCount) { query += ` AND g.player_count = ?` + params.push(filters.playerCount) } if (filters.commanderId) { query += ` AND g.commander_id = ?` + params.push(filters.commanderId) } if (filters.dateFrom) { query += ` AND g.date >= ?` + params.push(filters.dateFrom) } if (filters.dateTo) { query += ` AND g.date <= ?` + params.push(filters.dateTo) } query += ` LIMIT ? OFFSET ?` + params.push(limit, offset) - const games = db.prepare(query).all([userId, limit, offset]) + const games = db.prepare(query).all(params) // Parse dates for frontend and transform to camelCase return games.map((game) => ({ @@ -135,6 +145,81 @@ class Game { } } + static async exportByUserId(userId, filters = {}) { + const db = await dbManager.initialize() + + try { + let query = ` + SELECT + g.id, + g.date, + g.player_count, + g.commander_id, + g.won, + g.rounds, + g.starting_player_won, + g.sol_ring_turn_one_won, + g.notes, + cmdr.name as commander_name, + cmdr.colors as commander_colors, + g.created_at, + g.updated_at + FROM games g + LEFT JOIN commanders cmdr ON g.commander_id = cmdr.id + WHERE g.user_id = ? + ${filters.commander ? `AND cmdr.name LIKE ?` : ''} + ` + + const params = [userId] + if (filters.commander) { + params.push(`%${filters.commander}%`) + } + + if (filters.playerCount) { + query += ` AND g.player_count = ?` + params.push(filters.playerCount) + } + + if (filters.commanderId) { + query += ` AND g.commander_id = ?` + params.push(filters.commanderId) + } + + if (filters.dateFrom) { + query += ` AND g.date >= ?` + params.push(filters.dateFrom) + } + + if (filters.dateTo) { + query += ` AND g.date <= ?` + params.push(filters.dateTo) + } + + query += ` ORDER BY g.date DESC` + + const games = db.prepare(query).all(params) + + // Return data for export (minimal transformation) + return games.map(game => ({ + id: game.id, + date: game.date, + playerCount: game.player_count, + commanderId: game.commander_id, + commanderName: game.commander_name, + commanderColors: JSON.parse(game.commander_colors || '[]'), + won: Boolean(game.won), + rounds: game.rounds || 0, + startingPlayerWon: Boolean(game.starting_player_won), + solRingTurnOneWon: Boolean(game.sol_ring_turn_one_won), + notes: game.notes, + createdAt: game.created_at, + updatedAt: game.updated_at + })) + } catch (error) { + throw new Error('Failed to export games') + } + } + static async update(id, updateData, userId) { const db = await dbManager.initialize() diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index 1f6fc0a..e01ab59 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -347,6 +347,66 @@ export default async function gameRoutes(fastify, options) { reply.code(500).send({ error: 'Failed to delete game' }) + } + } + ) + + // Export games as JSON + fastify.get( + '/export', + { + config: { rateLimit: { max: 5, 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 userId = request.user.id + const filters = {} + + // Parse optional query filters + if (request.query.commander) filters.commander = request.query.commander + if (request.query.playerCount) filters.playerCount = parseInt(request.query.playerCount) + if (request.query.commanderId) filters.commanderId = parseInt(request.query.commanderId) + if (request.query.dateFrom) filters.dateFrom = request.query.dateFrom + if (request.query.dateTo) filters.dateTo = request.query.dateTo + + const games = await Game.exportByUserId(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}"`) + + const exportData = { + metadata: { + exportDate: new Date().toISOString(), + totalGames: games.length, + userId: userId + }, + games: games + } + + reply.send(exportData) + } catch (error) { + fastify.log.error('Export games error:', error) + reply.code(500).send({ + error: 'Internal Server Error', + message: 'Failed to export games' + }) } } ) diff --git a/frontend/public/games.html b/frontend/public/games.html index b175813..91f1f70 100644 --- a/frontend/public/games.html +++ b/frontend/public/games.html @@ -35,10 +35,15 @@

Recent Games

- +
+ + +
diff --git a/frontend/public/js/games.js b/frontend/public/js/games.js index 5c92245..8aea200 100644 --- a/frontend/public/js/games.js +++ b/frontend/public/js/games.js @@ -377,6 +377,44 @@ function gameManager() { day: 'numeric', year: 'numeric' }) + }, + + async exportGames() { + try { + const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token') + const response = await fetch('/api/games/export', { + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Export failed') + } + + // Generate filename with current date + const today = new Date().toLocaleDateString('en-US').replace(/\//g, '_') + const filename = `edh_games_${today}.json` + + // Create blob and download + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.style.display = 'none' + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch (error) { + console.error('Export failed:', error) + // Show error message to user + this.serverError = 'Failed to export games. Please try again.' + setTimeout(() => { + this.serverError = '' + }, 5000) + } } } }