Files
ridebus-admin/backend/src/services/boardDeviceService.js
Zakhar Timoshenko 360b684019 feat: implement board device service with CRUD operations and validation
- Add boardDeviceService for managing device pairing, status, claiming, and revocation.
- Introduce boardDeviceSettings for default settings and validation logic.
- Create integration tests for device lifecycle, concurrent claims, and updates.
- Add unit tests for device pairing, claiming, and settings validation.
- Implement API tests for device endpoints with JWT authentication.
- Develop in-memory repository for testing purposes.
- Create frontend components for managing devices, including a form for device settings.
- Style DevicesPage with CSS for better user experience.
2026-10-10 00:47:26 +03:00

75 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
const tokenHash = token => createHash('sha256').update(token).digest('hex');
function credential(token) {
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
return tokenHash(token);
}
function name(value) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > 100) throw new DeviceError(400, 'invalid_name', 'Укажите название устройства до 100 символов');
return value.trim();
}
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
function publicDevice(row, now) {
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: row.settings,
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
}
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
async function checked(cityId, input) {
const settings = validSettings(input);
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
return settings;
}
return {
async start(info) {
const token = randomBytes(32).toString('base64url');
for (let attempt = 0; attempt < 30; attempt++) {
const code = makeCode(), expiresAt = new Date(now() + 300000).toISOString();
try { await repository.createPairing({ code, token_hash: tokenHash(token), device_info: deviceInfo(info), expires_at: expiresAt }); return { token, state: 'pending', code, expiresAt, pollSeconds: 3 }; }
catch (error) { if (error.code !== '23505') throw error; }
}
throw new DeviceError(503, 'pairing_busy', 'Попробуйте подключить табло позже');
},
async status(token) {
const hash = credential(token), row = await repository.device(hash);
if (row) {
if (row.revoked_at) throw new DeviceError(410, 'device_revoked');
return { state: 'paired', device: publicDevice(row, now()), pollSeconds: 15 };
}
const pairing = await repository.pairing(hash);
if (!pairing) throw new DeviceError(401, 'invalid_device_token');
if (Date.parse(pairing.expires_at) <= now()) return { state: 'expired' };
return { state: 'pending', code: pairing.code, expiresAt: pairing.expires_at, pollSeconds: 3 };
},
async claim(cityId, input, userId) {
if (typeof input?.code !== 'string' || !/^\d{4}$/.test(input.code)) throw new DeviceError(400, 'invalid_code', 'Введите четыре цифры с экрана табло');
const settings = await checked(cityId, input.settings), label = name(input.name);
const row = await repository.claim(input.code, { id: randomUUID(), name: label, city_id: cityId, stop_id: settings.stopId, settings, created_by: userId });
if (!row) throw new DeviceError(409, 'code_expired', 'Код истёк или уже использован. Проверьте код на табло');
return { id: row.id };
},
async routes(cityId, stopId) { await checked(cityId, { stopId }); return repository.routes(cityId, stopId); },
async list(cityId) { return (await repository.list(cityId)).map(row => publicDevice(row, now())); },
async update(id, cityId, input, userId) {
deviceId(id);
if (!Number.isInteger(input?.revision) || input.revision < 1) throw new DeviceError(400, 'invalid_revision');
const settings = await checked(cityId, input.settings);
const row = await repository.update(id, cityId, { name: name(input.name), settings, revision: input.revision, userId });
if (!row) throw new DeviceError(409, 'revision_conflict', 'Устройство изменено или отвязано. Обновите список и откройте настройки заново');
return { id: row.id, revision: row.revision };
},
async revoke(id, cityId, userId) { if (!await repository.revoke(deviceId(id), cityId, userId)) throw new DeviceError(404, 'device_not_found'); return { revoked: true }; },
async heartbeat(token, input) {
const hash = credential(token);
if (!Number.isInteger(input?.appliedRevision) || input.appliedRevision < 0) throw new DeviceError(400, 'invalid_revision');
const row = await repository.device(hash);
if (!row || row.revoked_at) throw new DeviceError(410, 'device_revoked');
if (input.appliedRevision > row.revision) throw new DeviceError(400, 'invalid_revision');
if (!await repository.heartbeat(hash, input.appliedRevision, deviceInfo(input))) throw new DeviceError(410, 'device_revoked');
return { ok: true };
},
};
}
module.exports = { createBoardDeviceService, tokenHash, publicDevice };