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(); // 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 { 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 { 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(items: T[], limit: number, fn: (item: T) => Promise): Promise { 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; } try { await processPosition(asset.id, asset.organizationId, { // Traccar пометил позицию как невалидную (valid=false) — протокольный // признак «нет GPS-фикса»: сохраняем как примерную (LBS), в геозоны // и пробег не пускаем forceLbs: !point.valid, 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 = {}; 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 = {}; 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])) ) ); }