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:
64
app/api/geofences/[id]/route.ts
Normal file
64
app/api/geofences/[id]/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/api/geofences/route.ts
Normal file
120
app/api/geofences/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
emails/geofence-enter.tsx
Normal file
134
emails/geofence-enter.tsx
Normal file
@@ -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 (
|
||||||
|
<EmailLayout preview={`${deviceName} hat ${geofenceName} betreten`}>
|
||||||
|
<EmailHeader title="Geofence Benachrichtigung" />
|
||||||
|
|
||||||
|
<Section style={content}>
|
||||||
|
<Text style={paragraph}>Hallo {username},</Text>
|
||||||
|
|
||||||
|
<Text style={paragraphBold}>
|
||||||
|
Ihr Gerät "{deviceName}" hat die Zone "{geofenceName}" betreten.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={detailsBox}>
|
||||||
|
<Text style={detailsTitle}>Details:</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Zeit:</strong> {formattedTimestamp}
|
||||||
|
</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Position:</strong> {latitude}, {longitude}
|
||||||
|
</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Distanz vom Zentrum:</strong> {formattedDistance} Meter
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{mapUrl && (
|
||||||
|
<Text style={paragraph}>
|
||||||
|
<Link href={mapUrl} style={link}>
|
||||||
|
→ Position auf Karte anzeigen
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={footerText}>
|
||||||
|
Diese Benachrichtigung wurde automatisch von Ihrem Location Tracker System gesendet.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<EmailFooter />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
134
emails/geofence-exit.tsx
Normal file
134
emails/geofence-exit.tsx
Normal file
@@ -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 (
|
||||||
|
<EmailLayout preview={`${deviceName} hat ${geofenceName} verlassen`}>
|
||||||
|
<EmailHeader title="Geofence Benachrichtigung" />
|
||||||
|
|
||||||
|
<Section style={content}>
|
||||||
|
<Text style={paragraph}>Hallo {username},</Text>
|
||||||
|
|
||||||
|
<Text style={paragraphBold}>
|
||||||
|
Ihr Gerät "{deviceName}" hat die Zone "{geofenceName}" verlassen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={detailsBox}>
|
||||||
|
<Text style={detailsTitle}>Details:</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Zeit:</strong> {formattedTimestamp}
|
||||||
|
</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Position:</strong> {latitude}, {longitude}
|
||||||
|
</Text>
|
||||||
|
<Text style={detailItem}>
|
||||||
|
<strong>Distanz vom Zentrum:</strong> {formattedDistance} Meter
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{mapUrl && (
|
||||||
|
<Text style={paragraph}>
|
||||||
|
<Link href={mapUrl} style={link}>
|
||||||
|
→ Position auf Karte anzeigen
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={footerText}>
|
||||||
|
Diese Benachrichtigung wurde automatisch von Ihrem Location Tracker System gesendet.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<EmailFooter />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import { render } from '@react-email/components';
|
|||||||
import WelcomeEmail from '@/emails/welcome';
|
import WelcomeEmail from '@/emails/welcome';
|
||||||
import PasswordResetEmail from '@/emails/password-reset';
|
import PasswordResetEmail from '@/emails/password-reset';
|
||||||
import MqttCredentialsEmail from '@/emails/mqtt-credentials';
|
import MqttCredentialsEmail from '@/emails/mqtt-credentials';
|
||||||
|
import GeofenceEnterEmail from '@/emails/geofence-enter';
|
||||||
|
import GeofenceExitEmail from '@/emails/geofence-exit';
|
||||||
|
|
||||||
export interface WelcomeEmailData {
|
export interface WelcomeEmailData {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -28,6 +30,17 @@ export interface MqttCredentialsEmailData {
|
|||||||
brokerPort?: string;
|
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> {
|
export async function renderWelcomeEmail(data: WelcomeEmailData): Promise<string> {
|
||||||
return render(WelcomeEmail(data));
|
return render(WelcomeEmail(data));
|
||||||
}
|
}
|
||||||
@@ -40,6 +53,14 @@ export async function renderMqttCredentialsEmail(data: MqttCredentialsEmailData)
|
|||||||
return render(MqttCredentialsEmail(data));
|
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(
|
export async function renderEmailTemplate(
|
||||||
template: string,
|
template: string,
|
||||||
data: any
|
data: any
|
||||||
@@ -51,6 +72,10 @@ export async function renderEmailTemplate(
|
|||||||
return renderPasswordResetEmail(data);
|
return renderPasswordResetEmail(data);
|
||||||
case 'mqtt-credentials':
|
case 'mqtt-credentials':
|
||||||
return renderMqttCredentialsEmail(data);
|
return renderMqttCredentialsEmail(data);
|
||||||
|
case 'geofence-enter':
|
||||||
|
return renderGeofenceEnterEmail(data);
|
||||||
|
case 'geofence-exit':
|
||||||
|
return renderGeofenceExitEmail(data);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown email template: ${template}`);
|
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
|
// MQTT Subscriber Service für OwnTracks Location Updates
|
||||||
import mqtt from 'mqtt';
|
import mqtt from 'mqtt';
|
||||||
import { locationDb, Location } from './db';
|
import { locationDb, Location } from './db';
|
||||||
|
import { checkGeofences } from './geofence-engine';
|
||||||
|
import { geofenceDb } from './geofence-db';
|
||||||
|
import { sendGeofenceNotifications } from './geofence-notifications';
|
||||||
|
|
||||||
// OwnTracks Message Format
|
// OwnTracks Message Format
|
||||||
interface OwnTracksMessage {
|
interface OwnTracksMessage {
|
||||||
@@ -139,6 +142,27 @@ class MQTTSubscriber {
|
|||||||
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
console.log(`✓ Location saved: ${device} at (${payload.lat}, ${payload.lon})`);
|
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 {
|
} else {
|
||||||
console.log(`⚠ Duplicate location ignored: ${device}`);
|
console.log(`⚠ Duplicate location ignored: ${device}`);
|
||||||
}
|
}
|
||||||
|
|||||||
86
lib/types.ts
86
lib/types.ts
@@ -28,3 +28,89 @@ export interface Device {
|
|||||||
name: string;
|
name: string;
|
||||||
color: 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;
|
||||||
|
}
|
||||||
|
|||||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -11,13 +11,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.5.7",
|
"@react-email/components": "^0.5.7",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.4",
|
"@types/react": "^19.2.4",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -35,6 +33,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/nodemailer": "^7.0.3",
|
"@types/nodemailer": "^7.0.3",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.17"
|
||||||
@@ -3244,15 +3243,6 @@
|
|||||||
"tailwindcss": "4.1.17"
|
"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": {
|
"node_modules/@types/bcryptjs": {
|
||||||
"version": "2.4.6",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
@@ -3340,6 +3330,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -3496,20 +3493,6 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"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": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
@@ -5189,26 +5172,6 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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:app": "node scripts/init-database.js",
|
||||||
"db:init:locations": "node scripts/init-locations-db.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": "node scripts/cleanup-old-locations.js",
|
||||||
"db:cleanup:7d": "node scripts/cleanup-old-locations.js 168",
|
"db:cleanup:7d": "node scripts/cleanup-old-locations.js 168",
|
||||||
"db:cleanup:30d": "node scripts/cleanup-old-locations.js 720",
|
"db:cleanup:30d": "node scripts/cleanup-old-locations.js 720",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/nodemailer": "^7.0.3",
|
"@types/nodemailer": "^7.0.3",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.17"
|
||||||
|
|||||||
168
scripts/init-geofence-db.js
Normal file
168
scripts/init-geofence-db.js
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user