feat(gps): фокус геозоны на карте, город по умолчанию для всех карт, доступ к GPS по пользователям/ролям (админ)

This commit is contained in:
2026-08-26 10:20:38 +03:00
parent 3e8849019a
commit 8f5789ec28
15 changed files with 718 additions and 122 deletions

View File

@@ -3,6 +3,7 @@ import type { Express, Response } from "express";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { gpsStorage } from "./storage";
import { processPosition, filterTrackPoints } from "./geozone.service";
import { hasGpsAccess } from "./access";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { storage } from "../storage";
@@ -152,8 +153,22 @@ export function registerGpsRoutes(app: Express) {
const router = Router();
router.use(authenticateToken);
/**
* Проверка доступа к GPS-вкладке (по настройкам организации).
* Применяется через router.use НИЖЕ — только к роутам вкладки;
* config и персональный трекинг (my-position/my-tracking) остаются открытыми.
*/
async function requireGpsAccess(req: AuthenticatedRequest, res: Response, next: () => void) {
const allowed = await hasGpsAccess(req.user!);
if (!allowed) {
return res.status(403).json({ success: false, error: "Нет доступа к GPS-модулю" });
}
next();
}
// =====================
// Конфигурация клиента (ключ Яндекс.Карт из external_services)
// Открыт всем — фронт использует allowed для гейтинга вкладки.
// =====================
router.get("/config", async (req: AuthenticatedRequest, res: Response) => {
@@ -166,9 +181,91 @@ export function registerGpsRoutes(app: Express) {
yandexMapsKey = decrypt(service.apiKey);
}
res.json({ success: true, yandexMapsKey });
const settings = await gpsStorage.getSettings(orgId);
const allowed = await hasGpsAccess(req.user!, settings);
const defaultCenter =
settings.defaultLat !== null && settings.defaultLng !== null && settings.defaultZoom !== null
? { lat: settings.defaultLat, lng: settings.defaultLng, zoom: settings.defaultZoom }
: null;
res.json({ success: true, yandexMapsKey, allowed, defaultCenter });
});
// =====================
// PWA-трекинг (личная геолокация пользователя)
// Не про GPS-вкладку — доступ не проверяем.
// =====================
router.post("/my-position", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const orgId = user.organizationId;
const { lat, lng, speed, course, accuracy } = req.body ?? {};
const latN = parseNum(lat);
const lngN = parseNum(lng);
if (latN === null || lngN === null) {
return badRequest(res, "Обязательные поля: lat, lng");
}
// Находим или создаём персональный объект пользователя
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
if (!asset) {
asset = await gpsStorage.createAsset({
organizationId: orgId,
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
type: "person",
userId: user.id,
});
}
if (!asset.isActive) {
return res.status(403).json({ success: false, error: "Отслеживание геолокации выключено" });
}
await processPosition(asset.id, orgId, {
lat: latN,
lng: lngN,
speed: parseNum(speed),
course: parseNum(course),
accuracy: parseNum(accuracy),
});
res.json({ success: true, assetId: asset.id });
});
router.get("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const asset = await gpsStorage.getPersonAssetByUser(user.id, user.organizationId);
res.json({ success: true, enabled: Boolean(asset?.isActive), assetId: asset?.id ?? null });
});
router.put("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
const user = req.user!;
const orgId = user.organizationId;
const enabled = Boolean(req.body?.enabled);
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
if (!asset) {
if (!enabled) {
// Выключать нечего — объекта ещё нет
return res.json({ success: true, enabled: false, assetId: null });
}
asset = await gpsStorage.createAsset({
organizationId: orgId,
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
type: "person",
userId: user.id,
isActive: true,
});
} else if (asset.isActive !== enabled) {
asset = (await gpsStorage.updateAsset(asset.id, orgId, { isActive: enabled }))!;
}
res.json({ success: true, enabled, assetId: asset.id });
});
// Все роуты ниже — только для пользователей с доступом к GPS-вкладке
router.use(requireGpsAccess);
// =====================
// Настройки GPS организации (фильтр джиттера)
// =====================
@@ -182,6 +279,7 @@ export function registerGpsRoutes(app: Express) {
// Обновляем только переданные поля (обратная совместимость с клиентом,
// который шлёт только часть настроек)
const current = await gpsStorage.getSettings(req.user!.organizationId);
const isAdmin = req.user!.appRole === "admin";
const jitterMeters =
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
@@ -200,12 +298,81 @@ export function registerGpsRoutes(app: Express) {
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh, tripStopMinutes });
// Поля центра карты и доступа — только для admin (у остальных молча игнорируются)
let defaultLat = current.defaultLat;
let defaultLng = current.defaultLng;
let defaultZoom = current.defaultZoom;
let allowedUserIds = current.allowedUserIds;
let allowedRoleIds = current.allowedRoleIds;
if (isAdmin) {
// Центр по умолчанию: либо все три поля заданы, либо все null (очистка)
const hasCenterField =
req.body?.defaultLat !== undefined || req.body?.defaultLng !== undefined || req.body?.defaultZoom !== undefined;
if (hasCenterField) {
const lat = req.body.defaultLat === null ? null : Number(req.body.defaultLat);
const lng = req.body.defaultLng === null ? null : Number(req.body.defaultLng);
const zoom = req.body.defaultZoom === null ? null : Number(req.body.defaultZoom);
if (lat === null && lng === null && zoom === null) {
defaultLat = defaultLng = defaultZoom = null;
} else {
if (lat === null || !Number.isFinite(lat) || lat < -90 || lat > 90) {
return badRequest(res, "defaultLat: число от -90 до 90");
}
if (lng === null || !Number.isFinite(lng) || lng < -180 || lng > 180) {
return badRequest(res, "defaultLng: число от -180 до 180");
}
if (zoom === null || !Number.isInteger(zoom) || zoom < 1 || zoom > 19) {
return badRequest(res, "defaultZoom: целое число от 1 до 19");
}
defaultLat = lat;
defaultLng = lng;
defaultZoom = zoom;
}
}
// Списки доступа: массивы целых чисел или null (очистка)
if (req.body?.allowedUserIds !== undefined) {
if (req.body.allowedUserIds === null) {
allowedUserIds = null;
} else if (!isIntArray(req.body.allowedUserIds)) {
return badRequest(res, "allowedUserIds: массив целых чисел или null");
} else {
allowedUserIds = req.body.allowedUserIds;
}
}
if (req.body?.allowedRoleIds !== undefined) {
if (req.body.allowedRoleIds === null) {
allowedRoleIds = null;
} else if (!isIntArray(req.body.allowedRoleIds)) {
return badRequest(res, "allowedRoleIds: массив целых чисел или null");
} else {
allowedRoleIds = req.body.allowedRoleIds;
}
}
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, {
jitterMeters,
maxJumpSpeedKmh,
tripStopMinutes,
defaultLat,
defaultLng,
defaultZoom,
allowedUserIds,
allowedRoleIds,
});
res.json({
success: true,
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
defaultLat: row.defaultLat,
defaultLng: row.defaultLng,
defaultZoom: row.defaultZoom,
allowedUserIds: row.allowedUserIds,
allowedRoleIds: row.allowedRoleIds,
});
});
@@ -510,77 +677,6 @@ export function registerGpsRoutes(app: Express) {
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);
}
@@ -593,3 +689,7 @@ function isValidPolygon(polygon: unknown): polygon is Array<[number, number]> {
)
);
}
function isIntArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every((v) => Number.isInteger(v));
}