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:
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',
|
||||
};
|
||||
Reference in New Issue
Block a user