Files
location-mqtt-tracker-app/lib/geofence-notifications.ts
Joachim Hummel 0d1dbeafda Add Telegram notification integration for geofencing
Features:
- Multi-channel notifications (Email + Telegram)
- User-configurable notification settings per channel
- Telegram bot integration with rich messages, location pins, and inline buttons
- QR code generation for easy bot access (@myidbot support)
- Admin UI for notification settings management
- Test functionality for Telegram connection
- Comprehensive documentation

Implementation:
- lib/telegram-service.ts: Telegram API integration
- lib/notification-settings-db.ts: Database layer for user notification preferences
- lib/geofence-notifications.ts: Extended for parallel multi-channel delivery
- API routes for settings management and testing
- Admin UI with QR code display and step-by-step instructions
- Database table: UserNotificationSettings

Documentation:
- docs/telegram.md: Technical implementation guide
- docs/telegram-anleitung.md: User guide with @myidbot instructions
- docs/telegram-setup.md: Admin setup guide
- README.md: Updated NPM scripts section

Docker:
- Updated Dockerfile to copy public directory
- Added TELEGRAM_BOT_TOKEN environment variable
- Integrated notification settings initialization in db:init

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:54:19 +00:00

258 lines
6.8 KiB
TypeScript

/**
* Geofence Notification Service
* Handles sending email and Telegram notifications for geofence events
*/
import { emailService } from './email-service';
import { telegramService } from './telegram-service';
import {
renderGeofenceEnterEmail,
renderGeofenceExitEmail,
GeofenceEmailData,
} from './email-renderer';
import { geofenceDb } from './geofence-db';
import {
getUserNotificationSettings,
} from './notification-settings-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
* Supports both Email and Telegram based on user preferences
*/
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) {
throw new Error(`Owner not found: ${geofence.owner_id}`);
}
// Get notification preferences
const settings = getUserNotificationSettings(owner.id);
// Prepare notification tasks
const promises: Promise<void>[] = [];
// Send Email if enabled
if (settings.email_enabled && owner.email) {
promises.push(
sendEmailNotification(event, owner, device, geofence)
);
}
// Send Telegram if enabled
if (settings.telegram_enabled && settings.telegram_chat_id) {
promises.push(
sendTelegramNotification(
event,
owner,
device,
geofence,
settings.telegram_chat_id
)
);
}
// If no notification channel enabled, skip
if (promises.length === 0) {
console.log(
`[GeofenceNotification] No notification channels enabled for user ${owner.id}`
);
geofenceDb.markNotificationSent(event.id!, true);
return;
}
// Send notifications in parallel
const results = await Promise.allSettled(promises);
// Check if at least one succeeded
const anySuccess = results.some((r) => r.status === 'fulfilled');
if (anySuccess) {
geofenceDb.markNotificationSent(event.id!, true);
console.log(
`[GeofenceNotification] Sent notification for geofence ${geofence.name}`
);
} else {
// All failed
const errors = results
.filter((r) => r.status === 'rejected')
.map((r: any) => r.reason.message)
.join('; ');
geofenceDb.markNotificationSent(event.id!, false, errors);
throw new Error(`All notification channels failed: ${errors}`);
}
} catch (error) {
console.error('[GeofenceNotification] Failed to send notification:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
geofenceDb.markNotificationSent(event.id!, false, errorMessage);
throw error;
}
}
/**
* Send email notification (extracted from main function)
*/
async function sendEmailNotification(
event: GeofenceEvent,
owner: UserInfo,
device: DeviceInfo,
geofence: GeofenceInfo
): Promise<void> {
try {
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,
};
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`;
}
await emailService['sendEmail'](owner.email!, subject, html);
console.log(
`[GeofenceNotification] Sent email to ${owner.email}`
);
} catch (error) {
console.error('[GeofenceNotification] Email error:', error);
throw error;
}
}
/**
* Send Telegram notification (new function)
*/
async function sendTelegramNotification(
event: GeofenceEvent,
owner: UserInfo,
device: DeviceInfo,
geofence: GeofenceInfo,
chatId: string
): Promise<void> {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Ensure latitude and longitude are numbers
const lat = typeof event.latitude === 'number' ? event.latitude : parseFloat(event.latitude);
const lon = typeof event.longitude === 'number' ? event.longitude : parseFloat(event.longitude);
await telegramService.sendGeofenceNotification({
chatId,
deviceName: device.name,
geofenceName: geofence.name,
eventType: event.event_type,
latitude: lat,
longitude: lon,
timestamp: event.timestamp,
mapUrl: `${baseUrl}/map?lat=${lat}&lon=${lon}`,
dashboardUrl: `${baseUrl}/admin/geofences/events`,
});
console.log(
`[GeofenceNotification] Sent Telegram notification to ${chatId}`
);
} catch (error) {
console.error('[GeofenceNotification] Telegram error:', error);
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);
}
}
}