diff --git a/Dockerfile b/Dockerfile index e5ee795..bb77fe1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ RUN npm ci --omit=dev # Kopiere Build Artifacts COPY --from=builder /app/.next ./.next COPY --from=builder /app/data ./data +COPY --from=builder /app/public ./public # Kopiere App Code (benötigt für instrumentation.ts, lib/, etc.) COPY --from=builder /app/instrumentation.ts ./ diff --git a/README.md b/README.md index bd464b2..cdbdd15 100644 --- a/README.md +++ b/README.md @@ -1093,7 +1093,6 @@ npm run email:dev # Email Template Dev Server # Linting npm run lint # ESLint ausführen ``` - --- ## 📚 Automatische Dokumentations-Synchronisation diff --git a/app/admin/settings/notifications/page.tsx b/app/admin/settings/notifications/page.tsx new file mode 100644 index 0000000..595122a --- /dev/null +++ b/app/admin/settings/notifications/page.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; + +export default function NotificationSettingsPage() { + const { data: session } = useSession(); + const [settings, setSettings] = useState({ + email_enabled: true, + telegram_enabled: false, + telegram_chat_id: '', + }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (session?.user) { + loadSettings(); + } + }, [session]); + + async function loadSettings() { + try { + const userId = (session!.user as any).id; + const res = await fetch(`/api/users/${userId}/notification-settings`); + const data = await res.json(); + setSettings(data.settings); + } catch (error) { + console.error('Failed to load settings:', error); + } + } + + async function handleSave() { + setLoading(true); + setMessage(''); + + try { + const userId = (session!.user as any).id; + const res = await fetch(`/api/users/${userId}/notification-settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + + if (res.ok) { + setMessage('✅ Einstellungen gespeichert!'); + } else { + setMessage('❌ Fehler beim Speichern'); + } + } catch (error) { + setMessage('❌ Fehler beim Speichern'); + } finally { + setLoading(false); + } + } + + async function handleTest() { + setLoading(true); + setMessage(''); + + try { + const userId = (session!.user as any).id; + const res = await fetch( + `/api/users/${userId}/notification-settings/test`, + { method: 'POST' } + ); + + if (res.ok) { + setMessage('✅ Test-Nachricht gesendet!'); + } else { + const data = await res.json(); + setMessage(`❌ Fehler: ${data.error}`); + } + } catch (error) { + setMessage('❌ Fehler beim Senden der Test-Nachricht'); + } finally { + setLoading(false); + } + } + + return ( +
+

Benachrichtigungseinstellungen

+ +
+ {/* Email Settings */} +
+ +

+ Geofence-Ereignisse per E-Mail erhalten +

+
+ + {/* Telegram Settings */} +
+ +

+ Geofence-Ereignisse per Telegram erhalten (inkl. Karte und Buttons) +

+ + {settings.telegram_enabled && ( +
+ + + setSettings({ ...settings, telegram_chat_id: e.target.value }) + } + placeholder="z.B. 123456789" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +

+ Deine Telegram Chat ID findest du über @userinfobot +

+
+ )} +
+ + {/* Action Buttons */} +
+ + + {settings.telegram_enabled && settings.telegram_chat_id && ( + + )} +
+ + {/* Status Message */} + {message && ( +
{message}
+ )} +
+ + {/* Help Section */} +
+

📱 Telegram Bot aktivieren

+ +
+ {/* QR Code */} +
+

Schritt 1: Bot starten

+ Telegram Bot QR Code +

+ Scanne den QR-Code mit deinem Smartphone +

+

+ oder öffne: @MeinTracking_bot +

+
+ + {/* Instructions */} +
+

Schritt 2: Chat ID holen

+
    +
  1. Starte den Bot mit /start
  2. +
  3. Suche in Telegram nach @myidbot
  4. +
  5. Sende /getid an @myidbot
  6. +
  7. Der Bot antwortet mit deiner Chat ID
  8. +
  9. Kopiere die Nummer und trage sie oben ein
  10. +
+

+ Alternative: @userinfobot mit /start +

+
+
+
+
+ ); +} diff --git a/app/api/users/[id]/notification-settings/route.ts b/app/api/users/[id]/notification-settings/route.ts new file mode 100644 index 0000000..5bde578 --- /dev/null +++ b/app/api/users/[id]/notification-settings/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { + getUserNotificationSettings, + updateUserNotificationSettings, +} from '@/lib/notification-settings-db'; + +// GET /api/users/[id]/notification-settings +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + // Users can only view their own settings + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const settings = getUserNotificationSettings(userId); + + return NextResponse.json({ settings }); + } catch (error) { + console.error('[GET /api/users/[id]/notification-settings]', error); + return NextResponse.json( + { error: 'Failed to get settings' }, + { status: 500 } + ); + } +} + +// PATCH /api/users/[id]/notification-settings +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + // Users can only update their own settings + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { email_enabled, telegram_enabled, telegram_chat_id } = body; + + const settings = updateUserNotificationSettings(userId, { + email_enabled, + telegram_enabled, + telegram_chat_id, + }); + + return NextResponse.json({ settings }); + } catch (error) { + console.error('[PATCH /api/users/[id]/notification-settings]', error); + return NextResponse.json( + { error: 'Failed to update settings' }, + { status: 500 } + ); + } +} diff --git a/app/api/users/[id]/notification-settings/test/route.ts b/app/api/users/[id]/notification-settings/test/route.ts new file mode 100644 index 0000000..18f1575 --- /dev/null +++ b/app/api/users/[id]/notification-settings/test/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { telegramService } from '@/lib/telegram-service'; +import { getUserNotificationSettings } from '@/lib/notification-settings-db'; + +// POST /api/users/[id]/notification-settings/test +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const settings = getUserNotificationSettings(userId); + + if (!settings.telegram_chat_id) { + return NextResponse.json( + { error: 'No Telegram chat ID configured' }, + { status: 400 } + ); + } + + const success = await telegramService.testConnection( + settings.telegram_chat_id + ); + + if (success) { + return NextResponse.json({ success: true, message: 'Test message sent' }); + } else { + return NextResponse.json( + { error: 'Failed to send test message' }, + { status: 500 } + ); + } + } catch (error) { + console.error('[POST /api/users/[id]/notification-settings/test]', error); + return NextResponse.json( + { error: 'Failed to send test message' }, + { status: 500 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 56ca4b7..db43194 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-Location Tracker} - ENCRYPTION_KEY=${ENCRYPTION_KEY} + # Telegram Configuration + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} volumes: - ./data:/app/data - ./public:/app/public # Mount public directory for static files diff --git a/docs/telegram-anleitung.md b/docs/telegram-anleitung.md new file mode 100644 index 0000000..909de37 --- /dev/null +++ b/docs/telegram-anleitung.md @@ -0,0 +1,163 @@ +# 📱 Telegram-Benachrichtigungen aktivieren + +## Übersicht + +Erhalte Geofence-Benachrichtigungen direkt in Telegram mit: +- 📍 Standort-Pin auf der Karte +- 🔔 Sofortige Push-Benachrichtigungen +- 🔗 Direktlinks zur Karte und zum Dashboard + +--- + +## 🚀 Schritt-für-Schritt Anleitung + +### Schritt 1: Bot starten + +#### Option A: QR-Code scannen (empfohlen für Smartphone) + +

+ Telegram Bot QR Code +

+ +1. Öffne die Kamera-App auf deinem Smartphone +2. Scanne den QR-Code +3. Telegram öffnet sich automatisch +4. Klicke auf **"Start"** oder sende `/start` + +#### Option B: Link öffnen + +1. Klicke auf: **[https://t.me/MeinTracking_bot](https://t.me/MeinTracking_bot)** +2. Telegram öffnet sich automatisch +3. Klicke auf **"Start"** oder sende `/start` + +✅ Du solltest eine Willkommensnachricht vom Bot erhalten + +--- + +### Schritt 2: Chat ID herausfinden + +#### Option A: Mit @myidbot (empfohlen) + +1. Suche in Telegram nach: **@myidbot** +2. Sende: `/getid` +3. Der Bot antwortet sofort mit deiner Chat ID: + ``` + Your user ID: 123456789 + ``` +4. **Kopiere die Nummer** (z.B. `123456789`) + +#### Option B: Mit @userinfobot + +1. Suche in Telegram nach: **@userinfobot** +2. Sende: `/start` +3. Der Bot antwortet mit deinen Informationen: + ``` + Id: 123456789 + First name: Dein Name + ... + ``` +4. **Kopiere die Nummer** bei "Id:" (z.B. `123456789`) + +--- + +### Schritt 3: Chat ID im Dashboard eintragen + +1. Melde dich im Location Tracker an +2. Gehe zu: **Einstellungen** → **Benachrichtigungen** +3. Aktiviere: ☑️ **Telegram Benachrichtigungen** +4. Trage deine **Chat ID** ein (die Nummer aus Schritt 2) +5. Klicke auf **"Speichern"** + +--- + +### Schritt 4: Verbindung testen + +1. Klicke auf den Button **"Telegram Test"** +2. Du solltest sofort eine Test-Nachricht in Telegram erhalten: + ``` + ✅ Telegram Connection Test + + Die Verbindung funktioniert! + ``` + +✅ **Fertig!** Ab jetzt erhältst du alle Geofence-Benachrichtigungen auch per Telegram. + +--- + +## 📨 Beispiel-Benachrichtigung + +So sieht eine Geofence-Benachrichtigung aus: + +``` +🟢 Geofence BETRETEN + +📱 Device: Mein iPhone +📍 Geofence: Zuhause +🕐 Zeit: 04.12.25, 14:30 +📊 Ereignis: Hat Zuhause betreten +``` + +**+ Standort-Pin auf der Karte** + +**+ Buttons:** +- 🗺️ Auf Karte zeigen +- 📊 Dashboard öffnen + +--- + +## ❓ Häufige Fragen + +### Kann ich sowohl Email als auch Telegram aktivieren? + +Ja! Du kannst beide Kanäle gleichzeitig nutzen: +- ☑️ E-Mail Benachrichtigungen +- ☑️ Telegram Benachrichtigungen + +### Sehen andere User meine Benachrichtigungen? + +**Nein!** Jede Chat ID ist ein privater 1-zu-1 Chat. Andere User können deine Nachrichten nicht sehen, auch wenn sie den gleichen Bot nutzen. + +### Was ist, wenn die Chat ID falsch ist? + +Dann erhältst du keine Benachrichtigungen. Nutze den "Telegram Test" Button, um die Verbindung zu prüfen. + +### Kann ich Benachrichtigungen pausieren? + +Ja! Deaktiviere einfach "Telegram Benachrichtigungen" in den Einstellungen. Deine Chat ID bleibt gespeichert. + +### Wo finde ich meine Chat ID nochmal? + +Sende `/getid` an **@myidbot** oder `/start` an **@userinfobot** in Telegram. + +--- + +## 🛠️ Troubleshooting + +### "No Telegram chat ID configured" + +→ Du hast noch keine Chat ID eingetragen. Folge Schritt 2 und 3. + +### "Failed to send test message" + +Mögliche Ursachen: +1. **Chat ID falsch** → Überprüfe die Nummer bei @userinfobot +2. **Bot nicht gestartet** → Sende `/start` an @MeinTracking_bot +3. **Bot blockiert** → Entsperre den Bot in Telegram + +### Test-Nachricht kommt nicht an + +1. Überprüfe, ob du den Bot gestartet hast (`/start` an @MeinTracking_bot) +2. Prüfe, ob die Chat ID korrekt ist (keine Leerzeichen, keine Buchstaben) + - Hole sie neu mit `/getid` an @myidbot +3. Stelle sicher, dass du den Bot nicht blockiert hast + +--- + +## 📞 Support + +Bei Problemen wende dich an den Administrator oder überprüfe die Logs im System. + +--- + +**Bot:** [@MeinTracking_bot](https://t.me/MeinTracking_bot) +**Letzte Aktualisierung:** 04.12.2025 diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md new file mode 100644 index 0000000..c0535d8 --- /dev/null +++ b/docs/telegram-setup.md @@ -0,0 +1,345 @@ +# 🚀 Telegram Integration - Setup Guide + +## ✅ Was wurde implementiert + +### Dateien erstellt: + +1. **Backend Services:** + - `lib/telegram-service.ts` - Telegram API Integration + - `lib/notification-settings-db.ts` - Datenbank-Layer für Settings + - `lib/geofence-notifications.ts` - Erweitert für Multi-Channel (Email + Telegram) + +2. **Database:** + - `scripts/init-notification-settings.js` - DB Init Script + - Tabelle: `UserNotificationSettings` erstellt + +3. **API Endpoints:** + - `GET/PATCH /api/users/[id]/notification-settings` - Settings verwalten + - `POST /api/users/[id]/notification-settings/test` - Test-Nachricht + +4. **Admin UI:** + - `app/admin/settings/notifications/page.tsx` - Settings Page mit QR Code + +5. **Dokumentation:** + - `docs/telegram-anleitung.md` - User-Anleitung + - `docs/telegram-setup.md` - Dieses Dokument + - `public/telegram-bot-qr.png` - QR Code für Bot + +--- + +## 🔧 Installation & Setup + +### 1. Dependencies installiert + +```bash +✅ npm install axios +✅ npm install qrcode +``` + +### 2. Datenbank initialisiert + +```bash +✅ node scripts/init-notification-settings.js +``` + +Die Tabelle `UserNotificationSettings` wurde erstellt mit: +- `user_id` (PK, FK → User.id) +- `email_enabled` (0/1) +- `telegram_enabled` (0/1) +- `telegram_chat_id` (TEXT) +- `created_at`, `updated_at` + +--- + +## 📱 Bot Setup + +### Telegram Bot erstellen (für Admin): + +1. Öffne Telegram und suche: **@BotFather** +2. Sende: `/newbot` +3. Folge den Anweisungen: + - Bot Name: `MeinTracking Bot` (oder anders) + - Bot Username: `MeinTracking_bot` (muss auf `_bot` enden) +4. BotFather gibt dir einen Token: `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz` +5. **Wichtig:** Token NIEMALS committen! + +### .env konfigurieren: + +```bash +# .env oder .env.local +TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +--- + +## 🎯 Für User: Bot aktivieren + +**Schritt-für-Schritt siehe:** `docs/telegram-anleitung.md` + +**Kurzversion:** +1. Bot starten: [https://t.me/MeinTracking_bot](https://t.me/MeinTracking_bot) oder QR scannen +2. Chat ID holen: `@myidbot` → `/getid` (oder `@userinfobot` → `/start`) +3. Im Dashboard: Settings → Notifications → Chat ID eintragen +4. Test Button drücken + +--- + +## 🧪 Testing + +### 1. QR Code generieren + +```bash +npm run telegram:qr +``` + +Erstellt: `public/telegram-bot-qr.png` + +### 2. Datenbank neu initialisieren + +```bash +npm run db:init:notifications +``` + +Oder alles zusammen: +```bash +npm run db:init +``` + +### 3. Test-Nachricht senden + +**Option A: Über Admin UI** +- Navigiere zu: `/admin/settings/notifications` +- Telegram aktivieren +- Chat ID eintragen +- Button "Telegram Test" klicken + +**Option B: Direkt via Script** + +Erstelle `scripts/test-telegram.js`: +```javascript +const { telegramService } = require('../lib/telegram-service'); + +const CHAT_ID = '123456789'; // Deine Chat ID + +async function test() { + await telegramService.testConnection(CHAT_ID); +} +test(); +``` + +```bash +TELEGRAM_BOT_TOKEN=dein_token node scripts/test-telegram.js +``` + +--- + +## 🔐 Sicherheit & Privacy + +### Wie funktioniert die Privacy? + +- **1 Bot für alle User** (in `.env`) +- **Jeder User hat eigene Chat ID** (in DB) +- **Bot sendet nur an spezifische Chat ID** → Privater 1-zu-1 Chat +- **User A sieht NIEMALS Nachrichten von User B** + +### Telegram API Garantie: + +```typescript +telegram.sendMessage(chatId: "123456789", text: "...") +→ Geht NUR an Chat ID 123456789 +→ Andere Chat IDs können dies NICHT sehen +``` + +### Vergleich: +Wie Email: 1 SMTP-Server (Bot) sendet an verschiedene Adressen (Chat IDs) + +--- + +## 📊 Wie es funktioniert + +### Notification Flow: + +``` +1. Device triggers Geofence + ↓ +2. geofence-engine.ts erkennt Event + ↓ +3. GeofenceEvent in DB erstellt + ↓ +4. geofence-notifications.ts aufgerufen + ↓ +5. getUserNotificationSettings(owner_id) + ↓ +6. Parallel execution: + ├─→ Email (if enabled) + └─→ Telegram (if enabled) + ↓ + ├─→ Text mit Emoji, Device, Geofence, Zeit + ├─→ Inline Buttons (Karte, Dashboard) + └─→ Location Pin +``` + +### Multi-Channel Strategy: + +- **Promise.allSettled()** → Beide parallel +- **Mind. 1 erfolgreich** → Event als "sent" markiert +- **Beide fehlgeschlagen** → Event als "failed" markiert +- **Robust:** Email-Fehler betrifft nicht Telegram (und umgekehrt) + +--- + +## 🎨 Admin UI Features + +**Page:** `/admin/settings/notifications` + +**Komponenten:** +- ☑️ Email Benachrichtigungen Toggle +- ☑️ Telegram Benachrichtigungen Toggle +- 📝 Telegram Chat ID Input +- 💾 Speichern Button +- 🧪 Telegram Test Button +- 📱 QR Code Display (400x400px) +- 📖 Schritt-für-Schritt Anleitung + +--- + +## 🛠️ NPM Scripts + +```bash +# Datenbank initialisieren +npm run db:init # Alles (inkl. Notifications) +npm run db:init:notifications # Nur Notifications Tabelle + +# QR Code generieren +npm run telegram:qr # Erstellt public/telegram-bot-qr.png + +# Testing +npm run test:geofence # Geofence Event simulieren +npm run test:geofence:email # Email Notification testen +``` + +--- + +## 📝 Telegram Message Format + +### Enter Event: +``` +🟢 Geofence BETRETEN + +📱 Device: Mein iPhone +📍 Geofence: Zuhause +🕐 Zeit: 04.12.25, 14:30 +📊 Ereignis: Hat Zuhause betreten +``` + +**+ Location Pin** (Karte mit exakter Position) + +**+ Buttons:** +- 🗺️ Auf Karte zeigen → `/map?lat=...&lon=...` +- 📊 Dashboard öffnen → `/admin/geofences/events` + +### Exit Event: +``` +🔴 Geofence VERLASSEN + +📱 Device: Mein iPhone +📍 Geofence: Zuhause +🕐 Zeit: 04.12.25, 18:45 +📊 Ereignis: Hat Zuhause verlassen +``` + +--- + +## 🚨 Troubleshooting + +### "Cannot find module telegram-service" + +→ TypeScript nicht kompiliert. Lösung: +```bash +npm run build +``` + +Oder für Development: +```bash +npm run dev +``` + +### "Telegram bot token not configured" + +→ `.env` fehlt `TELEGRAM_BOT_TOKEN` + +### "Failed to send test message" + +**Mögliche Ursachen:** +1. Bot nicht gestartet (`/start` an @MeinTracking_bot) +2. Chat ID falsch +3. Bot blockiert +4. Token falsch/abgelaufen + +**Debug:** +```bash +# Check .env +cat .env | grep TELEGRAM + +# Check DB +sqlite3 data/database.sqlite "SELECT * FROM UserNotificationSettings;" + +# Test Telegram API direkt +curl https://api.telegram.org/bot/getMe +``` + +### QR Code wird nicht angezeigt + +→ Prüfen ob Datei existiert: +```bash +ls -la public/telegram-bot-qr.png +``` + +Falls nicht: +```bash +npm run telegram:qr +``` + +--- + +## 📚 Environment Variables + +```bash +# Required für Telegram +TELEGRAM_BOT_TOKEN= + +# Optional (für Map/Dashboard Links) +NEXTAUTH_URL=https://deine-domain.de +``` + +--- + +## 🎯 Nächste Schritte + +**Für Produktion:** + +1. ✅ `.env.production` mit echtem Bot-Token erstellen +2. ✅ Datenbank migrieren: `npm run db:init` +3. ✅ QR Code generieren: `npm run telegram:qr` +4. ✅ User-Anleitung teilen: `docs/telegram-anleitung.md` +5. ✅ Test durchführen mit echtem Device + +**Optional:** +- Push Notifications hinzufügen +- SMS Integration +- Webhook Support +- Notification History UI +- Per-Geofence Settings (nicht global) + +--- + +## 📞 Support + +**Bot:** [@MeinTracking_bot](https://t.me/MeinTracking_bot) + +**Dokumentation:** +- User-Anleitung: `docs/telegram-anleitung.md` +- API Implementierung: `docs/telegram.md` +- Dieses Setup-Guide: `docs/telegram-setup.md` + +**Letzte Aktualisierung:** 04.12.2025 diff --git a/docs/telegram.md b/docs/telegram.md new file mode 100644 index 0000000..371690e --- /dev/null +++ b/docs/telegram.md @@ -0,0 +1,1162 @@ +# Telegram Notifications Implementation Guide + +## 📋 Overview + +This document describes how to implement Telegram notifications for the geofencing system, allowing users to receive geofence alerts via Telegram instead of or in addition to email notifications. + +## 🎯 Features + +- ✅ **Text notifications** - Rich formatted messages with device and geofence info +- ✅ **Location pins** - Telegram map with exact coordinates where event occurred +- ✅ **Inline buttons** - Interactive buttons to view on map or open dashboard +- ✅ **User-configurable** - Each user can choose: Email, Telegram, both, or neither +- ✅ **Parallel execution** - Email and Telegram sent simultaneously for speed +- ✅ **Robust** - If one channel fails, the other still works + +## 📐 Architecture + +``` +Geofence Event + ↓ +geofence-notifications.ts + ↓ +getUserNotificationSettings(userId) + ↓ + ├─→ Email Service (if email_enabled && email exists) + └─→ Telegram Service (if telegram_enabled && chat_id exists) + ↓ + Send Text + Location Pin + Inline Buttons +``` + +## 🗄️ Database Changes + +### Option A: Extend User Table + +```sql +-- Add to existing User table +ALTER TABLE User ADD COLUMN telegram_chat_id TEXT; +ALTER TABLE User ADD COLUMN notification_email INTEGER DEFAULT 1; +ALTER TABLE User ADD COLUMN notification_telegram INTEGER DEFAULT 0; +``` + +### Option B: Separate Settings Table (Recommended) + +```sql +-- Create dedicated notification settings table +CREATE TABLE UserNotificationSettings ( + user_id TEXT PRIMARY KEY, + email_enabled INTEGER DEFAULT 1, + telegram_enabled INTEGER DEFAULT 0, + telegram_chat_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE, + CHECK (email_enabled IN (0, 1)), + CHECK (telegram_enabled IN (0, 1)) +); + +-- Create index for performance +CREATE INDEX idx_user_notification_settings_user + ON UserNotificationSettings(user_id); +``` + +**Why Option B is better:** +- Cleaner separation of concerns +- Easier to add more notification channels later (Push, SMS, etc.) +- Can track settings changes history +- Doesn't pollute User table + +## 📝 Implementation Steps + +### 1. Create Telegram Service + +**File:** `lib/telegram-service.ts` + +```typescript +import axios from 'axios'; + +interface TelegramMessage { + chatId: string; + text: string; + latitude?: number; + longitude?: number; + buttons?: Array<{ text: string; url: string }>; +} + +interface GeofenceNotificationParams { + chatId: string; + deviceName: string; + geofenceName: string; + eventType: 'enter' | 'exit'; + latitude: number; + longitude: number; + timestamp: string; + mapUrl?: string; + dashboardUrl?: string; +} + +class TelegramService { + private botToken: string; + private baseUrl: string; + + constructor() { + this.botToken = process.env.TELEGRAM_BOT_TOKEN || ''; + if (!this.botToken) { + console.warn('[TelegramService] No TELEGRAM_BOT_TOKEN configured'); + } + this.baseUrl = `https://api.telegram.org/bot${this.botToken}`; + } + + /** + * Check if Telegram is configured + */ + isConfigured(): boolean { + return !!this.botToken; + } + + /** + * Send text message with optional inline buttons + */ + async sendMessage(params: TelegramMessage): Promise { + if (!this.isConfigured()) { + throw new Error('Telegram bot token not configured'); + } + + const { chatId, text, buttons } = params; + + const payload: any = { + chat_id: chatId, + text: text, + parse_mode: 'HTML', + }; + + // Add inline buttons if provided + if (buttons && buttons.length > 0) { + payload.reply_markup = { + inline_keyboard: [ + buttons.map(btn => ({ + text: btn.text, + url: btn.url, + })), + ], + }; + } + + const response = await axios.post(`${this.baseUrl}/sendMessage`, payload); + + if (!response.data.ok) { + throw new Error(`Telegram API error: ${response.data.description}`); + } + } + + /** + * Send location pin on map + */ + async sendLocation( + chatId: string, + latitude: number, + longitude: number + ): Promise { + if (!this.isConfigured()) { + throw new Error('Telegram bot token not configured'); + } + + const response = await axios.post(`${this.baseUrl}/sendLocation`, { + chat_id: chatId, + latitude, + longitude, + }); + + if (!response.data.ok) { + throw new Error(`Telegram API error: ${response.data.description}`); + } + } + + /** + * Send geofence notification (complete with text + location + buttons) + */ + async sendGeofenceNotification( + params: GeofenceNotificationParams + ): Promise { + const { + chatId, + deviceName, + geofenceName, + eventType, + latitude, + longitude, + timestamp, + mapUrl, + dashboardUrl, + } = params; + + // Format message + const emoji = eventType === 'enter' ? '🟢' : '🔴'; + const action = eventType === 'enter' ? 'BETRETEN' : 'VERLASSEN'; + const verb = eventType === 'enter' ? 'betreten' : 'verlassen'; + + const formattedDate = new Date(timestamp).toLocaleString('de-DE', { + dateStyle: 'short', + timeStyle: 'short', + }); + + const text = ` +${emoji} Geofence ${action} + +📱 Device: ${deviceName} +📍 Geofence: ${geofenceName} +🕐 Zeit: ${formattedDate} +📊 Ereignis: Hat ${geofenceName} ${verb} + `.trim(); + + // Prepare inline buttons + const buttons = []; + if (mapUrl) { + buttons.push({ text: '🗺️ Auf Karte zeigen', url: mapUrl }); + } + if (dashboardUrl) { + buttons.push({ text: '📊 Dashboard öffnen', url: dashboardUrl }); + } + + // Send text message with buttons + await this.sendMessage({ chatId, text, buttons }); + + // Send location pin + await this.sendLocation(chatId, latitude, longitude); + } + + /** + * Test connection by sending a simple message + */ + async testConnection(chatId: string): Promise { + try { + await this.sendMessage({ + chatId, + text: '✅ Telegram Connection Test\n\nDie Verbindung funktioniert!', + }); + return true; + } catch (error) { + console.error('[TelegramService] Test failed:', error); + return false; + } + } +} + +export const telegramService = new TelegramService(); +``` + +### 2. Create Notification Settings Database Layer + +**File:** `lib/notification-settings-db.ts` + +```typescript +import Database from 'better-sqlite3'; +import path from 'path'; + +const dbPath = path.join(process.cwd(), 'data', 'database.sqlite'); + +export interface UserNotificationSettings { + user_id: string; + email_enabled: boolean; + telegram_enabled: boolean; + telegram_chat_id: string | null; + created_at?: string; + updated_at?: string; +} + +/** + * Get notification settings for a user + * Returns default settings if not found + */ +export function getUserNotificationSettings( + userId: string +): UserNotificationSettings { + const db = new Database(dbPath); + try { + const stmt = db.prepare(` + SELECT + user_id, + email_enabled, + telegram_enabled, + telegram_chat_id, + created_at, + updated_at + FROM UserNotificationSettings + WHERE user_id = ? + `); + + const result = stmt.get(userId) as UserNotificationSettings | undefined; + + // Return defaults if no settings found + if (!result) { + return { + user_id: userId, + email_enabled: true, + telegram_enabled: false, + telegram_chat_id: null, + }; + } + + return result; + } finally { + db.close(); + } +} + +/** + * Update notification settings for a user + */ +export function updateUserNotificationSettings( + userId: string, + settings: Partial +): UserNotificationSettings { + const db = new Database(dbPath); + try { + // Check if settings exist + const existing = db + .prepare('SELECT user_id FROM UserNotificationSettings WHERE user_id = ?') + .get(userId); + + if (existing) { + // Update existing + const updates: string[] = []; + const values: any[] = []; + + if (settings.email_enabled !== undefined) { + updates.push('email_enabled = ?'); + values.push(settings.email_enabled ? 1 : 0); + } + if (settings.telegram_enabled !== undefined) { + updates.push('telegram_enabled = ?'); + values.push(settings.telegram_enabled ? 1 : 0); + } + if (settings.telegram_chat_id !== undefined) { + updates.push('telegram_chat_id = ?'); + values.push(settings.telegram_chat_id); + } + + updates.push('updated_at = datetime(\'now\')'); + values.push(userId); + + const stmt = db.prepare(` + UPDATE UserNotificationSettings + SET ${updates.join(', ')} + WHERE user_id = ? + `); + + stmt.run(...values); + } else { + // Insert new + const stmt = db.prepare(` + INSERT INTO UserNotificationSettings ( + user_id, + email_enabled, + telegram_enabled, + telegram_chat_id + ) VALUES (?, ?, ?, ?) + `); + + stmt.run( + userId, + settings.email_enabled ?? 1, + settings.telegram_enabled ?? 0, + settings.telegram_chat_id ?? null + ); + } + + return getUserNotificationSettings(userId); + } finally { + db.close(); + } +} + +/** + * Initialize notification settings table + */ +export function initNotificationSettingsTable(): void { + const db = new Database(dbPath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS UserNotificationSettings ( + user_id TEXT PRIMARY KEY, + email_enabled INTEGER DEFAULT 1, + telegram_enabled INTEGER DEFAULT 0, + telegram_chat_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE, + CHECK (email_enabled IN (0, 1)), + CHECK (telegram_enabled IN (0, 1)) + ); + + CREATE INDEX IF NOT EXISTS idx_user_notification_settings_user + ON UserNotificationSettings(user_id); + `); + + console.log('✓ UserNotificationSettings table initialized'); + } finally { + db.close(); + } +} +``` + +### 3. Update Geofence Notifications Handler + +**File:** `lib/geofence-notifications.ts` (extend existing) + +```typescript +import { emailService } from './email-service'; +import { telegramService } from './telegram-service'; +import { + getUserNotificationSettings, + UserNotificationSettings, +} from './notification-settings-db'; +import { + renderGeofenceEnterEmail, + renderGeofenceExitEmail, + GeofenceEmailData, +} from './email-renderer'; +// ... existing imports ... + +/** + * Send notification for a single geofence event + * Supports both Email and Telegram based on user preferences + */ +async function sendEventNotification(event: GeofenceEvent): Promise { + 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[] = []; + + // 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 (existing function - extracted) + */ +async function sendEmailNotification( + event: GeofenceEvent, + owner: UserInfo, + device: DeviceInfo, + geofence: GeofenceInfo +): Promise { + 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 { + try { + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + + await telegramService.sendGeofenceNotification({ + chatId, + deviceName: device.name, + geofenceName: geofence.name, + eventType: event.event_type, + latitude: event.latitude, + longitude: event.longitude, + timestamp: event.timestamp, + mapUrl: `${baseUrl}/map?lat=${event.latitude}&lon=${event.longitude}`, + dashboardUrl: `${baseUrl}/admin/geofences/events`, + }); + + console.log( + `[GeofenceNotification] Sent Telegram notification to ${chatId}` + ); + } catch (error) { + console.error('[GeofenceNotification] Telegram error:', error); + throw error; + } +} + +// Export for other modules +export { sendEventNotification }; +``` + +### 4. Create Database Init Script + +**File:** `scripts/init-notification-settings.js` + +```javascript +#!/usr/bin/env node +/** + * Initialize UserNotificationSettings table + */ + +const { initNotificationSettingsTable } = require('../lib/notification-settings-db'); + +try { + initNotificationSettingsTable(); + console.log('✅ Notification settings table initialized!'); +} catch (error) { + console.error('❌ Error:', error.message); + process.exit(1); +} +``` + +### 5. Add API Routes for Settings + +**File:** `app/api/users/[id]/notification-settings/route.ts` + +```typescript +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { + getUserNotificationSettings, + updateUserNotificationSettings, +} from '@/lib/notification-settings-db'; + +// GET /api/users/[id]/notification-settings +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + // Users can only view their own settings + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const settings = getUserNotificationSettings(userId); + + return NextResponse.json({ settings }); + } catch (error) { + console.error('[GET /api/users/[id]/notification-settings]', error); + return NextResponse.json( + { error: 'Failed to get settings' }, + { status: 500 } + ); + } +} + +// PATCH /api/users/[id]/notification-settings +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + // Users can only update their own settings + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { email_enabled, telegram_enabled, telegram_chat_id } = body; + + const settings = updateUserNotificationSettings(userId, { + email_enabled, + telegram_enabled, + telegram_chat_id, + }); + + return NextResponse.json({ settings }); + } catch (error) { + console.error('[PATCH /api/users/[id]/notification-settings]', error); + return NextResponse.json( + { error: 'Failed to update settings' }, + { status: 500 } + ); + } +} +``` + +**File:** `app/api/users/[id]/notification-settings/test/route.ts` + +```typescript +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { telegramService } from '@/lib/telegram-service'; +import { getUserNotificationSettings } from '@/lib/notification-settings-db'; + +// POST /api/users/[id]/notification-settings/test +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: userId } = await params; + const currentUserId = (session.user as any).id; + + if (userId !== currentUserId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const settings = getUserNotificationSettings(userId); + + if (!settings.telegram_chat_id) { + return NextResponse.json( + { error: 'No Telegram chat ID configured' }, + { status: 400 } + ); + } + + const success = await telegramService.testConnection( + settings.telegram_chat_id + ); + + if (success) { + return NextResponse.json({ success: true, message: 'Test message sent' }); + } else { + return NextResponse.json( + { error: 'Failed to send test message' }, + { status: 500 } + ); + } + } catch (error) { + console.error('[POST /api/users/[id]/notification-settings/test]', error); + return NextResponse.json( + { error: 'Failed to send test message' }, + { status: 500 } + ); + } +} +``` + +### 6. Add Admin UI Page + +**File:** `app/admin/settings/notifications/page.tsx` + +```tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; + +export default function NotificationSettingsPage() { + const { data: session } = useSession(); + const [settings, setSettings] = useState({ + email_enabled: true, + telegram_enabled: false, + telegram_chat_id: '', + }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (session?.user) { + loadSettings(); + } + }, [session]); + + async function loadSettings() { + try { + const userId = (session!.user as any).id; + const res = await fetch(`/api/users/${userId}/notification-settings`); + const data = await res.json(); + setSettings(data.settings); + } catch (error) { + console.error('Failed to load settings:', error); + } + } + + async function handleSave() { + setLoading(true); + setMessage(''); + + try { + const userId = (session!.user as any).id; + const res = await fetch(`/api/users/${userId}/notification-settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + + if (res.ok) { + setMessage('✅ Einstellungen gespeichert!'); + } else { + setMessage('❌ Fehler beim Speichern'); + } + } catch (error) { + setMessage('❌ Fehler beim Speichern'); + } finally { + setLoading(false); + } + } + + async function handleTest() { + setLoading(true); + setMessage(''); + + try { + const userId = (session!.user as any).id; + const res = await fetch( + `/api/users/${userId}/notification-settings/test`, + { method: 'POST' } + ); + + if (res.ok) { + setMessage('✅ Test-Nachricht gesendet!'); + } else { + const data = await res.json(); + setMessage(`❌ Fehler: ${data.error}`); + } + } catch (error) { + setMessage('❌ Fehler beim Senden der Test-Nachricht'); + } finally { + setLoading(false); + } + } + + return ( +
+

Benachrichtigungseinstellungen

+ +
+ {/* Email Settings */} +
+ +

+ Geofence-Ereignisse per E-Mail erhalten +

+
+ + {/* Telegram Settings */} +
+ +

+ Geofence-Ereignisse per Telegram erhalten (inkl. Karte und Buttons) +

+ + {settings.telegram_enabled && ( +
+ + + setSettings({ ...settings, telegram_chat_id: e.target.value }) + } + placeholder="z.B. 123456789" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +

+ Deine Telegram Chat ID findest du über @userinfobot +

+
+ )} +
+ + {/* Action Buttons */} +
+ + + {settings.telegram_enabled && settings.telegram_chat_id && ( + + )} +
+ + {/* Status Message */} + {message && ( +
{message}
+ )} +
+ + {/* Help Section */} +
+

📖 Wie bekomme ich meine Chat ID?

+
    +
  1. Öffne Telegram und suche nach @userinfobot
  2. +
  3. Starte den Bot mit /start
  4. +
  5. Der Bot sendet dir deine Chat ID
  6. +
  7. Kopiere die Nummer und füge sie oben ein
  8. +
+
+
+ ); +} +``` + +## 🔧 Environment Configuration + +Add to `.env` or `.env.local`: + +```env +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +## 🧪 Testing + +### 1. Initialize Database + +```bash +# Run the initialization script +node scripts/init-notification-settings.js + +# Or add to main db init +npm run db:init +``` + +### 2. Test Telegram Service + +Create `scripts/test-telegram.js`: + +```javascript +#!/usr/bin/env node +const { telegramService } = require('../lib/telegram-service'); + +// Replace with your chat ID +const CHAT_ID = '123456789'; + +async function test() { + console.log('🧪 Testing Telegram service...\n'); + + try { + // Test 1: Simple message + console.log('1. Sending simple message...'); + await telegramService.sendMessage({ + chatId: CHAT_ID, + text: '✅ Test erfolgreich!\n\nDie Telegram-Integration funktioniert.', + }); + console.log(' ✓ Message sent\n'); + + // Test 2: Location + console.log('2. Sending location...'); + await telegramService.sendLocation(CHAT_ID, 52.520008, 13.404954); + console.log(' ✓ Location sent\n'); + + // Test 3: Full geofence notification + console.log('3. Sending geofence notification...'); + await telegramService.sendGeofenceNotification({ + chatId: CHAT_ID, + deviceName: 'Test Device', + geofenceName: 'Test Geofence', + eventType: 'enter', + latitude: 52.520008, + longitude: 13.404954, + timestamp: new Date().toISOString(), + mapUrl: 'https://example.com/map', + dashboardUrl: 'https://example.com/admin', + }); + console.log(' ✓ Geofence notification sent\n'); + + console.log('✅ All tests passed!'); + } catch (error) { + console.error('❌ Test failed:', error.message); + process.exit(1); + } +} + +test(); +``` + +Run it: + +```bash +chmod +x scripts/test-telegram.js +TELEGRAM_BOT_TOKEN=your_token node scripts/test-telegram.js +``` + +### 3. Test via Admin UI + +1. Navigate to `/admin/settings/notifications` +2. Enable Telegram notifications +3. Enter your Chat ID +4. Click "Telegram Test" +5. Check your Telegram for the test message + +## 📦 Package Dependencies + +Add to `package.json`: + +```json +{ + "dependencies": { + "axios": "^1.6.0" + } +} +``` + +Install: + +```bash +npm install axios +``` + +## 🎯 Example Notification Flow + +``` +1. Device enters geofence + ↓ +2. geofence-engine.ts detects event + ↓ +3. GeofenceEvent created in database + ↓ +4. geofence-notifications.ts triggered + ↓ +5. getUserNotificationSettings(owner_id) + ↓ +6. Parallel execution: + ├─→ sendEmailNotification (if enabled) + └─→ sendTelegramNotification (if enabled) + ↓ + Telegram receives: + ├─→ Text message with emoji, device, geofence, time + ├─→ Inline buttons (map, dashboard) + └─→ Location pin on map +``` + +## 💡 Tips & Best Practices + +### Getting Chat ID + +Users can get their Telegram Chat ID: +1. Search for `@userinfobot` in Telegram +2. Send `/start` +3. Bot replies with Chat ID + +### Bot Setup + +To create a Telegram Bot: +1. Message `@BotFather` in Telegram +2. Send `/newbot` +3. Follow the prompts +4. Save the token provided + +### Rate Limiting + +Telegram API has rate limits: +- 30 messages per second per bot +- 20 messages per minute per chat + +The current implementation sends 2 messages per geofence event (text + location), so you can handle ~15 events per minute per user. + +### Error Handling + +The implementation uses `Promise.allSettled()`, meaning: +- If Telegram fails, Email still works (and vice versa) +- At least one successful channel = event marked as "sent" +- Both fail = event marked as "failed" with error details + +### Security + +- Bot token should be in environment variables, never committed to git +- Chat IDs are user-specific and stored per user +- Users can only configure their own notification settings (enforced by API) + +## 🔄 Migration Guide + +If you already have users, run this migration: + +```sql +-- Add default settings for existing users +INSERT INTO UserNotificationSettings (user_id, email_enabled, telegram_enabled) +SELECT id, 1, 0 FROM User +WHERE id NOT IN (SELECT user_id FROM UserNotificationSettings); +``` + +## 📊 Database Schema Reference + +```sql +UserNotificationSettings +├── user_id (TEXT, PK, FK → User.id) +├── email_enabled (INTEGER, 0 or 1) +├── telegram_enabled (INTEGER, 0 or 1) +├── telegram_chat_id (TEXT, nullable) +├── created_at (TEXT) +└── updated_at (TEXT) + +Indexes: +└── idx_user_notification_settings_user ON UserNotificationSettings(user_id) +``` + +## 🚀 Future Enhancements + +Possible extensions: +- [ ] Push notifications (web push) +- [ ] SMS notifications (Twilio) +- [ ] Webhook notifications (custom endpoints) +- [ ] Notification scheduling (quiet hours) +- [ ] Notification grouping (batch multiple events) +- [ ] Per-geofence notification settings +- [ ] Rich Telegram messages with photos/videos + +## 📝 Changelog + +### Version 1.0 (Initial Implementation) +- Basic Telegram notification support +- Text messages with emoji and formatting +- Location pins on map +- Inline buttons for map and dashboard +- User-configurable settings (Email, Telegram, both) +- Test functionality via Admin UI +- Parallel notification delivery +- Robust error handling + +--- + +**Author:** Claude Code +**Last Updated:** 2025-12-04 diff --git a/lib/geofence-notifications.ts b/lib/geofence-notifications.ts index 4ee29b0..065dcf4 100644 --- a/lib/geofence-notifications.ts +++ b/lib/geofence-notifications.ts @@ -1,15 +1,19 @@ /** * Geofence Notification Service - * Handles sending email notifications for geofence events + * 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'; @@ -62,6 +66,7 @@ function getUser(userId: string): UserInfo | null { /** * Send notification for a single geofence event + * Supports both Email and Telegram based on user preferences */ async function sendEventNotification(event: GeofenceEvent): Promise { try { @@ -79,13 +84,87 @@ async function sendEventNotification(event: GeofenceEvent): Promise { // Get owner details const owner = getUser(geofence.owner_id); - if (!owner || !owner.email) { - console.log(`[GeofenceNotification] No email for owner ${geofence.owner_id}, skipping notification`); - geofenceDb.markNotificationSent(event.id!, true); // Mark as "sent" (no email needed) + 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[] = []; + + // 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; } - // Prepare email data + // 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 { + try { const emailData: GeofenceEmailData = { username: owner.username, deviceName: device.name, @@ -94,11 +173,8 @@ async function sendEventNotification(event: GeofenceEvent): Promise { latitude: event.latitude, longitude: event.longitude, distanceFromCenter: event.distance_from_center || 0, - // Optional: Add map URL later - // mapUrl: `${process.env.NEXT_PUBLIC_URL}/map?lat=${event.latitude}&lon=${event.longitude}` }; - // Render and send email let html: string; let subject: string; @@ -110,20 +186,51 @@ async function sendEventNotification(event: GeofenceEvent): Promise { subject = `${device.name} hat ${geofence.name} verlassen`; } - // Send via existing email service - await emailService['sendEmail'](owner.email, subject, html); + await emailService['sendEmail'](owner.email!, subject, html); - // Mark notification as sent - geofenceDb.markNotificationSent(event.id!, true); - - console.log(`[GeofenceNotification] Sent ${event.event_type} notification for geofence ${geofence.name} to ${owner.email}`); + console.log( + `[GeofenceNotification] Sent email to ${owner.email}` + ); } catch (error) { - console.error('[GeofenceNotification] Failed to send notification:', error); + console.error('[GeofenceNotification] Email error:', error); + throw error; + } +} - // Mark notification as failed - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - geofenceDb.markNotificationSent(event.id!, false, errorMessage); +/** + * Send Telegram notification (new function) + */ +async function sendTelegramNotification( + event: GeofenceEvent, + owner: UserInfo, + device: DeviceInfo, + geofence: GeofenceInfo, + chatId: string +): Promise { + 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; } } diff --git a/lib/notification-settings-db.ts b/lib/notification-settings-db.ts new file mode 100644 index 0000000..959f4a2 --- /dev/null +++ b/lib/notification-settings-db.ts @@ -0,0 +1,157 @@ +import Database from 'better-sqlite3'; +import path from 'path'; + +const dbPath = path.join(process.cwd(), 'data', 'database.sqlite'); + +export interface UserNotificationSettings { + user_id: string; + email_enabled: boolean; + telegram_enabled: boolean; + telegram_chat_id: string | null; + created_at?: string; + updated_at?: string; +} + +/** + * Get notification settings for a user + * Returns default settings if not found + */ +export function getUserNotificationSettings( + userId: string +): UserNotificationSettings { + const db = new Database(dbPath); + try { + const stmt = db.prepare(` + SELECT + user_id, + email_enabled, + telegram_enabled, + telegram_chat_id, + created_at, + updated_at + FROM UserNotificationSettings + WHERE user_id = ? + `); + + const result = stmt.get(userId) as any; + + // Return defaults if no settings found + if (!result) { + return { + user_id: userId, + email_enabled: true, + telegram_enabled: false, + telegram_chat_id: null, + }; + } + + // Convert SQLite integers to booleans + return { + user_id: result.user_id, + email_enabled: result.email_enabled === 1, + telegram_enabled: result.telegram_enabled === 1, + telegram_chat_id: result.telegram_chat_id, + created_at: result.created_at, + updated_at: result.updated_at, + }; + } finally { + db.close(); + } +} + +/** + * Update notification settings for a user + */ +export function updateUserNotificationSettings( + userId: string, + settings: Partial +): UserNotificationSettings { + const db = new Database(dbPath); + try { + // Check if settings exist + const existing = db + .prepare('SELECT user_id FROM UserNotificationSettings WHERE user_id = ?') + .get(userId); + + if (existing) { + // Update existing + const updates: string[] = []; + const values: any[] = []; + + if (settings.email_enabled !== undefined) { + updates.push('email_enabled = ?'); + values.push(settings.email_enabled ? 1 : 0); + } + if (settings.telegram_enabled !== undefined) { + updates.push('telegram_enabled = ?'); + values.push(settings.telegram_enabled ? 1 : 0); + } + if (settings.telegram_chat_id !== undefined) { + updates.push('telegram_chat_id = ?'); + values.push(settings.telegram_chat_id); + } + + updates.push('updated_at = datetime(\'now\')'); + values.push(userId); + + const stmt = db.prepare(` + UPDATE UserNotificationSettings + SET ${updates.join(', ')} + WHERE user_id = ? + `); + + stmt.run(...values); + } else { + // Insert new + const stmt = db.prepare(` + INSERT INTO UserNotificationSettings ( + user_id, + email_enabled, + telegram_enabled, + telegram_chat_id + ) VALUES (?, ?, ?, ?) + `); + + stmt.run( + userId, + settings.email_enabled !== undefined ? (settings.email_enabled ? 1 : 0) : 1, + settings.telegram_enabled !== undefined ? (settings.telegram_enabled ? 1 : 0) : 0, + settings.telegram_chat_id ?? null + ); + } + + return getUserNotificationSettings(userId); + } finally { + db.close(); + } +} + +/** + * Initialize notification settings table + */ +export function initNotificationSettingsTable(): void { + const db = new Database(dbPath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS UserNotificationSettings ( + user_id TEXT PRIMARY KEY, + email_enabled INTEGER DEFAULT 1, + telegram_enabled INTEGER DEFAULT 0, + telegram_chat_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE, + CHECK (email_enabled IN (0, 1)), + CHECK (telegram_enabled IN (0, 1)) + ); + + CREATE INDEX IF NOT EXISTS idx_user_notification_settings_user + ON UserNotificationSettings(user_id); + `); + + console.log('✓ UserNotificationSettings table initialized'); + } finally { + db.close(); + } +} diff --git a/lib/telegram-service.ts b/lib/telegram-service.ts new file mode 100644 index 0000000..ef2e60d --- /dev/null +++ b/lib/telegram-service.ts @@ -0,0 +1,170 @@ +import axios from 'axios'; + +interface TelegramMessage { + chatId: string; + text: string; + latitude?: number; + longitude?: number; + buttons?: Array<{ text: string; url: string }>; +} + +interface GeofenceNotificationParams { + chatId: string; + deviceName: string; + geofenceName: string; + eventType: 'enter' | 'exit'; + latitude: number; + longitude: number; + timestamp: string; + mapUrl?: string; + dashboardUrl?: string; +} + +class TelegramService { + private botToken: string; + private baseUrl: string; + + constructor() { + this.botToken = process.env.TELEGRAM_BOT_TOKEN || ''; + if (!this.botToken) { + console.warn('[TelegramService] No TELEGRAM_BOT_TOKEN configured'); + } + this.baseUrl = `https://api.telegram.org/bot${this.botToken}`; + } + + /** + * Check if Telegram is configured + */ + isConfigured(): boolean { + return !!this.botToken; + } + + /** + * Send text message with optional inline buttons + */ + async sendMessage(params: TelegramMessage): Promise { + if (!this.isConfigured()) { + throw new Error('Telegram bot token not configured'); + } + + const { chatId, text, buttons } = params; + + const payload: any = { + chat_id: chatId, + text: text, + parse_mode: 'HTML', + }; + + // Add inline buttons if provided + if (buttons && buttons.length > 0) { + payload.reply_markup = { + inline_keyboard: [ + buttons.map(btn => ({ + text: btn.text, + url: btn.url, + })), + ], + }; + } + + const response = await axios.post(`${this.baseUrl}/sendMessage`, payload); + + if (!response.data.ok) { + throw new Error(`Telegram API error: ${response.data.description}`); + } + } + + /** + * Send location pin on map + */ + async sendLocation( + chatId: string, + latitude: number, + longitude: number + ): Promise { + if (!this.isConfigured()) { + throw new Error('Telegram bot token not configured'); + } + + const response = await axios.post(`${this.baseUrl}/sendLocation`, { + chat_id: chatId, + latitude, + longitude, + }); + + if (!response.data.ok) { + throw new Error(`Telegram API error: ${response.data.description}`); + } + } + + /** + * Send geofence notification (complete with text + location + buttons) + */ + async sendGeofenceNotification( + params: GeofenceNotificationParams + ): Promise { + const { + chatId, + deviceName, + geofenceName, + eventType, + latitude, + longitude, + timestamp, + mapUrl, + dashboardUrl, + } = params; + + // Format message + const emoji = eventType === 'enter' ? '🟢' : '🔴'; + const action = eventType === 'enter' ? 'BETRETEN' : 'VERLASSEN'; + const verb = eventType === 'enter' ? 'betreten' : 'verlassen'; + + const formattedDate = new Date(timestamp).toLocaleString('de-DE', { + dateStyle: 'short', + timeStyle: 'short', + }); + + const text = ` +${emoji} Geofence ${action} + +📱 Device: ${deviceName} +📍 Geofence: ${geofenceName} +🕐 Zeit: ${formattedDate} +📊 Ereignis: Hat ${geofenceName} ${verb} + `.trim(); + + // Prepare inline buttons + const buttons = []; + if (mapUrl) { + buttons.push({ text: '🗺️ Auf Karte zeigen', url: mapUrl }); + } + if (dashboardUrl) { + buttons.push({ text: '📊 Dashboard öffnen', url: dashboardUrl }); + } + + // Send text message with buttons + await this.sendMessage({ chatId, text, buttons }); + + // Send location pin + await this.sendLocation(chatId, latitude, longitude); + } + + /** + * Test connection by sending a simple message + */ + async testConnection(chatId: string): Promise { + try { + await this.sendMessage({ + chatId, + text: '✅ Telegram Connection Test\n\nDie Verbindung funktioniert!', + }); + return true; + } catch (error) { + console.error('[TelegramService] Test failed:', error); + return false; + } + } +} + +export const telegramService = new TelegramService(); diff --git a/package-lock.json b/package-lock.json index e79fbe2..e6c9c19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", + "axios": "^1.13.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.4.1", "leaflet": "^1.9.4", @@ -23,6 +24,7 @@ "next": "^16.0.7", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.10", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "react-email": "^4.3.2", @@ -3416,6 +3418,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", @@ -3454,6 +3462,17 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3619,6 +3638,28 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001754", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", @@ -3714,6 +3755,87 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3732,6 +3854,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -3848,6 +3982,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3881,6 +4024,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3890,6 +4042,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -3945,6 +4103,20 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4044,6 +4216,51 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4172,6 +4389,39 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4188,6 +4438,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4208,6 +4495,24 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -4220,6 +4525,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4249,12 +4591,63 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", @@ -4722,6 +5115,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -4768,6 +5173,15 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5352,6 +5766,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5371,6 +5821,15 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5428,6 +5887,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5560,6 +6028,12 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -5570,6 +6044,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5695,6 +6186,21 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5767,6 +6273,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -6385,6 +6897,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/worker-factory": { "version": "7.0.46", "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.46.tgz", @@ -6550,6 +7068,88 @@ } } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index dc35c18..8cd2f62 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "build": "next build", "start": "next start", "lint": "next lint", - "db:init": "node scripts/init-database.js && node scripts/init-locations-db.js && node scripts/init-geofence-db.js", + "db:init": "node scripts/init-database.js && node scripts/init-locations-db.js && node scripts/init-geofence-db.js && node scripts/init-notification-settings.js", "db:init:app": "node scripts/init-database.js", "db:init:locations": "node scripts/init-locations-db.js", "db:init:geofence": "node scripts/init-geofence-db.js", + "db:init:notifications": "node scripts/init-notification-settings.js", "db:cleanup": "node scripts/cleanup-old-locations.js", "db:cleanup:7d": "node scripts/cleanup-old-locations.js 168", "db:cleanup:30d": "node scripts/cleanup-old-locations.js 720", @@ -21,7 +22,8 @@ "test:geofence:mqtt": "node scripts/test-mqtt-geofence.js", "docs:check": "node scripts/check-docs.js", "docs:sync": "node scripts/sync-docs.js", - "email:dev": "email dev" + "email:dev": "email dev", + "telegram:qr": "node scripts/generate-telegram-qr.js" }, "keywords": [], "author": "", @@ -34,6 +36,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", + "axios": "^1.13.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.4.1", "leaflet": "^1.9.4", @@ -41,6 +44,7 @@ "next": "^16.0.7", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.10", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "react-email": "^4.3.2", diff --git a/public/telegram-bot-qr.png b/public/telegram-bot-qr.png new file mode 100644 index 0000000..91d9aad Binary files /dev/null and b/public/telegram-bot-qr.png differ diff --git a/scripts/generate-telegram-qr.js b/scripts/generate-telegram-qr.js new file mode 100755 index 0000000..ccec32b --- /dev/null +++ b/scripts/generate-telegram-qr.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * Generate QR Code for Telegram Bot + */ + +const QRCode = require('qrcode'); +const fs = require('fs'); +const path = require('path'); + +const BOT_URL = 'https://t.me/MeinTracking_bot'; +const OUTPUT_DIR = path.join(process.cwd(), 'public'); +const OUTPUT_FILE = path.join(OUTPUT_DIR, 'telegram-bot-qr.png'); + +async function generateQR() { + try { + // Ensure public directory exists + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // Generate QR Code + await QRCode.toFile(OUTPUT_FILE, BOT_URL, { + width: 400, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + + console.log(`✅ QR Code generated: ${OUTPUT_FILE}`); + console.log(`📱 Bot URL: ${BOT_URL}`); + console.log(`🖼️ QR Code size: 400x400px`); + } catch (error) { + console.error('❌ Error generating QR Code:', error.message); + process.exit(1); + } +} + +generateQR(); diff --git a/scripts/init-notification-settings.js b/scripts/init-notification-settings.js new file mode 100755 index 0000000..b27f17a --- /dev/null +++ b/scripts/init-notification-settings.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Initialize UserNotificationSettings table + */ + +const Database = require('better-sqlite3'); +const path = require('path'); + +const dbPath = path.join(process.cwd(), 'data', 'database.sqlite'); + +try { + const db = new Database(dbPath); + + db.exec(` + CREATE TABLE IF NOT EXISTS UserNotificationSettings ( + user_id TEXT PRIMARY KEY, + email_enabled INTEGER DEFAULT 1, + telegram_enabled INTEGER DEFAULT 0, + telegram_chat_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE, + CHECK (email_enabled IN (0, 1)), + CHECK (telegram_enabled IN (0, 1)) + ); + + CREATE INDEX IF NOT EXISTS idx_user_notification_settings_user + ON UserNotificationSettings(user_id); + `); + + db.close(); + + console.log('✅ Notification settings table initialized!'); +} catch (error) { + console.error('❌ Error:', error.message); + process.exit(1); +} diff --git a/scripts/test-telegram-token.js b/scripts/test-telegram-token.js new file mode 100755 index 0000000..945ff53 --- /dev/null +++ b/scripts/test-telegram-token.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Test Telegram Bot Token + */ + +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); + +// Read .env manually +const envPath = path.join(process.cwd(), '.env'); +const envContent = fs.readFileSync(envPath, 'utf-8'); +const BOT_TOKEN = envContent + .split('\n') + .find(line => line.startsWith('TELEGRAM_BOT_TOKEN=')) + ?.split('=')[1] + ?.trim(); + +async function testToken() { + if (!BOT_TOKEN) { + console.error('❌ TELEGRAM_BOT_TOKEN not found in .env'); + process.exit(1); + } + + console.log('🧪 Testing Telegram Bot Token...\n'); + console.log(`Token: ${BOT_TOKEN.substring(0, 15)}...`); + + try { + const response = await axios.get(`https://api.telegram.org/bot${BOT_TOKEN}/getMe`); + + if (response.data.ok) { + const bot = response.data.result; + console.log('\n✅ Bot Token is VALID!\n'); + console.log('📱 Bot Information:'); + console.log(` ID: ${bot.id}`); + console.log(` Name: ${bot.first_name}`); + console.log(` Username: @${bot.username}`); + console.log(` Can Join Groups: ${bot.can_join_groups}`); + console.log(` Can Read Messages: ${bot.can_read_all_group_messages}`); + console.log('\n🔗 Bot Link: https://t.me/' + bot.username); + } else { + console.error('❌ Invalid response:', response.data); + process.exit(1); + } + } catch (error) { + console.error('\n❌ Token test FAILED!'); + if (error.response) { + console.error('Error:', error.response.data.description); + } else { + console.error('Error:', error.message); + } + process.exit(1); + } +} + +testToken();