diff --git a/src/routes/clientErrors.js b/src/routes/clientErrors.js new file mode 100644 index 0000000..c60c822 --- /dev/null +++ b/src/routes/clientErrors.js @@ -0,0 +1,94 @@ +const crypto = require('node:crypto'); +const { pool, requireAuth, requireAdmin } = require('./context'); + +const MAX_ERRORS_PER_PAGE = 50; +const ERROR_RETENTION_HOURS = 72; + +async function initTable(client) { + await client.query(` + CREATE TABLE IF NOT EXISTS client_errors ( + id SERIAL PRIMARY KEY, + message TEXT NOT NULL, + stack TEXT, + source VARCHAR(32), + url TEXT, + user_agent TEXT, + session_id TEXT, + user_id INTEGER, + count INTEGER DEFAULT 1, + first_seen TIMESTAMP DEFAULT NOW(), + last_seen TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_client_errors_last_seen ON client_errors(last_seen DESC); + CREATE INDEX IF NOT EXISTS idx_client_errors_user ON client_errors(user_id); + `); +} + +async function storeError(client, error) { + const normalizedMessage = error.message.slice(0, 1000); + const { rows } = await client.query( + `SELECT id, count FROM client_errors WHERE message = $1 AND url = $2 AND source = $3 AND last_seen > NOW() - INTERVAL '10 minutes'`, + [normalizedMessage, error.url || '', error.source || 'manual'] + ); + if (rows.length > 0) { + await client.query( + `UPDATE client_errors SET count = count + 1, last_seen = NOW(), stack = COALESCE($1, stack) WHERE id = $2`, + [error.stack, rows[0].id] + ); + } else { + await client.query( + `INSERT INTO client_errors (message, stack, source, url, user_agent, session_id, user_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [normalizedMessage, error.stack, error.source, error.url, error.userAgent, error.sessionId, error.userId || null] + ); + } +} + +module.exports = function mountClientErrorRoutes(router) { + router.post('/client-errors', requireAuth, async (req, res) => { + try { + const { errors } = req.body; + if (!Array.isArray(errors) || !errors.length) return res.json({ ok: true }); + const { pool: db } = require('./context'); + const client = await db.connect(); + try { + await initTable(client); + for (const err of errors.slice(0, 10)) { + await storeError(client, { ...err, userId: req.user?.id }); + } + } finally { client.release(); } + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + router.get('/client-errors', requireAuth, requireAdmin, async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const { pool: db } = require('./context'); + const client = await db.connect(); + try { + await initTable(client); + await client.query(`DELETE FROM client_errors WHERE last_seen < NOW() - INTERVAL '$1 hours'`, [ERROR_RETENTION_HOURS]); + const { rows } = await client.query( + `SELECT * FROM client_errors ORDER BY last_seen DESC LIMIT $1 OFFSET $2`, + [MAX_ERRORS_PER_PAGE, (page - 1) * MAX_ERRORS_PER_PAGE] + ); + const { rows: countRows } = await client.query('SELECT COUNT(*) as total FROM client_errors'); + res.json({ items: rows, total: parseInt(countRows[0]?.total) || 0, page, pageSize: MAX_ERRORS_PER_PAGE }); + } finally { client.release(); } + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + router.delete('/client-errors', requireAuth, requireAdmin, async (req, res) => { + try { + const { pool: db } = require('./context'); + await db.query('DELETE FROM client_errors'); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); +}; diff --git a/src/routes/index.js b/src/routes/index.js index 57bd418..8129e36 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -17,7 +17,8 @@ const { registerConversationRoutes } = require('./conversations') const { registerReportRoutes } = require('./reports') const { registerAssetRoutes } = require('./assets') const { registerNotificationRoutes } = require('./notifications') -const { registerDraftRoutes } = require('./drafts') +const { registerDraftRoutes } = require('./drafts'); +const mountClientErrorRoutes = require('./clientErrors') const router = express.Router()