From 8b713e66c0f4fa3d41d40430134c79e2be46f87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 24 Aug 2026 16:50:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20task-=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82?= =?UTF-8?q?=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B3=D0=BE=20Enter/?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B5=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/TaskChat.tsx | 8 + docker-compose.yml | 19 + migrations/0069_gps_tracking.sql | 106 ++++++ server/gps/geozone.service.ts | 232 ++++++++++++ server/gps/routes.ts | 545 +++++++++++++++++++++++++++++ server/gps/storage.ts | 443 +++++++++++++++++++++++ server/gps/traccar.client.ts | 95 +++++ server/gps/worker.ts | 53 +++ server/index.ts | 5 + server/routes/index.ts | 3 + shared/schema.ts | 141 +++++++- traccar/traccar.xml | 25 ++ 12 files changed, 1674 insertions(+), 1 deletion(-) create mode 100644 migrations/0069_gps_tracking.sql create mode 100644 server/gps/geozone.service.ts create mode 100644 server/gps/routes.ts create mode 100644 server/gps/storage.ts create mode 100644 server/gps/traccar.client.ts create mode 100644 server/gps/worker.ts create mode 100644 traccar/traccar.xml diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index a142bc0..9458c71 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -1114,6 +1114,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => { } // В PWA/standalone-режиме Enter вставляет новую строку, отправка — только кнопкой if (isPWAInstalled()) return; + // Защита от двойного Enter: если сообщение уже отправляется — игнорируем + if (sendMessageMutation.isPending || isUploading) { + e.preventDefault(); + return; + } // Явная отправка формы по Enter (нужно для случая файл без текста) const hasContent = form.getValues('message')?.trim() || pendingFiles.length > 0; if (hasContent) { @@ -1181,6 +1186,9 @@ const TaskChat = ({ taskId }: TaskChatProps) => { }; const onSubmit = async (data: z.infer) => { + // Защита от повторной отправки при двойном Enter/клике + if (sendMessageMutation.isPending || isUploading) return; + // Анализ текста для поиска @упоминаний типа "@ильяс султанов" const mentionMatches = data.message.match(/@[а-яё\w\u00C0-\u024F\u1E00-\u1EFF\s]+/gi); let finalMentionedIds = [...(data.mentionedUserIds || [])]; diff --git a/docker-compose.yml b/docker-compose.yml index 7d19ae7..6c9cafb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: environment: OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434} DOC_WORKER_URL: ${DOC_WORKER_URL:-http://document-worker:3000} + # GPS: URL Traccar API внутри Docker-сети (user/password — в .env) + TRACCAR_API_URL: ${TRACCAR_API_URL:-http://traccar:8082} volumes: - crm_data:/app/data restart: unless-stopped @@ -135,8 +137,25 @@ services: retries: 3 start_period: 30s + traccar: + image: traccar/traccar:latest + container_name: traccar + restart: unless-stopped + ports: + - "5055:5055" # OsmAnd/HTTP — приём точек с трекеров/приложений + - "5023:5023" # GT06 + # Web UI Traccar наружу не публикуем — только внутренняя Docker-сеть. + # При необходимости доступа извне раскомментируйте: + # - "5082:8082" # web UI + volumes: + - traccar_logs:/opt/traccar/logs + - traccar_data:/opt/traccar/data + - ./traccar/traccar.xml:/opt/traccar/conf/traccar.xml:ro + volumes: crm_data: postgres_data: minio_data: ollama_data: + traccar_logs: + traccar_data: diff --git a/migrations/0069_gps_tracking.sql b/migrations/0069_gps_tracking.sql new file mode 100644 index 0000000..4eb717f --- /dev/null +++ b/migrations/0069_gps_tracking.sql @@ -0,0 +1,106 @@ +-- GPS-мониторинг: объекты (транспорт/люди), треки, геозоны, события, группы, подписки. +-- Интеграция с Traccar (forward позиций на /api/gps/ingest) и приём геолокации из PWA. + +-- Объекты мониторинга (машины, люди с трекерами) +CREATE TABLE IF NOT EXISTS gps_assets ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'vehicle', + user_id INTEGER REFERENCES users(id), + external_id TEXT, + color TEXT DEFAULT '#3b82f6', + is_active BOOLEAN DEFAULT TRUE, + is_online BOOLEAN DEFAULT FALSE, + last_lat DOUBLE PRECISION, + last_lng DOUBLE PRECISION, + last_speed DOUBLE PRECISION, + last_course DOUBLE PRECISION, + last_seen_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gps_assets_org_idx ON gps_assets(organization_id); +-- IMEI/идентификатор трекера уникален в рамках организации (только для заполненных) +CREATE UNIQUE INDEX IF NOT EXISTS gps_assets_org_external_idx + ON gps_assets(organization_id, external_id) WHERE external_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS gps_assets_user_idx ON gps_assets(user_id); + +-- История позиций (треки) +CREATE TABLE IF NOT EXISTS gps_positions ( + id BIGSERIAL PRIMARY KEY, + asset_id INTEGER NOT NULL REFERENCES gps_assets(id) ON DELETE CASCADE, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + speed DOUBLE PRECISION, + course DOUBLE PRECISION, + accuracy DOUBLE PRECISION, + recorded_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gps_positions_asset_recorded_idx ON gps_positions(asset_id, recorded_at); +CREATE INDEX IF NOT EXISTS gps_positions_org_recorded_idx ON gps_positions(organization_id, recorded_at); + +-- Геозоны (полигоны) +CREATE TABLE IF NOT EXISTS gps_geozones ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + polygon JSONB NOT NULL, + color TEXT DEFAULT '#f59e0b', + notify_enter BOOLEAN DEFAULT TRUE, + notify_exit BOOLEAN DEFAULT TRUE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gps_geozones_org_idx ON gps_geozones(organization_id); + +-- События входа/выхода из геозон +CREATE TABLE IF NOT EXISTS gps_geozone_events ( + id BIGSERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + geozone_id INTEGER NOT NULL REFERENCES gps_geozones(id) ON DELETE CASCADE, + asset_id INTEGER NOT NULL REFERENCES gps_assets(id) ON DELETE CASCADE, + event TEXT NOT NULL, + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gps_geozone_events_asset_idx ON gps_geozone_events(asset_id, created_at); +CREATE INDEX IF NOT EXISTS gps_geozone_events_org_idx ON gps_geozone_events(organization_id, created_at); + +-- Текущее состояние «объект внутри геозоны» (для детекта входа/выхода) +CREATE TABLE IF NOT EXISTS gps_geozone_states ( + asset_id INTEGER NOT NULL REFERENCES gps_assets(id) ON DELETE CASCADE, + geozone_id INTEGER NOT NULL REFERENCES gps_geozones(id) ON DELETE CASCADE, + inside BOOLEAN NOT NULL, + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (asset_id, geozone_id) +); + +-- Группы объектов (для фильтров на карте) +CREATE TABLE IF NOT EXISTS gps_groups ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gps_groups_org_idx ON gps_groups(organization_id); + +-- Связь групп и объектов (многие-ко-многим) +CREATE TABLE IF NOT EXISTS gps_group_assets ( + group_id INTEGER NOT NULL REFERENCES gps_groups(id) ON DELETE CASCADE, + asset_id INTEGER NOT NULL REFERENCES gps_assets(id) ON DELETE CASCADE, + PRIMARY KEY (group_id, asset_id) +); + +-- Подписчики на события объекта (уведомления о входе/выходе/офлайне) +CREATE TABLE IF NOT EXISTS gps_asset_subscribers ( + id SERIAL PRIMARY KEY, + asset_id INTEGER NOT NULL REFERENCES gps_assets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + on_enter BOOLEAN DEFAULT TRUE, + on_exit BOOLEAN DEFAULT TRUE, + on_offline BOOLEAN DEFAULT FALSE, + UNIQUE (asset_id, user_id) +); diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts new file mode 100644 index 0000000..24cfed1 --- /dev/null +++ b/server/gps/geozone.service.ts @@ -0,0 +1,232 @@ +import { gpsStorage } from "./storage"; +import { storage } from "../storage"; +import { webPushService } from "../services/web-push.service"; +import { eventBus, publishNotificationSSE } from "../routes/shared"; +import type { GpsAsset, GpsGeozone } from "@shared/schema"; + +/** + * Сервис обработки GPS-позиций и геозон. + * Детект входа/выхода — сравнение point-in-polygon с сохранённым состоянием + * gps_geozone_states; при смене состояния создаётся событие и рассылаются + * уведомления подписчикам объекта. + */ + +/** + * Ray casting: находится ли точка внутри полигона. + * polygon — массив пар [lat, lng]. Без внешних зависимостей. + */ +export function pointInPolygon(lat: number, lng: number, polygon: Array<[number, number]>): boolean { + if (!Array.isArray(polygon) || polygon.length < 3) return false; + + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [latI, lngI] = polygon[i]; + const [latJ, lngJ] = polygon[j]; + + const intersects = + lngI > lng !== lngJ > lng && + lat < ((latJ - latI) * (lng - lngI)) / (lngJ - lngI) + latI; + if (intersects) inside = !inside; + } + return inside; +} + +export interface ProcessPositionInput { + lat: number; + lng: number; + speed?: number | null; + course?: number | null; + accuracy?: number | null; + recordedAt?: Date; +} + +/** + * Основная точка входа: сохраняет позицию, обновляет last_* объекта, + * проверяет геозоны и рассылает SSE/уведомления. + */ +export async function processPosition( + assetId: number, + organizationId: number, + input: ProcessPositionInput +): Promise { + const recordedAt = input.recordedAt ?? new Date(); + + // 1. Сохраняем точку в историю + await gpsStorage.insertPosition({ + assetId, + organizationId, + lat: input.lat, + lng: input.lng, + speed: input.speed ?? null, + course: input.course ?? null, + accuracy: input.accuracy ?? null, + recordedAt, + }); + + // 2. Обновляем последнюю позицию объекта (и переводим в online) + await gpsStorage.updateAssetLastPosition(assetId, { + lat: input.lat, + lng: input.lng, + speed: input.speed, + course: input.course, + recordedAt, + }); + + // 3. SSE-событие о перемещении — broadcast по организации, + // чтобы карта у всех открытых клиентов обновилась в реальном времени + eventBus.publishEvent({ + type: "gps_asset_moved", + data: { + assetId, + lat: input.lat, + lng: input.lng, + speed: input.speed ?? null, + course: input.course ?? null, + recordedAt: recordedAt.toISOString(), + }, + organizationId, + }); + + // 4. Проверка геозон + await checkGeozones(assetId, organizationId, input, recordedAt); +} + +async function checkGeozones( + assetId: number, + organizationId: number, + input: ProcessPositionInput, + recordedAt: Date +): Promise { + const geozones = await gpsStorage.listActiveGeozones(organizationId); + if (geozones.length === 0) return; + + const states = await gpsStorage.getGeozoneStatesForAsset(assetId); + const asset = await gpsStorage.getAsset(assetId, organizationId); + if (!asset) return; + + for (const geozone of geozones) { + try { + const nowInside = pointInPolygon(input.lat, input.lng, geozone.polygon); + const wasInside = states.get(geozone.id) ?? false; + + if (nowInside === wasInside) continue; + + // Состояние изменилось — фиксируем и создаём событие + await gpsStorage.upsertGeozoneState(assetId, geozone.id, nowInside); + const eventType = nowInside ? ("enter" as const) : ("exit" as const); + await gpsStorage.insertGeozoneEvent({ + organizationId, + geozoneId: geozone.id, + assetId, + event: eventType, + lat: input.lat, + lng: input.lng, + }); + + const notifyEnabled = nowInside ? geozone.notifyEnter : geozone.notifyExit; + if (notifyEnabled) { + await notifySubscribers(asset, geozone, eventType, input.lat, input.lng, recordedAt); + } + } catch (err) { + console.error(`[GPS] Geozone check failed (asset ${assetId}, geozone ${geozone.id}):`, err); + } + } +} + +/** + * Рассылка уведомлений подписчикам объекта о событии геозоны. + * In-app (user_notifications) + Web Push + SSE для мгновенного обновления бейджа. + */ +async function notifySubscribers( + asset: GpsAsset, + geozone: GpsGeozone, + eventType: "enter" | "exit", + lat: number, + lng: number, + recordedAt: Date +): Promise { + const subscribers = await gpsStorage.listSubscribers(asset.id); + const eventText = eventType === "enter" ? "вошёл в зону" : "вышел из зоны"; + const title = `GPS: ${asset.name}`; + const message = `${asset.name} ${eventText} «${geozone.name}»`; + + for (const sub of subscribers) { + const enabled = eventType === "enter" ? sub.onEnter : sub.onExit; + if (!enabled) continue; + + try { + const notification = await storage.createUserNotification({ + userId: sub.userId, + organizationId: asset.organizationId, + type: "gps.geozone", + title, + message, + isRead: false, + }); + + publishNotificationSSE(sub.userId, asset.organizationId, { + type: "gps.geozone", + notificationId: notification.id, + }); + + await webPushService.sendToUser(sub.userId, asset.organizationId, { + title, + body: message, + icon: "/icon-192.png", + url: "/gps", + tag: `gps-geozone-${asset.id}-${geozone.id}`, + data: { + type: "gps", + event: eventType, + assetId: asset.id.toString(), + geozoneId: geozone.id.toString(), + lat: lat.toString(), + lng: lng.toString(), + recordedAt: recordedAt.toISOString(), + }, + }); + } catch (err) { + console.error(`[GPS] Notify subscriber ${sub.userId} failed:`, err); + } + } +} + +/** + * Уведомление подписчиков о переходе объекта в офлайн (вызывается воркером). + */ +export async function notifyOffline(asset: GpsAsset): Promise { + const subscribers = await gpsStorage.listSubscribers(asset.id); + const title = `GPS: ${asset.name}`; + const message = `${asset.name} не выходит на связь более 15 минут`; + + for (const sub of subscribers) { + if (!sub.onOffline) continue; + + try { + const notification = await storage.createUserNotification({ + userId: sub.userId, + organizationId: asset.organizationId, + type: "gps.offline", + title, + message, + isRead: false, + }); + + publishNotificationSSE(sub.userId, asset.organizationId, { + type: "gps.offline", + notificationId: notification.id, + }); + + await webPushService.sendToUser(sub.userId, asset.organizationId, { + title, + body: message, + icon: "/icon-192.png", + url: "/gps", + tag: `gps-offline-${asset.id}`, + data: { type: "gps", event: "offline", assetId: asset.id.toString() }, + }); + } catch (err) { + console.error(`[GPS] Notify offline subscriber ${sub.userId} failed:`, err); + } + } +} diff --git a/server/gps/routes.ts b/server/gps/routes.ts new file mode 100644 index 0000000..7340295 --- /dev/null +++ b/server/gps/routes.ts @@ -0,0 +1,545 @@ +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])) + ) + ); +} diff --git a/server/gps/storage.ts b/server/gps/storage.ts new file mode 100644 index 0000000..c1b214a --- /dev/null +++ b/server/gps/storage.ts @@ -0,0 +1,443 @@ +import { db } from "../db"; +import { + gpsAssets, + gpsPositions, + gpsGeozones, + gpsGeozoneEvents, + gpsGeozoneStates, + gpsGroups, + gpsGroupAssets, + gpsAssetSubscribers, + type GpsAsset, + type GpsPosition, + type GpsGeozone, + type GpsGeozoneEvent, + type GpsGroup, + type GpsAssetSubscriber, + type InsertGpsAsset, + type InsertGpsGeozone, + type InsertGpsPosition, +} from "@shared/schema"; +import { eq, and, asc, desc, gte, lte, lt, inArray, sql, isNotNull } from "drizzle-orm"; + +/** + * Хранилище GPS-модуля. Все публичные методы, работающие с данными + * конкретной организации, обязательно фильтруют по organizationId. + * Исключения (помечены отдельно): поиск по external_id для ingest + * и выборка «протухших» объектов для воркера — они обходят все организации. + */ +export class GpsStorage { + // ===================== + // Объекты (assets) + // ===================== + + async listAssets(organizationId: number) { + const assets = await db + .select() + .from(gpsAssets) + .where(eq(gpsAssets.organizationId, organizationId)) + .orderBy(asc(gpsAssets.name)); + + if (assets.length === 0) return []; + + const assetIds = assets.map((a) => a.id); + + // Группы каждого объекта + const groupLinks = await db + .select({ + assetId: gpsGroupAssets.assetId, + groupId: gpsGroups.id, + groupName: gpsGroups.name, + }) + .from(gpsGroupAssets) + .innerJoin(gpsGroups, eq(gpsGroupAssets.groupId, gpsGroups.id)) + .where( + and( + inArray(gpsGroupAssets.assetId, assetIds), + eq(gpsGroups.organizationId, organizationId) + ) + ); + + // Число подписчиков каждого объекта + const subscriberCounts = await db + .select({ + assetId: gpsAssetSubscribers.assetId, + count: sql`count(*)::int`, + }) + .from(gpsAssetSubscribers) + .where(inArray(gpsAssetSubscribers.assetId, assetIds)) + .groupBy(gpsAssetSubscribers.assetId); + + const groupsByAsset = new Map>(); + for (const link of groupLinks) { + const list = groupsByAsset.get(link.assetId) ?? []; + list.push({ id: link.groupId, name: link.groupName }); + groupsByAsset.set(link.assetId, list); + } + const countByAsset = new Map(subscriberCounts.map((r) => [r.assetId, r.count])); + + return assets.map((asset) => ({ + ...asset, + groups: groupsByAsset.get(asset.id) ?? [], + subscriberCount: countByAsset.get(asset.id) ?? 0, + })); + } + + async getAsset(id: number, organizationId: number): Promise { + const [asset] = await db + .select() + .from(gpsAssets) + .where(and(eq(gpsAssets.id, id), eq(gpsAssets.organizationId, organizationId))); + return asset; + } + + /** + * Поиск объекта по external_id без фильтра организации. + * Используется только публичным ingest-endpoint'ом (Traccar). + */ + async getAssetByExternalId(externalId: string): Promise { + const [asset] = await db + .select() + .from(gpsAssets) + .where(eq(gpsAssets.externalId, externalId)); + return asset; + } + + /** Персональный объект (type='person') пользователя в организации */ + async getPersonAssetByUser(userId: number, organizationId: number): Promise { + const [asset] = await db + .select() + .from(gpsAssets) + .where( + and( + eq(gpsAssets.userId, userId), + eq(gpsAssets.organizationId, organizationId), + eq(gpsAssets.type, "person") + ) + ); + return asset; + } + + async createAsset(data: InsertGpsAsset): Promise { + const [asset] = await db.insert(gpsAssets).values(data).returning(); + return asset; + } + + async updateAsset(id: number, organizationId: number, updates: Partial): Promise { + const [asset] = await db + .update(gpsAssets) + .set(updates) + .where(and(eq(gpsAssets.id, id), eq(gpsAssets.organizationId, organizationId))) + .returning(); + return asset; + } + + async deleteAsset(id: number, organizationId: number): Promise { + const deleted = await db + .delete(gpsAssets) + .where(and(eq(gpsAssets.id, id), eq(gpsAssets.organizationId, organizationId))) + .returning({ id: gpsAssets.id }); + return deleted.length > 0; + } + + /** Обновление последней позиции объекта (и перевод в online) */ + async updateAssetLastPosition( + assetId: number, + data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date } + ): Promise { + await db + .update(gpsAssets) + .set({ + lastLat: data.lat, + lastLng: data.lng, + lastSpeed: data.speed ?? null, + lastCourse: data.course ?? null, + lastSeenAt: data.recordedAt, + isOnline: true, + }) + .where(eq(gpsAssets.id, assetId)); + } + + async setAssetOnline(assetId: number, isOnline: boolean): Promise { + await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId)); + } + + /** Текущие позиции всех активных объектов организации */ + async listCurrentPositions(organizationId: number): Promise { + return db + .select() + .from(gpsAssets) + .where( + and( + eq(gpsAssets.organizationId, organizationId), + eq(gpsAssets.isActive, true), + isNotNull(gpsAssets.lastLat) + ) + ); + } + + /** + * Онлайн-объекты, от которых давно не было точек (для воркера офлайн-детекта). + * Обходит все организации — вызывается под withSuperAdmin. + */ + async getStaleOnlineAssets(cutoff: Date): Promise { + return db + .select() + .from(gpsAssets) + .where( + and( + eq(gpsAssets.isActive, true), + eq(gpsAssets.isOnline, true), + isNotNull(gpsAssets.lastSeenAt), + lt(gpsAssets.lastSeenAt, cutoff) + ) + ); + } + + // ===================== + // Позиции (треки) + // ===================== + + async insertPosition(data: InsertGpsPosition): Promise { + const [position] = await db.insert(gpsPositions).values(data).returning(); + return position; + } + + async getTrack(assetId: number, organizationId: number, from?: Date, to?: Date): Promise { + const conditions = [ + eq(gpsPositions.assetId, assetId), + eq(gpsPositions.organizationId, organizationId), + ]; + if (from) conditions.push(gte(gpsPositions.recordedAt, from)); + if (to) conditions.push(lte(gpsPositions.recordedAt, to)); + + return db + .select() + .from(gpsPositions) + .where(and(...conditions)) + .orderBy(asc(gpsPositions.recordedAt)); + } + + // ===================== + // Геозоны + // ===================== + + async listGeozones(organizationId: number): Promise { + return db + .select() + .from(gpsGeozones) + .where(eq(gpsGeozones.organizationId, organizationId)) + .orderBy(asc(gpsGeozones.name)); + } + + async listActiveGeozones(organizationId: number): Promise { + return db + .select() + .from(gpsGeozones) + .where(and(eq(gpsGeozones.organizationId, organizationId), eq(gpsGeozones.isActive, true))); + } + + async getGeozone(id: number, organizationId: number): Promise { + const [geozone] = await db + .select() + .from(gpsGeozones) + .where(and(eq(gpsGeozones.id, id), eq(gpsGeozones.organizationId, organizationId))); + return geozone; + } + + async createGeozone(data: InsertGpsGeozone): Promise { + const [geozone] = await db.insert(gpsGeozones).values(data).returning(); + return geozone; + } + + async updateGeozone(id: number, organizationId: number, updates: Partial): Promise { + const [geozone] = await db + .update(gpsGeozones) + .set(updates) + .where(and(eq(gpsGeozones.id, id), eq(gpsGeozones.organizationId, organizationId))) + .returning(); + return geozone; + } + + async deleteGeozone(id: number, organizationId: number): Promise { + const deleted = await db + .delete(gpsGeozones) + .where(and(eq(gpsGeozones.id, id), eq(gpsGeozones.organizationId, organizationId))) + .returning({ id: gpsGeozones.id }); + return deleted.length > 0; + } + + // ===================== + // События геозон и состояния + // ===================== + + async insertGeozoneEvent(data: { + organizationId: number; + geozoneId: number; + assetId: number; + event: "enter" | "exit"; + lat?: number | null; + lng?: number | null; + }): Promise { + const [event] = await db.insert(gpsGeozoneEvents).values(data).returning(); + return event; + } + + async listGeozoneEvents( + organizationId: number, + filters: { assetId?: number; geozoneId?: number; from?: Date; to?: Date; limit?: number } + ): Promise> { + const conditions = [eq(gpsGeozoneEvents.organizationId, organizationId)]; + if (filters.assetId) conditions.push(eq(gpsGeozoneEvents.assetId, filters.assetId)); + if (filters.geozoneId) conditions.push(eq(gpsGeozoneEvents.geozoneId, filters.geozoneId)); + if (filters.from) conditions.push(gte(gpsGeozoneEvents.createdAt, filters.from)); + if (filters.to) conditions.push(lte(gpsGeozoneEvents.createdAt, filters.to)); + + const rows = await db + .select({ + event: gpsGeozoneEvents, + assetName: gpsAssets.name, + geozoneName: gpsGeozones.name, + }) + .from(gpsGeozoneEvents) + .leftJoin(gpsAssets, eq(gpsGeozoneEvents.assetId, gpsAssets.id)) + .leftJoin(gpsGeozones, eq(gpsGeozoneEvents.geozoneId, gpsGeozones.id)) + .where(and(...conditions)) + .orderBy(desc(gpsGeozoneEvents.createdAt)) + .limit(filters.limit ?? 200); + + return rows.map((r) => ({ ...r.event, assetName: r.assetName, geozoneName: r.geozoneName })); + } + + /** Состояния «внутри геозоны» для конкретного объекта */ + async getGeozoneStatesForAsset(assetId: number): Promise> { + const rows = await db + .select() + .from(gpsGeozoneStates) + .where(eq(gpsGeozoneStates.assetId, assetId)); + return new Map(rows.map((r) => [r.geozoneId, r.inside])); + } + + async upsertGeozoneState(assetId: number, geozoneId: number, inside: boolean): Promise { + await db + .insert(gpsGeozoneStates) + .values({ assetId, geozoneId, inside, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: [gpsGeozoneStates.assetId, gpsGeozoneStates.geozoneId], + set: { inside, updatedAt: new Date() }, + }); + } + + // ===================== + // Группы + // ===================== + + async listGroups(organizationId: number): Promise> { + const groups = await db + .select() + .from(gpsGroups) + .where(eq(gpsGroups.organizationId, organizationId)) + .orderBy(asc(gpsGroups.name)); + + if (groups.length === 0) return []; + + const links = await db + .select() + .from(gpsGroupAssets) + .where(inArray(gpsGroupAssets.groupId, groups.map((g) => g.id))); + + const assetsByGroup = new Map(); + for (const link of links) { + const list = assetsByGroup.get(link.groupId) ?? []; + list.push(link.assetId); + assetsByGroup.set(link.groupId, list); + } + + return groups.map((g) => ({ ...g, assetIds: assetsByGroup.get(g.id) ?? [] })); + } + + async getGroup(id: number, organizationId: number): Promise { + const [group] = await db + .select() + .from(gpsGroups) + .where(and(eq(gpsGroups.id, id), eq(gpsGroups.organizationId, organizationId))); + return group; + } + + async createGroup(organizationId: number, name: string, assetIds: number[]): Promise { + const [group] = await db.insert(gpsGroups).values({ organizationId, name }).returning(); + await this.setGroupAssets(group.id, assetIds); + return group; + } + + async updateGroup(id: number, organizationId: number, updates: { name?: string; assetIds?: number[] }): Promise { + let group: GpsGroup | undefined; + if (updates.name !== undefined) { + [group] = await db + .update(gpsGroups) + .set({ name: updates.name }) + .where(and(eq(gpsGroups.id, id), eq(gpsGroups.organizationId, organizationId))) + .returning(); + } else { + group = await this.getGroup(id, organizationId); + } + if (!group) return undefined; + if (updates.assetIds !== undefined) { + await this.setGroupAssets(id, updates.assetIds); + } + return group; + } + + async deleteGroup(id: number, organizationId: number): Promise { + const deleted = await db + .delete(gpsGroups) + .where(and(eq(gpsGroups.id, id), eq(gpsGroups.organizationId, organizationId))) + .returning({ id: gpsGroups.id }); + return deleted.length > 0; + } + + /** Полная замена состава группы */ + private async setGroupAssets(groupId: number, assetIds: number[]): Promise { + await db.delete(gpsGroupAssets).where(eq(gpsGroupAssets.groupId, groupId)); + if (assetIds.length > 0) { + await db + .insert(gpsGroupAssets) + .values(assetIds.map((assetId) => ({ groupId, assetId }))) + .onConflictDoNothing(); + } + } + + // ===================== + // Подписчики + // ===================== + + async listSubscribers(assetId: number): Promise { + return db + .select() + .from(gpsAssetSubscribers) + .where(eq(gpsAssetSubscribers.assetId, assetId)); + } + + /** Полная замена списка подписчиков объекта */ + async replaceSubscribers( + assetId: number, + subscribers: Array<{ userId: number; onEnter: boolean; onExit: boolean; onOffline: boolean }> + ): Promise { + await db.delete(gpsAssetSubscribers).where(eq(gpsAssetSubscribers.assetId, assetId)); + if (subscribers.length === 0) return []; + return db + .insert(gpsAssetSubscribers) + .values( + subscribers.map((s) => ({ + assetId, + userId: s.userId, + onEnter: s.onEnter, + onExit: s.onExit, + onOffline: s.onOffline, + })) + ) + .returning(); + } +} + +export const gpsStorage = new GpsStorage(); diff --git a/server/gps/traccar.client.ts b/server/gps/traccar.client.ts new file mode 100644 index 0000000..d4bdd61 --- /dev/null +++ b/server/gps/traccar.client.ts @@ -0,0 +1,95 @@ +/** + * Клиент Traccar REST API. + * Настройка через env: TRACCAR_API_URL, TRACCAR_USER, TRACCAR_PASSWORD. + * Если переменные не заданы — все методы тихо no-op с логом, + * чтобы GPS-модуль работал и без Traccar (например, только PWA-трекинг). + */ + +const TRACCAR_API_URL = process.env.TRACCAR_API_URL; +const TRACCAR_USER = process.env.TRACCAR_USER; +const TRACCAR_PASSWORD = process.env.TRACCAR_PASSWORD; + +function isConfigured(): boolean { + return Boolean(TRACCAR_API_URL && TRACCAR_USER && TRACCAR_PASSWORD); +} + +function authHeaders(): Record { + const basic = Buffer.from(`${TRACCAR_USER}:${TRACCAR_PASSWORD}`).toString("base64"); + return { + Authorization: `Basic ${basic}`, + "Content-Type": "application/json", + }; +} + +interface TraccarDevice { + id: number; + uniqueId: string; + name: string; +} + +/** Найти устройство по uniqueId; возвращает null, если не найдено */ +async function findDevice(uniqueId: string): Promise { + const url = `${TRACCAR_API_URL}/api/devices?uniqueId=${encodeURIComponent(uniqueId)}`; + const res = await fetch(url, { headers: authHeaders() }); + if (!res.ok) { + throw new Error(`Traccar GET /api/devices: HTTP ${res.status}`); + } + const devices = (await res.json()) as TraccarDevice[]; + return devices.find((d) => d.uniqueId === uniqueId) ?? null; +} + +/** + * Создаёт устройство в Traccar, если его ещё нет. + * Возвращает id устройства или null при ошибке/отсутствии конфигурации. + */ +export async function ensureDevice(uniqueId: string, name: string): Promise { + if (!isConfigured()) { + console.log("[GPS] Traccar не настроен (TRACCAR_API_URL/USER/PASSWORD), ensureDevice пропущен"); + return null; + } + + try { + const existing = await findDevice(uniqueId); + if (existing) return existing.id; + + const res = await fetch(`${TRACCAR_API_URL}/api/devices`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ uniqueId, name }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Traccar POST /api/devices: HTTP ${res.status} ${text}`); + } + const device = (await res.json()) as TraccarDevice; + console.log(`[GPS] Traccar: создано устройство '${name}' (${uniqueId}), id=${device.id}`); + return device.id; + } catch (err) { + console.error("[GPS] Traccar ensureDevice error:", err); + return null; + } +} + +/** Удаляет устройство из Traccar по uniqueId (если существует) */ +export async function deleteDevice(uniqueId: string): Promise { + if (!isConfigured()) { + console.log("[GPS] Traccar не настроен, deleteDevice пропущен"); + return; + } + + try { + const existing = await findDevice(uniqueId); + if (!existing) return; + + const res = await fetch(`${TRACCAR_API_URL}/api/devices/${existing.id}`, { + method: "DELETE", + headers: authHeaders(), + }); + if (!res.ok && res.status !== 404) { + throw new Error(`Traccar DELETE /api/devices/${existing.id}: HTTP ${res.status}`); + } + console.log(`[GPS] Traccar: удалено устройство ${uniqueId}`); + } catch (err) { + console.error("[GPS] Traccar deleteDevice error:", err); + } +} diff --git a/server/gps/worker.ts b/server/gps/worker.ts new file mode 100644 index 0000000..b203409 --- /dev/null +++ b/server/gps/worker.ts @@ -0,0 +1,53 @@ +import { withSuperAdmin } from "../db"; +import { gpsStorage } from "./storage"; +import { notifyOffline } from "./geozone.service"; +import { eventBus } from "../routes/shared"; + +/** + * Воркер офлайн-детекта GPS-объектов. + * Раз в минуту ищет активные онлайн-объекты, от которых не было точек + * более 15 минут, переводит их в offline и уведомляет подписчиков (on_offline). + * Обратный переход в online происходит в processPosition при новой точке. + * Работает независимо от Traccar (PWA-трекинг тоже покрывается). + */ + +const TICK_MS = 60_000; +const OFFLINE_THRESHOLD_MS = 15 * 60_000; + +let isRunning = false; + +async function tick(): Promise { + const cutoff = new Date(Date.now() - OFFLINE_THRESHOLD_MS); + const staleAssets = await withSuperAdmin(() => gpsStorage.getStaleOnlineAssets(cutoff)); + + for (const asset of staleAssets) { + try { + await withSuperAdmin(async () => { + await gpsStorage.setAssetOnline(asset.id, false); + await notifyOffline(asset); + }); + + // SSE — чтобы открытые карты сразу «погасили» маркер + eventBus.publishEvent({ + type: "gps_asset_offline", + data: { assetId: asset.id }, + organizationId: asset.organizationId, + }); + + console.log(`[GPS Worker] Объект ${asset.id} ('${asset.name}', org ${asset.organizationId}) переведён в offline`); + } catch (err) { + console.error(`[GPS Worker] Ошибка обработки объекта ${asset.id}:`, err); + } + } +} + +export function startGpsWorker(): void { + if (isRunning) return; + isRunning = true; + + setInterval(() => { + tick().catch((err) => console.error("[GPS Worker] Tick error:", err)); + }, TICK_MS); + + console.log("[GPS Worker] started"); +} diff --git a/server/index.ts b/server/index.ts index 35479bf..532cf49 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,6 +8,7 @@ import { notificationService } from "./services/notification.service"; import { webPushService } from "./services/web-push.service"; import { startMedScheduleWorker } from "./medschedule/worker"; import { startAutomationScheduler } from "./workers/automation-scheduler"; +import { startGpsWorker } from "./gps/worker"; import { storage } from "./storage"; import type { ReminderRecipient } from "@shared/schema"; import { db, withSuperAdmin } from "./db"; // lazy proxy — safe at module load; throws on first use if DATABASE_URL missing @@ -1099,6 +1100,10 @@ async function runStartupDataPatches() { startAutomationScheduler(); log('Automation scheduler started'); + // Start GPS worker (offline detection for tracked assets) + startGpsWorker(); + log('GPS worker started'); + // Clean up duplicate subscriptions then backfill defaults for existing users (async () => { try { diff --git a/server/routes/index.ts b/server/routes/index.ts index 6243aaa..84002ff 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -41,6 +41,7 @@ import organizationSkinsRouter from "./organization-skins.routes"; import { registerSyncRoutes } from "./sync.routes"; import { registerMedScheduleRoutes } from "../medschedule/routes"; import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes"; +import { registerGpsRoutes } from "../gps/routes"; import { registerVpnRoutes } from "../vpn"; import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types"; export { publishNotificationSSE }; @@ -120,6 +121,8 @@ export async function registerRoutes(app: Express): Promise { registerSyncRoutes(app); registerMedScheduleRoutes(app); registerMedScheduleBotRoutes(app); + // GPS-модуль: публичный /api/gps/ingest регистрируется внутри до authenticateToken + registerGpsRoutes(app); registerVpnRoutes(app); const httpServer = createServer(app); diff --git a/shared/schema.ts b/shared/schema.ts index 817aef9..bc16360 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1,5 +1,5 @@ import { sql, relations } from "drizzle-orm"; -import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, unique, foreignKey, uniqueIndex, index, numeric, real, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core"; +import { pgTable, text, varchar, integer, boolean, timestamp, jsonb, serial, bigserial, doublePrecision, unique, foreignKey, uniqueIndex, index, numeric, real, date, uuid, type AnyPgColumn, customType } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; @@ -3967,3 +3967,142 @@ export const medBotBindings = pgTable("med_bot_bindings", { export type MedBotBinding = typeof medBotBindings.$inferSelect; export type InsertMedBotBinding = typeof medBotBindings.$inferInsert; + + +// ===================== +// GPS-мониторинг (объекты, треки, геозоны) +// ===================== + +// Объекты мониторинга: транспорт ('vehicle') или люди ('person') +export const gpsAssets = pgTable("gps_assets", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + type: text("type").$type<"vehicle" | "person">().notNull().default("vehicle"), + userId: integer("user_id").references(() => users.id), + externalId: text("external_id"), // IMEI/идентификатор трекера (уникален в рамках организации) + color: text("color").default("#3b82f6"), + isActive: boolean("is_active").default(true), + isOnline: boolean("is_online").default(false), + lastLat: doublePrecision("last_lat"), + lastLng: doublePrecision("last_lng"), + lastSpeed: doublePrecision("last_speed"), + lastCourse: doublePrecision("last_course"), + lastSeenAt: timestamp("last_seen_at"), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + orgIdx: index("gps_assets_org_idx").on(table.organizationId), + orgExternalIdx: uniqueIndex("gps_assets_org_external_idx").on(table.organizationId, table.externalId), + userIdx: index("gps_assets_user_idx").on(table.userId), +})); + +export type GpsAsset = typeof gpsAssets.$inferSelect; +export type InsertGpsAsset = typeof gpsAssets.$inferInsert; + +// История позиций (треки) +export const gpsPositions = pgTable("gps_positions", { + id: bigserial("id", { mode: "number" }).primaryKey(), + assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + lat: doublePrecision("lat").notNull(), + lng: doublePrecision("lng").notNull(), + speed: doublePrecision("speed"), + course: doublePrecision("course"), + accuracy: doublePrecision("accuracy"), + recordedAt: timestamp("recorded_at").notNull(), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + assetRecordedIdx: index("gps_positions_asset_recorded_idx").on(table.assetId, table.recordedAt), + orgRecordedIdx: index("gps_positions_org_recorded_idx").on(table.organizationId, table.recordedAt), +})); + +export type GpsPosition = typeof gpsPositions.$inferSelect; +export type InsertGpsPosition = typeof gpsPositions.$inferInsert; + +// Геозоны (полигоны; polygon — массив пар [lat, lng]) +export const gpsGeozones = pgTable("gps_geozones", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + polygon: jsonb("polygon").$type>().notNull(), + color: text("color").default("#f59e0b"), + notifyEnter: boolean("notify_enter").default(true), + notifyExit: boolean("notify_exit").default(true), + isActive: boolean("is_active").default(true), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + orgIdx: index("gps_geozones_org_idx").on(table.organizationId), +})); + +export type GpsGeozone = typeof gpsGeozones.$inferSelect; +export type InsertGpsGeozone = typeof gpsGeozones.$inferInsert; + +// События входа/выхода из геозон +export const gpsGeozoneEvents = pgTable("gps_geozone_events", { + id: bigserial("id", { mode: "number" }).primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + geozoneId: integer("geozone_id").notNull().references(() => gpsGeozones.id, { onDelete: "cascade" }), + assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), + event: text("event").$type<"enter" | "exit">().notNull(), + lat: doublePrecision("lat"), + lng: doublePrecision("lng"), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + assetIdx: index("gps_geozone_events_asset_idx").on(table.assetId, table.createdAt), + orgIdx: index("gps_geozone_events_org_idx").on(table.organizationId, table.createdAt), +})); + +export type GpsGeozoneEvent = typeof gpsGeozoneEvents.$inferSelect; +export type InsertGpsGeozoneEvent = typeof gpsGeozoneEvents.$inferInsert; + +// Текущее состояние «объект внутри геозоны» (для детекта входа/выхода) +export const gpsGeozoneStates = pgTable("gps_geozone_states", { + assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), + geozoneId: integer("geozone_id").notNull().references(() => gpsGeozones.id, { onDelete: "cascade" }), + inside: boolean("inside").notNull(), + updatedAt: timestamp("updated_at").defaultNow(), +}, (table) => ({ + pk: uniqueIndex("gps_geozone_states_pk").on(table.assetId, table.geozoneId), +})); + +export type GpsGeozoneState = typeof gpsGeozoneStates.$inferSelect; +export type InsertGpsGeozoneState = typeof gpsGeozoneStates.$inferInsert; + +// Группы объектов (для фильтров на карте) +export const gpsGroups = pgTable("gps_groups", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + orgIdx: index("gps_groups_org_idx").on(table.organizationId), +})); + +export type GpsGroup = typeof gpsGroups.$inferSelect; +export type InsertGpsGroup = typeof gpsGroups.$inferInsert; + +// Связь групп и объектов (многие-ко-многим) +export const gpsGroupAssets = pgTable("gps_group_assets", { + groupId: integer("group_id").notNull().references(() => gpsGroups.id, { onDelete: "cascade" }), + assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), +}, (table) => ({ + pk: uniqueIndex("gps_group_assets_pk").on(table.groupId, table.assetId), +})); + +export type GpsGroupAsset = typeof gpsGroupAssets.$inferSelect; +export type InsertGpsGroupAsset = typeof gpsGroupAssets.$inferInsert; + +// Подписчики на события объекта +export const gpsAssetSubscribers = pgTable("gps_asset_subscribers", { + id: serial("id").primaryKey(), + assetId: integer("asset_id").notNull().references(() => gpsAssets.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + onEnter: boolean("on_enter").default(true), + onExit: boolean("on_exit").default(true), + onOffline: boolean("on_offline").default(false), +}, (table) => ({ + assetUserIdx: uniqueIndex("gps_asset_subscribers_asset_user_idx").on(table.assetId, table.userId), +})); + +export type GpsAssetSubscriber = typeof gpsAssetSubscribers.$inferSelect; +export type InsertGpsAssetSubscriber = typeof gpsAssetSubscribers.$inferInsert; diff --git a/traccar/traccar.xml b/traccar/traccar.xml new file mode 100644 index 0000000..a34ae1c --- /dev/null +++ b/traccar/traccar.xml @@ -0,0 +1,25 @@ + + + + + + + + org.h2.Driver + jdbc:h2:/opt/traccar/data/database + sa + + + + true + 8082 + + + true + http://app:5000/api/gps/ingest + X-Gps-Token: ВСТАВИТЬ_СЮДА_ТОКЕН_GPS_INGEST_TOKEN + +