diff --git a/app/api/geofences/[id]/route.ts b/app/api/geofences/[id]/route.ts
new file mode 100644
index 0000000..62f623c
--- /dev/null
+++ b/app/api/geofences/[id]/route.ts
@@ -0,0 +1,64 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { geofenceDb } from "@/lib/geofence-db";
+
+// DELETE /api/geofences/[id] - Delete a geofence
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+
+ if (!session?.user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = (session.user as any).id;
+ const { id: geofenceId } = await params;
+
+ // Check if geofence exists
+ const geofence = geofenceDb.findById(geofenceId);
+
+ if (!geofence) {
+ return NextResponse.json(
+ { error: "Geofence not found" },
+ { status: 404 }
+ );
+ }
+
+ // Check ownership
+ if (geofence.owner_id !== userId) {
+ return NextResponse.json(
+ { error: "Forbidden: You can only delete your own geofences" },
+ { status: 403 }
+ );
+ }
+
+ // Delete geofence (CASCADE will delete related events and status)
+ const deleted = geofenceDb.delete(geofenceId);
+
+ if (!deleted) {
+ return NextResponse.json(
+ { error: "Failed to delete geofence" },
+ { status: 500 }
+ );
+ }
+
+ console.log(`[DELETE /api/geofences/${geofenceId}] Deleted geofence ${geofence.name} for user ${userId}`);
+
+ return NextResponse.json({
+ success: true,
+ message: "Geofence deleted successfully",
+ });
+ } catch (error) {
+ console.error(`[DELETE /api/geofences/[id]] Error:`, error);
+ return NextResponse.json(
+ {
+ error: "Failed to delete geofence",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/geofences/route.ts b/app/api/geofences/route.ts
new file mode 100644
index 0000000..47e3788
--- /dev/null
+++ b/app/api/geofences/route.ts
@@ -0,0 +1,120 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { geofenceDb } from "@/lib/geofence-db";
+import { v4 as uuidv4 } from 'uuid';
+import type { CreateGeofenceInput } from "@/lib/types";
+
+// GET /api/geofences - List all geofences for the authenticated user
+export async function GET() {
+ try {
+ const session = await auth();
+
+ if (!session?.user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = (session.user as any).id;
+
+ // Get all geofences owned by this user
+ const geofences = geofenceDb.findByOwner(userId);
+
+ return NextResponse.json({
+ success: true,
+ geofences,
+ total: geofences.length,
+ });
+ } catch (error) {
+ console.error("[GET /api/geofences] Error:", error);
+ return NextResponse.json(
+ {
+ error: "Failed to fetch geofences",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
+
+// POST /api/geofences - Create a new geofence
+export async function POST(request: Request) {
+ try {
+ const session = await auth();
+
+ if (!session?.user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = (session.user as any).id;
+ const body = await request.json();
+
+ // Validate required fields
+ const { name, center_latitude, center_longitude, radius_meters, device_id, description, color } = body;
+
+ if (!name || typeof name !== 'string') {
+ return NextResponse.json(
+ { error: "Name is required and must be a string" },
+ { status: 400 }
+ );
+ }
+
+ if (typeof center_latitude !== 'number' || center_latitude < -90 || center_latitude > 90) {
+ return NextResponse.json(
+ { error: "Invalid center_latitude (must be between -90 and 90)" },
+ { status: 400 }
+ );
+ }
+
+ if (typeof center_longitude !== 'number' || center_longitude < -180 || center_longitude > 180) {
+ return NextResponse.json(
+ { error: "Invalid center_longitude (must be between -180 and 180)" },
+ { status: 400 }
+ );
+ }
+
+ if (typeof radius_meters !== 'number' || radius_meters < 50 || radius_meters > 50000) {
+ return NextResponse.json(
+ { error: "Invalid radius_meters (must be between 50 and 50000)" },
+ { status: 400 }
+ );
+ }
+
+ if (!device_id || typeof device_id !== 'string') {
+ return NextResponse.json(
+ { error: "device_id is required" },
+ { status: 400 }
+ );
+ }
+
+ // Create geofence data
+ const geofenceData: CreateGeofenceInput = {
+ id: uuidv4(),
+ name: name.trim(),
+ description: description?.trim() || undefined,
+ center_latitude,
+ center_longitude,
+ radius_meters,
+ owner_id: userId,
+ device_id,
+ color: color || '#3b82f6',
+ };
+
+ // Create geofence in database
+ const geofence = geofenceDb.create(geofenceData);
+
+ console.log(`[POST /api/geofences] Created geofence ${geofence.name} for user ${userId}`);
+
+ return NextResponse.json({
+ success: true,
+ geofence,
+ }, { status: 201 });
+ } catch (error) {
+ console.error("[POST /api/geofences] Error:", error);
+ return NextResponse.json(
+ {
+ error: "Failed to create geofence",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/emails/geofence-enter.tsx b/emails/geofence-enter.tsx
new file mode 100644
index 0000000..5fc1773
--- /dev/null
+++ b/emails/geofence-enter.tsx
@@ -0,0 +1,134 @@
+import { Link, Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { EmailLayout } from './components/email-layout';
+import { EmailHeader } from './components/email-header';
+import { EmailFooter } from './components/email-footer';
+
+interface GeofenceEnterEmailProps {
+ username: string;
+ deviceName: string;
+ geofenceName: string;
+ timestamp: string;
+ latitude: number | string;
+ longitude: number | string;
+ distanceFromCenter: number;
+ mapUrl?: string;
+}
+
+export const GeofenceEnterEmail = ({
+ username = 'User',
+ deviceName = 'Device',
+ geofenceName = 'Zone',
+ timestamp = new Date().toISOString(),
+ latitude = 0,
+ longitude = 0,
+ distanceFromCenter = 0,
+ mapUrl,
+}: GeofenceEnterEmailProps) => {
+ const formattedTimestamp = new Date(timestamp).toLocaleString('de-DE', {
+ dateStyle: 'long',
+ timeStyle: 'short',
+ });
+
+ const formattedDistance = Math.round(distanceFromCenter);
+
+ return (
+
+
+
+
+ Hallo {username},
+
+
+ Ihr Gerät "{deviceName}" hat die Zone "{geofenceName}" betreten.
+
+
+
+ Details:
+
+ Zeit: {formattedTimestamp}
+
+
+ Position: {latitude}, {longitude}
+
+
+ Distanz vom Zentrum: {formattedDistance} Meter
+
+
+
+ {mapUrl && (
+
+
+ → Position auf Karte anzeigen
+
+
+ )}
+
+
+ Diese Benachrichtigung wurde automatisch von Ihrem Location Tracker System gesendet.
+
+
+
+
+
+ );
+};
+
+export default GeofenceEnterEmail;
+
+const content = {
+ padding: '20px 40px',
+};
+
+const paragraph = {
+ color: '#374151',
+ fontSize: '16px',
+ lineHeight: '1.6',
+ margin: '0 0 16px',
+};
+
+const paragraphBold = {
+ ...paragraph,
+ fontSize: '18px',
+ fontWeight: '600',
+ color: '#16a34a', // Green for enter event
+};
+
+const detailsBox = {
+ backgroundColor: '#f9fafb',
+ border: '1px solid #e5e7eb',
+ borderRadius: '8px',
+ padding: '16px',
+ margin: '20px 0',
+};
+
+const detailsTitle = {
+ color: '#111827',
+ fontSize: '14px',
+ fontWeight: '600',
+ margin: '0 0 12px',
+ textTransform: 'uppercase' as const,
+ letterSpacing: '0.5px',
+};
+
+const detailItem = {
+ color: '#374151',
+ fontSize: '15px',
+ lineHeight: '1.6',
+ margin: '0 0 8px',
+};
+
+const link = {
+ color: '#2563eb',
+ textDecoration: 'underline',
+ fontSize: '16px',
+};
+
+const footerText = {
+ color: '#6b7280',
+ fontSize: '14px',
+ lineHeight: '1.5',
+ margin: '24px 0 0',
+ paddingTop: '16px',
+ borderTop: '1px solid #e5e7eb',
+};
diff --git a/emails/geofence-exit.tsx b/emails/geofence-exit.tsx
new file mode 100644
index 0000000..2d50749
--- /dev/null
+++ b/emails/geofence-exit.tsx
@@ -0,0 +1,134 @@
+import { Link, Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { EmailLayout } from './components/email-layout';
+import { EmailHeader } from './components/email-header';
+import { EmailFooter } from './components/email-footer';
+
+interface GeofenceExitEmailProps {
+ username: string;
+ deviceName: string;
+ geofenceName: string;
+ timestamp: string;
+ latitude: number | string;
+ longitude: number | string;
+ distanceFromCenter: number;
+ mapUrl?: string;
+}
+
+export const GeofenceExitEmail = ({
+ username = 'User',
+ deviceName = 'Device',
+ geofenceName = 'Zone',
+ timestamp = new Date().toISOString(),
+ latitude = 0,
+ longitude = 0,
+ distanceFromCenter = 0,
+ mapUrl,
+}: GeofenceExitEmailProps) => {
+ const formattedTimestamp = new Date(timestamp).toLocaleString('de-DE', {
+ dateStyle: 'long',
+ timeStyle: 'short',
+ });
+
+ const formattedDistance = Math.round(distanceFromCenter);
+
+ return (
+
+
+
+
+ Hallo {username},
+
+
+ Ihr Gerät "{deviceName}" hat die Zone "{geofenceName}" verlassen.
+
+
+
+ Details:
+
+ Zeit: {formattedTimestamp}
+
+
+ Position: {latitude}, {longitude}
+
+
+ Distanz vom Zentrum: {formattedDistance} Meter
+
+
+
+ {mapUrl && (
+
+
+ → Position auf Karte anzeigen
+
+
+ )}
+
+
+ Diese Benachrichtigung wurde automatisch von Ihrem Location Tracker System gesendet.
+
+
+
+
+
+ );
+};
+
+export default GeofenceExitEmail;
+
+const content = {
+ padding: '20px 40px',
+};
+
+const paragraph = {
+ color: '#374151',
+ fontSize: '16px',
+ lineHeight: '1.6',
+ margin: '0 0 16px',
+};
+
+const paragraphBold = {
+ ...paragraph,
+ fontSize: '18px',
+ fontWeight: '600',
+ color: '#dc2626', // Red for exit event
+};
+
+const detailsBox = {
+ backgroundColor: '#f9fafb',
+ border: '1px solid #e5e7eb',
+ borderRadius: '8px',
+ padding: '16px',
+ margin: '20px 0',
+};
+
+const detailsTitle = {
+ color: '#111827',
+ fontSize: '14px',
+ fontWeight: '600',
+ margin: '0 0 12px',
+ textTransform: 'uppercase' as const,
+ letterSpacing: '0.5px',
+};
+
+const detailItem = {
+ color: '#374151',
+ fontSize: '15px',
+ lineHeight: '1.6',
+ margin: '0 0 8px',
+};
+
+const link = {
+ color: '#2563eb',
+ textDecoration: 'underline',
+ fontSize: '16px',
+};
+
+const footerText = {
+ color: '#6b7280',
+ fontSize: '14px',
+ lineHeight: '1.5',
+ margin: '24px 0 0',
+ paddingTop: '16px',
+ borderTop: '1px solid #e5e7eb',
+};
diff --git a/lib/email-renderer.ts b/lib/email-renderer.ts
index 9074423..5771669 100644
--- a/lib/email-renderer.ts
+++ b/lib/email-renderer.ts
@@ -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 {
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 {
+ return render(GeofenceEnterEmail(data));
+}
+
+export async function renderGeofenceExitEmail(data: GeofenceEmailData): Promise {
+ 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}`);
}
diff --git a/lib/geofence-db.ts b/lib/geofence-db.ts
new file mode 100644
index 0000000..51b237d
--- /dev/null
+++ b/lib/geofence-db.ts
@@ -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();
+ }
+ },
+};
diff --git a/lib/geofence-engine.ts b/lib/geofence-engine.ts
new file mode 100644
index 0000000..a07fdf5
--- /dev/null
+++ b/lib/geofence-engine.ts
@@ -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 {
+ 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;
+}
diff --git a/lib/geofence-notifications.ts b/lib/geofence-notifications.ts
new file mode 100644
index 0000000..4ee29b0
--- /dev/null
+++ b/lib/geofence-notifications.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/lib/mqtt-subscriber.ts b/lib/mqtt-subscriber.ts
index c55c018..fe0f279 100644
--- a/lib/mqtt-subscriber.ts
+++ b/lib/mqtt-subscriber.ts
@@ -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}`);
}
diff --git a/lib/types.ts b/lib/types.ts
index ebf2ca2..1acd9df 100644
--- a/lib/types.ts
+++ b/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;
+}
diff --git a/package-lock.json b/package-lock.json
index 61ceb0b..8b2ee27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,13 +11,11 @@
"dependencies": {
"@react-email/components": "^0.5.7",
"@tailwindcss/postcss": "^4.1.17",
- "@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
- "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.1",
"leaflet": "^1.9.4",
@@ -35,6 +33,7 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^7.0.3",
+ "@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17"
@@ -3244,15 +3243,6 @@
"tailwindcss": "4.1.17"
}
},
- "node_modules/@types/bcrypt": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
- "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
@@ -3340,6 +3330,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3496,20 +3493,6 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
- "node_modules/bcrypt": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
- "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "node-addon-api": "^8.3.0",
- "node-gyp-build": "^4.8.4"
- },
- "engines": {
- "node": ">= 18"
- }
- },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -5189,26 +5172,6 @@
"node": ">=10"
}
},
- "node_modules/node-addon-api": {
- "version": "8.5.0",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
- "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
- "license": "MIT",
- "engines": {
- "node": "^18 || ^20 || >= 21"
- }
- },
- "node_modules/node-gyp-build": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
- "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
- "license": "MIT",
- "bin": {
- "node-gyp-build": "bin.js",
- "node-gyp-build-optional": "optional.js",
- "node-gyp-build-test": "build-test.js"
- }
- },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
diff --git a/package.json b/package.json
index 06f9d1e..388d343 100644
--- a/package.json
+++ b/package.json
@@ -8,9 +8,10 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
- "db:init": "node scripts/init-database.js && node scripts/init-locations-db.js",
+ "db:init": "node scripts/init-database.js && node scripts/init-locations-db.js && node scripts/init-geofence-db.js",
"db:init:app": "node scripts/init-database.js",
"db:init:locations": "node scripts/init-locations-db.js",
+ "db:init:geofence": "node scripts/init-geofence-db.js",
"db:cleanup": "node scripts/cleanup-old-locations.js",
"db:cleanup:7d": "node scripts/cleanup-old-locations.js 168",
"db:cleanup:30d": "node scripts/cleanup-old-locations.js 720",
@@ -45,6 +46,7 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^7.0.3",
+ "@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17"
diff --git a/scripts/init-geofence-db.js b/scripts/init-geofence-db.js
new file mode 100644
index 0000000..71498e5
--- /dev/null
+++ b/scripts/init-geofence-db.js
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+/**
+ * Initialize geofence tables in database.sqlite
+ * This creates the schema for geofencing functionality
+ */
+
+const Database = require('better-sqlite3');
+const path = require('path');
+const fs = require('fs');
+
+const dataDir = path.join(__dirname, '..', 'data');
+const dbPath = path.join(dataDir, 'database.sqlite');
+
+// Ensure data directory exists
+if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+ console.log('✓ Created data directory');
+}
+
+// Open existing database
+const db = new Database(dbPath);
+
+// Enable WAL mode for better concurrency
+db.pragma('journal_mode = WAL');
+
+// Create Geofence table (simplified for MVP)
+db.exec(`
+ CREATE TABLE IF NOT EXISTS Geofence (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+
+ -- Geometry (circle only for MVP)
+ shape_type TEXT NOT NULL DEFAULT 'circle',
+ center_latitude REAL NOT NULL,
+ center_longitude REAL NOT NULL,
+ radius_meters INTEGER NOT NULL,
+
+ -- Assignment (device_id is required for MVP)
+ owner_id TEXT NOT NULL,
+ device_id TEXT NOT NULL,
+
+ -- Status & Metadata
+ is_active INTEGER DEFAULT 1,
+ color TEXT DEFAULT '#3b82f6',
+
+ -- Timestamps
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+
+ FOREIGN KEY (owner_id) REFERENCES User(id) ON DELETE CASCADE,
+ FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
+
+ CHECK (shape_type = 'circle'),
+ CHECK (radius_meters > 0 AND radius_meters <= 50000),
+ CHECK (center_latitude BETWEEN -90 AND 90),
+ CHECK (center_longitude BETWEEN -180 AND 180),
+ CHECK (is_active IN (0, 1))
+ );
+`);
+console.log('✓ Created Geofence table');
+
+// Create GeofenceEvent table
+db.exec(`
+ CREATE TABLE IF NOT EXISTS GeofenceEvent (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ geofence_id TEXT NOT NULL,
+ device_id TEXT NOT NULL,
+ location_id INTEGER NOT NULL,
+
+ -- Event details
+ event_type TEXT NOT NULL,
+ latitude REAL NOT NULL,
+ longitude REAL NOT NULL,
+
+ -- Metadata
+ distance_from_center REAL,
+ notification_sent INTEGER DEFAULT 0,
+ notification_error TEXT,
+
+ -- Timestamps
+ timestamp TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+
+ FOREIGN KEY (geofence_id) REFERENCES Geofence(id) ON DELETE CASCADE,
+ FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
+
+ CHECK (event_type IN ('enter', 'exit')),
+ CHECK (notification_sent IN (0, 1, 2))
+ );
+`);
+console.log('✓ Created GeofenceEvent table');
+
+// Create GeofenceStatus table (for state tracking)
+db.exec(`
+ CREATE TABLE IF NOT EXISTS GeofenceStatus (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_id TEXT NOT NULL,
+ geofence_id TEXT NOT NULL,
+
+ -- Current status
+ is_inside INTEGER NOT NULL DEFAULT 0,
+ last_enter_time TEXT,
+ last_exit_time TEXT,
+ last_checked_at TEXT,
+
+ -- Timestamps
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+
+ FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
+ FOREIGN KEY (geofence_id) REFERENCES Geofence(id) ON DELETE CASCADE,
+
+ UNIQUE(device_id, geofence_id),
+ CHECK (is_inside IN (0, 1))
+ );
+`);
+console.log('✓ Created GeofenceStatus table');
+
+// Create indexes for performance
+db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_geofence_owner
+ ON Geofence(owner_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_device
+ ON Geofence(device_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_active
+ ON Geofence(is_active);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_event_geofence
+ ON GeofenceEvent(geofence_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_event_device
+ ON GeofenceEvent(device_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_event_timestamp
+ ON GeofenceEvent(timestamp DESC);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_event_notification
+ ON GeofenceEvent(notification_sent);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_event_composite
+ ON GeofenceEvent(device_id, geofence_id, timestamp DESC);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_status_device
+ ON GeofenceStatus(device_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_status_geofence
+ ON GeofenceStatus(geofence_id);
+
+ CREATE INDEX IF NOT EXISTS idx_geofence_status_inside
+ ON GeofenceStatus(is_inside);
+`);
+console.log('✓ Created indexes');
+
+// Get stats
+const geofenceCount = db.prepare('SELECT COUNT(*) as count FROM Geofence').get();
+const eventCount = db.prepare('SELECT COUNT(*) as count FROM GeofenceEvent').get();
+const statusCount = db.prepare('SELECT COUNT(*) as count FROM GeofenceStatus').get();
+
+console.log(`\n✓ Geofence tables initialized successfully!`);
+console.log(` Path: ${dbPath}`);
+console.log(` Geofences: ${geofenceCount.count}`);
+console.log(` Events: ${eventCount.count}`);
+console.log(` Status records: ${statusCount.count}`);
+
+db.close();