-1

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,
  };
}

New contributor
Pavan Raghuwanshi is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.
1
  • 1
    The code you posted is not wrong. The “is this correct/optimized” part of the question is basically design/opinion and more CodeReview than SO. Commented yesterday

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.