1170 lines
30 KiB
C
1170 lines
30 KiB
C
|
|
|
|
// === 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 <token>"' });
|
|
}
|
|
|
|
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 ===
|