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>
135 lines
3.1 KiB
TypeScript
135 lines
3.1 KiB
TypeScript
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',
|
|
};
|