Files
iistwin/server/gps/geozone.service.ts

233 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { gpsStorage } from "./storage";
import { storage } from "../storage";
import { webPushService } from "../services/web-push.service";
import { eventBus, publishNotificationSSE } from "../routes/shared";
import type { GpsAsset, GpsGeozone } from "@shared/schema";
/**
* Сервис обработки GPS-позиций и геозон.
* Детект входа/выхода — сравнение point-in-polygon с сохранённым состоянием
* gps_geozone_states; при смене состояния создаётся событие и рассылаются
* уведомления подписчикам объекта.
*/
/**
* Ray casting: находится ли точка внутри полигона.
* polygon — массив пар [lat, lng]. Без внешних зависимостей.
*/
export function pointInPolygon(lat: number, lng: number, polygon: Array<[number, number]>): boolean {
if (!Array.isArray(polygon) || polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [latI, lngI] = polygon[i];
const [latJ, lngJ] = polygon[j];
const intersects =
lngI > lng !== lngJ > lng &&
lat < ((latJ - latI) * (lng - lngI)) / (lngJ - lngI) + latI;
if (intersects) inside = !inside;
}
return inside;
}
export interface ProcessPositionInput {
lat: number;
lng: number;
speed?: number | null;
course?: number | null;
accuracy?: number | null;
recordedAt?: Date;
}
/**
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
* проверяет геозоны и рассылает SSE/уведомления.
*/
export async function processPosition(
assetId: number,
organizationId: number,
input: ProcessPositionInput
): Promise<void> {
const recordedAt = input.recordedAt ?? new Date();
// 1. Сохраняем точку в историю
await gpsStorage.insertPosition({
assetId,
organizationId,
lat: input.lat,
lng: input.lng,
speed: input.speed ?? null,
course: input.course ?? null,
accuracy: input.accuracy ?? null,
recordedAt,
});
// 2. Обновляем последнюю позицию объекта (и переводим в online)
await gpsStorage.updateAssetLastPosition(assetId, {
lat: input.lat,
lng: input.lng,
speed: input.speed,
course: input.course,
recordedAt,
});
// 3. SSE-событие о перемещении — broadcast по организации,
// чтобы карта у всех открытых клиентов обновилась в реальном времени
eventBus.publishEvent({
type: "gps_asset_moved",
data: {
assetId,
lat: input.lat,
lng: input.lng,
speed: input.speed ?? null,
course: input.course ?? null,
recordedAt: recordedAt.toISOString(),
},
organizationId,
});
// 4. Проверка геозон
await checkGeozones(assetId, organizationId, input, recordedAt);
}
async function checkGeozones(
assetId: number,
organizationId: number,
input: ProcessPositionInput,
recordedAt: Date
): Promise<void> {
const geozones = await gpsStorage.listActiveGeozones(organizationId);
if (geozones.length === 0) return;
const states = await gpsStorage.getGeozoneStatesForAsset(assetId);
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
for (const geozone of geozones) {
try {
const nowInside = pointInPolygon(input.lat, input.lng, geozone.polygon);
const wasInside = states.get(geozone.id) ?? false;
if (nowInside === wasInside) continue;
// Состояние изменилось — фиксируем и создаём событие
await gpsStorage.upsertGeozoneState(assetId, geozone.id, nowInside);
const eventType = nowInside ? ("enter" as const) : ("exit" as const);
await gpsStorage.insertGeozoneEvent({
organizationId,
geozoneId: geozone.id,
assetId,
event: eventType,
lat: input.lat,
lng: input.lng,
});
const notifyEnabled = nowInside ? geozone.notifyEnter : geozone.notifyExit;
if (notifyEnabled) {
await notifySubscribers(asset, geozone, eventType, input.lat, input.lng, recordedAt);
}
} catch (err) {
console.error(`[GPS] Geozone check failed (asset ${assetId}, geozone ${geozone.id}):`, err);
}
}
}
/**
* Рассылка уведомлений подписчикам объекта о событии геозоны.
* In-app (user_notifications) + Web Push + SSE для мгновенного обновления бейджа.
*/
async function notifySubscribers(
asset: GpsAsset,
geozone: GpsGeozone,
eventType: "enter" | "exit",
lat: number,
lng: number,
recordedAt: Date
): Promise<void> {
const subscribers = await gpsStorage.listSubscribers(asset.id);
const eventText = eventType === "enter" ? "вошёл в зону" : "вышел из зоны";
const title = `GPS: ${asset.name}`;
const message = `${asset.name} ${eventText} «${geozone.name}»`;
for (const sub of subscribers) {
const enabled = eventType === "enter" ? sub.onEnter : sub.onExit;
if (!enabled) continue;
try {
const notification = await storage.createUserNotification({
userId: sub.userId,
organizationId: asset.organizationId,
type: "gps.geozone",
title,
message,
isRead: false,
});
publishNotificationSSE(sub.userId, asset.organizationId, {
type: "gps.geozone",
notificationId: notification.id,
});
await webPushService.sendToUser(sub.userId, asset.organizationId, {
title,
body: message,
icon: "/icon-192.png",
url: "/gps",
tag: `gps-geozone-${asset.id}-${geozone.id}`,
data: {
type: "gps",
event: eventType,
assetId: asset.id.toString(),
geozoneId: geozone.id.toString(),
lat: lat.toString(),
lng: lng.toString(),
recordedAt: recordedAt.toISOString(),
},
});
} catch (err) {
console.error(`[GPS] Notify subscriber ${sub.userId} failed:`, err);
}
}
}
/**
* Уведомление подписчиков о переходе объекта в офлайн (вызывается воркером).
*/
export async function notifyOffline(asset: GpsAsset): Promise<void> {
const subscribers = await gpsStorage.listSubscribers(asset.id);
const title = `GPS: ${asset.name}`;
const message = `${asset.name} не выходит на связь более 15 минут`;
for (const sub of subscribers) {
if (!sub.onOffline) continue;
try {
const notification = await storage.createUserNotification({
userId: sub.userId,
organizationId: asset.organizationId,
type: "gps.offline",
title,
message,
isRead: false,
});
publishNotificationSSE(sub.userId, asset.organizationId, {
type: "gps.offline",
notificationId: notification.id,
});
await webPushService.sendToUser(sub.userId, asset.organizationId, {
title,
body: message,
icon: "/icon-192.png",
url: "/gps",
tag: `gps-offline-${asset.id}`,
data: { type: "gps", event: "offline", assetId: asset.id.toString() },
});
} catch (err) {
console.error(`[GPS] Notify offline subscriber ${sub.userId} failed:`, err);
}
}
}