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();