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