diff --git a/client/src/components/MobileBottomNav.tsx b/client/src/components/MobileBottomNav.tsx index 509bbcd..697ab06 100644 --- a/client/src/components/MobileBottomNav.tsx +++ b/client/src/components/MobileBottomNav.tsx @@ -125,6 +125,13 @@ export function MobileBottomNav() { const inboxCount = useInboxCount(); + // Прячем GPS, если у пользователя нет доступа к модулю + const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({ + queryKey: ['/api/gps/config'], + staleTime: 5 * 60_000, + }); + const gpsAllowed = gpsConfig?.allowed !== false; + useEvents({ onConvMessageCreated: useCallback(() => { queryClient.setQueryData<{ success: boolean; unreadCount: number }>(['/api/messenger/unread-count'], (old) => { @@ -381,12 +388,14 @@ export function MobileBottomNav() { )} - - - - GPS-мониторинг - - + {gpsAllowed && ( + + + + GPS-мониторинг + + + )} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index e648057..32c90ac 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1271,6 +1271,13 @@ function GanttNavItem({ collapsed }: { collapsed: boolean }) { function GpsNavItem({ collapsed }: { collapsed: boolean }) { const [location] = useLocation(); const isActive = location === '/gps' || location.startsWith('/gps/'); + // Прячем пункт, если у пользователя нет доступа к GPS-модулю + const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({ + queryKey: ['/api/gps/config'], + staleTime: 5 * 60_000, + }); + + if (gpsConfig && gpsConfig.allowed === false) return null; if (collapsed) { return ( diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 952741f..2ccc0af 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -42,6 +42,8 @@ export interface GpsMapPolygon { points: Array<[number, number]>; color?: string | null; fillOpacity?: number; + /** Толщина обводки (по умолчанию 2; выбранная геозона — толще) */ + strokeWidth?: number; } export interface GpsMapPolyline { @@ -110,7 +112,7 @@ function readProviderPref(): ProviderPref { export function GpsMap({ center, - zoom = 10, + zoom, bounds, markers, polygons, @@ -122,6 +124,13 @@ export function GpsMap({ const { toast } = useToast(); const apiKey = configData?.yandexMapsKey ?? null; + // Центр по умолчанию из настроек организации (город по умолчанию); + // используется, только если таб не передал явный center/bounds + const defaultCenter = configData?.defaultCenter ?? null; + const effectiveCenter: [number, number] | undefined = + center ?? (defaultCenter ? [defaultCenter.lat, defaultCenter.lng] : undefined); + const effectiveZoom = zoom ?? defaultCenter?.zoom ?? 10; + // Ручной выбор подложки: 'auto' | 'yandex' | 'osm', хранится в localStorage const [pref, setPrefState] = useState(readProviderPref); const prefRef = useRef(pref); @@ -224,8 +233,8 @@ export function GpsMap({ {provider === 'yandex' ? ( ) : ( [p.id, p.points, p.color, p.fillOpacity]), + polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]), polylines.map((l) => [l.id, l.points, l.color, l.dashed]), ]); useEffect(() => { @@ -197,7 +197,7 @@ export function OsmMap({ const color = p.color || DEFAULT_COLOR; L.polygon(p.points, { color, - weight: 2, + weight: p.strokeWidth ?? 2, fillColor: color, fillOpacity: p.fillOpacity ?? 0.15, }).addTo(layer); diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index e03fc2e..49d6af2 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -190,7 +190,7 @@ export function Yandex21Map({ // колбэки кликов не входят в ключ — они читаются из ref в момент клика const dataKey = JSON.stringify([ markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]), - polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]), + polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]), polylines.map((l) => [l.id, l.points, l.color, l.dashed]), ]); useEffect(() => { @@ -208,7 +208,7 @@ export function Yandex21Map({ fillColor: color, fillOpacity: p.fillOpacity ?? 0.15, strokeColor: color, - strokeWidth: 2, + strokeWidth: p.strokeWidth ?? 2, }) ); } diff --git a/client/src/pages/gps/GeozonesTab.tsx b/client/src/pages/gps/GeozonesTab.tsx index b6f590c..23d69c9 100644 --- a/client/src/pages/gps/GeozonesTab.tsx +++ b/client/src/pages/gps/GeozonesTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { apiRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap'; import { invalidateGps, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps'; +import { cn } from '@/lib/utils'; import type { GpsGeozone } from './types'; function formatDateTime(value: string): string { @@ -137,10 +138,41 @@ export function GpsGeozonesTab() { setEditing(g); }; + // Фокус геозоны: клик по зоне в списке — карта подгоняет bounds под полигон, + // зона выделяется (ярче/толще), остальные приглушены; повторный клик — снятие + const [focusedZoneId, setFocusedZoneId] = useState(null); + + const focusedBounds = useMemo((): [[number, number], [number, number]] | undefined => { + if (focusedZoneId === null) return undefined; + const zone = geozones.find((g) => g.id === focusedZoneId); + if (!zone || zone.polygon.length === 0) return undefined; + const lats = zone.polygon.map(([lat]) => lat); + const lngs = zone.polygon.map(([, lng]) => lng); + const pad = 0.002; + return [ + [Math.min(...lats) - pad, Math.min(...lngs) - pad], + [Math.max(...lats) + pad, Math.max(...lngs) + pad], + ]; + }, [focusedZoneId, geozones]); + + const toggleZoneFocus = (id: number) => { + setFocusedZoneId((prev) => (prev === id ? null : id)); + }; + // Данные для провайдер-агностичной карты: существующие зоны, строящийся // полигон (замкнутая заливка при 3+ точках, иначе пунктирная линия), вершины const mapPolygons: GpsMapPolygon[] = [ - ...geozones.map((g): GpsMapPolygon => ({ id: g.id, points: g.polygon, color: g.color })), + ...geozones.map((g): GpsMapPolygon => { + const focused = focusedZoneId === g.id; + const dimmed = focusedZoneId !== null && !focused; + return { + id: g.id, + points: g.polygon, + color: g.color, + fillOpacity: focused ? 0.3 : dimmed ? 0.06 : 0.15, + strokeWidth: focused ? 3 : dimmed ? 1 : 2, + }; + }), ...(draft.length >= 3 ? [{ id: 'draft', points: draft, color: '#f97316', fillOpacity: 0.25 }] : []), @@ -189,7 +221,13 @@ export function GpsGeozonesTab() { {geozones.map((g) => (
toggleZoneFocus(g.id)} + title="Показать на карте" >
{g.name} -
@@ -113,9 +129,7 @@ export function GpsSettingsTab() { 0 = выключен. Точки, требующие перемещения быстрее N км/ч от предыдущей, отбрасываются — отсекает LBS-прыжки на вышки при потере GPS. Действует на новые точки.

- {!jumpValid && ( -

Целое число от 0 до 5000

- )} + {!jumpValid &&

Целое число от 0 до 5000

}
@@ -131,18 +145,16 @@ export function GpsSettingsTab() { 0 = не делить на поездки. Перерыв в точках дольше N минут при стоянке разбивает маршрут дня на отдельные поездки на вкладке «Маршруты».

- {!tripValid && ( -

Целое число от 0 до 120

- )} + {!tripValid &&

Целое число от 0 до 120

}
); } + +/** Card «Карта»: город по умолчанию (центр всех карт модуля) */ +function DefaultCityCard({ + settings, + isLoading, + isAdminUser, + onSaved, +}: { + settings: GpsSettings | undefined; + isLoading: boolean; + isAdminUser: boolean; + onSaved: () => void; +}) { + const { toast } = useToast(); + const [query, setQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const debounceRef = useRef | null>(null); + + // Подсказки адресов через серверный геокодинг (debounce 300 мс) + const handleQueryChange = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!value || value.length < 3) { + setSuggestions([]); + setOpen(false); + return; + } + debounceRef.current = setTimeout(async () => { + try { + const res = await apiRequest('POST', '/api/geo/suggest', { query: value, count: 5 }); + const data = await res.json(); + const list: GeoSuggestion[] = data.suggestions || []; + setSuggestions(list); + setOpen(list.length > 0); + } catch { + setSuggestions([]); + setOpen(false); + } + }, 300); + }; + + const saveMutation = useMutation({ + mutationFn: async (center: { lat: number; lng: number; zoom: number } | null) => { + // Все три поля вместе: либо координаты, либо null (очистка) + const res = await apiRequest('PUT', '/api/gps/settings', { + defaultLat: center?.lat ?? null, + defaultLng: center?.lng ?? null, + defaultZoom: center?.zoom ?? null, + }); + return res.json(); + }, + onSuccess: (_d, center) => { + toast({ title: center ? 'Город по умолчанию сохранён' : 'Город по умолчанию очищен' }); + setQuery(''); + setSuggestions([]); + setOpen(false); + onSaved(); + }, + onError: (err: any) => { + toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить', variant: 'destructive' }); + }, + }); + + const selectSuggestion = (s: GeoSuggestion) => { + const lat = Number(s.data.geo_lat); + const lng = Number(s.data.geo_lon); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + toast({ title: 'У адреса нет координат', variant: 'destructive' }); + return; + } + setQuery(s.value); + setOpen(false); + saveMutation.mutate({ lat, lng, zoom: 11 }); + }; + + const hasCenter = settings?.defaultLat != null && settings?.defaultLng != null; + + return ( + + + + + Карта + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {hasCenter && ( +
+ + Текущий центр: {settings!.defaultLat!.toFixed(5)}, {settings!.defaultLng!.toFixed(5)} (zoom{' '} + {settings!.defaultZoom}) + + {isAdminUser && ( + + )} +
+ )} +
+ + handleQueryChange(e.target.value)} + disabled={!isAdminUser || saveMutation.isPending} + /> + {!isAdminUser && ( +

Меняет администратор

+ )} + {isAdminUser && open && suggestions.length > 0 && ( +
+ {suggestions.map((s, idx) => ( + + ))} +
+ )} + {isAdminUser && ( +

+ Выберите адрес из подсказок — карты модуля будут открываться на нём (zoom 11) +

+ )} +
+ + )} +
+
+ ); +} + +/** Card «Доступ к GPS-модулю» (только admin): пользователи и роли */ +function AccessCard({ settings, onSaved }: { settings: GpsSettings | undefined; onSaved: () => void }) { + const { toast } = useToast(); + const [selectedUsers, setSelectedUsers] = useState | null>(null); + const [selectedRoles, setSelectedRoles] = useState | null>(null); + + // Инициализация из настроек (один раз при загрузке) + useEffect(() => { + if (settings && selectedUsers === null) { + setSelectedUsers(new Set(settings.allowedUserIds ?? [])); + setSelectedRoles(new Set(settings.allowedRoleIds ?? [])); + } + }, [settings, selectedUsers]); + + const { data: usersData } = useQuery<{ success: boolean; users: Array<{ id: number; firstName?: string; lastName?: string; email: string; fullName?: string }> }>({ + queryKey: ['/api/users'], + }); + const { data: rolesData } = useQuery<{ success: boolean; roles: Array<{ id: number; name: string }> }>({ + queryKey: ['/api/roles'], + }); + const users = usersData?.users || []; + const roles = rolesData?.roles || []; + + const toggle = (set: Set, id: number): Set => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + const userIds = [...(selectedUsers ?? [])]; + const roleIds = [...(selectedRoles ?? [])]; + // Семантика бэкенда: оба списка пустые = доступ у всех → шлём null/null + const res = await apiRequest('PUT', '/api/gps/settings', { + allowedUserIds: userIds.length > 0 ? userIds : null, + allowedRoleIds: roleIds.length > 0 ? roleIds : null, + }); + return res.json(); + }, + onSuccess: () => { + toast({ title: 'Доступ сохранён' }); + onSaved(); + }, + onError: (err: any) => { + toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить доступ', variant: 'destructive' }); + }, + }); + + const userLabel = (u: { firstName?: string; lastName?: string; email: string; fullName?: string }) => + u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email; + + if (selectedUsers === null || selectedRoles === null) { + return ( + + + + + + ); + } + + return ( + + + + + Доступ к GPS-модулю + + + +

+ Пусто = доступ у всех. Если выбраны пользователи или роли — модуль виден только им + (администраторы видят всегда). +

+ +
+
+ +
+ {users.length === 0 ? ( +

Нет пользователей

+ ) : ( + users.map((u) => ( + + )) + )} +
+
+
+ +
+ {roles.length === 0 ? ( +

Нет ролей

+ ) : ( + roles.map((r) => ( + + )) + )} +
+
+
+ + +
+
+ ); +} diff --git a/client/src/pages/gps/index.tsx b/client/src/pages/gps/index.tsx index 9d2542f..89e5269 100644 --- a/client/src/pages/gps/index.tsx +++ b/client/src/pages/gps/index.tsx @@ -1,7 +1,9 @@ import { useLocation } from 'wouter'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings, ShieldAlert, Loader2 } from 'lucide-react'; import Layout from '@/components/Layout'; +import { useGpsConfig } from '@/hooks/useGps'; import { GpsMapTab } from './MapTab'; import { GpsAssetsTab } from './AssetsTab'; import { GpsGeozonesTab } from './GeozonesTab'; @@ -29,8 +31,39 @@ export default function GpsPage() { const match = location.match(/\/gps\/(.*)/); const activeTab = TABS.some((t) => t.value === match?.[1]) ? match![1] : 'map'; + // Гейтинг доступа: config открыт всем, allowed=false — заглушка вместо модуля + const { data: configData, isLoading: configLoading } = useGpsConfig(); + const onTabChange = (value: string) => setLocation(value === 'map' ? '/gps' : `/gps/${value}`); + if (configLoading) { + return ( + +
+ +
+
+ ); + } + + if (configData && configData.allowed === false) { + return ( + +
+ + + +

Нет доступа к GPS-модулю

+

+ Доступ выдаёт администратор в настройках модуля. +

+
+
+
+
+ ); + } + return (
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(), });