Files
iistwin/server/gps/routes.ts

595 lines
22 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, classifyPoint } from "./geozone.service";
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;
}
/**
* Нормализация одной точки. Поддерживает два формата:
* 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;
return {
externalId: String(raw.device.uniqueId),
lat,
lng,
speed: parseNum(p.speed),
course: parseNum(p.course),
accuracy: parseNum(p.accuracy),
recordedAt: parseDateParam(p.fixTime ?? p.deviceTime),
};
}
// Простой формат
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),
};
}
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;
}
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);
// =====================
// Конфигурация клиента (ключ Яндекс.Карт из external_services)
// =====================
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);
}
res.json({ success: true, yandexMapsKey });
});
// =====================
// Настройки 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) => {
// Обновляем только переданные поля (обратная совместимость с клиентом,
// который шлёт только jitterMeters)
const current = await gpsStorage.getSettings(req.user!.organizationId);
const jitterMeters =
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
const maxJumpSpeedKmh =
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
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 = фильтр выключен)");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh });
res.json({ success: true, jitterMeters: row.jitterMeters, maxJumpSpeedKmh: row.maxJumpSpeedKmh });
});
// =====================
// Объекты (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-скачки) — те же правила,
// что при приёме, сравнение с предыдущей принятой точкой
const settings = await gpsStorage.getSettings(orgId);
const filtered: typeof all = [];
let filteredCount = 0;
let prevAccepted: { lat: number; lng: number; recordedAt: Date } | null = null;
for (const point of all) {
const current = { lat: point.lat, lng: point.lng, recordedAt: point.recordedAt };
if (classifyPoint(prevAccepted, current, settings) === "accept") {
filtered.push(point);
prevAccepted = current;
} else {
filteredCount++;
}
}
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 });
});
// =====================
// PWA-трекинг (личная геолокация пользователя)
// =====================
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 });
});
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]))
)
);
}