import { Router } from "express"; import type { Express, Response } from "express"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { gpsStorage } from "./storage"; import { processPosition } 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 }); }); // ===================== // Объекты (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, })), }); }); // Трек объекта за период (точки 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; let points = all; let sampled = false; if (total > MAX_TRACK_POINTS) { const step = Math.ceil(total / MAX_TRACK_POINTS); points = all.filter((_, idx) => idx % step === 0); // Всегда включаем последнюю точку трека if (points[points.length - 1] !== all[total - 1]) { points.push(all[total - 1]); } sampled = true; } res.json({ success: true, points, total, 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 = {}; 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])) ) ); }