// === Início de: ./utils/pushService.js === // utils/pushService.js const webpush = require('web-push'); const db = require('../db'); const hasVapid = !!process.env.VAPID_PUBLIC_KEY && !!process.env.VAPID_PRIVATE_KEY; if (!hasVapid) { console.warn('[Push] VAPID keys não definidas. Push desativado.'); } else { webpush.setVapidDetails( process.env.VAPID_SUBJECT || 'mailto:admin@evstation.local', process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ); } // retry simples p/ 429 e 5xx async function sendWithRetry(subscription, message, tries = 2) { try { return await webpush.sendNotification(subscription, message); } catch (err) { const code = err?.statusCode; if ((code === 429 || code >= 500) && tries > 1) { await new Promise((r) => setTimeout(r, 1000)); return sendWithRetry(subscription, message, tries - 1); } throw err; } } async function sendPushToUser(userId, payload) { if (!hasVapid) return; const subs = await db('push_subscriptions') .where({ user_id: userId }) .select('id', 'endpoint', 'p256dh', 'auth'); if (!subs.length) return; const message = JSON.stringify(payload); await Promise.allSettled( subs.map(async (s) => { const subscription = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth, }, }; try { await sendWithRetry(subscription, message); // TTL opcional (default 15min) // se quiseres usar TTL do payload, descomenta e passa options no sendWithRetry // await webpush.sendNotification(subscription, message, { // TTL: payload.ttl ?? 60 * 15, // }); } catch (err) { const code = err?.statusCode; // remove subscriptions mortas if (code === 404 || code === 410) { await db('push_subscriptions').where({ id: s.id }).del(); } else { console.error('[Push] erro ao enviar:', err.message); } } }) ); } module.exports = { sendPushToUser }; // === Fim de: ./utils/pushService.js === // === Início de: ./routes/charger_sessions.js === // routes/charger_sessions.js const express = require('express'); const { param, query, body, validationResult } = require('express-validator'); const router = express.Router(); const verifyToken = require('../middleware/verifyToken'); const db = require('../db'); router.use(verifyToken); function handleValidation(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } next(); } // GET /api/charger_sessions?chargerId=... router.get( '/', [query('chargerId').isUUID().withMessage('chargerId deve ser UUID válido')], handleValidation, async (req, res) => { const { chargerId } = req.query; const userId = req.user.id; const charger = await db('chargers') .where({ id: chargerId, user_id: userId }) .first(); if (!charger) { return res .status(403) .json({ success: false, message: 'Acesso não autorizado' }); } const sessions = await db('charger_sessions') .where({ charger_id: chargerId }) .orderBy('started_at', 'desc'); res.json({ success: true, data: sessions }); } ); // GET /api/charger_sessions/:id router.get( '/:id', [param('id').isUUID().withMessage('ID de sessão inválido')], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; const session = await db('charger_sessions') .join('chargers', 'charger_sessions.charger_id', 'chargers.id') .where({ 'charger_sessions.id': id, 'chargers.user_id': userId }) .select('charger_sessions.*') .first(); if (!session) { return res .status(404) .json({ success: false, message: 'Sessão não encontrada' }); } res.json({ success: true, data: session }); } ); // POST /api/charger_sessions router.post( '/', [body('charger_id').isUUID().withMessage('charger_id deve ser UUID válido')], handleValidation, async (req, res) => { const { charger_id } = req.body; const userId = req.user.id; const charger = await db('chargers') .where({ id: charger_id, user_id: userId }) .first(); if (!charger) { return res .status(403) .json({ success: false, message: 'Acesso não autorizado' }); } const [inserted] = await db('charger_sessions') .insert({ charger_id, started_at: new Date() }) .returning('*'); res.status(201).json({ success: true, data: inserted }); } ); // PUT /api/charger_sessions/:id router.put( '/:id', [ param('id').isUUID().withMessage('ID de sessão inválido'), body('ended_at').optional().isISO8601().toDate(), body('kwh').optional().isFloat({ min: 0 }), body('cost').optional().isFloat({ min: 0 }), ], handleValidation, async (req, res) => { const { id } = req.params; const { ended_at, kwh, cost } = req.body; const userId = req.user.id; const session = await db('charger_sessions') .join('chargers', 'charger_sessions.charger_id', 'chargers.id') .where({ 'charger_sessions.id': id, 'chargers.user_id': userId }) .first(); if (!session) { return res .status(404) .json({ success: false, message: 'Sessão não encontrada' }); } const [updated] = await db('charger_sessions') .where({ id }) .update({ ended_at, kwh, cost }) .returning('*'); res.json({ success: true, data: updated }); } ); // DELETE /api/charger_sessions/:id router.delete( '/:id', [param('id').isUUID().withMessage('ID de sessão inválido')], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; const deleted = await db('charger_sessions') .join('chargers', 'charger_sessions.charger_id', 'chargers.id') .where({ 'charger_sessions.id': id, 'chargers.user_id': userId }) .del(); if (!deleted) { return res .status(404) .json({ success: false, message: 'Sessão não encontrada' }); } res.json({ success: true, message: 'Sessão excluída com sucesso' }); } ); // GET /api/charger_sessions/history/:chargerId router.get( '/history/:chargerId', [ param('chargerId').isUUID().withMessage('chargerId deve ser UUID válido'), query('viewMode') .isIn(['Day', 'Week', 'Month']) .withMessage('viewMode inválido'), ], handleValidation, async (req, res) => { const { chargerId } = req.params; const { viewMode } = req.query; const userId = req.user.id; // ownership check const charger = await db('chargers') .where({ id: chargerId, user_id: userId }) .first(); if (!charger) { return res .status(403) .json({ success: false, message: 'Acesso não autorizado' }); } let qb = db('charger_sessions') .where({ charger_id: chargerId }) .sum('kwh as total_kwh'); switch (viewMode) { case 'Day': qb = qb .select(db.raw('DATE(started_at) AS period')) .groupBy(db.raw('DATE(started_at)')) .orderBy('period', 'desc'); break; case 'Week': qb = qb .select( db.raw('EXTRACT(ISOYEAR FROM started_at) AS y'), db.raw('EXTRACT(WEEK FROM started_at) AS w'), db.raw( "EXTRACT(ISOYEAR FROM started_at)||'-'||LPAD(EXTRACT(WEEK FROM started_at)::text,2,'0') AS period" ) ) .groupBy('y', 'w') .orderBy([{ column: 'y', order: 'desc' }, { column: 'w', order: 'desc' }]); break; case 'Month': qb = qb .select( db.raw('EXTRACT(YEAR FROM started_at) AS y'), db.raw('EXTRACT(MONTH FROM started_at) AS m'), db.raw( "EXTRACT(YEAR FROM started_at)||'-'||LPAD(EXTRACT(MONTH FROM started_at)::text,2,'0') AS period" ) ) .groupBy('y', 'm') .orderBy([{ column: 'y', order: 'desc' }, { column: 'm', order: 'desc' }]); break; } const rows = await qb; // ✅ devolve lista vazia em vez de 404 if (!rows.length) { return res.json({ success: true, data: [] }); } const data = rows.map((r) => ({ started_at: r.period, kwh: parseFloat(r.total_kwh) || 0, })); res.json({ success: true, data }); } ); module.exports = router; // === Fim de: ./routes/charger_sessions.js === // === Início de: ./routes/push.js === // routes/push.js const express = require('express'); const { body, validationResult } = require('express-validator'); const verifyToken = require('../middleware/verifyToken'); const db = require('../db'); const { sendPushToUser } = require('../utils/pushService'); const router = express.Router(); router.use(verifyToken); function handleValidation(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } next(); } // GET /api/push/vapid-public-key router.get('/vapid-public-key', (req, res) => { if (!process.env.VAPID_PUBLIC_KEY) { return res .status(503) .json({ success: false, message: 'Push indisponível' }); } res.json({ success: true, data: { key: process.env.VAPID_PUBLIC_KEY } }); }); // POST /api/push/subscribe router.post( '/subscribe', [ body('endpoint').isString(), body('keys.p256dh').isString(), body('keys.auth').isString(), ], handleValidation, async (req, res) => { const userId = req.user.id; const { endpoint, keys } = req.body; const ua = req.headers['user-agent'] || null; // evita duplicados const existing = await db('push_subscriptions') .where({ endpoint, user_id: userId }) .first(); if (existing) { return res.json({ success: true, data: existing }); } const [inserted] = await db('push_subscriptions') .insert({ user_id: userId, endpoint, p256dh: keys.p256dh, auth: keys.auth, user_agent: ua, created_at: new Date().toISOString(), }) .returning('*'); res.status(201).json({ success: true, data: inserted }); } ); // POST /api/push/unsubscribe router.post( '/unsubscribe', [body('endpoint').optional().isString()], handleValidation, async (req, res) => { const userId = req.user.id; const { endpoint } = req.body || {}; // se não houver sub no navegador, responde ok if (!endpoint) { return res.json({ success: true, message: 'No subscription' }); } await db('push_subscriptions') .where({ endpoint, user_id: userId }) .del(); res.json({ success: true, message: 'Unsubscribed' }); } ); // POST /api/push/test router.post('/test', async (req, res) => { const userId = req.user.id; await sendPushToUser(userId, { title: '📬 Teste EV Station', body: 'Push notifications estão a funcionar!', url: '/', }); res.json({ success: true, message: 'Push enviado' }); }); module.exports = router; // === Fim de: ./routes/push.js === // === Início de: ./routes/users.js === const express = require('express'); const router = express.Router(); const db = require('../db'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const rateLimit = require('express-rate-limit'); if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET não definido no .env'); } // limiter só para auth const authLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, }); // POST /api/users/login router.post('/login', authLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res .status(400) .json({ success: false, message: 'Usuário e senha são obrigatórios' }); } try { const user = await db('users').where({ username }).first(); if (!user) { return res .status(401) .json({ success: false, message: 'Credenciais inválidas' }); } const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { return res .status(401) .json({ success: false, message: 'Credenciais inválidas' }); } const token = jwt.sign( { id: user.id, username: user.username }, process.env.JWT_SECRET, { expiresIn: '24h' } ); return res.json({ success: true, data: { token } }); } catch (err) { console.error('Erro ao autenticar usuário:', err); return res .status(500) .json({ success: false, message: 'Erro interno do servidor' }); } }); // POST /api/users/register router.post('/register', authLimiter, async (req, res) => { const { username, password } = req.body; if ( !username || !password || typeof username !== 'string' || typeof password !== 'string' || username.length < 3 || password.length < 4 ) { return res.status(400).json({ success: false, message: 'Nome de usuário deve ter pelo menos 3 caracteres e senha pelo menos 4 caracteres', }); } try { const existing = await db('users').where({ username }).first(); if (existing) { return res .status(409) .json({ success: false, message: 'Nome de usuário já está em uso' }); } const hashedPassword = await bcrypt.hash(password, 10); const [row] = await db('users') .insert({ username, password: hashedPassword }) .returning('id'); const token = jwt.sign( { id: row.id, username }, process.env.JWT_SECRET, { expiresIn: '24h' } ); return res.status(201).json({ success: true, data: { token } }); } catch (err) { console.error('Erro ao registrar usuário:', err); return res.status(500).json({ success: false, message: 'Erro interno ao registrar usuário', }); } }); module.exports = router; // === Fim de: ./routes/users.js === // === Início de: ./routes/chargers.js === // routes/chargers.js const express = require('express'); const { body, param, validationResult } = require('express-validator'); const router = express.Router(); const verifyToken = require('../middleware/verifyToken'); const db = require('../db'); const crypto = require('crypto'); const axios = require('axios'); const mqttClient = require('../mqtt/client'); router.use(verifyToken); function handleValidation(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } next(); } // throttling simples em memória (por charger) const lastConfigUpdateAt = new Map(); // GET /api/chargers router.get('/', async (req, res) => { const userId = req.user.id; try { const chargers = await db('chargers') .where({ user_id: userId }) .select('*'); res.json({ success: true, data: chargers }); } catch (err) { console.error('Erro ao buscar carregadores:', err); res.status(500).json({ success: false, message: 'Erro no servidor' }); } }); // GET /api/chargers/:id router.get( '/:id', [param('id').isUUID()], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; try { const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res .status(404) .json({ success: false, message: 'Carregador não encontrado' }); } let config = null; try { config = await db('charger_configs') .where({ charger_id: charger.id }) .first(); } catch (cfgErr) { console.error('[GET charger] erro ao buscar charger_configs:', cfgErr); } if (!config) config = { charger_id: charger.id, max_charging_current: 32, require_auth: false, rcm_enabled: false, temperature_limit: 60, }; const numericFields = [ 'power_l1', 'power_l2', 'power_l3', 'voltage_l1', 'voltage_l2', 'voltage_l3', 'current_l1', 'current_l2', 'current_l3', 'charging_current', 'consumption', ]; numericFields.forEach((field) => { const v = charger[field]; charger[field] = v === null || v === undefined || v === '' ? 0 : Number(v); if (Number.isNaN(charger[field])) charger[field] = 0; }); return res.json({ success: true, data: { ...charger, config } }); } catch (err) { console.error('Erro ao buscar carregador:', err); return res.status(500).json({ success: false, message: process.env.NODE_ENV === 'development' ? err.message || 'Erro no servidor' : 'Erro no servidor', }); } } ); // POST /api/chargers router.post( '/', [ body('location') .exists() .withMessage('O campo location é obrigatório') .isString() .withMessage('Location deve ser uma string') .isLength({ min: 1 }) .withMessage('Location não pode estar vazio') .trim(), ], handleValidation, async (req, res) => { const { location } = req.body; const userId = req.user.id; const now = new Date().toISOString(); let chargerID; do { chargerID = crypto.randomBytes(6).toString('hex'); } while ( await db('chargers').where({ mqtt_topic: chargerID }).first() ); const mqtt_topic = chargerID; const mqtt_user = chargerID; const mqtt_pass = crypto.randomBytes(6).toString('hex'); try { const [charger] = await db('chargers') .insert({ user_id: userId, location, status: 'offline', charging_current: 0, charging_time: 0, consumption: 0, power_l1: 0.0, power_l2: 0.0, power_l3: 0.0, voltage_l1: 0.0, voltage_l2: 0.0, voltage_l3: 0.0, current_l1: 0.0, current_l2: 0.0, current_l3: 0.0, mqtt_user, mqtt_pass, mqtt_topic, updated_at: now, }) .returning('*'); await db('charger_configs').insert({ charger_id: charger.id, max_charging_current: 32, require_auth: false, rcm_enabled: false, temperature_limit: 60, config_received_at: now, }); try { await axios.post( 'http://192.168.1.110:7000/client/create', { client_name: charger.mqtt_user, chargeID: charger.mqtt_topic, password: charger.mqtt_pass, }, { timeout: 5000 } ); } catch (err) { console.error( 'Erro ao criar cliente Mosquitto:', err?.response?.data || err.message ); } res.status(201).json({ success: true, data: charger }); } catch (err) { console.error('Erro ao criar carregador:', err); res.status(500).json({ success: false, message: 'Erro no servidor' }); } } ); // PUT /api/chargers/:id router.put( '/:id', [ param('id').isUUID(), body('charger').optional().isObject(), body('config').optional().isObject(), body('location').optional().isString(), // compat front ], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; let { charger = {}, config = {} } = req.body; // ✅ compatibilidade com front antigo: { location } if (req.body.location && !charger.location) { charger.location = req.body.location; } const trx = await db.transaction(); const ALLOWED_CHARGER_FIELDS = ['location']; const safeChargerUpdate = Object.fromEntries( Object.entries(charger).filter(([k]) => ALLOWED_CHARGER_FIELDS.includes(k) ) ); try { let updatedCharger; if (Object.keys(safeChargerUpdate).length > 0) { [updatedCharger] = await trx('chargers') .where({ id, user_id: userId }) .update({ ...safeChargerUpdate, updated_at: new Date().toISOString(), }) .returning('*'); } else { updatedCharger = await trx('chargers') .where({ id, user_id: userId }) .first(); } if (!updatedCharger) { await trx.rollback(); return res .status(404) .json({ success: false, message: 'Carregador não encontrado' }); } if (Object.keys(config).length > 0) { const existing = await trx('charger_configs') .where({ charger_id: id }) .first(); const data = { ...config, config_received_at: new Date().toISOString(), }; if (existing) { await trx('charger_configs') .where({ charger_id: id }) .update(data); } else { await trx('charger_configs') .insert({ charger_id: id, ...data }); } } await trx.commit(); res.json({ success: true, data: updatedCharger }); } catch (err) { await trx.rollback(); console.error('Erro ao atualizar carregador:', err); res.status(500).json({ success: false, message: 'Erro no servidor' }); } } ); // DELETE /api/chargers/:id router.delete( '/:id', [param('id').isUUID()], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; try { const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res .status(404) .json({ success: false, message: 'Carregador não encontrado' }); } try { await axios.post( 'http://192.168.1.110:7000/client/delete', { client_name: charger.mqtt_user, chargeID: charger.mqtt_topic, }, { timeout: 5000 } ); } catch (err) { console.error( 'Erro ao deletar cliente Mosquitto:', err?.response?.data || err.message ); } await db('chargers').where({ id }).del(); res.json({ success: true, message: 'Carregador excluído com sucesso' }); } catch (err) { console.error('Erro ao excluir carregador:', err); res.status(500).json({ success: false, message: 'Erro no servidor' }); } } ); // PUT /api/chargers/:id/config router.put( '/:id/config', [param('id').isUUID(), body('config').isObject()], handleValidation, async (req, res) => { const { id } = req.params; const { config } = req.body; const userId = req.user.id; try { const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res.status(404).json({ success: false, message: 'Charger not found or unauthorized', }); } // lê config atual const existing = await db('charger_configs') .where({ charger_id: id }) .first(); // throttle simples por charger const nowMs = Date.now(); const lastMs = lastConfigUpdateAt.get(id) || 0; const tooSoon = nowMs - lastMs < 800; // se veio max_charging_current, normaliza/clampa let safeConfig = { ...config }; if (safeConfig.max_charging_current !== undefined) { const safeAmp = Math.max(6, Math.min(Number(safeConfig.max_charging_current), 64)); safeConfig.max_charging_current = safeAmp; } const onlyAmp = Object.keys(safeConfig).length === 1 && safeConfig.max_charging_current !== undefined; // dedupe: mesmo valor -> responde ok sem update nem mqtt if ( existing && onlyAmp && existing.max_charging_current === safeConfig.max_charging_current ) { return res.json({ success: true, data: existing, message: 'Config unchanged' }); } // se é update muito rápido e igual ao que já está, ignora if (tooSoon && existing && onlyAmp) { return res.json({ success: true, data: existing, message: 'Throttled' }); } // ✅ upsert let updatedConfig; if (existing) { [updatedConfig] = await db('charger_configs') .where({ charger_id: id }) .update({ ...safeConfig, config_received_at: new Date().toISOString(), }) .returning('*'); } else { [updatedConfig] = await db('charger_configs') .insert({ charger_id: id, ...safeConfig, config_received_at: new Date().toISOString(), }) .returning('*'); } lastConfigUpdateAt.set(id, nowMs); const keyMap = { max_charging_current: 'maxChargingCurrent', require_auth: 'requireAuth', temperature_limit: 'temperatureThreshold', }; Object.entries(safeConfig).forEach(([dbKey, value]) => { const publishKey = keyMap[dbKey] || dbKey; mqttClient.sendConfig(charger.mqtt_topic, publishKey, value); }); return res.json({ success: true, data: updatedConfig }); } catch (err) { console.error('Error updating config:', err); return res .status(500) .json({ success: false, message: 'Server error' }); } } ); // GET /api/chargers/:id/schedule router.get( '/:id/schedule', [param('id').isUUID()], handleValidation, async (req, res) => { const { id } = req.params; const userId = req.user.id; const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res .status(404) .json({ success: false, message: 'Carregador não encontrado' }); } try { const schedules = await db('charger_schedules') .where({ charger_id: id }) .orderBy('created_at', 'desc'); return res.json({ success: true, data: schedules }); } catch (err) { console.error('Erro ao buscar schedules:', err); return res.json({ success: true, data: [] }); } } ); // POST /api/chargers/:id/schedule router.post( '/:id/schedule', [ param('id').isUUID(), body('start').matches(/^\d{2}:\d{2}$/).withMessage('start inválido'), body('end').matches(/^\d{2}:\d{2}$/).withMessage('end inválido'), body('repeat') .isIn(['everyday', 'weekdays', 'weekends']) .withMessage('repeat inválido'), ], handleValidation, async (req, res) => { const { id } = req.params; const { start, end, repeat } = req.body; const userId = req.user.id; const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res .status(404) .json({ success: false, message: 'Carregador não encontrado' }); } try { const [inserted] = await db('charger_schedules') .insert({ charger_id: id, start, end, repeat, created_at: new Date().toISOString(), }) .returning('*'); return res.status(201).json({ success: true, data: inserted }); } catch (err) { console.error('Erro ao criar schedule:', err); return res .status(500) .json({ success: false, message: 'Erro ao criar schedule' }); } } ); // POST /api/chargers/:id/action router.post( '/:id/action', [ param('id').isUUID().withMessage('ID do carregador inválido'), body('action').isIn(['start', 'stop']).withMessage('Ação inválida'), body('ampLimit') .optional() .isInt({ min: 6, max: 64 }) .withMessage('ampLimit deve estar entre 6 e 64'), ], handleValidation, async (req, res) => { const { id } = req.params; const { action, ampLimit } = req.body; const userId = req.user.id; try { const charger = await db('chargers') .where({ id, user_id: userId }) .first(); if (!charger) { return res.status(404).json({ success: false, message: 'Carregador não encontrado ou não autorizado', }); } if (ampLimit !== undefined) { const safeAmp = Math.max(6, Math.min(Number(ampLimit), 64)); await db('charger_configs') .where({ charger_id: id }) .update({ max_charging_current: safeAmp, config_received_at: new Date().toISOString(), }); mqttClient.sendConfig( charger.mqtt_topic, 'maxChargingCurrent', safeAmp ); } const enable = action === 'start'; mqttClient.sendEnable(charger.mqtt_topic, enable); return res.json({ success: true, message: `Comando '${action}' enviado com sucesso`, }); } catch (err) { console.error( `Erro ao processar ação '${action}' para carregador ${id}:`, err ); return res .status(500) .json({ success: false, message: 'Erro ao processar ação' }); } } ); module.exports = router; // === Fim de: ./routes/chargers.js === // === Início de: ./middleware/verifyToken.js === const jwt = require('jsonwebtoken'); if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET não definido no .env'); } function verifyToken(req, res, next) { const authHeader = req.headers['authorization'] || req.headers['Authorization']; if (!authHeader) { return res.status(403).json({ error: 'Token não fornecido' }); } const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { return res .status(403) .json({ error: 'Token malformado. Use "Bearer "' }); } const token = match[1]; jwt.verify(token, process.env.JWT_SECRET, (err, payload) => { if (err) { if (err.name === 'TokenExpiredError') { return res.status(403).json({ error: 'Sessão expirada' }); } return res.status(403).json({ error: 'Token inválido' }); } if (!payload?.id) { return res.status(403).json({ error: 'Token inválido' }); } req.user = payload; next(); }); } module.exports = verifyToken; // === Fim de: ./middleware/verifyToken.js ===