Files
iistwin/server/gps/routes.ts

822 lines
33 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;
// =====================
// OSRM map matching (привязка трека к дорогам)
// =====================
// URL OSRM-сервиса (контейнер во внутренней Docker-сети)
const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
// Лимит 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 });
}
// =====================
// Приём позиций от 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");
}
// Центр карты по умолчанию — только для 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,
defaultLat,
defaultLng,
defaultZoom,
});
res.json({
success: true,
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
gpsLostHours: row.gpsLostHours,
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);
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;
}
// Привязка трека к дорогам через 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,
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]))
)
);
}