I am receiving position data from a GPS device and fetching geofence latitude-longitude coordinates from my database. I also check which geofence is linked to which child and get the parents’ FCM tokens. If the geofence point and the position point match within the given radius, I send a notification to the parents. Is this the correct way to do it, and is it optimized for handling around 2000 GPS data points or geofences? I receive GPS data every 5 seconds.
const NOTIFY_THROTTLE_MS = 60 * 1000;
const CACHE_REFRESH_INTERVAL_MS = 60 * 1000;
const POSITION_STALE_MS = 30 * 1000;
const NOTIFY_CONCURRENCY = 8;
const childrenById = new Map();
const childrenByBus = new Map();
const lastGeoStatus = new Map();
const tripModeByDevice = new Map();
let lastCacheUpdate = new Date(0);
const notifyQueue = [];
let runningNotify = 0;
function enqueueNotify(job) {
notifyQueue.push(job);
processNotifyQueue();
}
function processNotifyQueue() {
while (runningNotify < NOTIFY_CONCURRENCY && notifyQueue.length) {
const job = notifyQueue.shift();
runningNotify++;
job().finally(() => {
runningNotify--;
// yield
setImmediate(processNotifyQueue);
});
}
}
// >>>>>>>>>>>>>>>>>>>>>>>>>>>correction start
export function buildGeoMeta(geo) {
if (!geo) return null;
// Case 1: When structure is like geo.area.center = [lat, lng]
if (geo.area && Array.isArray(geo.area.center) && typeof geo.area.radius === "number") {
const [lat, lng] = geo.area.center;
const r = geo.area.radius; // meters
// Approximate deltas for bounding box
const dLat = r / 111320;
const dLng = r / (111320 * Math.cos((lat * Math.PI) / 180) || 1);
return {
type: "circle",
center: { lat, lng },
radius: r,
bbox: {
minLat: lat - dLat,
maxLat: lat + dLat,
minLng: lng - dLng,
maxLng: lng + dLng,
},
};
}
// Case 2: Polygon-type geofence
if (geo.area && Array.isArray(geo.area.coordinates)) {
const coords = geo.area.coordinates.map((p) =>
Array.isArray(p)
? { lat: p[1], lng: p[0] }
: { lat: p.lat, lng: p.lng }
);
let minLat = Infinity,
maxLat = -Infinity,
minLng = Infinity,
maxLng = -Infinity;
coords.forEach((c) => {
minLat = Math.min(minLat, c.lat);
maxLat = Math.max(maxLat, c.lat);
minLng = Math.min(minLng, c.lng);
maxLng = Math.max(maxLng, c.lng);
});
return {
type: "polygon",
coords,
bbox: { minLat, maxLat, minLng, maxLng },
};
}
// Case 3: Old structure (backward compatibility)
if (geo.center && typeof geo.radius === "number") {
const lat = geo.center.lat;
const lng = geo.center.lng;
const r = geo.radius;
const dLat = r / 111320;
const dLng = r / (111320 * Math.cos((lat * Math.PI) / 180) || 1);
return {
type: "circle",
center: { lat, lng },
radius: r,
bbox: {
minLat: lat - dLat,
maxLat: lat + dLat,
minLng: lng - dLng,
maxLng: lng + dLng,
},
};
}
// Fallback
return null;
}
export async function refreshChildrenCacheOnce() {
try {
if (!lastCacheUpdate) {
// console.log("[Geofence] No previous cache update timestamp found, performing full reload...");
return await fullChildrenCacheReload();
}
const changed = await Child.find({
updatedAt: { $gt: lastCacheUpdate },
$or: [
{ pickupGeoId: { $exists: true, $ne: null } },
{ dropGeoId: { $exists: true, $ne: null } },
],
})
.populate({
path: "pickupGeoId",
select: "geofenceName area",
})
.populate({
path: "dropGeoId",
select: "geofenceName area",
})
.populate({
path: "parentId",
select: "fcmToken",
}).lean();
// console.log(`[Geofence] Found ${changed.length} updated children since last cache refresh`);
for (const c of changed) {
const id = String(c._id);
const imei = c.routeObjId?.deviceObjId?.uniqueId;
if (!imei) continue; // Skip if no device linked
const childData = {
childId: id,
childName: c.childName,
imei,
parentFcmToken: c.parentId?.fcmToken || [],
pickupGeoRaw: c.pickupGeoId,
dropGeoRaw: c.dropGeoId,
pickupGeo: buildGeoMeta(c.pickupGeoId),
dropGeo: buildGeoMeta(c.dropGeoId),
pickupTime:c.pickupTime,
dropTime:c.dropTime
};
childrenById.set(id, childData);
const busArr = childrenByBus.get(imei) || [];
const existsIndex = busArr.findIndex((x) => x.childId === id);
if (existsIndex >= 0) busArr[existsIndex] = childData;
else busArr.push(childData);
childrenByBus.set(imei, busArr);
if (!lastGeoStatus.has(id)) {
lastGeoStatus.set(id, {
pickup: "outside",
drop: "outside",
lastNotified: { pickup: 0, drop: 0 },
lastSeenTs: 0,
});
}
}
lastCacheUpdate = new Date();
// console.log(`[Geofence] Cache refresh applied successfully: ${changed.length} children updated`);
} catch (err) {
console.error("[Geofence] refreshChildrenCacheOnce error:", err);
}
}
export async function fullChildrenCacheReload() {
try {
const activeBranches = await Branch.find({
"notificationsEnabled.geofence": true,
}).select('_id');
const all = await Child.find({
branchId: { $in: activeBranches.map(b => b._id) },
$or: [
{ pickupGeoId: { $exists: true, $ne: null } },
{ dropGeoId: { $exists: true, $ne: null } },
],
})
.populate({
path: "branchId",
match: { "notificationsEnabled.geofence": true },
})
.populate({
path: "routeObjId",
populate: {
path: "deviceObjId",
select: "uniqueId",
},
})
.populate({
path: "pickupGeoId",
select: "geofenceName area",
})
.populate({
path: "dropGeoId",
select: "geofenceName area",
})
.populate({
path: "parentId",
select: "fcmToken",
}).lean();
console.log(`[Geofence] Total fetched children: ${all.length}`);
childrenById.clear();
childrenByBus.clear();
lastGeoStatus.clear();
for (const c of all) {
const id = String(c._id);
const imei = c.routeObjId?.deviceObjId?.uniqueId;
// Skip if no linked device or invalid IMEI
if (!imei) continue;
const childData = {
childId: id,
childName: c.childName,
imei,
parentFcmToken: c.parentId?.fcmToken || [],
pickupGeoRaw: c.pickupGeoId,
dropGeoRaw: c.dropGeoId,
pickupGeo: buildGeoMeta(c.pickupGeoId),
dropGeo: buildGeoMeta(c.dropGeoId),
pickupTime:c.pickupTime,
dropTime:c.dropTime,
};
// Store child data by ID
childrenById.set(id, childData);
// Group by IMEI (device)
const arr = childrenByBus.get(imei) || [];
arr.push(childData);
childrenByBus.set(imei, arr);
// Initialize last known status
lastGeoStatus.set(id, {
pickup: "outside",
drop: "outside",
lastNotified: { pickup: 0, drop: 0 },
lastSeenTs: 0,
});
}
lastCacheUpdate = new Date();
// console.log(`[Geofence] Full cache reloaded: ${childrenById.size} active children`);
} catch (err) {
console.error("[Geofence] fullChildrenCacheReload error:", err);
}
}
function isPointInCircleBBox(lat, lng, geo) {
const b = geo.bbox;
return lat >= b.minLat && lat <= b.maxLat && lng >= b.minLng && lng <= b.maxLng;
}
// >>>>>>>>>>>>>>>>>>>>>>>>>>>correction end
function shouldThrottleNotification(childId, type) {
// console.log("aaaaaaaaaaaaaaaaa",childId,type)
const st = lastGeoStatus.get(childId);
if (!st) return false;
const last = st.lastNotified[type] || 0;
return Date.now() - last < NOTIFY_THROTTLE_MS;
}
function markNotified(childId, type) {
const st = lastGeoStatus.get(childId) || {};
st.lastNotified = st.lastNotified || {};
st.lastNotified[type] = Date.now();
lastGeoStatus.set(childId, st);
}
async function sendNotificationSafe(fcmTokens, title, body) {
try {
if (!fcmTokens) return;
// Ensure we have an array
const tokensArray = Array.isArray(fcmTokens) ? fcmTokens : [fcmTokens];
// console.log(tokensArray,"sssssssssssssssssssss")
// Send notification to each token
for (const token of tokensArray) {
if (token) {
// console.log("Sending to:", token, title, body);
await sendFirebaseNotification({
token,
title,
body,
data: { customKey: 'customValue' }, // optional
});
}
}
} catch (err) {
console.error("[Geofence] sendFirebaseNotification failed:", err?.message || err);
}
}
// >>>>>>>>>>>>>> main working function start
export function processDevicePosition(imei, device) {
if (!device || typeof device.latitude !== "number" || typeof device.longitude !== "number") return;
const children = childrenByBus.get(String(imei));
if (!children || children.length === 0) return;
const now = new Date();
for (const child of children) {
const childId = child.childId;
const prev = lastGeoStatus.get(childId) || {
pickup: "outside",
drop: "outside",
lastNotified: { pickup: 0, drop: 0 },
lastSeenTs: 0,
};
const next = { ...prev };
const parseTime = (timeStr) => {
if (!timeStr) return null;
const [time, modifier] = timeStr.split(" ");
if (!time || !modifier) return null;
let [hours, minutes] = time.split(":").map(Number);
if (modifier.toLowerCase() === "pm" && hours < 12) hours += 12;
if (modifier.toLowerCase() === "am" && hours === 12) hours = 0;
const date = new Date();
date.setHours(hours, minutes || 0, 0, 0);
return date;
};
const pickupTime = typeof child.pickupTime === "string" ? parseTime(child.pickupTime) : new Date(child.pickupTime);
const dropTime = typeof child.dropTime === "string" ? parseTime(child.dropTime) : new Date(child.dropTime);
// Determine which geofence (pickup or drop) is more relevant now
let nearestType = null;
if (pickupTime && dropTime) {
const diffPickup = Math.abs(now - pickupTime);
const diffDrop = Math.abs(now - dropTime);
nearestType = diffPickup <= diffDrop ? "pickup" : "drop";
} else if (pickupTime) nearestType = "pickup";
else if (dropTime) nearestType = "drop";
if (child.pickupGeo && child.pickupGeo.type === "circle" && nearestType === "pickup") {
const geo = child.pickupGeo;
let isInside = false;
if (isPointInCircleBBox(device.latitude, device.longitude, geo)) {
const dist = getDistance(
{ latitude: device.latitude, longitude: device.longitude },
{ latitude: geo.center.lat, longitude: geo.center.lng }
);
isInside = dist <= geo.radius;
}
if (isInside && prev.pickup === "outside") {
next.pickup = "inside";
if (!shouldThrottleNotification(childId, "pickup")) {
enqueueNotify(async () => {
const title = "Bus Arrived at Pickup Point";
const body = `Your child ${child.childName} bus has reached ${child.pickupGeoRaw?.geofenceName || "pickup point"}.`;
await sendNotificationSafe(child.parentFcmToken, title, body);
markNotified(childId, "pickup");
});
}
} else if (!isInside && prev.pickup === "inside") {
next.pickup = "outside";
}
}
if (child.dropGeo && child.dropGeo.type === "circle" && nearestType === "drop") {
const geo = child.dropGeo;
let isInside = false;
if (isPointInCircleBBox(device.latitude, device.longitude, geo)) {
const dist = getDistance(
{ latitude: device.latitude, longitude: device.longitude },
{ latitude: geo.center.lat, longitude: geo.center.lng }
);
isInside = dist <= geo.radius;
}
if (isInside && prev.drop === "outside") {
next.drop = "inside";
if (!shouldThrottleNotification(childId, "drop")) {
enqueueNotify(async () => {
const title = "Bus Arrived at Drop Point";
const body = `Your child's bus has reached ${child.dropGeoRaw?.geofenceName || "drop point"}.`;
await sendNotificationSafe(child.parentFcmToken, title, body);
markNotified(childId, "drop");
});
}
} else if (!isInside && prev.drop === "inside") {
next.drop = "outside";
}
}
lastGeoStatus.set(childId, { ...next, lastSeenTs: Date.now() });
}
}
export async function startChildGeofenceChecker(opts = {}) {
eventBus.on("traccarMergedData", async (data) => {
if (!data || !Array.isArray(data)) return;
for (const device of data) {
if (!device?.uniqueId) continue;
processDevicePosition(device.uniqueId, device);
}
});
await fullChildrenCacheReload().catch(console.error);
// setInterval(refreshChildrenCacheOnce, CACHE_REFRESH_INTERVAL_MS);
setInterval(fullChildrenCacheReload, 5 * 60 * 1000);
return {
reload: fullChildrenCacheReload,
};
}