diff --git a/addon/components/map/leaflet-live-map.js b/addon/components/map/leaflet-live-map.js index 7ae2a21b0..6ed9bd1ff 100644 --- a/addon/components/map/leaflet-live-map.js +++ b/addon/components/map/leaflet-live-map.js @@ -29,6 +29,7 @@ export default class MapLeafletLiveMapComponent extends Component { @service intl; @service universe; @service('universe/menu-service') menuService; + @service geofenceEventBus; /** properties */ id = guidFor(this); @@ -57,6 +58,12 @@ export default class MapLeafletLiveMapComponent extends Component { // Ensure we have valid coordinates on initialization this.#updateCoordinatesFromLocation(); + + // Subscribe to geofence events so the live map can react to boundary crossings + this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this); + this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this); + this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler); + this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler); } willDestroy() { @@ -67,6 +74,14 @@ export default class MapLeafletLiveMapComponent extends Component { this.universe.off('user.located', this._locationUpdateHandler); this._locationUpdateHandler = null; } + if (this._geofenceEnteredHandler) { + this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler); + this._geofenceEnteredHandler = null; + } + if (this._geofenceExitedHandler) { + this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler); + this._geofenceExitedHandler = null; + } } @action didLoad({ target: map }) { @@ -259,6 +274,62 @@ export default class MapLeafletLiveMapComponent extends Component { return 14; } + /** + * Handles a geofence.entered event from the GeofenceEventBus. + * Briefly highlights the geofence layer on the map to provide visual feedback. + * + * @param {Object} event - Normalised geofence event object + */ + #handleGeofenceEntered(event) { + debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`); + this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green + } + + /** + * Handles a geofence.exited event from the GeofenceEventBus. + * Briefly highlights the geofence layer on the map to provide visual feedback. + * + * @param {Object} event - Normalised geofence event object + */ + #handleGeofenceExited(event) { + debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`); + this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red + } + + /** + * Briefly changes the fill colour of a geofence polygon layer on the map + * to provide visual feedback when a driver enters or exits. + * + * @param {string} geofenceUuid - UUID of the zone or service area + * @param {string} flashColor - Hex colour to flash + */ + #flashGeofenceLayer(geofenceUuid, flashColor) { + if (!geofenceUuid || !this.map) { + return; + } + + // Iterate over all Leaflet layers to find the matching geofence polygon + this.map.eachLayer((layer) => { + const model = layer._model; + if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') { + const originalStyle = { + color: layer.options.color, + fillColor: layer.options.fillColor, + weight: layer.options.weight, + }; + + // Flash to the event colour + layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 }); + + // Restore original style after 2 seconds + setTimeout(() => { + if (!layer._map) return; // layer may have been removed + layer.setStyle(originalStyle); + }, 2000); + } + }); + } + /** * Handles location updates from the location service * @param {Object} coordinates - The new coordinates diff --git a/addon/components/map/toolbar/geofence-events-panel.hbs b/addon/components/map/toolbar/geofence-events-panel.hbs new file mode 100644 index 000000000..d8300204a --- /dev/null +++ b/addon/components/map/toolbar/geofence-events-panel.hbs @@ -0,0 +1,80 @@ +
+ {{! Panel header }} +
+
+ +

Geofence Events

+ {{#if this.events.length}} + + {{this.events.length}} + + {{/if}} +
+ +
+ + {{! Loading state }} + {{#if this.isLoading}} +
+ + Loading events… +
+ + {{! Empty state }} + {{else if (eq this.events.length 0)}} +
+ +

No geofence events yet

+

+ Events will appear here in real time as drivers cross zone and service area boundaries. +

+
+ + {{! Event feed }} + {{else}} +
+ {{#each this.events as |event|}} +
+
+
+ {{! Event type badge }} + + {{this.labelFor event.eventType}} + + + {{! Driver and geofence names }} +

+ {{event.driverName}} + + {{event.geofenceName}} +

+ + {{! Dwell duration (only for exited/dwelled events) }} + {{#if event.dwellMinutes}} +

+ Dwell: {{event.dwellMinutes}} min +

+ {{/if}} +
+ + {{! Timestamp }} +
+

+ +

+

+ {{event.geofenceType}} +

+
+
+
+ {{/each}} +
+ {{/if}} +
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');