Files
iistwin/server/gps/routes.ts

883 lines
36 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, offRoadDistance } from "./geozone.service";
import { hasGpsAccess } from "./access";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { OSRM_URL } from "./osrm";
import { storage } from "../storage";
/**
* Роуты GPS-модуля.
* Публичный POST /api/gps/ingest (приём точек от Traccar, секрет в заголовке)
* регистрируется отдельно, ДО роутера с authenticateToken.
*/
const MAX_TRACK_POINTS = 2000;
// =====================
// OSRM map matching (привязка трека к дорогам)
// =====================
// URL OSRM-сервиса — см. ./osrm (общий модуль, используется и в geozone.service)
// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
const OSRM_CHUNK_SIZE = 95;
// Таймаут запроса к OSRM
const OSRM_TIMEOUT_MS = 5000;
// TTL in-memory кэша привязанных треков (точки меняются по мере поступления новых)
const MATCHED_CACHE_TTL_MS = 10 * 60 * 1000;
interface MatchedTrack {
/** Геометрия привязанного трека в [lat, lng] (для единообразия с остальными точками) */
points: Array<[number, number]>;
/** Суммарная длина по дорогам, метры */
distance: number;
/** Суммарная длительность по дорогам, секунды */
duration: number;
}
// In-memory кэш: ключ `${assetId}:${from}:${to}` → результат (или null при недоступности OSRM)
const matchedTrackCache = new Map<string, { data: MatchedTrack | null; expiresAt: number }>();
// Warn про недоступность OSRM пишем один раз до следующего успешного ответа
let osrmUnavailableWarned = false;
interface OsrmMatchResponse {
code?: string;
matchings?: Array<{
geometry?: { coordinates?: Array<[number, number]> };
distance?: number;
duration?: number;
}>;
}
/**
* Привязка трека к дорогам через OSRM Match API.
* Возвращает null, если OSRM недоступен (endpoint при этом не падает).
* LBS-точки сюда не передаём — привязываем только точные GPS-данные.
*/
async function matchTrack(points: Array<{ lat: number; lng: number }>): Promise<MatchedTrack | null> {
const allCoords: Array<[number, number]> = [];
let distance = 0;
let duration = 0;
for (let i = 0; i < points.length; i += OSRM_CHUNK_SIZE) {
const chunk = points.slice(i, i + OSRM_CHUNK_SIZE);
if (chunk.length < 2) continue;
// ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
const coordsParam = chunk.map((p) => `${p.lng},${p.lat}`).join(";");
const url = `${OSRM_URL}/match/v1/driving/${coordsParam}?overview=full&geometries=geojson&gaps=split&tidy=true`;
let data: OsrmMatchResponse;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_TIMEOUT_MS) });
data = (await res.json()) as OsrmMatchResponse;
if (osrmUnavailableWarned) {
console.log("[GPS] OSRM снова доступен");
osrmUnavailableWarned = false;
}
} catch (err) {
// OSRM недоступен (ECONNREFUSED/таймаут) — трек без привязки, endpoint не падает
if (!osrmUnavailableWarned) {
console.warn(`[GPS] OSRM недоступен (${OSRM_URL}):`, err instanceof Error ? err.message : err);
osrmUnavailableWarned = true;
}
return null;
}
// Чанк не смэтчился (code != 'Ok') — пропускаем его и продолжаем
if (data.code !== "Ok" || !Array.isArray(data.matchings)) continue;
for (const matching of data.matchings) {
const coords = matching.geometry?.coordinates;
if (Array.isArray(coords)) {
for (const [lng, lat] of coords) {
// Дедупликация стыковочной точки между чанками/matching'ами
const prev = allCoords[allCoords.length - 1];
if (prev && prev[0] === lat && prev[1] === lng) continue;
// Храним в [lat, lng] для единообразия с точками трека
allCoords.push([lat, lng]);
}
}
distance += matching.distance ?? 0;
duration += matching.duration ?? 0;
}
}
if (allCoords.length < 2) return null;
return { points: allCoords, distance: Math.round(distance), duration: Math.round(duration) };
}
/** Привязка трека с in-memory кэшем (TTL 10 минут) */
async function matchTrackCached(
assetId: number,
from: Date | null,
to: Date | null,
points: Array<{ lat: number; lng: number }>
): Promise<MatchedTrack | null> {
const key = `${assetId}:${from?.toISOString() ?? ""}:${to?.toISOString() ?? ""}`;
const cached = matchedTrackCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.data;
const data = await matchTrack(points);
matchedTrackCache.set(key, { data, expiresAt: Date.now() + MATCHED_CACHE_TTL_MS });
// Простая уборка протухших записей, чтобы Map не рос бесконечно
if (matchedTrackCache.size > 100) {
const now = Date.now();
for (const [k, v] of matchedTrackCache) {
if (v.expiresAt <= now) matchedTrackCache.delete(k);
}
}
return data;
}
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 });
}
// Максимум LBS-точек, проверяемых offroad-фильтром при выдаче трека
// (бюджет ~5 сек при ~1-2 мс на OSRM nearest и конкуренции 10)
const MAX_LBS_OFFROAD_CHECK = 2000;
// Конкуренция запросов к OSRM nearest при выдаче трека
const OFFROAD_CHECK_CONCURRENCY = 10;
/**
* Простой p-limit без зависимостей: выполняет fn над элементами
* с ограничением числа одновременных вызовов.
*/
async function runWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let index = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (index < items.length) {
const item = items[index++];
await fn(item);
}
});
await Promise.all(workers);
}
// =====================
// Приём позиций от 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 = фильтр выключен)");
}
const gpsLostHours =
req.body?.gpsLostHours !== undefined ? Number(req.body.gpsLostHours) : current.gpsLostHours;
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
}
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
}
const offRoadMeters =
req.body?.offRoadMeters !== undefined ? Number(req.body.offRoadMeters) : current.offRoadMeters;
if (!Number.isInteger(offRoadMeters) || offRoadMeters < 0 || offRoadMeters > 2000) {
return badRequest(res, "offRoadMeters: целое число от 0 до 2000 (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,
gpsLostHours,
offRoadMeters,
defaultLat,
defaultLng,
defaultZoom,
});
res.json({
success: true,
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
gpsLostHours: row.gpsLostHours,
offRoadMeters: row.offRoadMeters,
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, // время последней точки по устройству
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
gpsLost: a.gpsLost, // режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS
lastIsLbs: a.lastIsLbs, // последняя точка — координаты вышки (примерная позиция)
})),
});
});
// Трек объекта за период (точки 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);
// Offroad-фильтр при выдаче: точки далеко от дорог — выкидываем через
// OSRM nearest. Проверяются ВСЕ точки, а не только isLbs: «легализованный»
// гистерезисом стабильный LBS идёт с is_lbs=false и обходит фильтр приёма,
// а настоящий GPS всё равно на дороге (замерено: p90 = 7 м). 0 = выкл.
let points = filtered;
let offroadFiltered = 0;
if (settings.offRoadMeters > 0) {
let lbsPoints = points;
if (lbsPoints.length > MAX_LBS_OFFROAD_CHECK) {
// Бюджет времени ~5 сек: прореживаем точки равномерно до лимита;
// не попавшие в проверку остаются в выдаче как есть
const step = Math.ceil(lbsPoints.length / MAX_LBS_OFFROAD_CHECK);
const thinned = lbsPoints.filter((_, idx) => idx % step === 0);
console.log(
`[GPS] track offroad: asset=${id} точек ${lbsPoints.length} > ${MAX_LBS_OFFROAD_CHECK}, проверяем ${thinned.length} (прорежено)`
);
lbsPoints = thinned;
}
if (lbsPoints.length > 0) {
const dropped = new Set<(typeof lbsPoints)[number]>();
await runWithConcurrency(lbsPoints, OFFROAD_CHECK_CONCURRENCY, async (p) => {
const dist = await offRoadDistance(p.lat, p.lng, settings.offRoadMeters);
if (dist !== null) dropped.add(p);
});
if (dropped.size > 0) {
offroadFiltered = dropped.size;
points = points.filter((p) => !dropped.has(p));
}
}
}
let sampled = false;
if (points.length > MAX_TRACK_POINTS) {
// Точки ДО прореживания — для гарантии включения последней точки трека
const beforeSample = points;
const step = Math.ceil(points.length / MAX_TRACK_POINTS);
points = points.filter((_, idx) => idx % step === 0);
// Всегда включаем последнюю точку трека
if (points[points.length - 1] !== beforeSample[beforeSample.length - 1]) {
points.push(beforeSample[beforeSample.length - 1]);
}
sampled = true;
}
// Привязка трека к дорогам через OSRM (?matched=1): только точные
// GPS-точки (isLbs исключаем). При недоступности OSRM — matched: null,
// endpoint не падает.
let matched: MatchedTrack | null | undefined;
if (req.query.matched === "1") {
const gpsOnly = points.filter((p) => !p.isLbs);
matched = gpsOnly.length >= 2 ? await matchTrackCached(id, from, to, gpsOnly) : null;
}
res.json({
success: true,
points,
total,
filteredCount,
offroadFiltered,
sampled,
...(matched !== undefined ? { matched } : {}),
});
});
// =====================
// Геозоны
// =====================
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]))
)
);
}