diff --git a/addon/components/map/toolbar/geofence-events-panel.js b/addon/components/map/toolbar/geofence-events-panel.js
new file mode 100644
index 000000000..dec34bf0b
--- /dev/null
+++ b/addon/components/map/toolbar/geofence-events-panel.js
@@ -0,0 +1,165 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { later } from '@ember/runloop';
+
+/**
+ * GeofenceEventsPanel
+ *
+ * A live map toolbar panel that displays a real-time stream of geofence
+ * events (entered, exited, dwelled) as they occur. Events are received
+ * via the WebSocket channel and displayed in a scrollable feed with
+ * colour-coded badges.
+ *
+ * The panel also provides a link to the full geofence event log.
+ */
+export default class GeofenceEventsPanelComponent extends Component {
+ @service store;
+ @service fetch;
+ @service socket;
+
+ /**
+ * Live event feed — most recent events at the top.
+ * Capped at 50 entries to avoid unbounded memory growth.
+ *
+ * @type {Array}
+ */
+ @tracked events = [];
+
+ /**
+ * Whether the panel is currently loading historical events.
+ *
+ * @type {boolean}
+ */
+ @tracked isLoading = false;
+
+ /**
+ * Maximum number of events to keep in the live feed.
+ */
+ MAX_EVENTS = 50;
+
+ constructor() {
+ super(...arguments);
+ this.loadRecentEvents();
+ this.subscribeToGeofenceEvents();
+ }
+
+ /**
+ * Load the most recent geofence events from the API to populate the
+ * feed on initial render.
+ */
+ @action
+ async loadRecentEvents() {
+ this.isLoading = true;
+ try {
+ const response = await this.fetch.get('geofences/events', { per_page: 20 });
+ if (response && response.data) {
+ this.events = response.data.map(this.normalizeEvent);
+ }
+ } catch (error) {
+ // Silently fail — the panel will populate as live events arrive
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ /**
+ * Subscribe to the geofence.* WebSocket events broadcast by the server.
+ * Incoming events are prepended to the live feed.
+ */
+ subscribeToGeofenceEvents() {
+ if (!this.socket) {
+ return;
+ }
+
+ const channelId = this.args.channelId;
+ if (!channelId) {
+ return;
+ }
+
+ // Listen for all three geofence event types
+ ['geofence.entered', 'geofence.exited', 'geofence.dwelled'].forEach((eventType) => {
+ this.socket.listen(channelId, eventType, (data) => {
+ this.onGeofenceEvent(data);
+ });
+ });
+ }
+
+ /**
+ * Handle an incoming geofence WebSocket event.
+ *
+ * @param {Object} data - The event payload from the server
+ */
+ @action
+ onGeofenceEvent(data) {
+ const event = this.normalizeEvent(data);
+
+ // Prepend to the feed and cap at MAX_EVENTS
+ this.events = [event, ...this.events].slice(0, this.MAX_EVENTS);
+
+ // Flash the new event row briefly to draw attention
+ later(() => {
+ event.isNew = false;
+ }, 3000);
+ }
+
+ /**
+ * Normalise a raw event payload into a display-friendly object.
+ *
+ * @param {Object} raw
+ * @returns {Object}
+ */
+ normalizeEvent(raw) {
+ return {
+ id: raw.id ?? raw.uuid ?? Math.random().toString(36),
+ eventType: raw.event_type,
+ occurredAt: raw.occurred_at,
+ driverName: raw.driver?.name ?? 'Unknown Driver',
+ geofenceName: raw.geofence?.name ?? 'Unknown Geofence',
+ geofenceType: raw.geofence?.type ?? 'zone',
+ dwellMinutes: raw.dwell_duration_minutes ?? null,
+ isNew: true,
+ };
+ }
+
+ /**
+ * Returns a Tailwind CSS badge colour class for the given event type.
+ *
+ * @param {string} eventType
+ * @returns {string}
+ */
+ @action
+ badgeColorFor(eventType) {
+ const colors = {
+ 'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
+ 'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
+ 'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
+ };
+ return colors[eventType] ?? 'bg-gray-100 text-gray-800';
+ }
+
+ /**
+ * Returns a human-readable label for the given event type.
+ *
+ * @param {string} eventType
+ * @returns {string}
+ */
+ @action
+ labelFor(eventType) {
+ const labels = {
+ 'geofence.entered': 'Entered',
+ 'geofence.exited': 'Exited',
+ 'geofence.dwelled': 'Dwelled',
+ };
+ return labels[eventType] ?? eventType;
+ }
+
+ /**
+ * Clear the live feed.
+ */
+ @action
+ clearFeed() {
+ this.events = [];
+ }
+}
diff --git a/addon/components/service-area/form.hbs b/addon/components/service-area/form.hbs
index d46abe21d..ae40369a9 100644
--- a/addon/components/service-area/form.hbs
+++ b/addon/components/service-area/form.hbs
@@ -46,4 +46,63 @@
+
+
+
+
+ Configure when this service area fires active geofence events. Entry and exit events are dispatched to
+ webhooks, WebSocket channels, and the notification system whenever a driver crosses the service area boundary.
+
+
+
+ {{! Entry trigger toggle }}
+
+
+
+
+
+
Trigger on Entry
+
+ Fire a geofence.entered event when a driver enters this service area.
+
+
+
+
+ {{! Exit trigger toggle }}
+
+
+
+
+
+
Trigger on Exit
+
+ Fire a geofence.exited event when a driver leaves this service area.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addon/components/zone/form.hbs b/addon/components/zone/form.hbs
index fdc1e7524..cb81a9b4c 100644
--- a/addon/components/zone/form.hbs
+++ b/addon/components/zone/form.hbs
@@ -30,4 +30,63 @@
-
\ No newline at end of file
+
+
+
+
+ Configure when this zone fires active geofence events. Entry and exit events are dispatched to webhooks,
+ WebSocket channels, and the notification system whenever a driver crosses the zone boundary.
+
+
+
+ {{! Entry trigger toggle }}
+
+
+
+
+
+
Trigger on Entry
+
+ Fire a geofence.entered event when a driver enters this zone.
+
+
+
+
+ {{! Exit trigger toggle }}
+
+
+
+
+
+
Trigger on Exit
+
+ Fire a geofence.exited event when a driver leaves this zone.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addon/services/geofence-event-bus.js b/addon/services/geofence-event-bus.js
new file mode 100644
index 000000000..820e87d64
--- /dev/null
+++ b/addon/services/geofence-event-bus.js
@@ -0,0 +1,206 @@
+import Service from '@ember/service';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { debug } from '@ember/debug';
+
+/**
+ * GeofenceEventBus
+ *
+ * A singleton service that subscribes to the company-level geofence WebSocket
+ * channel and maintains a live feed of geofence events (entered, exited, dwelled).
+ *
+ * Other components (e.g. the live map, the geofence events panel) inject this
+ * service and observe `this.events` to react to incoming events.
+ *
+ * Usage:
+ * @service geofenceEventBus;
+ *
+ * // In a template: {{#each this.geofenceEventBus.events as |event|}} ...
+ * // In JS: this.geofenceEventBus.on('geofence.entered', this.handleEntry);
+ */
+export default class GeofenceEventBusService extends Service {
+ @service socket;
+ @service currentUser;
+ @service universe;
+
+ /**
+ * Live event feed — most recent events at the front.
+ * Capped at MAX_EVENTS to prevent unbounded memory growth.
+ *
+ * @type {Array}
+ */
+ @tracked events = [];
+
+ /**
+ * Whether the service has successfully subscribed to the socket channel.
+ *
+ * @type {boolean}
+ */
+ @tracked isSubscribed = false;
+
+ /**
+ * Maximum number of events to retain in the live feed.
+ */
+ MAX_EVENTS = 100;
+
+ /**
+ * The geofence event types to listen for.
+ */
+ GEOFENCE_EVENTS = ['geofence.entered', 'geofence.exited', 'geofence.dwelled'];
+
+ /**
+ * Internal map of event type → array of handler functions.
+ * Supports the simple pub/sub API.
+ *
+ * @type {Map}
+ */
+ #handlers = new Map();
+
+ /**
+ * The active socket channel subscription.
+ */
+ #channel = null;
+
+ /**
+ * Subscribe to the company geofence channel.
+ * Should be called once after the user session is established.
+ *
+ * @param {string} companyUuid - The company UUID to subscribe to
+ */
+ @action
+ async subscribe(companyUuid) {
+ if (this.isSubscribed || !companyUuid) {
+ return;
+ }
+
+ try {
+ const socketInstance = this.socket.instance();
+ const channelId = `company.${companyUuid}`;
+
+ this.#channel = socketInstance.subscribe(channelId);
+ await this.#channel.listener('subscribe').once();
+
+ this.isSubscribed = true;
+ debug(`[GeofenceEventBus] Subscribed to channel: ${channelId}`);
+
+ // Start consuming events from the channel
+ (async () => {
+ for await (const output of this.#channel) {
+ const { event, data } = output;
+
+ if (this.GEOFENCE_EVENTS.includes(event)) {
+ this.#handleIncomingEvent(event, data);
+ }
+ }
+ })();
+ } catch (error) {
+ debug(`[GeofenceEventBus] Failed to subscribe: ${error.message}`);
+ }
+ }
+
+ /**
+ * Unsubscribe from the geofence channel and clear state.
+ * Called on user logout or session end.
+ */
+ @action
+ unsubscribe() {
+ if (this.#channel) {
+ this.#channel.close();
+ this.#channel = null;
+ }
+ this.isSubscribed = false;
+ this.events = [];
+ this.#handlers.clear();
+ debug('[GeofenceEventBus] Unsubscribed.');
+ }
+
+ /**
+ * Register a handler function for a specific geofence event type.
+ *
+ * @param {string} eventType - 'geofence.entered' | 'geofence.exited' | 'geofence.dwelled'
+ * @param {Function} handler - Called with the normalised event object
+ */
+ on(eventType, handler) {
+ if (!this.#handlers.has(eventType)) {
+ this.#handlers.set(eventType, []);
+ }
+ this.#handlers.get(eventType).push(handler);
+ }
+
+ /**
+ * Unregister a previously registered handler.
+ *
+ * @param {string} eventType
+ * @param {Function} handler
+ */
+ off(eventType, handler) {
+ if (!this.#handlers.has(eventType)) {
+ return;
+ }
+ const handlers = this.#handlers.get(eventType).filter((h) => h !== handler);
+ this.#handlers.set(eventType, handlers);
+ }
+
+ /**
+ * Clear the live event feed.
+ */
+ @action
+ clearFeed() {
+ this.events = [];
+ }
+
+ /**
+ * Process an incoming geofence event from the WebSocket channel.
+ *
+ * @param {string} eventType
+ * @param {Object} data
+ */
+ #handleIncomingEvent(eventType, data) {
+ const normalised = this.#normaliseEvent(eventType, data);
+
+ // Prepend and cap
+ this.events = [normalised, ...this.events].slice(0, this.MAX_EVENTS);
+
+ debug(`[GeofenceEventBus] ${eventType} — driver: ${normalised.driverName}, geofence: ${normalised.geofenceName}`);
+
+ // Notify registered handlers
+ const handlers = this.#handlers.get(eventType) ?? [];
+ handlers.forEach((handler) => {
+ try {
+ handler(normalised);
+ } catch (e) {
+ debug(`[GeofenceEventBus] Handler error for ${eventType}: ${e.message}`);
+ }
+ });
+
+ // Also emit on the universe bus so any component can react
+ this.universe.trigger(`fleet-ops.geofence.${eventType.replace('geofence.', '')}`, normalised);
+ }
+
+ /**
+ * Normalise a raw WebSocket payload into a consistent display object.
+ *
+ * @param {string} eventType
+ * @param {Object} raw
+ * @returns {Object}
+ */
+ #normaliseEvent(eventType, raw) {
+ return {
+ id: raw.id ?? raw.uuid ?? `${Date.now()}-${Math.random()}`,
+ eventType,
+ occurredAt: raw.occurred_at ?? new Date().toISOString(),
+ driverName: raw.driver?.name ?? 'Unknown Driver',
+ driverUuid: raw.driver?.uuid ?? null,
+ vehiclePlate: raw.vehicle?.plate ?? null,
+ geofenceName: raw.geofence?.name ?? 'Unknown Geofence',
+ geofenceUuid: raw.geofence?.uuid ?? null,
+ geofenceType: raw.geofence?.type ?? 'zone',
+ orderPublicId: raw.order?.id ?? null,
+ latitude: raw.location?.latitude ?? null,
+ longitude: raw.location?.longitude ?? null,
+ dwellMinutes: raw.dwell_duration_minutes ?? null,
+ isNew: true,
+ };
+ }
+}
diff --git a/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php b/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php
new file mode 100644
index 000000000..c0ee80320
--- /dev/null
+++ b/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php
@@ -0,0 +1,48 @@
+boolean('trigger_on_entry')->default(true)->after('stroke_color');
+
+ // Whether to fire a GeofenceExited event when a driver exits this zone
+ $table->boolean('trigger_on_exit')->default(true)->after('trigger_on_entry');
+
+ // Minutes a driver must remain inside before a GeofenceDwelled event fires.
+ // NULL disables dwell tracking for this zone.
+ $table->unsignedSmallInteger('dwell_threshold_minutes')->nullable()->after('trigger_on_exit');
+
+ // Optional speed limit (km/h) enforced within this zone.
+ // NULL means no speed limit is configured.
+ $table->unsignedSmallInteger('speed_limit_kmh')->nullable()->after('dwell_threshold_minutes');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('zones', function (Blueprint $table) {
+ $table->dropColumn([
+ 'trigger_on_entry',
+ 'trigger_on_exit',
+ 'dwell_threshold_minutes',
+ 'speed_limit_kmh',
+ ]);
+ });
+ }
+};
diff --git a/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php b/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php
new file mode 100644
index 000000000..9fdcac480
--- /dev/null
+++ b/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php
@@ -0,0 +1,38 @@
+boolean('trigger_on_entry')->default(true)->after('stroke_color');
+ $table->boolean('trigger_on_exit')->default(true)->after('trigger_on_entry');
+ $table->unsignedSmallInteger('dwell_threshold_minutes')->nullable()->after('trigger_on_exit');
+ $table->unsignedSmallInteger('speed_limit_kmh')->nullable()->after('dwell_threshold_minutes');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('service_areas', function (Blueprint $table) {
+ $table->dropColumn([
+ 'trigger_on_entry',
+ 'trigger_on_exit',
+ 'dwell_threshold_minutes',
+ 'speed_limit_kmh',
+ ]);
+ });
+ }
+};
diff --git a/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php b/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php
new file mode 100644
index 000000000..61b963403
--- /dev/null
+++ b/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php
@@ -0,0 +1,62 @@
+id();
+ $table->uuid('driver_uuid')->index();
+ $table->uuid('geofence_uuid')->index();
+
+ // Discriminator: 'zone' or 'service_area'
+ $table->string('geofence_type', 50)->default('zone');
+
+ // Whether the driver is currently inside this geofence
+ $table->boolean('is_inside')->default(false)->index();
+
+ // Timestamp of when the driver most recently entered this geofence
+ $table->timestamp('entered_at')->nullable();
+
+ // Timestamp of when the driver most recently exited this geofence
+ $table->timestamp('exited_at')->nullable();
+
+ // Queue job ID of the pending CheckGeofenceDwell job.
+ // Stored so it can be cancelled if the driver exits before the dwell threshold.
+ $table->string('dwell_job_id')->nullable();
+
+ $table->timestamps();
+
+ // Composite unique key: one state record per driver-geofence pair
+ $table->unique(['driver_uuid', 'geofence_uuid'], 'driver_geofence_unique');
+
+ // Cascade delete when the driver is deleted
+ $table->foreign('driver_uuid')
+ ->references('uuid')
+ ->on('drivers')
+ ->onDelete('cascade');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('driver_geofence_states');
+ }
+};
diff --git a/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php b/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php
new file mode 100644
index 000000000..09ccc10aa
--- /dev/null
+++ b/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php
@@ -0,0 +1,66 @@
+id();
+ $table->uuid('uuid')->unique()->index();
+ $table->uuid('company_uuid')->index();
+ $table->uuid('driver_uuid')->index();
+ $table->uuid('vehicle_uuid')->nullable()->index();
+ $table->uuid('order_uuid')->nullable()->index();
+ $table->uuid('geofence_uuid')->index();
+
+ // Discriminator: 'zone' or 'service_area'
+ $table->string('geofence_type', 50)->default('zone');
+
+ // Snapshot of the geofence name at the time of the event
+ $table->string('geofence_name')->nullable();
+
+ // The type of geofence event
+ $table->enum('event_type', ['entered', 'exited', 'dwelled'])->index();
+
+ // Driver location at the time of the event
+ $table->decimal('latitude', 10, 7)->nullable();
+ $table->decimal('longitude', 10, 7)->nullable();
+
+ // Driver speed at the time of the event (km/h)
+ $table->decimal('speed_kmh', 8, 2)->nullable();
+
+ // Populated for 'exited' and 'dwelled' events
+ $table->unsignedInteger('dwell_duration_minutes')->nullable();
+
+ // The precise time the event occurred (not the DB insert time)
+ $table->timestamp('occurred_at')->index();
+
+ $table->timestamps();
+
+ // Composite indexes for common query patterns
+ $table->index(['company_uuid', 'occurred_at'], 'gel_company_occurred_idx');
+ $table->index(['geofence_uuid', 'occurred_at'], 'gel_geofence_occurred_idx');
+ $table->index(['driver_uuid', 'occurred_at'], 'gel_driver_occurred_idx');
+ $table->index(['order_uuid', 'event_type'], 'gel_order_event_idx');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('geofence_events_log');
+ }
+};
diff --git a/server/src/Events/GeofenceDwelled.php b/server/src/Events/GeofenceDwelled.php
new file mode 100644
index 000000000..d491bf843
--- /dev/null
+++ b/server/src/Events/GeofenceDwelled.php
@@ -0,0 +1,140 @@
+driver = $driver;
+ $this->geofence = $geofence;
+ $this->geofenceType = $geofenceType;
+ $this->enteredAt = $enteredAt;
+ $this->dwellDurationMinutes = (int) $enteredAt->diffInMinutes(now());
+ $this->timestamp = now();
+ }
+
+ /**
+ * Returns the company UUID for this event.
+ *
+ * @return string
+ */
+ public function getCompanyUuid(): string
+ {
+ return $this->driver->company_uuid;
+ }
+
+ /**
+ * Returns the standardised webhook payload for this event.
+ *
+ * @return array
+ */
+ public function broadcastWith(): array
+ {
+ $driver = $this->driver;
+ $geofence = $this->geofence;
+
+ $payload = [
+ 'event_type' => 'geofence.dwelled',
+ 'occurred_at' => $this->timestamp->toIso8601String(),
+ 'entered_at' => $this->enteredAt->toIso8601String(),
+ 'dwell_duration_minutes' => $this->dwellDurationMinutes,
+ 'driver' => [
+ 'id' => $driver->public_id,
+ 'uuid' => $driver->uuid,
+ 'name' => $driver->name,
+ 'phone' => $driver->phone,
+ ],
+ 'vehicle' => $driver->vehicle ? [
+ 'id' => $driver->vehicle->public_id,
+ 'uuid' => $driver->vehicle->uuid,
+ 'name' => $driver->vehicle->display_name ?? null,
+ 'plate' => $driver->vehicle->plate_number ?? null,
+ ] : null,
+ 'geofence' => [
+ 'id' => $geofence->public_id,
+ 'uuid' => $geofence->uuid,
+ 'name' => $geofence->name,
+ 'type' => $this->geofenceType,
+ ],
+ ];
+
+ $order = $driver->getCurrentOrder();
+ if ($order) {
+ $payload['order'] = [
+ 'id' => $order->public_id,
+ 'uuid' => $order->uuid,
+ 'status' => $order->status,
+ ];
+ }
+
+ return $payload;
+ }
+}
diff --git a/server/src/Events/GeofenceEntered.php b/server/src/Events/GeofenceEntered.php
new file mode 100644
index 000000000..fb8def0e0
--- /dev/null
+++ b/server/src/Events/GeofenceEntered.php
@@ -0,0 +1,137 @@
+driver = $driver;
+ $this->geofence = $geofence;
+ $this->geofenceType = $geofenceType;
+ $this->location = $location;
+ $this->timestamp = now();
+ }
+
+ /**
+ * Returns the company UUID for this event.
+ * Used by the webhook infrastructure to scope delivery.
+ *
+ * @return string
+ */
+ public function getCompanyUuid(): string
+ {
+ return $this->driver->company_uuid;
+ }
+
+ /**
+ * Returns the standardised webhook payload for this event.
+ *
+ * @return array
+ */
+ public function broadcastWith(): array
+ {
+ $driver = $this->driver;
+ $geofence = $this->geofence;
+
+ $payload = [
+ 'event_type' => 'geofence.entered',
+ 'occurred_at' => $this->timestamp->toIso8601String(),
+ 'driver' => [
+ 'id' => $driver->public_id,
+ 'uuid' => $driver->uuid,
+ 'name' => $driver->name,
+ 'phone' => $driver->phone,
+ ],
+ 'vehicle' => $driver->vehicle ? [
+ 'id' => $driver->vehicle->public_id,
+ 'uuid' => $driver->vehicle->uuid,
+ 'name' => $driver->vehicle->display_name ?? null,
+ 'plate' => $driver->vehicle->plate_number ?? null,
+ ] : null,
+ 'geofence' => [
+ 'id' => $geofence->public_id,
+ 'uuid' => $geofence->uuid,
+ 'name' => $geofence->name,
+ 'type' => $this->geofenceType,
+ ],
+ 'location' => [
+ 'latitude' => $this->location->getLat(),
+ 'longitude' => $this->location->getLng(),
+ ],
+ ];
+
+ // Attach active order context if the driver has one
+ $order = $driver->getCurrentOrder();
+ if ($order) {
+ $payload['order'] = [
+ 'id' => $order->public_id,
+ 'uuid' => $order->uuid,
+ 'status' => $order->status,
+ ];
+ }
+
+ return $payload;
+ }
+}
diff --git a/server/src/Events/GeofenceExited.php b/server/src/Events/GeofenceExited.php
new file mode 100644
index 000000000..12edba8e4
--- /dev/null
+++ b/server/src/Events/GeofenceExited.php
@@ -0,0 +1,146 @@
+driver = $driver;
+ $this->geofence = $geofence;
+ $this->geofenceType = $geofenceType;
+ $this->location = $location;
+ $this->timestamp = now();
+ $this->dwellDurationMinutes = $dwellDurationMinutes;
+ }
+
+ /**
+ * Returns the company UUID for this event.
+ *
+ * @return string
+ */
+ public function getCompanyUuid(): string
+ {
+ return $this->driver->company_uuid;
+ }
+
+ /**
+ * Returns the standardised webhook payload for this event.
+ *
+ * @return array
+ */
+ public function broadcastWith(): array
+ {
+ $driver = $this->driver;
+ $geofence = $this->geofence;
+
+ $payload = [
+ 'event_type' => 'geofence.exited',
+ 'occurred_at' => $this->timestamp->toIso8601String(),
+ 'dwell_duration_minutes' => $this->dwellDurationMinutes,
+ 'driver' => [
+ 'id' => $driver->public_id,
+ 'uuid' => $driver->uuid,
+ 'name' => $driver->name,
+ 'phone' => $driver->phone,
+ ],
+ 'vehicle' => $driver->vehicle ? [
+ 'id' => $driver->vehicle->public_id,
+ 'uuid' => $driver->vehicle->uuid,
+ 'name' => $driver->vehicle->display_name ?? null,
+ 'plate' => $driver->vehicle->plate_number ?? null,
+ ] : null,
+ 'geofence' => [
+ 'id' => $geofence->public_id,
+ 'uuid' => $geofence->uuid,
+ 'name' => $geofence->name,
+ 'type' => $this->geofenceType,
+ ],
+ 'location' => [
+ 'latitude' => $this->location->getLat(),
+ 'longitude' => $this->location->getLng(),
+ ],
+ ];
+
+ $order = $driver->getCurrentOrder();
+ if ($order) {
+ $payload['order'] = [
+ 'id' => $order->public_id,
+ 'uuid' => $order->uuid,
+ 'status' => $order->status,
+ ];
+ }
+
+ return $payload;
+ }
+}
diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php
index 62c1f6f03..7f0c29f28 100644
--- a/server/src/Http/Controllers/Api/v1/DriverController.php
+++ b/server/src/Http/Controllers/Api/v1/DriverController.php
@@ -3,7 +3,12 @@
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
use Fleetbase\FleetOps\Events\DriverLocationChanged;
+use Fleetbase\FleetOps\Events\GeofenceDwelled;
+use Fleetbase\FleetOps\Events\GeofenceEntered;
+use Fleetbase\FleetOps\Events\GeofenceExited;
use Fleetbase\FleetOps\Events\VehicleLocationChanged;
+use Fleetbase\FleetOps\Jobs\CheckGeofenceDwell;
+use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest;
use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
@@ -374,6 +379,101 @@ public function track(string $id, Request $request)
broadcast(new DriverLocationChanged($driver));
+ // ----------------------------------------------------------------
+ // Geofence intersection detection
+ //
+ // After broadcasting the location change, run the geofence engine
+ // asynchronously. We catch all exceptions so that a geofence error
+ // never prevents the location update response from being returned.
+ // ----------------------------------------------------------------
+ try {
+ $newLocation = new Point($latitude, $longitude);
+ $geofenceService = app(GeofenceIntersectionService::class);
+ $crossings = $geofenceService->detectCrossings($driver, $newLocation);
+
+ foreach ($crossings as $crossing) {
+ $geofence = $crossing['geofence'];
+ $geofenceType = $crossing['geofence_type'];
+
+ if ($crossing['type'] === 'entered') {
+ // Only trigger if the geofence has entry triggers enabled
+ if (!$geofence->trigger_on_entry) {
+ continue;
+ }
+
+ // Upsert the state record to mark the driver as inside
+ DB::table('driver_geofence_states')->upsert(
+ [
+ 'driver_uuid' => $driver->uuid,
+ 'geofence_uuid' => $geofence->uuid,
+ 'geofence_type' => $geofenceType,
+ 'is_inside' => true,
+ 'entered_at' => now(),
+ 'exited_at' => null,
+ 'dwell_job_id' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ ['driver_uuid', 'geofence_uuid'],
+ ['is_inside', 'entered_at', 'exited_at', 'dwell_job_id', 'updated_at']
+ );
+
+ // Dispatch the GeofenceEntered event (handled by queued listeners)
+ event(new GeofenceEntered($driver, $geofence, $geofenceType, $newLocation));
+
+ // Schedule a dwell check if the geofence has a dwell threshold
+ if ($geofence->dwell_threshold_minutes > 0) {
+ $dwellJob = CheckGeofenceDwell::dispatch(
+ $driver->uuid,
+ $geofence->uuid,
+ $geofenceType
+ )->delay(now()->addMinutes($geofence->dwell_threshold_minutes));
+
+ // Store the job ID so it can be identified if needed
+ DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->where('geofence_uuid', $geofence->uuid)
+ ->update(['dwell_job_id' => (string) $dwellJob]);
+ }
+ } elseif ($crossing['type'] === 'exited') {
+ // Only trigger if the geofence has exit triggers enabled
+ if (!$geofence->trigger_on_exit) {
+ continue;
+ }
+
+ // Calculate dwell duration from the state record
+ $state = DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->where('geofence_uuid', $geofence->uuid)
+ ->first();
+
+ $dwellMinutes = null;
+ if ($state && $state->entered_at) {
+ $dwellMinutes = (int) \Carbon\Carbon::parse($state->entered_at)->diffInMinutes(now());
+ }
+
+ // Update the state record to mark the driver as outside
+ DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->where('geofence_uuid', $geofence->uuid)
+ ->update([
+ 'is_inside' => false,
+ 'exited_at' => now(),
+ 'dwell_job_id' => null,
+ 'updated_at' => now(),
+ ]);
+
+ // Dispatch the GeofenceExited event
+ event(new GeofenceExited($driver, $geofence, $geofenceType, $newLocation, $dwellMinutes));
+ }
+ }
+ } catch (\Throwable $geofenceException) {
+ // Log the error but never let geofence processing block the response
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($geofenceException);
+ }
+ }
+
return new DriverResource($driver);
}
diff --git a/server/src/Http/Controllers/Api/v1/GeofenceController.php b/server/src/Http/Controllers/Api/v1/GeofenceController.php
new file mode 100644
index 000000000..434a73a9d
--- /dev/null
+++ b/server/src/Http/Controllers/Api/v1/GeofenceController.php
@@ -0,0 +1,183 @@
+orderBy('occurred_at', 'desc');
+
+ if ($request->filled('driver_uuid')) {
+ $query->where('driver_uuid', $request->input('driver_uuid'));
+ }
+
+ if ($request->filled('geofence_uuid')) {
+ $query->where('geofence_uuid', $request->input('geofence_uuid'));
+ }
+
+ if ($request->filled('event_type')) {
+ $query->where('event_type', $request->input('event_type'));
+ }
+
+ if ($request->filled('from')) {
+ $query->where('occurred_at', '>=', $request->input('from'));
+ }
+
+ if ($request->filled('to')) {
+ $query->where('occurred_at', '<=', $request->input('to'));
+ }
+
+ $perPage = min((int) $request->input('per_page', 50), 200);
+
+ return response()->json($query->paginate($perPage));
+ }
+
+ /**
+ * GET /api/v1/geofences/inventory
+ *
+ * Returns a real-time snapshot of which drivers are currently inside
+ * which geofences for the authenticated company.
+ *
+ * @return JsonResponse
+ */
+ public function inventory(): JsonResponse
+ {
+ $companyUuid = session('company');
+
+ $states = DB::table('driver_geofence_states as dgs')
+ ->join('drivers as d', 'd.uuid', '=', 'dgs.driver_uuid')
+ ->leftJoin('zones as z', function ($join) {
+ $join->on('z.uuid', '=', 'dgs.geofence_uuid')
+ ->where('dgs.geofence_type', '=', 'zone');
+ })
+ ->leftJoin('service_areas as sa', function ($join) {
+ $join->on('sa.uuid', '=', 'dgs.geofence_uuid')
+ ->where('dgs.geofence_type', '=', 'service_area');
+ })
+ ->where('d.company_uuid', $companyUuid)
+ ->where('dgs.is_inside', true)
+ ->whereNull('d.deleted_at')
+ ->select([
+ 'dgs.driver_uuid',
+ 'd.name as driver_name',
+ 'dgs.geofence_uuid',
+ DB::raw('COALESCE(z.name, sa.name) as geofence_name'),
+ 'dgs.geofence_type',
+ 'dgs.entered_at',
+ DB::raw('TIMESTAMPDIFF(MINUTE, dgs.entered_at, NOW()) as minutes_inside'),
+ ])
+ ->orderBy('dgs.entered_at', 'asc')
+ ->get();
+
+ return response()->json([
+ 'data' => $states,
+ 'total' => $states->count(),
+ ]);
+ }
+
+ /**
+ * GET /api/v1/geofences/dwell-report
+ *
+ * Returns aggregated dwell time statistics per geofence for the
+ * authenticated company.
+ *
+ * Query parameters:
+ * - from (string) Start of reporting period (ISO 8601)
+ * - to (string) End of reporting period (ISO 8601)
+ *
+ * @param Request $request
+ *
+ * @return JsonResponse
+ */
+ public function dwellReport(Request $request): JsonResponse
+ {
+ $companyUuid = session('company');
+
+ $query = GeofenceEventLog::where('company_uuid', $companyUuid)
+ ->where('event_type', 'exited')
+ ->whereNotNull('dwell_duration_minutes');
+
+ if ($request->filled('from')) {
+ $query->where('occurred_at', '>=', $request->input('from'));
+ }
+
+ if ($request->filled('to')) {
+ $query->where('occurred_at', '<=', $request->input('to'));
+ }
+
+ $report = $query
+ ->groupBy('geofence_uuid', 'geofence_name', 'geofence_type')
+ ->select([
+ 'geofence_uuid',
+ 'geofence_name',
+ 'geofence_type',
+ DB::raw('COUNT(*) as visit_count'),
+ DB::raw('ROUND(AVG(dwell_duration_minutes), 1) as avg_dwell_minutes'),
+ DB::raw('MAX(dwell_duration_minutes) as max_dwell_minutes'),
+ DB::raw('MIN(dwell_duration_minutes) as min_dwell_minutes'),
+ DB::raw('SUM(dwell_duration_minutes) as total_dwell_minutes'),
+ ])
+ ->orderBy('visit_count', 'desc')
+ ->get();
+
+ return response()->json(['data' => $report]);
+ }
+
+ /**
+ * GET /api/v1/geofences/driver/{driverUuid}/history
+ *
+ * Returns the geofence event history for a specific driver.
+ *
+ * @param Request $request
+ * @param string $driverUuid
+ *
+ * @return JsonResponse
+ */
+ public function driverHistory(Request $request, string $driverUuid): JsonResponse
+ {
+ $companyUuid = session('company');
+ $perPage = min((int) $request->input('per_page', 50), 200);
+
+ $events = GeofenceEventLog::where('company_uuid', $companyUuid)
+ ->where('driver_uuid', $driverUuid)
+ ->orderBy('occurred_at', 'desc')
+ ->paginate($perPage);
+
+ return response()->json($events);
+ }
+}
diff --git a/server/src/Jobs/CheckGeofenceDwell.php b/server/src/Jobs/CheckGeofenceDwell.php
new file mode 100644
index 000000000..52d60f0db
--- /dev/null
+++ b/server/src/Jobs/CheckGeofenceDwell.php
@@ -0,0 +1,132 @@
+driverUuid = $driverUuid;
+ $this->geofenceUuid = $geofenceUuid;
+ $this->geofenceType = $geofenceType;
+ $this->onQueue('geofence');
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle(): void
+ {
+ // Check if the driver is still inside the geofence
+ $state = DB::table('driver_geofence_states')
+ ->where('driver_uuid', $this->driverUuid)
+ ->where('geofence_uuid', $this->geofenceUuid)
+ ->where('is_inside', true)
+ ->first();
+
+ if (!$state) {
+ // Driver has already exited; dwell event should not fire
+ return;
+ }
+
+ // Load the driver
+ $driver = Driver::where('uuid', $this->driverUuid)->withoutGlobalScopes()->first();
+ if (!$driver) {
+ Log::warning('CheckGeofenceDwell: Driver not found', ['driver_uuid' => $this->driverUuid]);
+ return;
+ }
+
+ // Load the geofence model
+ $geofence = $this->geofenceType === 'service_area'
+ ? ServiceArea::where('uuid', $this->geofenceUuid)->first()
+ : Zone::where('uuid', $this->geofenceUuid)->first();
+
+ if (!$geofence) {
+ Log::warning('CheckGeofenceDwell: Geofence not found', [
+ 'geofence_uuid' => $this->geofenceUuid,
+ 'geofence_type' => $this->geofenceType,
+ ]);
+ return;
+ }
+
+ // Parse the entry timestamp
+ $enteredAt = \Carbon\Carbon::parse($state->entered_at);
+
+ // Fire the dwell event
+ event(new GeofenceDwelled($driver, $geofence, $this->geofenceType, $enteredAt));
+ }
+}
diff --git a/server/src/Listeners/HandleGeofenceDwelled.php b/server/src/Listeners/HandleGeofenceDwelled.php
new file mode 100644
index 000000000..99716d133
--- /dev/null
+++ b/server/src/Listeners/HandleGeofenceDwelled.php
@@ -0,0 +1,64 @@
+driver;
+ $order = $driver->getCurrentOrder();
+
+ GeofenceEventLog::create([
+ 'uuid' => Str::uuid()->toString(),
+ 'company_uuid' => $driver->company_uuid,
+ 'driver_uuid' => $driver->uuid,
+ 'vehicle_uuid' => $driver->vehicle_uuid ?? null,
+ 'order_uuid' => $order?->uuid,
+ 'geofence_uuid' => $event->geofence->uuid,
+ 'geofence_type' => $event->geofenceType,
+ 'geofence_name' => $event->geofence->name,
+ 'event_type' => 'dwelled',
+ 'latitude' => null,
+ 'longitude' => null,
+ 'dwell_duration_minutes' => $event->dwellDurationMinutes,
+ 'occurred_at' => $event->timestamp,
+ ]);
+ }
+}
diff --git a/server/src/Listeners/HandleGeofenceEntered.php b/server/src/Listeners/HandleGeofenceEntered.php
new file mode 100644
index 000000000..670ee6170
--- /dev/null
+++ b/server/src/Listeners/HandleGeofenceEntered.php
@@ -0,0 +1,205 @@
+driver;
+ $geofence = $event->geofence;
+
+ // Set company session context for any subsequent queries
+ session(['company' => $driver->company_uuid]);
+
+ // ----------------------------------------------------------------
+ // 1. Write to the geofence event log
+ // ----------------------------------------------------------------
+ $order = $driver->getCurrentOrder();
+
+ GeofenceEventLog::create([
+ 'uuid' => Str::uuid()->toString(),
+ 'company_uuid' => $driver->company_uuid,
+ 'driver_uuid' => $driver->uuid,
+ 'vehicle_uuid' => $driver->vehicle_uuid ?? null,
+ 'order_uuid' => $order?->uuid,
+ 'geofence_uuid' => $geofence->uuid,
+ 'geofence_type' => $event->geofenceType,
+ 'geofence_name' => $geofence->name,
+ 'event_type' => 'entered',
+ 'latitude' => $event->location->getLat(),
+ 'longitude' => $event->location->getLng(),
+ 'occurred_at' => $event->timestamp,
+ ]);
+
+ // ----------------------------------------------------------------
+ // 2. Order status automation
+ //
+ // If the driver has an active order and the geofence is in
+ // proximity to the current destination waypoint, auto-transition
+ // the order to "arrived" status and notify the customer.
+ // ----------------------------------------------------------------
+ if ($order) {
+ $this->handleOrderArrival($driver, $geofence, $order, $event);
+ }
+ }
+
+ /**
+ * Attempt to auto-transition an order to "arrived" status when the
+ * driver enters a geofence near the order's current destination.
+ *
+ * @param mixed $driver
+ * @param mixed $geofence
+ * @param Order $order
+ * @param GeofenceEntered $event
+ *
+ * @return void
+ */
+ private function handleOrderArrival($driver, $geofence, Order $order, GeofenceEntered $event): void
+ {
+ // Do not re-trigger if the order is already arrived or completed
+ if (in_array($order->status, ['arrived', 'completed', 'canceled'])) {
+ return;
+ }
+
+ // Get the current destination waypoint
+ $destination = null;
+ try {
+ $destination = $order->payload?->getPickupOrCurrentWaypoint();
+ } catch (\Throwable $e) {
+ Log::warning('GeofenceEntered: Could not resolve order destination', [
+ 'order_uuid' => $order->uuid,
+ 'driver_uuid' => $driver->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ if (!$destination) {
+ return;
+ }
+
+ // Resolve the destination place
+ $place = null;
+ try {
+ $place = $destination->place ?? $destination->getPlace();
+ } catch (\Throwable $e) {
+ return;
+ }
+
+ if (!$place || !$place->location) {
+ return;
+ }
+
+ // Check proximity: is the geofence centroid within 500m of the destination place?
+ try {
+ $geofenceLat = $geofence->getLatitudeAttribute();
+ $geofenceLng = $geofence->getLongitudeAttribute();
+ } catch (\Throwable $e) {
+ return;
+ }
+
+ $placeLat = $place->location->getLat();
+ $placeLng = $place->location->getLng();
+
+ $distanceMeters = $this->haversineDistance($geofenceLat, $geofenceLng, $placeLat, $placeLng);
+
+ // Only trigger if the geofence is within 500m of the destination
+ if ($distanceMeters > 500) {
+ return;
+ }
+
+ // Auto-transition order to "arrived"
+ try {
+ $order->setStatus('arrived');
+ $order->createActivity(
+ [
+ 'status' => 'arrived',
+ 'details' => sprintf('Driver entered destination geofence "%s".', $geofence->name),
+ ],
+ $event->location
+ );
+ } catch (\Throwable $e) {
+ Log::error('GeofenceEntered: Failed to set order status to arrived', [
+ 'order_uuid' => $order->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Notify the customer
+ if ($order->customer) {
+ try {
+ $order->customer->notify(new DriverArrivedAtGeofence($order, $geofence));
+ } catch (\Throwable $e) {
+ // Notification failure must not interrupt the geofence pipeline
+ Log::warning('GeofenceEntered: Failed to notify customer', [
+ 'order_uuid' => $order->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Calculate the haversine distance in metres between two lat/lng points.
+ *
+ * @param float $lat1
+ * @param float $lng1
+ * @param float $lat2
+ * @param float $lng2
+ *
+ * @return float Distance in metres
+ */
+ private function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float
+ {
+ $earthRadius = 6371000; // metres
+ $dLat = deg2rad($lat2 - $lat1);
+ $dLng = deg2rad($lng2 - $lng1);
+ $a = sin($dLat / 2) ** 2
+ + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
+
+ return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
+ }
+}
diff --git a/server/src/Listeners/HandleGeofenceExited.php b/server/src/Listeners/HandleGeofenceExited.php
new file mode 100644
index 000000000..0693cd2b5
--- /dev/null
+++ b/server/src/Listeners/HandleGeofenceExited.php
@@ -0,0 +1,64 @@
+driver;
+ $order = $driver->getCurrentOrder();
+
+ GeofenceEventLog::create([
+ 'uuid' => Str::uuid()->toString(),
+ 'company_uuid' => $driver->company_uuid,
+ 'driver_uuid' => $driver->uuid,
+ 'vehicle_uuid' => $driver->vehicle_uuid ?? null,
+ 'order_uuid' => $order?->uuid,
+ 'geofence_uuid' => $event->geofence->uuid,
+ 'geofence_type' => $event->geofenceType,
+ 'geofence_name' => $event->geofence->name,
+ 'event_type' => 'exited',
+ 'latitude' => $event->location->getLat(),
+ 'longitude' => $event->location->getLng(),
+ 'dwell_duration_minutes' => $event->dwellDurationMinutes,
+ 'occurred_at' => $event->timestamp,
+ ]);
+ }
+}
diff --git a/server/src/Models/GeofenceEventLog.php b/server/src/Models/GeofenceEventLog.php
new file mode 100644
index 000000000..a8a959fbc
--- /dev/null
+++ b/server/src/Models/GeofenceEventLog.php
@@ -0,0 +1,139 @@
+ 'datetime',
+ 'latitude' => 'float',
+ 'longitude' => 'float',
+ 'speed_kmh' => 'float',
+ 'dwell_duration_minutes' => 'integer',
+ ];
+
+ /**
+ * The attributes excluded from the model's JSON form.
+ *
+ * @var array
+ */
+ protected $hidden = [];
+
+ /**
+ * Get the driver associated with this event.
+ *
+ * @return BelongsTo
+ */
+ public function driver(): BelongsTo
+ {
+ return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid');
+ }
+
+ /**
+ * Get the order associated with this event (if any).
+ *
+ * @return BelongsTo
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class, 'order_uuid', 'uuid');
+ }
+
+ /**
+ * Get the vehicle associated with this event (if any).
+ *
+ * @return BelongsTo
+ */
+ public function vehicle(): BelongsTo
+ {
+ return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid');
+ }
+
+ /**
+ * Scope to filter by company.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param string $companyUuid
+ *
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeForCompany($query, string $companyUuid)
+ {
+ return $query->where('company_uuid', $companyUuid);
+ }
+
+ /**
+ * Scope to filter by event type.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param string $type
+ *
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeOfType($query, string $type)
+ {
+ return $query->where('event_type', $type);
+ }
+}
diff --git a/server/src/Models/ServiceArea.php b/server/src/Models/ServiceArea.php
index c1f93e427..91ed31d32 100644
--- a/server/src/Models/ServiceArea.php
+++ b/server/src/Models/ServiceArea.php
@@ -60,7 +60,22 @@ class ServiceArea extends Model
*
* @var array
*/
- protected $fillable = ['_key', 'company_uuid', 'name', 'type', 'parent_uuid', 'border', 'color', 'stroke_color', 'status', 'country'];
+ protected $fillable = [
+ '_key',
+ 'company_uuid',
+ 'name',
+ 'type',
+ 'parent_uuid',
+ 'border',
+ 'color',
+ 'stroke_color',
+ 'status',
+ 'country',
+ 'trigger_on_entry',
+ 'trigger_on_exit',
+ 'dwell_threshold_minutes',
+ 'speed_limit_kmh',
+ ];
/**
* The attributes that are spatial columns.
@@ -96,7 +111,11 @@ class ServiceArea extends Model
* @var array
*/
protected $casts = [
- 'border' => MultiPolygonCast::class,
+ 'border' => MultiPolygonCast::class,
+ 'trigger_on_entry' => 'boolean',
+ 'trigger_on_exit' => 'boolean',
+ 'dwell_threshold_minutes' => 'integer',
+ 'speed_limit_kmh' => 'integer',
];
/**
diff --git a/server/src/Models/Zone.php b/server/src/Models/Zone.php
index 4e8a8bfe2..046d601d1 100644
--- a/server/src/Models/Zone.php
+++ b/server/src/Models/Zone.php
@@ -62,7 +62,21 @@ class Zone extends Model
*
* @var array
*/
- protected $fillable = ['_key', 'company_uuid', 'service_area_uuid', 'name', 'description', 'border', 'color', 'stroke_color', 'status'];
+ protected $fillable = [
+ '_key',
+ 'company_uuid',
+ 'service_area_uuid',
+ 'name',
+ 'description',
+ 'border',
+ 'color',
+ 'stroke_color',
+ 'status',
+ 'trigger_on_entry',
+ 'trigger_on_exit',
+ 'dwell_threshold_minutes',
+ 'speed_limit_kmh',
+ ];
/**
* The attributes that should be cast to native types.
@@ -70,7 +84,11 @@ class Zone extends Model
* @var array
*/
protected $casts = [
- 'border' => PolygonCast::class,
+ 'border' => PolygonCast::class,
+ 'trigger_on_entry' => 'boolean',
+ 'trigger_on_exit' => 'boolean',
+ 'dwell_threshold_minutes' => 'integer',
+ 'speed_limit_kmh' => 'integer',
];
/**
diff --git a/server/src/Notifications/DriverArrivedAtGeofence.php b/server/src/Notifications/DriverArrivedAtGeofence.php
new file mode 100644
index 000000000..caa80d9b9
--- /dev/null
+++ b/server/src/Notifications/DriverArrivedAtGeofence.php
@@ -0,0 +1,103 @@
+order = $order;
+ $this->geofence = $geofence;
+ }
+
+ /**
+ * Get the notification's delivery channels.
+ *
+ * @param mixed $notifiable
+ *
+ * @return array
+ */
+ public function via($notifiable): array
+ {
+ return ['mail', 'database'];
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ *
+ * @param mixed $notifiable
+ *
+ * @return MailMessage
+ */
+ public function toMail($notifiable): MailMessage
+ {
+ $geofenceName = $this->geofence->name ?? 'your location';
+ $orderId = $this->order->public_id ?? $this->order->uuid;
+
+ return (new MailMessage)
+ ->subject("Your driver has arrived — Order #{$orderId}")
+ ->greeting('Good news!')
+ ->line("Your driver has arrived at {$geofenceName} for order #{$orderId}.")
+ ->line('Please be ready to receive your delivery.')
+ ->action('Track Your Order', url("/tracking/{$this->order->tracking_number}"))
+ ->line('Thank you for using our service.');
+ }
+
+ /**
+ * Get the array representation of the notification (database channel).
+ *
+ * @param mixed $notifiable
+ *
+ * @return array
+ */
+ public function toArray($notifiable): array
+ {
+ return [
+ 'event' => 'driver.arrived_at_geofence',
+ 'order_id' => $this->order->public_id,
+ 'order_uuid' => $this->order->uuid,
+ 'geofence_id' => $this->geofence->public_id ?? null,
+ 'geofence_name' => $this->geofence->name ?? null,
+ 'message' => sprintf(
+ 'Your driver has arrived at %s for order #%s.',
+ $this->geofence->name ?? 'your location',
+ $this->order->public_id
+ ),
+ ];
+ }
+}
diff --git a/server/src/Providers/EventServiceProvider.php b/server/src/Providers/EventServiceProvider.php
index 0447f661f..6e15c96b0 100644
--- a/server/src/Providers/EventServiceProvider.php
+++ b/server/src/Providers/EventServiceProvider.php
@@ -23,6 +23,25 @@ class EventServiceProvider extends ServiceProvider
\Fleetbase\FleetOps\Events\OrderFailed::class => [\Fleetbase\Listeners\SendResourceLifecycleWebhook::class, \Fleetbase\FleetOps\Listeners\NotifyOrderEvent::class],
\Fleetbase\FleetOps\Events\OrderReady::class => [\Fleetbase\FleetOps\Listeners\HandleOrderReady::class],
+ /*
+ * Geofence Events
+ *
+ * Each event is handled by a domain listener (business logic, event log)
+ * and the generic SendResourceLifecycleWebhook listener (webhook delivery).
+ */
+ \Fleetbase\FleetOps\Events\GeofenceEntered::class => [
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceEntered::class,
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
+ ],
+ \Fleetbase\FleetOps\Events\GeofenceExited::class => [
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceExited::class,
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
+ ],
+ \Fleetbase\FleetOps\Events\GeofenceDwelled::class => [
+ \Fleetbase\FleetOps\Listeners\HandleGeofenceDwelled::class,
+ \Fleetbase\Listeners\SendResourceLifecycleWebhook::class,
+ ],
+
/*
* Core Events
*/
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 452ab51ef..22d960b4d 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -80,6 +80,14 @@ public function register()
{
$this->app->register(CoreServiceProvider::class);
$this->app->register(ReportSchemaServiceProvider::class);
+
+ // Register the GeofenceIntersectionService as a singleton so that
+ // the same instance is reused across the request lifecycle, avoiding
+ // repeated instantiation on high-frequency location update calls.
+ $this->app->singleton(
+ \Fleetbase\FleetOps\Support\GeofenceIntersectionService::class,
+ fn () => new \Fleetbase\FleetOps\Support\GeofenceIntersectionService()
+ );
}
/**
@@ -128,6 +136,7 @@ public function registerNotifications()
\Fleetbase\FleetOps\Notifications\OrderPing::class,
\Fleetbase\FleetOps\Notifications\OrderFailed::class,
\Fleetbase\FleetOps\Notifications\OrderCompleted::class,
+ \Fleetbase\FleetOps\Notifications\DriverArrivedAtGeofence::class,
]);
// Register Notifiables
diff --git a/server/src/Support/GeofenceIntersectionService.php b/server/src/Support/GeofenceIntersectionService.php
new file mode 100644
index 000000000..b4bbf8c3b
--- /dev/null
+++ b/server/src/Support/GeofenceIntersectionService.php
@@ -0,0 +1,174 @@
+company_uuid;
+
+ // Build the WKT point string. MySQL ST_GeomFromText expects (lng lat) order.
+ $wkt = sprintf('POINT(%s %s)', $newLocation->getLng(), $newLocation->getLat());
+
+ // ----------------------------------------------------------------
+ // 1. Find all Zones the driver is currently inside.
+ //
+ // Two-pass strategy for MySQL performance:
+ // - MBRContains uses the SPATIAL index to filter by minimum
+ // bounding rectangle (fast, may include false positives).
+ // - ST_Contains performs the precise polygon containment check
+ // on the reduced candidate set (accurate, no index needed).
+ // ----------------------------------------------------------------
+ $insideZones = Zone::where('company_uuid', $companyUuid)
+ ->whereNotNull('border')
+ ->where(function ($q) {
+ $q->where('trigger_on_entry', true)->orWhere('trigger_on_exit', true);
+ })
+ ->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
+ ->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
+ ->get();
+
+ // ----------------------------------------------------------------
+ // 2. Find all Service Areas the driver is currently inside.
+ //
+ // Service areas use MultiPolygon borders, so we use
+ // ST_Contains which handles both Polygon and MultiPolygon.
+ // ----------------------------------------------------------------
+ $insideServiceAreas = ServiceArea::where('company_uuid', $companyUuid)
+ ->whereNotNull('border')
+ ->where(function ($q) {
+ $q->where('trigger_on_entry', true)->orWhere('trigger_on_exit', true);
+ })
+ ->whereRaw('MBRContains(`border`, ST_GeomFromText(?))', [$wkt])
+ ->whereRaw('ST_Contains(`border`, ST_GeomFromText(?))', [$wkt])
+ ->get();
+
+ // Merge into a unified collection with a type discriminator
+ $currentlyInside = collect()
+ ->merge($insideZones->map(fn ($z) => ['model' => $z, 'geofence_type' => 'zone']))
+ ->merge($insideServiceAreas->map(fn ($sa) => ['model' => $sa, 'geofence_type' => 'service_area']));
+
+ $currentlyInsideUuids = $currentlyInside->pluck('model.uuid')->toArray();
+
+ // ----------------------------------------------------------------
+ // 3. Load the driver's current geofence state records.
+ // ----------------------------------------------------------------
+ $currentStates = DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->get()
+ ->keyBy('geofence_uuid');
+
+ // ----------------------------------------------------------------
+ // 4. Detect ENTRIES: geofences the driver is now inside but
+ // was not inside before (or has no state record yet).
+ // ----------------------------------------------------------------
+ foreach ($currentlyInside as $item) {
+ $geofence = $item['model'];
+ $state = $currentStates->get($geofence->uuid);
+
+ if (!$state || !$state->is_inside) {
+ $crossings[] = [
+ 'type' => 'entered',
+ 'geofence' => $geofence,
+ 'geofence_type' => $item['geofence_type'],
+ ];
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // 5. Detect EXITS: geofences the driver was inside but is no
+ // longer inside (not in the current ST_Contains result set).
+ // ----------------------------------------------------------------
+ foreach ($currentStates as $geofenceUuid => $state) {
+ if ($state->is_inside && !in_array($geofenceUuid, $currentlyInsideUuids)) {
+ $geofence = $state->geofence_type === 'service_area'
+ ? ServiceArea::where('uuid', $geofenceUuid)->first()
+ : Zone::where('uuid', $geofenceUuid)->first();
+
+ if ($geofence) {
+ $crossings[] = [
+ 'type' => 'exited',
+ 'geofence' => $geofence,
+ 'geofence_type' => $state->geofence_type,
+ ];
+ }
+ }
+ }
+
+ return $crossings;
+ }
+
+ /**
+ * Check if a driver is currently recorded as inside a specific geofence.
+ *
+ * @param Driver $driver
+ * @param mixed $geofence Zone or ServiceArea
+ *
+ * @return bool
+ */
+ public function isDriverInsideGeofence(Driver $driver, $geofence): bool
+ {
+ return DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->where('geofence_uuid', $geofence->uuid)
+ ->where('is_inside', true)
+ ->exists();
+ }
+
+ /**
+ * Clear all geofence state records for a driver.
+ * Called when a driver goes offline or completes a shift.
+ *
+ * @param Driver $driver
+ *
+ * @return void
+ */
+ public function clearDriverState(Driver $driver): void
+ {
+ DB::table('driver_geofence_states')
+ ->where('driver_uuid', $driver->uuid)
+ ->update([
+ 'is_inside' => false,
+ 'exited_at' => now(),
+ 'dwell_job_id' => null,
+ 'updated_at' => now(),
+ ]);
+ }
+}
diff --git a/server/src/routes.php b/server/src/routes.php
index d6c9b086c..a97e85289 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -145,6 +145,13 @@ function ($router) {
$router->put('{id}', 'ServiceAreaController@update');
$router->delete('{id}', 'ServiceAreaController@delete');
});
+ // geofences routes
+ $router->group(['prefix' => 'geofences'], function () use ($router) {
+ $router->get('events', 'GeofenceController@events');
+ $router->get('inventory', 'GeofenceController@inventory');
+ $router->get('dwell-report', 'GeofenceController@dwellReport');
+ $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory');
+ });
// service-rates routes
$router->group(['prefix' => 'service-rates'], function () use ($router) {
$router->post('/', 'ServiceRateController@create');