595 lines
22 KiB
TypeScript
595 lines
22 KiB
TypeScript
import { Router } from "express";
|
||
import type { Express, Response } from "express";
|
||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||
import { gpsStorage } from "./storage";
|
||
import { processPosition, classifyPoint } 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 });
|
||
});
|
||
|
||
// =====================
|
||
// Настройки 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) => {
|
||
// Обновляем только переданные поля (обратная совместимость с клиентом,
|
||
// который шлёт только jitterMeters)
|
||
const current = await gpsStorage.getSettings(req.user!.organizationId);
|
||
|
||
const jitterMeters =
|
||
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
|
||
const maxJumpSpeedKmh =
|
||
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
|
||
|
||
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 row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh });
|
||
res.json({ success: true, jitterMeters: row.jitterMeters, maxJumpSpeedKmh: row.maxJumpSpeedKmh });
|
||
});
|
||
|
||
// =====================
|
||
// Объекты (assets)
|
||
// =====================
|
||
|
||
router.get("/assets", async (req: AuthenticatedRequest, res: Response) => {
|
||
const assets = await gpsStorage.listAssets(req.user!.organizationId);
|
||
res.json({ success: true, assets });
|
||
});
|
||
|
||
router.post("/assets", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const { name, type, externalId, color, userId } = req.body ?? {};
|
||
|
||
if (!name || typeof name !== "string") {
|
||
return badRequest(res, "Название объекта обязательно");
|
||
}
|
||
if (type !== undefined && !["vehicle", "person"].includes(type)) {
|
||
return badRequest(res, "Тип объекта: vehicle или person");
|
||
}
|
||
|
||
const asset = await gpsStorage.createAsset({
|
||
organizationId: orgId,
|
||
name: name.trim(),
|
||
type: type === "person" ? "person" : "vehicle",
|
||
externalId: externalId ? String(externalId).trim() : null,
|
||
color: color || undefined,
|
||
userId: userId ?? null,
|
||
});
|
||
|
||
// Регистрируем устройство в Traccar (no-op, если Traccar не настроен)
|
||
if (asset.externalId) {
|
||
await ensureDevice(asset.externalId, asset.name);
|
||
}
|
||
|
||
res.status(201).json({ success: true, asset });
|
||
});
|
||
|
||
router.patch("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
const { name, type, externalId, color, isActive, userId } = req.body ?? {};
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = String(name).trim();
|
||
if (type !== undefined) {
|
||
if (!["vehicle", "person"].includes(type)) {
|
||
return badRequest(res, "Тип объекта: vehicle или person");
|
||
}
|
||
updates.type = type;
|
||
}
|
||
if (externalId !== undefined) updates.externalId = externalId ? String(externalId).trim() : null;
|
||
if (color !== undefined) updates.color = color;
|
||
if (isActive !== undefined) updates.isActive = Boolean(isActive);
|
||
if (userId !== undefined) updates.userId = userId ?? null;
|
||
|
||
const asset = await gpsStorage.updateAsset(id, orgId, updates);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
// Если появился/сменился external_id — регистрируем устройство в Traccar
|
||
if (updates.externalId && asset.externalId) {
|
||
await ensureDevice(asset.externalId, asset.name);
|
||
}
|
||
|
||
res.json({ success: true, asset });
|
||
});
|
||
|
||
router.delete("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
if (asset.externalId) {
|
||
await deleteDevice(asset.externalId);
|
||
}
|
||
|
||
await gpsStorage.deleteAsset(id, orgId);
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// =====================
|
||
// Позиции и треки
|
||
// =====================
|
||
|
||
// Текущие last_* всех активных объектов организации
|
||
router.get("/assets/positions", async (req: AuthenticatedRequest, res: Response) => {
|
||
const assets = await gpsStorage.listCurrentPositions(req.user!.organizationId);
|
||
res.json({
|
||
success: true,
|
||
positions: assets.map((a) => ({
|
||
assetId: a.id,
|
||
name: a.name,
|
||
type: a.type,
|
||
color: a.color,
|
||
isOnline: a.isOnline,
|
||
lat: a.lastLat,
|
||
lng: a.lastLng,
|
||
speed: a.lastSpeed,
|
||
course: a.lastCourse,
|
||
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
|
||
lastSeenAt: a.lastSeenAt,
|
||
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
|
||
})),
|
||
});
|
||
});
|
||
|
||
// Трек объекта за период (точки 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-скачки) — те же правила,
|
||
// что при приёме, сравнение с предыдущей принятой точкой
|
||
const settings = await gpsStorage.getSettings(orgId);
|
||
const filtered: typeof all = [];
|
||
let filteredCount = 0;
|
||
let prevAccepted: { lat: number; lng: number; recordedAt: Date } | null = null;
|
||
for (const point of all) {
|
||
const current = { lat: point.lat, lng: point.lng, recordedAt: point.recordedAt };
|
||
if (classifyPoint(prevAccepted, current, settings) === "accept") {
|
||
filtered.push(point);
|
||
prevAccepted = current;
|
||
} else {
|
||
filteredCount++;
|
||
}
|
||
}
|
||
|
||
let points = filtered;
|
||
let sampled = false;
|
||
if (points.length > MAX_TRACK_POINTS) {
|
||
const step = Math.ceil(points.length / MAX_TRACK_POINTS);
|
||
points = points.filter((_, idx) => idx % step === 0);
|
||
// Всегда включаем последнюю точку трека
|
||
if (points[points.length - 1] !== filtered[filtered.length - 1]) {
|
||
points.push(filtered[filtered.length - 1]);
|
||
}
|
||
sampled = true;
|
||
}
|
||
|
||
res.json({ success: true, points, total, filteredCount, 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<string, unknown> = {};
|
||
if (name !== undefined) updates.name = String(name).trim();
|
||
if (polygon !== undefined) {
|
||
if (!isValidPolygon(polygon)) {
|
||
return badRequest(res, "Полигон: массив минимум из 3 пар [lat, lng]");
|
||
}
|
||
updates.polygon = polygon;
|
||
}
|
||
if (color !== undefined) updates.color = color;
|
||
if (notifyEnter !== undefined) updates.notifyEnter = Boolean(notifyEnter);
|
||
if (notifyExit !== undefined) updates.notifyExit = Boolean(notifyExit);
|
||
if (isActive !== undefined) updates.isActive = Boolean(isActive);
|
||
|
||
const geozone = await gpsStorage.updateGeozone(id, orgId, updates);
|
||
if (!geozone) return res.status(404).json({ success: false, error: "Геозона не найдена" });
|
||
|
||
res.json({ success: true, geozone });
|
||
});
|
||
|
||
router.delete("/geozones/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const deleted = await gpsStorage.deleteGeozone(Number(req.params.id), req.user!.organizationId);
|
||
if (!deleted) return res.status(404).json({ success: false, error: "Геозона не найдена" });
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// События входа/выхода из геозон
|
||
router.get("/geozone-events", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const events = await gpsStorage.listGeozoneEvents(orgId, {
|
||
assetId: req.query.assetId ? Number(req.query.assetId) : undefined,
|
||
geozoneId: req.query.geozoneId ? Number(req.query.geozoneId) : undefined,
|
||
from: parseDateParam(req.query.from) ?? undefined,
|
||
to: parseDateParam(req.query.to) ?? undefined,
|
||
limit: req.query.limit ? Math.min(Number(req.query.limit), 1000) : undefined,
|
||
});
|
||
res.json({ success: true, events });
|
||
});
|
||
|
||
// =====================
|
||
// Группы
|
||
// =====================
|
||
|
||
router.get("/groups", async (req: AuthenticatedRequest, res: Response) => {
|
||
const groups = await gpsStorage.listGroups(req.user!.organizationId);
|
||
res.json({ success: true, groups });
|
||
});
|
||
|
||
router.post("/groups", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const { name, assetIds } = req.body ?? {};
|
||
|
||
if (!name || typeof name !== "string") {
|
||
return badRequest(res, "Название группы обязательно");
|
||
}
|
||
|
||
const group = await gpsStorage.createGroup(
|
||
orgId,
|
||
name.trim(),
|
||
Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : []
|
||
);
|
||
res.status(201).json({ success: true, group });
|
||
});
|
||
|
||
router.patch("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
const { name, assetIds } = req.body ?? {};
|
||
|
||
const group = await gpsStorage.updateGroup(id, orgId, {
|
||
name: name !== undefined ? String(name).trim() : undefined,
|
||
assetIds: Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : undefined,
|
||
});
|
||
if (!group) return res.status(404).json({ success: false, error: "Группа не найдена" });
|
||
|
||
res.json({ success: true, group });
|
||
});
|
||
|
||
router.delete("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const deleted = await gpsStorage.deleteGroup(Number(req.params.id), req.user!.organizationId);
|
||
if (!deleted) return res.status(404).json({ success: false, error: "Группа не найдена" });
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// =====================
|
||
// Подписчики объекта
|
||
// =====================
|
||
|
||
router.get("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
const subscribers = await gpsStorage.listSubscribers(id);
|
||
res.json({ success: true, subscribers });
|
||
});
|
||
|
||
// Полная замена списка подписчиков: [{ userId, onEnter, onExit, onOffline }]
|
||
router.put("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
const list = Array.isArray(req.body) ? req.body : req.body?.subscribers;
|
||
if (!Array.isArray(list)) {
|
||
return badRequest(res, "Ожидается массив подписчиков [{ userId, onEnter, onExit, onOffline }]");
|
||
}
|
||
|
||
const subscribers = list.map((s: any) => ({
|
||
userId: Number(s.userId),
|
||
onEnter: Boolean(s.onEnter ?? true),
|
||
onExit: Boolean(s.onExit ?? true),
|
||
onOffline: Boolean(s.onOffline ?? false),
|
||
})).filter((s: { userId: number }) => Number.isFinite(s.userId));
|
||
|
||
const result = await gpsStorage.replaceSubscribers(id, subscribers);
|
||
res.json({ success: true, subscribers: result });
|
||
});
|
||
|
||
// =====================
|
||
// 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]))
|
||
)
|
||
);
|
||
}
|