233 lines
7.5 KiB
TypeScript
233 lines
7.5 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|