Files
iistwin/server/gps/routes.ts

678 lines
26 KiB
TypeScript
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.

import { Router } from "express";
import type { Express, Response } from "express";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { gpsStorage } from "./storage";
import { processPosition, filterTrackPoints } from "./geozone.service";
import { hasGpsAccess } from "./access";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { storage } from "../storage";
/**
* Роуты GPS-модуля.
* Публичный POST /api/gps/ingest (приём точек от Traccar, секрет в заголовке)
* регистрируется отдельно, ДО роутера с authenticateToken.
*/
const MAX_TRACK_POINTS = 2000;
function parseDateParam(value: unknown): Date | null {
if (typeof value !== "string" || !value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
function parseNum(value: unknown): number | null {
if (value === undefined || value === null || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function badRequest(res: Response, error: string) {
return res.status(400).json({ success: false, error });
}
// =====================
// Приём позиций от Traccar (публичный, по секрету)
// =====================
interface NormalizedPoint {
externalId: string;
lat: number;
lng: number;
speed: number | null;
course: number | null;
accuracy: number | null;
recordedAt: Date | null;
/** false только у нативного формата Traccar (position.valid === false) */
valid: boolean;
}
/**
* Нормализация одной точки. Поддерживает два формата:
* 1. Простой: { externalId, lat, lng, speed?, course?, accuracy?, timestamp? }
* 2. Нативный forward Traccar: { position: {...}, device: { uniqueId } }
*/
function normalizePoint(raw: any): NormalizedPoint | null {
if (!raw || typeof raw !== "object") return null;
// Нативный формат Traccar event forwarding
if (raw.position && raw.device?.uniqueId) {
const p = raw.position;
const lat = parseNum(p.latitude);
const lng = parseNum(p.longitude);
if (lat === null || lng === null) return null;
// Traccar отдаёт скорость в узлах (knots) — в БД храним км/ч
const speedKnots = parseNum(p.speed);
return {
externalId: String(raw.device.uniqueId),
lat,
lng,
speed: speedKnots !== null ? Math.round(speedKnots * 1.852 * 10) / 10 : null,
course: parseNum(p.course),
accuracy: parseNum(p.accuracy),
recordedAt: parseDateParam(p.fixTime ?? p.deviceTime),
valid: p.valid !== false,
};
}
// Простой формат
const lat = parseNum(raw.lat);
const lng = parseNum(raw.lng);
if (lat === null || lng === null || !raw.externalId) return null;
return {
externalId: String(raw.externalId),
lat,
lng,
speed: parseNum(raw.speed),
course: parseNum(raw.course),
accuracy: parseNum(raw.accuracy),
recordedAt: parseDateParam(raw.timestamp),
valid: true,
};
}
async function handleIngest(req: AuthenticatedRequest, res: Response) {
// Endpoint отключён, если секрет не задан в env
const expectedToken = process.env.GPS_INGEST_TOKEN;
if (!expectedToken) {
return res.status(503).json({ success: false, error: "GPS ingest отключён: не задан GPS_INGEST_TOKEN" });
}
const token = req.headers["x-gps-token"];
if (token !== expectedToken) {
return res.status(401).json({ success: false, error: "Неверный токен" });
}
const items = Array.isArray(req.body) ? req.body : [req.body];
let processed = 0;
let ignored = 0;
for (const item of items) {
const point = normalizePoint(item);
if (!point) {
ignored++;
continue;
}
// Объект ищется по external_id в любой организации;
// невалидный external_id не раскрываем — просто считаем проигнорированным
const asset = await gpsStorage.getAssetByExternalId(point.externalId);
if (!asset || !asset.isActive) {
ignored++;
continue;
}
// Traccar пометил позицию как невалидную (valid=false) — не сохраняем,
// но считаем признаком жизни устройства
if (!point.valid) {
await gpsStorage.touchAssetSeen(asset.id);
processed++;
continue;
}
try {
await processPosition(asset.id, asset.organizationId, {
lat: point.lat,
lng: point.lng,
speed: point.speed,
course: point.course,
accuracy: point.accuracy,
recordedAt: point.recordedAt ?? undefined,
});
processed++;
} catch (err) {
console.error(`[GPS] Ingest error (asset ${asset.id}):`, err);
ignored++;
}
}
res.json({ success: true, processed, ...(ignored > 0 ? { ignored } : {}) });
}
// =====================
// Авторизованные роуты
// =====================
export function registerGpsRoutes(app: Express) {
// Публичный приём точек — без authenticateToken, защита секретом в заголовке
app.post("/api/gps/ingest", (req, res) => {
handleIngest(req as AuthenticatedRequest, res).catch((err) => {
console.error("[GPS] Ingest handler error:", err);
res.status(500).json({ success: false, error: "Внутренняя ошибка" });
});
});
const router = Router();
router.use(authenticateToken);
/**
* Проверка доступа к GPS-вкладке (по настройкам организации).
* Применяется через router.use НИЖЕ — только к роутам вкладки;
* config и персональный трекинг (my-position/my-tracking) остаются открытыми.
*/
async function requireGpsAccess(req: AuthenticatedRequest, res: Response, next: () => void) {
const allowed = await hasGpsAccess(req.user!);
if (!allowed) {
return res.status(403).json({ success: false, error: "Нет доступа к GPS-модулю" });
}
next();
}
// =====================
// Конфигурация клиента (ключ Яндекс.Карт из external_services)
// Открыт всем — фронт использует allowed для гейтинга вкладки.
// =====================
router.get("/config", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
let yandexMapsKey: string | null = null;
const service = await storage.getExternalServiceByType("yandex_maps", orgId);
if (service?.isActive && service.apiKey) {
const { decrypt } = await import("../crypto");
yandexMapsKey = decrypt(service.apiKey);
}
const settings = await gpsStorage.getSettings(orgId);
const allowed = await hasGpsAccess(req.user!);
const defaultCenter =
settings.defaultLat !== null && settings.defaultLng !== null && settings.defaultZoom !== null
? { lat: settings.defaultLat, lng: settings.defaultLng, zoom: settings.defaultZoom }
: null;
res.json({ success: true, yandexMapsKey, allowed, defaultCenter });
});
// =====================
// PWA-трекинг (личная геолокация пользователя)
// Не про GPS-вкладку — доступ не проверяем.
// =====================
router.post("/my-position", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const orgId = user.organizationId;
const { lat, lng, speed, course, accuracy } = req.body ?? {};
const latN = parseNum(lat);
const lngN = parseNum(lng);
if (latN === null || lngN === null) {
return badRequest(res, "Обязательные поля: lat, lng");
}
// Находим или создаём персональный объект пользователя
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
if (!asset) {
asset = await gpsStorage.createAsset({
organizationId: orgId,
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
type: "person",
userId: user.id,
});
}
if (!asset.isActive) {
return res.status(403).json({ success: false, error: "Отслеживание геолокации выключено" });
}
await processPosition(asset.id, orgId, {
lat: latN,
lng: lngN,
speed: parseNum(speed),
course: parseNum(course),
accuracy: parseNum(accuracy),
});
res.json({ success: true, assetId: asset.id });
});
router.get("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const asset = await gpsStorage.getPersonAssetByUser(user.id, user.organizationId);
res.json({ success: true, enabled: Boolean(asset?.isActive), assetId: asset?.id ?? null });
});
router.put("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const orgId = user.organizationId;
const enabled = Boolean(req.body?.enabled);
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
if (!asset) {
if (!enabled) {
// Выключать нечего — объекта ещё нет
return res.json({ success: true, enabled: false, assetId: null });
}
asset = await gpsStorage.createAsset({
organizationId: orgId,
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
type: "person",
userId: user.id,
isActive: true,
});
} else if (asset.isActive !== enabled) {
asset = (await gpsStorage.updateAsset(asset.id, orgId, { isActive: enabled }))!;
}
res.json({ success: true, enabled, assetId: asset.id });
});
// Все роуты ниже — только для пользователей с доступом к GPS-вкладке
router.use(requireGpsAccess);
// =====================
// Настройки GPS организации (фильтр джиттера)
// =====================
router.get("/settings", async (req: AuthenticatedRequest, res: Response) => {
const settings = await gpsStorage.getSettings(req.user!.organizationId);
res.json({ success: true, ...settings });
});
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
// Обновляем только переданные поля (обратная совместимость с клиентом,
// который шлёт только часть настроек)
const current = await gpsStorage.getSettings(req.user!.organizationId);
const isAdmin = req.user!.appRole === "admin";
const jitterMeters =
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
const maxJumpSpeedKmh =
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
const tripStopMinutes =
req.body?.tripStopMinutes !== undefined ? Number(req.body.tripStopMinutes) : current.tripStopMinutes;
if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) {
return badRequest(res, "jitterMeters: целое число от 0 до 1000");
}
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
}
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
}
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
let defaultLat = current.defaultLat;
let defaultLng = current.defaultLng;
let defaultZoom = current.defaultZoom;
if (isAdmin) {
// Либо все три поля заданы, либо все null (очистка)
const hasCenterField =
req.body?.defaultLat !== undefined || req.body?.defaultLng !== undefined || req.body?.defaultZoom !== undefined;
if (hasCenterField) {
const lat = req.body.defaultLat === null ? null : Number(req.body.defaultLat);
const lng = req.body.defaultLng === null ? null : Number(req.body.defaultLng);
const zoom = req.body.defaultZoom === null ? null : Number(req.body.defaultZoom);
if (lat === null && lng === null && zoom === null) {
defaultLat = defaultLng = defaultZoom = null;
} else {
if (lat === null || !Number.isFinite(lat) || lat < -90 || lat > 90) {
return badRequest(res, "defaultLat: число от -90 до 90");
}
if (lng === null || !Number.isFinite(lng) || lng < -180 || lng > 180) {
return badRequest(res, "defaultLng: число от -180 до 180");
}
if (zoom === null || !Number.isInteger(zoom) || zoom < 1 || zoom > 19) {
return badRequest(res, "defaultZoom: целое число от 1 до 19");
}
defaultLat = lat;
defaultLng = lng;
defaultZoom = zoom;
}
}
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, {
jitterMeters,
maxJumpSpeedKmh,
tripStopMinutes,
defaultLat,
defaultLng,
defaultZoom,
});
res.json({
success: true,
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
defaultLat: row.defaultLat,
defaultLng: row.defaultLng,
defaultZoom: row.defaultZoom,
});
});
// =====================
// Объекты (assets)
// =====================
router.get("/assets", async (req: AuthenticatedRequest, res: Response) => {
const assets = await gpsStorage.listAssets(req.user!.organizationId);
res.json({ success: true, assets });
});
router.post("/assets", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const { name, type, externalId, color, userId } = req.body ?? {};
if (!name || typeof name !== "string") {
return badRequest(res, "Название объекта обязательно");
}
if (type !== undefined && !["vehicle", "person"].includes(type)) {
return badRequest(res, "Тип объекта: vehicle или person");
}
const asset = await gpsStorage.createAsset({
organizationId: orgId,
name: name.trim(),
type: type === "person" ? "person" : "vehicle",
externalId: externalId ? String(externalId).trim() : null,
color: color || undefined,
userId: userId ?? null,
});
// Регистрируем устройство в Traccar (no-op, если Traccar не настроен)
if (asset.externalId) {
await ensureDevice(asset.externalId, asset.name);
}
res.status(201).json({ success: true, asset });
});
router.patch("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const { name, type, externalId, color, isActive, userId } = req.body ?? {};
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = String(name).trim();
if (type !== undefined) {
if (!["vehicle", "person"].includes(type)) {
return badRequest(res, "Тип объекта: vehicle или person");
}
updates.type = type;
}
if (externalId !== undefined) updates.externalId = externalId ? String(externalId).trim() : null;
if (color !== undefined) updates.color = color;
if (isActive !== undefined) updates.isActive = Boolean(isActive);
if (userId !== undefined) updates.userId = userId ?? null;
const asset = await gpsStorage.updateAsset(id, orgId, updates);
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
// Если появился/сменился external_id — регистрируем устройство в Traccar
if (updates.externalId && asset.externalId) {
await ensureDevice(asset.externalId, asset.name);
}
res.json({ success: true, asset });
});
router.delete("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const asset = await gpsStorage.getAsset(id, orgId);
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
if (asset.externalId) {
await deleteDevice(asset.externalId);
}
await gpsStorage.deleteAsset(id, orgId);
res.json({ success: true });
});
// =====================
// Позиции и треки
// =====================
// Текущие last_* всех активных объектов организации
router.get("/assets/positions", async (req: AuthenticatedRequest, res: Response) => {
const assets = await gpsStorage.listCurrentPositions(req.user!.organizationId);
res.json({
success: true,
positions: assets.map((a) => ({
assetId: a.id,
name: a.name,
type: a.type,
color: a.color,
isOnline: a.isOnline,
lat: a.lastLat,
lng: a.lastLng,
speed: a.lastSpeed,
course: a.lastCourse,
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
lastSeenAt: a.lastSeenAt,
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
})),
});
});
// Трек объекта за период (точки ASC; при > 2000 — прореживание)
router.get("/assets/:id/track", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const asset = await gpsStorage.getAsset(id, orgId);
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
const from = parseDateParam(req.query.from);
const to = parseDateParam(req.query.to);
const all = await gpsStorage.getTrack(id, orgId, from ?? undefined, to ?? undefined);
const total = all.length;
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила
// с гистерезисом подтверждения, что при приёме (общий walker по точкам)
const settings = await gpsStorage.getSettings(orgId);
const { points: filtered, filteredCount } = filterTrackPoints(all, settings);
let points = filtered;
let sampled = false;
if (points.length > MAX_TRACK_POINTS) {
const step = Math.ceil(points.length / MAX_TRACK_POINTS);
points = points.filter((_, idx) => idx % step === 0);
// Всегда включаем последнюю точку трека
if (points[points.length - 1] !== filtered[filtered.length - 1]) {
points.push(filtered[filtered.length - 1]);
}
sampled = true;
}
res.json({ success: true, points, total, filteredCount, sampled });
});
// =====================
// Геозоны
// =====================
router.get("/geozones", async (req: AuthenticatedRequest, res: Response) => {
const geozones = await gpsStorage.listGeozones(req.user!.organizationId);
res.json({ success: true, geozones });
});
router.post("/geozones", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const { name, polygon, color, notifyEnter, notifyExit } = req.body ?? {};
if (!name || typeof name !== "string") {
return badRequest(res, "Название геозоны обязательно");
}
if (!isValidPolygon(polygon)) {
return badRequest(res, "Полигон: массив минимум из 3 пар [lat, lng]");
}
const geozone = await gpsStorage.createGeozone({
organizationId: orgId,
name: name.trim(),
polygon,
color: color || undefined,
notifyEnter: notifyEnter ?? undefined,
notifyExit: notifyExit ?? undefined,
});
res.status(201).json({ success: true, geozone });
});
router.patch("/geozones/:id", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const { name, polygon, color, notifyEnter, notifyExit, isActive } = req.body ?? {};
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = String(name).trim();
if (polygon !== undefined) {
if (!isValidPolygon(polygon)) {
return badRequest(res, "Полигон: массив минимум из 3 пар [lat, lng]");
}
updates.polygon = polygon;
}
if (color !== undefined) updates.color = color;
if (notifyEnter !== undefined) updates.notifyEnter = Boolean(notifyEnter);
if (notifyExit !== undefined) updates.notifyExit = Boolean(notifyExit);
if (isActive !== undefined) updates.isActive = Boolean(isActive);
const geozone = await gpsStorage.updateGeozone(id, orgId, updates);
if (!geozone) return res.status(404).json({ success: false, error: "Геозона не найдена" });
res.json({ success: true, geozone });
});
router.delete("/geozones/:id", async (req: AuthenticatedRequest, res: Response) => {
const deleted = await gpsStorage.deleteGeozone(Number(req.params.id), req.user!.organizationId);
if (!deleted) return res.status(404).json({ success: false, error: "Геозона не найдена" });
res.json({ success: true });
});
// События входа/выхода из геозон
router.get("/geozone-events", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const events = await gpsStorage.listGeozoneEvents(orgId, {
assetId: req.query.assetId ? Number(req.query.assetId) : undefined,
geozoneId: req.query.geozoneId ? Number(req.query.geozoneId) : undefined,
from: parseDateParam(req.query.from) ?? undefined,
to: parseDateParam(req.query.to) ?? undefined,
limit: req.query.limit ? Math.min(Number(req.query.limit), 1000) : undefined,
});
res.json({ success: true, events });
});
// =====================
// Группы
// =====================
router.get("/groups", async (req: AuthenticatedRequest, res: Response) => {
const groups = await gpsStorage.listGroups(req.user!.organizationId);
res.json({ success: true, groups });
});
router.post("/groups", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const { name, assetIds } = req.body ?? {};
if (!name || typeof name !== "string") {
return badRequest(res, "Название группы обязательно");
}
const group = await gpsStorage.createGroup(
orgId,
name.trim(),
Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : []
);
res.status(201).json({ success: true, group });
});
router.patch("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const { name, assetIds } = req.body ?? {};
const group = await gpsStorage.updateGroup(id, orgId, {
name: name !== undefined ? String(name).trim() : undefined,
assetIds: Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : undefined,
});
if (!group) return res.status(404).json({ success: false, error: "Группа не найдена" });
res.json({ success: true, group });
});
router.delete("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
const deleted = await gpsStorage.deleteGroup(Number(req.params.id), req.user!.organizationId);
if (!deleted) return res.status(404).json({ success: false, error: "Группа не найдена" });
res.json({ success: true });
});
// =====================
// Подписчики объекта
// =====================
router.get("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const asset = await gpsStorage.getAsset(id, orgId);
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
const subscribers = await gpsStorage.listSubscribers(id);
res.json({ success: true, subscribers });
});
// Полная замена списка подписчиков: [{ userId, onEnter, onExit, onOffline }]
router.put("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
const orgId = req.user!.organizationId;
const id = Number(req.params.id);
const asset = await gpsStorage.getAsset(id, orgId);
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
const list = Array.isArray(req.body) ? req.body : req.body?.subscribers;
if (!Array.isArray(list)) {
return badRequest(res, "Ожидается массив подписчиков [{ userId, onEnter, onExit, onOffline }]");
}
const subscribers = list.map((s: any) => ({
userId: Number(s.userId),
onEnter: Boolean(s.onEnter ?? true),
onExit: Boolean(s.onExit ?? true),
onOffline: Boolean(s.onOffline ?? false),
})).filter((s: { userId: number }) => Number.isFinite(s.userId));
const result = await gpsStorage.replaceSubscribers(id, subscribers);
res.json({ success: true, subscribers: result });
});
app.use("/api/gps", router);
}
function isValidPolygon(polygon: unknown): polygon is Array<[number, number]> {
return (
Array.isArray(polygon) &&
polygon.length >= 3 &&
polygon.every(
(p) => Array.isArray(p) && p.length === 2 && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]))
)
);
}