refact
This commit is contained in:
338
src/services/chargers.service.js
Executable file
338
src/services/chargers.service.js
Executable file
@@ -0,0 +1,338 @@
|
||||
// src/services/chargers.service.js
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
|
||||
const chargersRepo = require('../repositories/chargers.repo');
|
||||
const { httpError } = require('../utils/httpError');
|
||||
const mqttClient = require('../mqtt');
|
||||
const config = require('../config');
|
||||
|
||||
function stripUndef(obj) {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
// throttle in-memory (por charger)
|
||||
const lastConfigUpdateAt = new Map();
|
||||
|
||||
function clampAmp(v) {
|
||||
const n = Number(v);
|
||||
return Math.max(6, Math.min(n, 64));
|
||||
}
|
||||
|
||||
function clampTemp(v) {
|
||||
const n = Number(v);
|
||||
return Math.max(0, Math.min(n, 120));
|
||||
}
|
||||
|
||||
function normalizeNumericFields(charger) {
|
||||
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 charger;
|
||||
}
|
||||
|
||||
function mosquittoUrl(path) {
|
||||
const base = config.mosquittoMgmt.baseUrl;
|
||||
if (!base) return '';
|
||||
return `${base.replace(/\/+$/, '')}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
async function mosquittoCreateClient(charger) {
|
||||
const url = mosquittoUrl('/client/create');
|
||||
if (!url) {
|
||||
console.warn('[MosquittoMgmt] MOSQUITTO_MGMT_URL não definido. Skip create.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
client_name: charger.mqtt_user,
|
||||
chargeID: charger.mqtt_topic,
|
||||
password: charger.mqtt_pass,
|
||||
},
|
||||
{ timeout: config.mosquittoMgmt.timeoutMs }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[MosquittoMgmt] Erro ao criar cliente:', err?.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function mosquittoDeleteClient(charger) {
|
||||
const url = mosquittoUrl('/client/delete');
|
||||
if (!url) {
|
||||
console.warn('[MosquittoMgmt] MOSQUITTO_MGMT_URL não definido. Skip delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
client_name: charger.mqtt_user,
|
||||
chargeID: charger.mqtt_topic,
|
||||
},
|
||||
{ timeout: config.mosquittoMgmt.timeoutMs }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[MosquittoMgmt] Erro ao deletar cliente:', err?.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function list(userId) {
|
||||
return chargersRepo.listByUser(userId);
|
||||
}
|
||||
|
||||
async function getOne(userId, id) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Carregador não encontrado');
|
||||
|
||||
let cfg = await chargersRepo.getConfig(charger.id);
|
||||
if (!cfg) {
|
||||
cfg = {
|
||||
charger_id: charger.id,
|
||||
max_charging_current: 32,
|
||||
require_auth: false,
|
||||
rcm_enabled: false,
|
||||
temperature_limit: 60,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...normalizeNumericFields(charger), config: cfg };
|
||||
}
|
||||
|
||||
async function create(userId, location) {
|
||||
if (!location || typeof location !== 'string' || location.trim().length < 1) {
|
||||
throw httpError(400, 'O campo location é obrigatório');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let chargerID;
|
||||
do {
|
||||
chargerID = crypto.randomBytes(6).toString('hex');
|
||||
} while (await chargersRepo.findByMqttTopic(chargerID));
|
||||
|
||||
const mqtt_topic = chargerID;
|
||||
const mqtt_user = chargerID;
|
||||
const mqtt_pass = crypto.randomBytes(6).toString('hex');
|
||||
|
||||
const charger = await chargersRepo.insertCharger({
|
||||
user_id: userId,
|
||||
location: location.trim(),
|
||||
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,
|
||||
});
|
||||
|
||||
await chargersRepo.insertConfig({
|
||||
charger_id: charger.id,
|
||||
max_charging_current: 32,
|
||||
require_auth: false,
|
||||
rcm_enabled: false,
|
||||
temperature_limit: 60,
|
||||
config_received_at: now,
|
||||
});
|
||||
|
||||
await mosquittoCreateClient(charger);
|
||||
|
||||
return charger;
|
||||
}
|
||||
|
||||
async function update(userId, id, payload = {}) {
|
||||
let { charger = {}, config: cfgPatch = {} } = payload;
|
||||
|
||||
if (payload.location && !charger.location) charger.location = payload.location;
|
||||
|
||||
const safeChargerUpdate = {};
|
||||
if (charger.location !== undefined) safeChargerUpdate.location = charger.location;
|
||||
|
||||
let updatedCharger = null;
|
||||
|
||||
if (Object.keys(safeChargerUpdate).length > 0) {
|
||||
updatedCharger = await chargersRepo.updateChargerForUser(id, userId, {
|
||||
...safeChargerUpdate,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
updatedCharger = await chargersRepo.findByIdForUser(id, userId);
|
||||
}
|
||||
|
||||
if (!updatedCharger) throw httpError(404, 'Carregador não encontrado');
|
||||
|
||||
// config patch com whitelist
|
||||
if (cfgPatch && Object.keys(cfgPatch).length > 0) {
|
||||
const ALLOWED = ['max_charging_current', 'require_auth', 'rcm_enabled', 'temperature_limit'];
|
||||
let safeConfig = Object.fromEntries(
|
||||
Object.entries(cfgPatch || {}).filter(([k]) => ALLOWED.includes(k))
|
||||
);
|
||||
|
||||
if (safeConfig.max_charging_current !== undefined) {
|
||||
safeConfig.max_charging_current = clampAmp(safeConfig.max_charging_current);
|
||||
}
|
||||
if (safeConfig.temperature_limit !== undefined) {
|
||||
safeConfig.temperature_limit = clampTemp(safeConfig.temperature_limit);
|
||||
}
|
||||
|
||||
await chargersRepo.upsertConfig(id, {
|
||||
...stripUndef(safeConfig),
|
||||
config_received_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return updatedCharger;
|
||||
}
|
||||
|
||||
async function remove(userId, id) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Carregador não encontrado');
|
||||
|
||||
await mosquittoDeleteClient(charger);
|
||||
|
||||
await chargersRepo.deleteChargerForUser(id, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateConfig(userId, id, incomingConfig = {}) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Charger not found or unauthorized');
|
||||
|
||||
const existing = await chargersRepo.getConfig(id);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const lastMs = lastConfigUpdateAt.get(id) || 0;
|
||||
const tooSoon = nowMs - lastMs < 800;
|
||||
|
||||
const ALLOWED = ['max_charging_current', 'require_auth', 'rcm_enabled', 'temperature_limit'];
|
||||
let safeConfig = Object.fromEntries(
|
||||
Object.entries(incomingConfig || {}).filter(([k]) => ALLOWED.includes(k))
|
||||
);
|
||||
|
||||
if (safeConfig.max_charging_current !== undefined) {
|
||||
safeConfig.max_charging_current = clampAmp(safeConfig.max_charging_current);
|
||||
}
|
||||
if (safeConfig.temperature_limit !== undefined) {
|
||||
safeConfig.temperature_limit = clampTemp(safeConfig.temperature_limit);
|
||||
}
|
||||
|
||||
const onlyAmp =
|
||||
Object.keys(safeConfig).length === 1 && safeConfig.max_charging_current !== undefined;
|
||||
|
||||
if (
|
||||
existing &&
|
||||
onlyAmp &&
|
||||
Number(existing.max_charging_current) === Number(safeConfig.max_charging_current)
|
||||
) {
|
||||
return { data: existing, message: 'Config unchanged' };
|
||||
}
|
||||
|
||||
if (tooSoon && existing && onlyAmp) {
|
||||
return { data: existing, message: 'Throttled' };
|
||||
}
|
||||
|
||||
const updated = await chargersRepo.upsertConfig(id, {
|
||||
...safeConfig,
|
||||
config_received_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
lastConfigUpdateAt.set(id, nowMs);
|
||||
|
||||
const evseSettings = {};
|
||||
if (safeConfig.max_charging_current !== undefined) {
|
||||
evseSettings.currentLimit = Number(safeConfig.max_charging_current);
|
||||
}
|
||||
if (safeConfig.temperature_limit !== undefined) {
|
||||
evseSettings.temperatureLimit = Number(safeConfig.temperature_limit);
|
||||
}
|
||||
if (Object.keys(evseSettings).length > 0) {
|
||||
mqttClient.sendEvseSettings(charger.mqtt_topic, evseSettings);
|
||||
}
|
||||
|
||||
return { data: updated };
|
||||
}
|
||||
|
||||
async function getSchedules(userId, id) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Carregador não encontrado');
|
||||
return chargersRepo.listSchedules(id);
|
||||
}
|
||||
|
||||
async function createSchedule(userId, id, start, end, repeat) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Carregador não encontrado');
|
||||
|
||||
const row = await chargersRepo.insertSchedule({
|
||||
charger_id: id,
|
||||
start,
|
||||
end,
|
||||
repeat,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function action(userId, id, actionName, ampLimit) {
|
||||
const charger = await chargersRepo.findByIdForUser(id, userId);
|
||||
if (!charger) throw httpError(404, 'Carregador não encontrado ou não autorizado');
|
||||
|
||||
if (ampLimit !== undefined) {
|
||||
const safeAmp = clampAmp(ampLimit);
|
||||
|
||||
await chargersRepo.upsertConfig(id, {
|
||||
max_charging_current: safeAmp,
|
||||
config_received_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mqttClient.sendEvseSettings(charger.mqtt_topic, { currentLimit: safeAmp });
|
||||
}
|
||||
|
||||
const enable = actionName === 'start';
|
||||
mqttClient.sendEnable(charger.mqtt_topic, enable);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
updateConfig,
|
||||
getSchedules,
|
||||
createSchedule,
|
||||
action,
|
||||
};
|
||||
0
src/services/configs.service.js
Executable file
0
src/services/configs.service.js
Executable file
56
src/services/push.service.js
Executable file
56
src/services/push.service.js
Executable file
@@ -0,0 +1,56 @@
|
||||
// src/services/push.service.js
|
||||
const webpush = require('web-push');
|
||||
const config = require('../config');
|
||||
const pushRepo = require('../repositories/push.repo');
|
||||
|
||||
const hasVapid = !!config.vapid.publicKey && !!config.vapid.privateKey;
|
||||
|
||||
if (!hasVapid) {
|
||||
console.warn('[Push] VAPID keys não definidas. Push desativado.');
|
||||
} else {
|
||||
webpush.setVapidDetails(config.vapid.subject, config.vapid.publicKey, config.vapid.privateKey);
|
||||
}
|
||||
|
||||
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 pushRepo.listByUser(userId);
|
||||
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);
|
||||
} catch (err) {
|
||||
const code = err?.statusCode;
|
||||
if (code === 404 || code === 410) {
|
||||
await pushRepo.deleteById(s.id);
|
||||
} else {
|
||||
console.error('[Push] erro ao enviar:', err.message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { sendPushToUser };
|
||||
38
src/services/pushHttp.service.js
Normal file
38
src/services/pushHttp.service.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/services/pushHttp.service.js
|
||||
const pushRepo = require('../repositories/push.repo');
|
||||
const { httpError } = require('../utils/httpError');
|
||||
|
||||
async function subscribe(userId, endpoint, keys, userAgent) {
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
throw httpError(400, 'Subscription inválida');
|
||||
}
|
||||
|
||||
// dedupe (mesmo user)
|
||||
const existing = await pushRepo.findByUserAndEndpoint(userId, endpoint);
|
||||
if (existing) return { row: existing, created: false };
|
||||
|
||||
// como endpoint é UNIQUE na tabela, evita conflito com outro user
|
||||
const usedByOther = await pushRepo.findByEndpoint(endpoint);
|
||||
if (usedByOther && usedByOther.user_id !== userId) {
|
||||
throw httpError(409, 'Este endpoint já está associado a outro utilizador');
|
||||
}
|
||||
|
||||
const inserted = await pushRepo.insertSubscription({
|
||||
user_id: userId,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
user_agent: userAgent || null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { row: inserted, created: true };
|
||||
}
|
||||
|
||||
async function unsubscribe(userId, endpoint) {
|
||||
if (!endpoint) return { ok: true, message: 'No subscription' };
|
||||
await pushRepo.deleteByUserAndEndpoint(userId, endpoint);
|
||||
return { ok: true, message: 'Unsubscribed' };
|
||||
}
|
||||
|
||||
module.exports = { subscribe, unsubscribe };
|
||||
57
src/services/sessions.service.js
Executable file
57
src/services/sessions.service.js
Executable file
@@ -0,0 +1,57 @@
|
||||
// src/services/sessions.service.js
|
||||
const chargersRepo = require('../repositories/chargers.repo');
|
||||
const sessionsRepo = require('../repositories/sessions.repo');
|
||||
const { httpError } = require('../utils/httpError');
|
||||
|
||||
function stripUndef(obj) {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
async function listByCharger(userId, chargerId) {
|
||||
const charger = await chargersRepo.findByIdForUser(chargerId, userId);
|
||||
if (!charger) throw httpError(403, 'Acesso não autorizado');
|
||||
|
||||
return sessionsRepo.listByCharger(chargerId);
|
||||
}
|
||||
|
||||
async function history(userId, chargerId, viewMode) {
|
||||
const charger = await chargersRepo.findByIdForUser(chargerId, userId);
|
||||
if (!charger) throw httpError(403, 'Acesso não autorizado');
|
||||
|
||||
const rows = await sessionsRepo.historyAgg(chargerId, viewMode);
|
||||
if (!rows.length) return [];
|
||||
|
||||
return rows.map((r) => ({
|
||||
started_at: r.period,
|
||||
kwh: parseFloat(r.total_kwh) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getById(userId, sessionId) {
|
||||
const session = await sessionsRepo.findByIdForUser(sessionId, userId);
|
||||
if (!session) throw httpError(404, 'Sessão não encontrada');
|
||||
return session;
|
||||
}
|
||||
|
||||
async function create(userId, charger_id) {
|
||||
const charger = await chargersRepo.findByIdForUser(charger_id, userId);
|
||||
if (!charger) throw httpError(403, 'Acesso não autorizado');
|
||||
|
||||
return sessionsRepo.insertSession({ charger_id, started_at: new Date() });
|
||||
}
|
||||
|
||||
async function update(userId, sessionId, patch) {
|
||||
const session = await sessionsRepo.findByIdForUser(sessionId, userId);
|
||||
if (!session) throw httpError(404, 'Sessão não encontrada');
|
||||
|
||||
const clean = stripUndef(patch);
|
||||
return sessionsRepo.updateById(sessionId, clean);
|
||||
}
|
||||
|
||||
async function remove(userId, sessionId) {
|
||||
const deleted = await sessionsRepo.deleteByIdForUser(sessionId, userId);
|
||||
if (!deleted) throw httpError(404, 'Sessão não encontrada');
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { listByCharger, history, getById, create, update, remove };
|
||||
51
src/services/users.service.js
Normal file
51
src/services/users.service.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/services/users.service.js
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const config = require('../config');
|
||||
const usersRepo = require('../repositories/users.repo');
|
||||
const { httpError } = require('../utils/httpError');
|
||||
|
||||
async function login(username, password) {
|
||||
if (!username || !password) {
|
||||
throw httpError(400, 'Usuário e senha são obrigatórios');
|
||||
}
|
||||
|
||||
const user = await usersRepo.findByUsername(username);
|
||||
if (!user) throw httpError(401, 'Credenciais inválidas');
|
||||
|
||||
const ok = await bcrypt.compare(password, user.password);
|
||||
if (!ok) throw httpError(401, 'Credenciais inválidas');
|
||||
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, {
|
||||
expiresIn: '24h',
|
||||
});
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
async function register(username, password) {
|
||||
if (
|
||||
!username ||
|
||||
!password ||
|
||||
typeof username !== 'string' ||
|
||||
typeof password !== 'string' ||
|
||||
username.length < 3 ||
|
||||
password.length < 4
|
||||
) {
|
||||
throw httpError(
|
||||
400,
|
||||
'Nome de usuário deve ter pelo menos 3 caracteres e senha pelo menos 4 caracteres'
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await usersRepo.findByUsername(username);
|
||||
if (existing) throw httpError(409, 'Nome de usuário já está em uso');
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
const id = await usersRepo.insertUser({ username, passwordHash: hashed });
|
||||
|
||||
const token = jwt.sign({ id, username }, config.jwtSecret, { expiresIn: '24h' });
|
||||
return { token };
|
||||
}
|
||||
|
||||
module.exports = { login, register };
|
||||
Reference in New Issue
Block a user