Finish JSON export feature and simplify JWT config
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## frontend/public/games.html
|
## 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
|
## frontend/public/round-counter.html
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ const rootDir = resolve(__dirname, '../../..')
|
|||||||
config({ path: resolve(rootDir, '.env') })
|
config({ path: resolve(rootDir, '.env') })
|
||||||
|
|
||||||
export const jwtConfig = {
|
export const jwtConfig = {
|
||||||
secret: process.env.JWT_SECRET || 'fallback-secret-for-development',
|
secret: process.env.JWT_SECRET || 'fallback-secret-for-development'
|
||||||
algorithm: 'HS512',
|
|
||||||
expiresIn: '24h',
|
|
||||||
refreshExpiresIn: '7d',
|
|
||||||
issuer: 'edh-stats',
|
|
||||||
audience: 'edh-stats-users'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const corsConfig = {
|
export const corsConfig = {
|
||||||
|
|||||||
@@ -94,25 +94,35 @@ class Game {
|
|||||||
ORDER BY g.date DESC
|
ORDER BY g.date DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const params = [userId]
|
||||||
|
if (filters.commander) {
|
||||||
|
params.push(`%${filters.commander}%`)
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.playerCount) {
|
if (filters.playerCount) {
|
||||||
query += ` AND g.player_count = ?`
|
query += ` AND g.player_count = ?`
|
||||||
|
params.push(filters.playerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.commanderId) {
|
if (filters.commanderId) {
|
||||||
query += ` AND g.commander_id = ?`
|
query += ` AND g.commander_id = ?`
|
||||||
|
params.push(filters.commanderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.dateFrom) {
|
if (filters.dateFrom) {
|
||||||
query += ` AND g.date >= ?`
|
query += ` AND g.date >= ?`
|
||||||
|
params.push(filters.dateFrom)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.dateTo) {
|
if (filters.dateTo) {
|
||||||
query += ` AND g.date <= ?`
|
query += ` AND g.date <= ?`
|
||||||
|
params.push(filters.dateTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ` LIMIT ? OFFSET ?`
|
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
|
// Parse dates for frontend and transform to camelCase
|
||||||
return games.map((game) => ({
|
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) {
|
static async update(id, updateData, userId) {
|
||||||
const db = await dbManager.initialize()
|
const db = await dbManager.initialize()
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,66 @@ export default async function gameRoutes(fastify, options) {
|
|||||||
reply.code(500).send({
|
reply.code(500).send({
|
||||||
error: 'Failed to delete game'
|
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'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,10 +35,15 @@
|
|||||||
<!-- Header & Action -->
|
<!-- Header & Action -->
|
||||||
<div class="mb-8 flex justify-between items-center">
|
<div class="mb-8 flex justify-between items-center">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
|
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
|
||||||
<button @click="showLogForm = !showLogForm" class="btn btn-primary">
|
<div class="flex space-x-4">
|
||||||
<span x-show="!showLogForm">Log New Game</span>
|
<button @click="exportGames()" class="btn btn-secondary">
|
||||||
<span x-show="showLogForm">Cancel</span>
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Game Form -->
|
<!-- Log Game Form -->
|
||||||
|
|||||||
@@ -377,6 +377,44 @@ function gameManager() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: '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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user