feat(gps): фокус геозоны на карте, город по умолчанию для всех карт, доступ к GPS по пользователям/ролям (админ)
This commit is contained in:
33
server/gps/access.ts
Normal file
33
server/gps/access.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { gpsStorage } from "./storage";
|
||||
|
||||
/**
|
||||
* Контроль доступа к GPS-вкладке.
|
||||
*
|
||||
* Правило:
|
||||
* - admin приложения (users.app_role = 'admin') — доступ всегда;
|
||||
* - если allowed_user_ids и allowed_role_ids оба пустые/NULL — доступ у всех;
|
||||
* - иначе — если user.id ∈ allowedUserIds ИЛИ пользователь состоит
|
||||
* в организационной роли из allowedRoleIds (таблицы roles/role_members).
|
||||
*/
|
||||
export async function hasGpsAccess(
|
||||
user: { id: number; organizationId: number; appRole?: string },
|
||||
settings?: Awaited<ReturnType<typeof gpsStorage.getSettings>>
|
||||
): Promise<boolean> {
|
||||
if (user.appRole === "admin") return true;
|
||||
|
||||
const s = settings ?? (await gpsStorage.getSettings(user.organizationId));
|
||||
const userIds = s.allowedUserIds ?? [];
|
||||
const roleIds = s.allowedRoleIds ?? [];
|
||||
|
||||
// Оба списка пустые — модуль открыт всем
|
||||
if (userIds.length === 0 && roleIds.length === 0) return true;
|
||||
|
||||
if (userIds.includes(user.id)) return true;
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
const userRoleIds = await gpsStorage.getUserRoleIds(user.id, user.organizationId);
|
||||
if (userRoleIds.some((id) => roleIds.includes(id))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
gpsGroupAssets,
|
||||
gpsAssetSubscribers,
|
||||
gpsSettings,
|
||||
roles,
|
||||
roleMembers,
|
||||
type GpsAsset,
|
||||
type GpsPosition,
|
||||
type GpsGeozone,
|
||||
@@ -462,7 +464,16 @@ export class GpsStorage {
|
||||
// =====================
|
||||
|
||||
/** Настройки GPS организации; если строки нет — дефолты */
|
||||
async getSettings(organizationId: number): Promise<{ jitterMeters: number; maxJumpSpeedKmh: number; tripStopMinutes: number }> {
|
||||
async getSettings(organizationId: number): Promise<{
|
||||
jitterMeters: number;
|
||||
maxJumpSpeedKmh: number;
|
||||
tripStopMinutes: number;
|
||||
defaultLat: number | null;
|
||||
defaultLng: number | null;
|
||||
defaultZoom: number | null;
|
||||
allowedUserIds: number[] | null;
|
||||
allowedRoleIds: number[] | null;
|
||||
}> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(gpsSettings)
|
||||
@@ -471,12 +482,26 @@ export class GpsStorage {
|
||||
jitterMeters: row?.jitterMeters ?? 25,
|
||||
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
|
||||
tripStopMinutes: row?.tripStopMinutes ?? 10,
|
||||
defaultLat: row?.defaultLat ?? null,
|
||||
defaultLng: row?.defaultLng ?? null,
|
||||
defaultZoom: row?.defaultZoom ?? null,
|
||||
allowedUserIds: row?.allowedUserIds ?? null,
|
||||
allowedRoleIds: row?.allowedRoleIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertSettings(
|
||||
organizationId: number,
|
||||
settings: { jitterMeters: number; maxJumpSpeedKmh: number; tripStopMinutes: number }
|
||||
settings: {
|
||||
jitterMeters: number;
|
||||
maxJumpSpeedKmh: number;
|
||||
tripStopMinutes: number;
|
||||
defaultLat: number | null;
|
||||
defaultLng: number | null;
|
||||
defaultZoom: number | null;
|
||||
allowedUserIds: number[] | null;
|
||||
allowedRoleIds: number[] | null;
|
||||
}
|
||||
): Promise<GpsSettings> {
|
||||
const [row] = await db
|
||||
.insert(gpsSettings)
|
||||
@@ -488,6 +513,16 @@ export class GpsStorage {
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Организационные роли пользователя (через role_members) в рамках организации */
|
||||
async getUserRoleIds(userId: number, organizationId: number): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: roleMembers.roleId })
|
||||
.from(roleMembers)
|
||||
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
|
||||
.where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId)));
|
||||
return rows.map((r) => r.roleId);
|
||||
}
|
||||
}
|
||||
|
||||
export const gpsStorage = new GpsStorage();
|
||||
|
||||
Reference in New Issue
Block a user