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>
258 lines
6.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|