Add Geofence MVP feature implementation
Implemented complete MVP for geofencing functionality with database, backend logic, MQTT integration, and API endpoints. **Phase 1: Database & Core Logic** - scripts/init-geofence-db.js: Database initialization for Geofence tables - lib/types.ts: TypeScript types for Geofence, GeofenceEvent, GeofenceStatus - lib/geofence-engine.ts: Core geofencing logic (Haversine distance, state tracking) - lib/geofence-db.ts: Database layer with CRUD operations - package.json: Added db:init:geofence script **Phase 2: MQTT Integration & Email Notifications** - emails/geofence-enter.tsx: React Email template for enter events - emails/geofence-exit.tsx: React Email template for exit events - lib/email-renderer.ts: Added geofence email rendering functions - lib/geofence-notifications.ts: Notification service for geofence events - lib/mqtt-subscriber.ts: Integrated automatic geofence checking on location updates **Phase 3: Minimal API** - app/api/geofences/route.ts: GET (list) and POST (create) endpoints - app/api/geofences/[id]/route.ts: DELETE endpoint - All endpoints with authentication and ownership checks **MVP Simplifications:** - No zone limit enforcement (unlimited for all users) - No notification flags (always send Enter + Exit emails) - Device assignment required (no NULL device logic) - Circular geofences only **Features:** ✅ Automatic geofence detection on MQTT location updates ✅ Email notifications for enter/exit events ✅ State tracking to prevent duplicate events ✅ REST API for geofence management ✅ Non-blocking async processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import { render } from '@react-email/components';
|
||||
import WelcomeEmail from '@/emails/welcome';
|
||||
import PasswordResetEmail from '@/emails/password-reset';
|
||||
import MqttCredentialsEmail from '@/emails/mqtt-credentials';
|
||||
import GeofenceEnterEmail from '@/emails/geofence-enter';
|
||||
import GeofenceExitEmail from '@/emails/geofence-exit';
|
||||
|
||||
export interface WelcomeEmailData {
|
||||
username: string;
|
||||
@@ -28,6 +30,17 @@ export interface MqttCredentialsEmailData {
|
||||
brokerPort?: string;
|
||||
}
|
||||
|
||||
export interface GeofenceEmailData {
|
||||
username: string;
|
||||
deviceName: string;
|
||||
geofenceName: string;
|
||||
timestamp: string;
|
||||
latitude: number | string;
|
||||
longitude: number | string;
|
||||
distanceFromCenter: number;
|
||||
mapUrl?: string;
|
||||
}
|
||||
|
||||
export async function renderWelcomeEmail(data: WelcomeEmailData): Promise<string> {
|
||||
return render(WelcomeEmail(data));
|
||||
}
|
||||
@@ -40,6 +53,14 @@ export async function renderMqttCredentialsEmail(data: MqttCredentialsEmailData)
|
||||
return render(MqttCredentialsEmail(data));
|
||||
}
|
||||
|
||||
export async function renderGeofenceEnterEmail(data: GeofenceEmailData): Promise<string> {
|
||||
return render(GeofenceEnterEmail(data));
|
||||
}
|
||||
|
||||
export async function renderGeofenceExitEmail(data: GeofenceEmailData): Promise<string> {
|
||||
return render(GeofenceExitEmail(data));
|
||||
}
|
||||
|
||||
export async function renderEmailTemplate(
|
||||
template: string,
|
||||
data: any
|
||||
@@ -51,6 +72,10 @@ export async function renderEmailTemplate(
|
||||
return renderPasswordResetEmail(data);
|
||||
case 'mqtt-credentials':
|
||||
return renderMqttCredentialsEmail(data);
|
||||
case 'geofence-enter':
|
||||
return renderGeofenceEnterEmail(data);
|
||||
case 'geofence-exit':
|
||||
return renderGeofenceExitEmail(data);
|
||||
default:
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
|
||||
274
lib/geofence-db.ts
Normal file
274
lib/geofence-db.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Geofence Database Layer
|
||||
* CRUD operations for Geofence, GeofenceEvent, and GeofenceStatus
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import type {
|
||||
Geofence,
|
||||
GeofenceEvent,
|
||||
GeofenceStatus,
|
||||
CreateGeofenceInput,
|
||||
CreateGeofenceEventInput,
|
||||
} from './types';
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
|
||||
|
||||
function getDb() {
|
||||
return new Database(dbPath);
|
||||
}
|
||||
|
||||
export const geofenceDb = {
|
||||
/**
|
||||
* Create a new geofence
|
||||
*/
|
||||
create(data: CreateGeofenceInput): Geofence {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO Geofence (
|
||||
id, name, description, shape_type,
|
||||
center_latitude, center_longitude, radius_meters,
|
||||
owner_id, device_id, color
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
data.id,
|
||||
data.name,
|
||||
data.description || null,
|
||||
'circle',
|
||||
data.center_latitude,
|
||||
data.center_longitude,
|
||||
data.radius_meters,
|
||||
data.owner_id,
|
||||
data.device_id,
|
||||
data.color || '#3b82f6'
|
||||
);
|
||||
|
||||
return this.findById(data.id)!;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find geofence by ID
|
||||
*/
|
||||
findById(id: string): Geofence | null {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM Geofence WHERE id = ?');
|
||||
return stmt.get(id) as Geofence | null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all geofences for an owner
|
||||
*/
|
||||
findByOwner(ownerId: string): Geofence[] {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM Geofence WHERE owner_id = ? ORDER BY created_at DESC');
|
||||
return stmt.all(ownerId) as Geofence[];
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all active geofences for a specific device
|
||||
*/
|
||||
findActiveForDevice(deviceId: string): Geofence[] {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM Geofence
|
||||
WHERE device_id = ? AND is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
return stmt.all(deviceId) as Geofence[];
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a geofence
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare('DELETE FROM Geofence WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current status for device/geofence pair
|
||||
*/
|
||||
getStatus(deviceId: string, geofenceId: string): GeofenceStatus | null {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM GeofenceStatus
|
||||
WHERE device_id = ? AND geofence_id = ?
|
||||
`);
|
||||
return stmt.get(deviceId, geofenceId) as GeofenceStatus | null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update or create status for device/geofence pair
|
||||
*/
|
||||
updateStatus(deviceId: string, geofenceId: string, isInside: boolean): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const isInsideInt = isInside ? 1 : 0;
|
||||
|
||||
// Try to update existing status
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE GeofenceStatus
|
||||
SET is_inside = ?,
|
||||
last_enter_time = CASE WHEN ? = 1 AND is_inside = 0 THEN ? ELSE last_enter_time END,
|
||||
last_exit_time = CASE WHEN ? = 0 AND is_inside = 1 THEN ? ELSE last_exit_time END,
|
||||
last_checked_at = ?,
|
||||
updated_at = ?
|
||||
WHERE device_id = ? AND geofence_id = ?
|
||||
`);
|
||||
|
||||
const result = updateStmt.run(
|
||||
isInsideInt,
|
||||
isInsideInt, now, // for enter
|
||||
isInsideInt, now, // for exit
|
||||
now,
|
||||
now,
|
||||
deviceId,
|
||||
geofenceId
|
||||
);
|
||||
|
||||
// If no rows updated, insert new status
|
||||
if (result.changes === 0) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO GeofenceStatus (
|
||||
device_id, geofence_id, is_inside,
|
||||
last_enter_time, last_exit_time, last_checked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertStmt.run(
|
||||
deviceId,
|
||||
geofenceId,
|
||||
isInsideInt,
|
||||
isInside ? now : null,
|
||||
!isInside ? now : null,
|
||||
now
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new geofence event
|
||||
*/
|
||||
createEvent(event: CreateGeofenceEventInput): GeofenceEvent {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO GeofenceEvent (
|
||||
geofence_id, device_id, location_id,
|
||||
event_type, latitude, longitude,
|
||||
distance_from_center, timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
event.geofence_id,
|
||||
event.device_id,
|
||||
event.location_id,
|
||||
event.event_type,
|
||||
event.latitude,
|
||||
event.longitude,
|
||||
event.distance_from_center,
|
||||
event.timestamp
|
||||
);
|
||||
|
||||
// Return the created event
|
||||
const selectStmt = db.prepare('SELECT * FROM GeofenceEvent WHERE id = ?');
|
||||
return selectStmt.get(result.lastInsertRowid) as GeofenceEvent;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find events for a specific device or geofence
|
||||
*/
|
||||
findEvents(filters: {
|
||||
deviceId?: string;
|
||||
geofenceId?: string;
|
||||
limit?: number;
|
||||
}): GeofenceEvent[] {
|
||||
const db = getDb();
|
||||
try {
|
||||
let query = 'SELECT * FROM GeofenceEvent WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.deviceId) {
|
||||
query += ' AND device_id = ?';
|
||||
params.push(filters.deviceId);
|
||||
}
|
||||
|
||||
if (filters.geofenceId) {
|
||||
query += ' AND geofence_id = ?';
|
||||
params.push(filters.geofenceId);
|
||||
}
|
||||
|
||||
query += ' ORDER BY timestamp DESC';
|
||||
|
||||
if (filters.limit) {
|
||||
query += ' LIMIT ?';
|
||||
params.push(filters.limit);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params) as GeofenceEvent[];
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark notification as sent or failed
|
||||
*/
|
||||
markNotificationSent(eventId: number, success: boolean, error?: string): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE GeofenceEvent
|
||||
SET notification_sent = ?,
|
||||
notification_error = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
success ? 1 : 2, // 1 = sent, 2 = failed
|
||||
error || null,
|
||||
eventId
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
123
lib/geofence-engine.ts
Normal file
123
lib/geofence-engine.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Geofence Engine - Core logic for geofence calculations
|
||||
* Handles distance calculations and geofence state tracking
|
||||
*/
|
||||
|
||||
import type { Location, Geofence, GeofenceEvent, CreateGeofenceEventInput } from './types';
|
||||
|
||||
/**
|
||||
* Calculate distance between two geographic points using Haversine formula
|
||||
* Returns distance in meters
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371e3; // Earth radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // Distance in meters
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is inside a circular geofence
|
||||
*/
|
||||
export function isInsideGeofence(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
geofence: Geofence
|
||||
): boolean {
|
||||
if (geofence.shape_type === 'circle') {
|
||||
const distance = calculateDistance(
|
||||
latitude,
|
||||
longitude,
|
||||
geofence.center_latitude,
|
||||
geofence.center_longitude
|
||||
);
|
||||
return distance <= geofence.radius_meters;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check location against all active geofences for a device
|
||||
* Returns array of events (enter/exit) that should be generated
|
||||
*/
|
||||
export async function checkGeofences(
|
||||
location: Location,
|
||||
deviceId: string,
|
||||
geofenceDb: any
|
||||
): Promise<CreateGeofenceEventInput[]> {
|
||||
const events: CreateGeofenceEventInput[] = [];
|
||||
|
||||
// Convert latitude/longitude to numbers if they're strings
|
||||
const lat = typeof location.latitude === 'string'
|
||||
? parseFloat(location.latitude)
|
||||
: location.latitude;
|
||||
const lon = typeof location.longitude === 'string'
|
||||
? parseFloat(location.longitude)
|
||||
: location.longitude;
|
||||
|
||||
// Get all active geofences for this device
|
||||
const geofences = await geofenceDb.findActiveForDevice(deviceId);
|
||||
|
||||
for (const geofence of geofences) {
|
||||
// Check if currently inside
|
||||
const isInside = isInsideGeofence(lat, lon, geofence);
|
||||
|
||||
// Get previous status
|
||||
const status = await geofenceDb.getStatus(deviceId, geofence.id);
|
||||
const wasInside = status ? status.is_inside === 1 : false;
|
||||
|
||||
// Calculate distance from center
|
||||
const distanceFromCenter = calculateDistance(
|
||||
lat,
|
||||
lon,
|
||||
geofence.center_latitude,
|
||||
geofence.center_longitude
|
||||
);
|
||||
|
||||
// Generate events based on state change
|
||||
if (isInside && !wasInside) {
|
||||
// ENTER event
|
||||
events.push({
|
||||
geofence_id: geofence.id,
|
||||
device_id: deviceId,
|
||||
location_id: location.id!,
|
||||
event_type: 'enter',
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
timestamp: location.timestamp,
|
||||
distance_from_center: distanceFromCenter,
|
||||
});
|
||||
} else if (!isInside && wasInside) {
|
||||
// EXIT event
|
||||
events.push({
|
||||
geofence_id: geofence.id,
|
||||
device_id: deviceId,
|
||||
location_id: location.id!,
|
||||
event_type: 'exit',
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
timestamp: location.timestamp,
|
||||
distance_from_center: distanceFromCenter,
|
||||
});
|
||||
}
|
||||
|
||||
// Update status for next check
|
||||
await geofenceDb.updateStatus(deviceId, geofence.id, isInside);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
150
lib/geofence-notifications.ts
Normal file
150
lib/geofence-notifications.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Geofence Notification Service
|
||||
* Handles sending email notifications for geofence events
|
||||
*/
|
||||
|
||||
import { emailService } from './email-service';
|
||||
import {
|
||||
renderGeofenceEnterEmail,
|
||||
renderGeofenceExitEmail,
|
||||
GeofenceEmailData,
|
||||
} from './email-renderer';
|
||||
import { geofenceDb } from './geofence-db';
|
||||
import type { GeofenceEvent } from './types';
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
|
||||
|
||||
interface DeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface GeofenceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device information from database
|
||||
*/
|
||||
function getDevice(deviceId: string): DeviceInfo | null {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const stmt = db.prepare('SELECT id, name, ownerId FROM Device WHERE id = ?');
|
||||
return stmt.get(deviceId) as DeviceInfo | null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information from database
|
||||
*/
|
||||
function getUser(userId: string): UserInfo | null {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const stmt = db.prepare('SELECT id, username, email FROM User WHERE id = ?');
|
||||
return stmt.get(userId) as UserInfo | null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification for a single geofence event
|
||||
*/
|
||||
async function sendEventNotification(event: GeofenceEvent): Promise<void> {
|
||||
try {
|
||||
// Get geofence details
|
||||
const geofence = geofenceDb.findById(event.geofence_id);
|
||||
if (!geofence) {
|
||||
throw new Error(`Geofence not found: ${event.geofence_id}`);
|
||||
}
|
||||
|
||||
// Get device details
|
||||
const device = getDevice(event.device_id);
|
||||
if (!device) {
|
||||
throw new Error(`Device not found: ${event.device_id}`);
|
||||
}
|
||||
|
||||
// Get owner details
|
||||
const owner = getUser(geofence.owner_id);
|
||||
if (!owner || !owner.email) {
|
||||
console.log(`[GeofenceNotification] No email for owner ${geofence.owner_id}, skipping notification`);
|
||||
geofenceDb.markNotificationSent(event.id!, true); // Mark as "sent" (no email needed)
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare email data
|
||||
const emailData: GeofenceEmailData = {
|
||||
username: owner.username,
|
||||
deviceName: device.name,
|
||||
geofenceName: geofence.name,
|
||||
timestamp: event.timestamp,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
distanceFromCenter: event.distance_from_center || 0,
|
||||
// Optional: Add map URL later
|
||||
// mapUrl: `${process.env.NEXT_PUBLIC_URL}/map?lat=${event.latitude}&lon=${event.longitude}`
|
||||
};
|
||||
|
||||
// Render and send email
|
||||
let html: string;
|
||||
let subject: string;
|
||||
|
||||
if (event.event_type === 'enter') {
|
||||
html = await renderGeofenceEnterEmail(emailData);
|
||||
subject = `${device.name} hat ${geofence.name} betreten`;
|
||||
} else {
|
||||
html = await renderGeofenceExitEmail(emailData);
|
||||
subject = `${device.name} hat ${geofence.name} verlassen`;
|
||||
}
|
||||
|
||||
// Send via existing email service
|
||||
await emailService['sendEmail'](owner.email, subject, html);
|
||||
|
||||
// Mark notification as sent
|
||||
geofenceDb.markNotificationSent(event.id!, true);
|
||||
|
||||
console.log(`[GeofenceNotification] Sent ${event.event_type} notification for geofence ${geofence.name} to ${owner.email}`);
|
||||
} catch (error) {
|
||||
console.error('[GeofenceNotification] Failed to send notification:', error);
|
||||
|
||||
// Mark notification as failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
geofenceDb.markNotificationSent(event.id!, false, errorMessage);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications for multiple geofence events
|
||||
* Processes events sequentially to avoid overwhelming SMTP server
|
||||
*/
|
||||
export async function sendGeofenceNotifications(events: GeofenceEvent[]): Promise<void> {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GeofenceNotification] Processing ${events.length} geofence event(s)`);
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
await sendEventNotification(event);
|
||||
} catch (error) {
|
||||
// Log error but continue with other notifications
|
||||
console.error(`[GeofenceNotification] Failed to send notification for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// MQTT Subscriber Service für OwnTracks Location Updates
|
||||
import mqtt from 'mqtt';
|
||||
import { locationDb, Location } from './db';
|
||||
import { checkGeofences } from './geofence-engine';
|
||||
import { geofenceDb } from './geofence-db';
|
||||
import { sendGeofenceNotifications } from './geofence-notifications';
|
||||
|
||||
// OwnTracks Message Format
|
||||
interface OwnTracksMessage {
|
||||
@@ -139,6 +142,27 @@ class MQTTSubscriber {
|
||||
|
||||
if (saved) {
|
||||
console.log(`✓ Location saved: ${device} at (${payload.lat}, ${payload.lon})`);
|
||||
|
||||
// Geofence-Check asynchron ausführen (nicht blockieren)
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const events = await checkGeofences(saved, device, geofenceDb);
|
||||
|
||||
if (events.length > 0) {
|
||||
console.log(`[Geofence] Detected ${events.length} event(s) for device ${device}`);
|
||||
|
||||
// Events in Datenbank speichern
|
||||
const savedEvents = events.map((eventData) =>
|
||||
geofenceDb.createEvent(eventData)
|
||||
);
|
||||
|
||||
// Benachrichtigungen versenden (asynchron)
|
||||
await sendGeofenceNotifications(savedEvents);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Geofence] Check failed:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`⚠ Duplicate location ignored: ${device}`);
|
||||
}
|
||||
|
||||
86
lib/types.ts
86
lib/types.ts
@@ -28,3 +28,89 @@ export interface Device {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Geofence types
|
||||
export interface Geofence {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
|
||||
// Geometry
|
||||
shape_type: 'circle';
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
radius_meters: number;
|
||||
|
||||
// Assignment
|
||||
owner_id: string;
|
||||
device_id: string;
|
||||
|
||||
// Status & Metadata
|
||||
is_active: number; // 0 or 1
|
||||
color: string;
|
||||
|
||||
// Timestamps
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GeofenceEvent {
|
||||
id?: number;
|
||||
geofence_id: string;
|
||||
device_id: string;
|
||||
location_id: number;
|
||||
|
||||
// Event details
|
||||
event_type: 'enter' | 'exit';
|
||||
latitude: number | string;
|
||||
longitude: number | string;
|
||||
|
||||
// Metadata
|
||||
distance_from_center: number | null;
|
||||
notification_sent: number; // 0 = pending, 1 = sent, 2 = failed
|
||||
notification_error: string | null;
|
||||
|
||||
// Timestamps
|
||||
timestamp: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface GeofenceStatus {
|
||||
id?: number;
|
||||
device_id: string;
|
||||
geofence_id: string;
|
||||
|
||||
// Current status
|
||||
is_inside: number; // 0 or 1
|
||||
last_enter_time: string | null;
|
||||
last_exit_time: string | null;
|
||||
last_checked_at: string | null;
|
||||
|
||||
// Timestamps
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Input types for creating/updating geofences
|
||||
export interface CreateGeofenceInput {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
radius_meters: number;
|
||||
owner_id: string;
|
||||
device_id: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface CreateGeofenceEventInput {
|
||||
geofence_id: string;
|
||||
device_id: string;
|
||||
location_id: number;
|
||||
event_type: 'enter' | 'exit';
|
||||
latitude: number | string;
|
||||
longitude: number | string;
|
||||
distance_from_center: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user