diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts
index a5ea316..b0b0c72 100644
--- a/client/src/pages/gps/types.ts
+++ b/client/src/pages/gps/types.ts
@@ -117,4 +117,8 @@ export interface GpsSubscriber {
export interface GpsConfig {
success: boolean;
yandexMapsKey: string | null;
+ /** false — у пользователя нет доступа к GPS-модулю (гейтинг UI) */
+ allowed: boolean;
+ /** Центр карты по умолчанию (задаётся админом в настройках GPS) */
+ defaultCenter: { lat: number; lng: number; zoom: number } | null;
}
diff --git a/migrations/0073_gps_access_default_center.sql b/migrations/0073_gps_access_default_center.sql
new file mode 100644
index 0000000..425b6d9
--- /dev/null
+++ b/migrations/0073_gps_access_default_center.sql
@@ -0,0 +1,10 @@
+-- GPS: центр карты по умолчанию и списки доступа к GPS-вкладке.
+-- allowed_user_ids / allowed_role_ids: NULL или [] = доступ у всех;
+-- иначе доступ по user.id ∈ allowed_user_ids ИЛИ членство в роли из allowed_role_ids
+-- (admin'ам приложения доступ всегда).
+ALTER TABLE gps_settings
+ ADD COLUMN IF NOT EXISTS default_lat DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS default_lng DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS default_zoom INTEGER,
+ ADD COLUMN IF NOT EXISTS allowed_user_ids JSONB,
+ ADD COLUMN IF NOT EXISTS allowed_role_ids JSONB;
diff --git a/server/gps/access.ts b/server/gps/access.ts
new file mode 100644
index 0000000..1d13ac3
--- /dev/null
+++ b/server/gps/access.ts
@@ -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>
+): Promise {
+ 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;
+}
diff --git a/server/gps/routes.ts b/server/gps/routes.ts
index 9120249..2612bea 100644
--- a/server/gps/routes.ts
+++ b/server/gps/routes.ts
@@ -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));
+}
diff --git a/server/gps/storage.ts b/server/gps/storage.ts
index bfffd2c..e725464 100644
--- a/server/gps/storage.ts
+++ b/server/gps/storage.ts
@@ -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 {
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 {
+ 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();
diff --git a/shared/schema.ts b/shared/schema.ts
index 234a45b..745b1ed 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -4115,6 +4115,11 @@ export const gpsSettings = pgTable("gps_settings", {
jitterMeters: integer("jitter_meters").notNull().default(25),
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
+ defaultLat: doublePrecision("default_lat"),
+ defaultLng: doublePrecision("default_lng"),
+ defaultZoom: integer("default_zoom"),
+ allowedUserIds: jsonb("allowed_user_ids").$type(),
+ allowedRoleIds: jsonb("allowed_role_ids").$type(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});