From b1190e2e50ef51d05dc4af3681272e98dae89cbc Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Mon, 24 Nov 2025 20:33:15 +0000 Subject: [PATCH] Edit files --- .env.example | 3 - GEMINI.md | 108 ++++++ MQTT_INTEGRATION.md | 559 ----------------------------- N8N_INTEGRATION.md | 349 ------------------ README.md | 129 +++---- app/api/export/csv/route.ts | 143 ++++++++ app/api/locations/ingest/route.ts | 4 +- app/api/locations/route.ts | 124 +------ app/api/locations/sync/route.ts | 86 ----- app/export/page.tsx | 416 +++++++++++++++++++++ app/map/page.tsx | 20 +- components/map/MapView.tsx | 1 - lib/geo-utils.ts | 111 ++++++ pictures/n8n-MQTT-GPS-Tracking.png | Bin 61843 -> 0 bytes 14 files changed, 846 insertions(+), 1207 deletions(-) create mode 100644 GEMINI.md delete mode 100644 MQTT_INTEGRATION.md delete mode 100644 N8N_INTEGRATION.md create mode 100644 app/api/export/csv/route.ts delete mode 100644 app/api/locations/sync/route.ts create mode 100644 app/export/page.tsx create mode 100644 lib/geo-utils.ts delete mode 100644 pictures/n8n-MQTT-GPS-Tracking.png diff --git a/.env.example b/.env.example index 842abc6..56209d4 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,6 @@ NEXTAUTH_URL=http://localhost:3000 # Production (change to your domain) # NEXTAUTH_URL=https://your-domain.com -# n8n API (optional - currently using client-side fetch) -N8N_API_URL=https://n8n.example.com/webhook/location - # SMTP Configuration (Fallback when DB config is empty) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..43a381c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,108 @@ +# Gemini Code Assistant Context + +This document provides a comprehensive overview of the **Location Tracker** project, designed to give the Gemini code assistant the necessary context to understand the codebase, its architecture, and its conventions. + +## 1. Project Overview + +This is a full-stack web application for real-time location tracking, built with Next.js 14. Its primary purpose is to receive location data from [OwnTracks](https://owntracks.org/) mobile clients via an MQTT broker and display the tracks on an interactive map. + +### Key Technologies + +- **Framework**: Next.js 14 (App Router) +- **Language**: TypeScript +- **Database**: Dual SQLite databases using `better-sqlite3`. + - `data/database.sqlite`: For core application data like users, devices, and settings. + - `data/locations.sqlite`: For high-volume, time-series location data. +- **Data Ingestion**: An MQTT subscriber (`lib/mqtt-subscriber.ts`) connects to a Mosquitto broker to receive location updates from OwnTracks clients. An HTTP endpoint (`/api/locations/ingest`) is also available for manual data pushes. +- **Authentication**: `next-auth` (v5) with a credentials (username/password) provider. It supports `ADMIN` and `VIEWER` roles. +- **Frontend**: React, [React Leaflet](https://react-leaflet.js.org/) for mapping, and Tailwind CSS for styling. +- **Deployment**: The project includes a `Dockerfile` and `docker-compose.yml` for containerized deployment alongside a Mosquitto MQTT broker. + +### Core Features + +- Interactive map displaying real-time location data. +- Filtering by device and time range. +- Admin dashboard for user and device management. +- Secure authentication with role-based access control. +- Database maintenance tasks (cleanup, optimization) via API or scripts. + +## 2. Building and Running the Project + +### Prerequisites + +- Node.js 18+ +- npm + +### Development Mode + +1. **Install dependencies:** + ```bash + npm install + ``` + +2. **Initialize the databases:** + This command creates the two SQLite databases in the `data/` directory and seeds them with a default admin user (`admin` / `admin123`). + ```bash + npm run db:init + ``` + +3. **Run the development server:** + This starts the Next.js application on `http://localhost:3000`. It also starts the MQTT subscriber service. + ```bash + npm run dev + ``` + +### Production Mode + +- **Build the application:** + ```bash + npm run build + ``` +- **Start the application:** + ```bash + npm run start + ``` + +### Docker Compose + +The most straightforward way to run the application and its dependent MQTT broker is using Docker Compose. + +1. **Create an environment file:** + Copy `.env.example` to `.env` and fill in the required secrets, especially `AUTH_SECRET`. + +2. **Start the services:** + ```bash + docker-compose up --build + ``` + This will build the Next.js app image and start both the application container and the Mosquitto container. The `data` directory is mounted as a volume to persist the SQLite databases. + +## 3. Architecture and Data Flow + +The application follows a clean, decoupled architecture. + +**Data Ingestion Flow:** +1. An OwnTracks client publishes a location update to a specific MQTT topic (e.g., `owntracks/user/device10`). +2. The Mosquitto broker receives the message. +3. The Next.js application's MQTT Subscriber (`lib/mqtt-subscriber.ts`), which is started on server boot via `instrumentation.ts`, is subscribed to the `owntracks/+/+` topic. +4. Upon receiving a message, the subscriber parses the payload, transforms it into the application's `Location` format, and saves it to `data/locations.sqlite` using the functions in `lib/db.ts`. + +**Data Retrieval Flow:** +1. The user's browser, viewing the map page, periodically fetches data from the `/api/locations` endpoint. +2. This API route queries the `data/locations.sqlite` database, applying any user-specified filters for device or time. +3. The API returns the location data as a JSON response. +4. The frontend `MapView.tsx` component renders the received data as markers and polylines on the Leaflet map. + +**Authentication & Authorization:** +- Users log in via the `/login` page, which uses the NextAuth.js `Credentials` provider. +- The `lib/auth.ts` file contains the NextAuth configuration, validating credentials against the `User` table in `data/database.sqlite`. +- The `middleware.ts` file protects the `/admin` and `/map` routes, checking for a valid session and enforcing role-based access (`ADMIN` vs. `VIEWER`). + +## 4. Development Conventions + +- **Database Access**: All direct database operations are encapsulated within the `lib/db.ts` file. This provides a single, consistent interface (`userDb`, `deviceDb`, `locationDb`) for interacting with the data layer. +- **Authentication**: All authentication and session logic is centralized in `lib/auth.ts`. The `auth()` object exported from this file is the primary entry point for accessing session data on the server. +- **Background Services**: Long-running background services, like the MQTT subscriber, should be initialized in `lib/startup.ts` and triggered from the `instrumentation.ts` file. This is the standard Next.js way to run code on server startup. +- **Configuration**: Environment variables are used for configuration (see `.env.example`). +- **Scripts**: The `scripts/` directory contains Node.js scripts for various administrative tasks, such as database initialization (`init-database.js`), cleanup (`cleanup-old-locations.js`), and testing. They should be run using `node scripts/.js`. +- **Styling**: Utility-first CSS using Tailwind CSS. +- **Types**: TypeScript types are used throughout. Core application types are defined in files like `lib/db.ts` and `types/location.ts`. diff --git a/MQTT_INTEGRATION.md b/MQTT_INTEGRATION.md deleted file mode 100644 index ec52d8c..0000000 --- a/MQTT_INTEGRATION.md +++ /dev/null @@ -1,559 +0,0 @@ -# MQTT Provisioning Integration - -Diese Anleitung beschreibt die Integration des MQTT Provisioning Systems (aus `mosquitto-automation`) in die Location Tracker App. - -## 🎯 Übersicht - -Die Integration vereint zwei vormals separate Systeme: -- **mosquitto-automation**: Device Provisioning und MQTT Credential Management -- **location-tracker-app**: GPS Tracking Visualisierung mit OwnTracks - -### Was wurde integriert? - -✅ **MQTT Credentials Management** - Direkt im Admin Panel -✅ **ACL (Access Control List) Management** - Feine Kontrolle über Topic-Berechtigungen -✅ **Mosquitto Sync** - Password & ACL Files werden automatisch generiert -✅ **MQTT Subscriber** - Direkte Verarbeitung von OwnTracks Messages (kein n8n mehr nötig) -✅ **Docker Compose Setup** - All-in-One Deployment - ---- - -## 📋 Features - -### Admin Panel: MQTT Provisioning - -**Route:** `/admin/mqtt` - -#### Device Provisioning -- Erstelle MQTT Credentials für registrierte Devices -- Automatische Generierung von Username & Passwort -- Passwörter werden mit `mosquitto_passwd` gehasht -- Default ACL Regel: `owntracks/[device-id]/#` (readwrite) - -#### Credentials Management -- Liste aller provisionierten Devices -- Enable/Disable MQTT Zugriff pro Device -- Passwort Regenerierung -- Credentials löschen (inkl. ACL Regeln) - -#### ACL Management -- Custom Topic Patterns definieren -- Berechtigungen: `read`, `write`, `readwrite` -- Wildcard Support mit `#` -- Regeln pro Device verwalten - -#### Mosquitto Sync -- **"Zu Mosquitto Syncen"** Button im Admin Panel -- Generiert `/mosquitto/config/password.txt` -- Generiert `/mosquitto/config/acl.txt` -- Sendet SIGHUP an Mosquitto Container (Config Reload) -- Zeigt ausstehende Änderungen an - ---- - -## 🗄️ Datenbankschema - -### Neue Tabellen - -#### `mqtt_credentials` -```sql -CREATE TABLE mqtt_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_id TEXT NOT NULL UNIQUE, -- Referenz zu Device Tabelle - mqtt_username TEXT NOT NULL UNIQUE, - mqtt_password_hash TEXT NOT NULL, -- Mosquitto-kompatible Hash - enabled INTEGER DEFAULT 1, -- 0 = disabled, 1 = enabled - created_at TEXT, - updated_at TEXT, - FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE -); -``` - -#### `mqtt_acl_rules` -```sql -CREATE TABLE mqtt_acl_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_id TEXT NOT NULL, - topic_pattern TEXT NOT NULL, -- z.B. "owntracks/device10/#" - permission TEXT NOT NULL, -- read | write | readwrite - created_at TEXT, - FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE -); -``` - -#### `mqtt_sync_status` -```sql -CREATE TABLE mqtt_sync_status ( - id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton - pending_changes INTEGER DEFAULT 0, - last_sync_at TEXT, - last_sync_status TEXT, -- success | error: ... - created_at TEXT, - updated_at TEXT -); -``` - -### Migration - -```bash -# Datenbanken initialisieren -npm run db:init - -# MQTT Tabellen hinzufügen -node scripts/add-mqtt-tables.js -``` - ---- - -## 🚀 Installation & Setup - -### Voraussetzungen - -- Docker & Docker Compose -- Node.js 20+ (für lokale Entwicklung) - -### 1. Repository Setup - -```bash -cd location-tracker-app - -# Dependencies installieren -npm install - -# .env Datei erstellen -cp .env.example .env -``` - -### 2. Environment Variables - -Bearbeite `.env`: - -```env -# MQTT Configuration -MQTT_BROKER_URL=mqtt://mosquitto:1883 -MQTT_ADMIN_USERNAME=admin -MQTT_ADMIN_PASSWORD=dein-sicheres-passwort - -MOSQUITTO_PASSWORD_FILE=/mosquitto/config/password.txt -MOSQUITTO_ACL_FILE=/mosquitto/config/acl.txt -MOSQUITTO_CONTAINER_NAME=mosquitto - -# NextAuth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET= - -# Verschlüsselung für SMTP Passwords -ENCRYPTION_KEY= -``` - -### 3. Docker Compose Start - -```bash -# Build und Start -docker-compose up -d - -# Logs verfolgen -docker-compose logs -f - -# Status prüfen -docker-compose ps -``` - -Die App läuft auf: `http://localhost:3000` -Mosquitto MQTT Broker: `mqtt://localhost:1883` - -### 4. Admin Zugang - -**Default Credentials:** -- Username: `admin` -- Password: `admin123` - -⚠️ **Ändere das Passwort nach dem ersten Login!** - ---- - -## 🔧 Entwicklung - -### Lokale Entwicklung (ohne Docker) - -```bash -# 1. Mosquitto extern starten (oder Docker Compose nur Mosquitto) -docker run -d -p 1883:1883 -p 9001:9001 \ - -v $(pwd)/mosquitto.conf:/mosquitto/config/mosquitto.conf \ - -v mosquitto_data:/mosquitto/data \ - eclipse-mosquitto:2 - -# 2. .env anpassen -MQTT_BROKER_URL=mqtt://localhost:1883 - -# 3. Datenbanken initialisieren -npm run db:init -node scripts/add-mqtt-tables.js - -# 4. App starten -npm run dev -``` - -### Neue MQTT Credentials testen - -```bash -# Mit mosquitto_sub testen -mosquitto_sub -h localhost -p 1883 \ - -u "device_10_abc123" \ - -P "dein-generiertes-passwort" \ - -t "owntracks/10/#" \ - -v - -# OwnTracks Message simulieren -mosquitto_pub -h localhost -p 1883 \ - -u "device_10_abc123" \ - -P "dein-generiertes-passwort" \ - -t "owntracks/10/device" \ - -m '{"_type":"location","lat":52.5200,"lon":13.4050,"tst":1234567890,"batt":85,"vel":5.2}' -``` - ---- - -## 📡 MQTT Subscriber - -Der MQTT Subscriber läuft automatisch beim App-Start und verarbeitet OwnTracks Messages. - -### Implementierung - -- **Service:** `lib/mqtt-subscriber.ts` -- **Startup:** `instrumentation.ts` (Next.js Hook) -- **Topic:** `owntracks/+/+` -- **Datenbank:** Schreibt direkt in `locations.sqlite` - -### OwnTracks Message Format - -```json -{ - "_type": "location", - "tid": "XX", - "lat": 52.5200, - "lon": 13.4050, - "tst": 1234567890, - "batt": 85, - "vel": 5.2, - "acc": 10, - "alt": 50 -} -``` - -### Logs - -```bash -# Docker Logs -docker-compose logs -f app - -# Du solltest sehen: -# ✓ Connected to MQTT broker -# ✓ Subscribed to owntracks/+/+ -# ✓ Location saved: device10 at (52.52, 13.405) -``` - ---- - -## 🔐 Sicherheit - -### Mosquitto Authentication - -- **Keine Anonymous Connections:** `allow_anonymous false` -- **Password File:** Mosquitto-kompatible Hashes (SHA512) -- **ACL File:** Topic-basierte Access Control - -### Best Practices - -1. **Starke Admin Passwörter:** Ändere `MQTT_ADMIN_PASSWORD` in `.env` -2. **Device Passwörter:** Auto-generierte Passwörter haben 128 Bit Entropie -3. **ACL Regeln:** Gib Devices nur Zugriff auf ihre eigenen Topics -4. **Docker Socket:** Container benötigt Zugriff für Mosquitto Reload (optional) - -### ACL Beispiele - -```text -# Device darf nur in eigenes Topic schreiben -user device_10_abc123 -topic readwrite owntracks/10/# - -# Device mit zusätzlichem Read-only Topic -user device_11_xyz789 -topic readwrite owntracks/11/# -topic read status/# - -# Admin hat vollen Zugriff -user admin -topic readwrite # -``` - ---- - -## 🐛 Troubleshooting - -### Problem: "Mosquitto configuration reloaded" fehlgeschlagen - -**Symptom:** Nach Sync kommt Warnung "Could not reload Mosquitto automatically" - -**Lösung:** Docker Socket Zugriff fehlt. Entweder: - -```bash -# Option 1: Manuelle Mosquitto Neustart -docker-compose restart mosquitto - -# Option 2: Docker Socket in docker-compose.yml freigeben (bereits konfiguriert) -volumes: - - /var/run/docker.sock:/var/run/docker.sock -``` - -### Problem: MQTT Subscriber verbindet nicht - -**Debug Steps:** - -```bash -# 1. Prüfe Mosquitto läuft -docker-compose ps mosquitto - -# 2. Prüfe Mosquitto Logs -docker-compose logs mosquitto - -# 3. Prüfe App Logs -docker-compose logs app | grep MQTT - -# 4. Teste MQTT Verbindung manuell -mosquitto_sub -h localhost -p 1883 -u admin -P admin -t '#' -``` - -### Problem: Password Hash falsch - -**Symptom:** Authentication failed im Mosquitto Log - -**Lösung:** `mosquitto_passwd` Tool muss im Container verfügbar sein (ist im Dockerfile installiert) - -```bash -# Im Container testen -docker exec -it location-tracker-app mosquitto_passwd -h -``` - -### Problem: ACL Regeln funktionieren nicht - -**Debug:** - -```bash -# ACL File prüfen -docker exec -it location-tracker-app cat /mosquitto/config/acl.txt - -# Mosquitto Logs auf "Access denied" prüfen -docker-compose logs mosquitto | grep -i denied -``` - ---- - -## 📊 API Endpoints - -### MQTT Credentials - -```http -# Liste aller Credentials -GET /api/mqtt/credentials - -# Credential für Device abrufen -GET /api/mqtt/credentials/{device_id} - -# Neue Credentials erstellen -POST /api/mqtt/credentials -Content-Type: application/json - -{ - "device_id": "10", - "auto_generate": true -} - -# Credentials aktualisieren -PATCH /api/mqtt/credentials/{device_id} -Content-Type: application/json - -{ - "regenerate_password": true, - "enabled": true -} - -# Credentials löschen -DELETE /api/mqtt/credentials/{device_id} -``` - -### ACL Rules - -```http -# ACL Regeln für Device -GET /api/mqtt/acl?device_id=10 - -# Neue ACL Regel erstellen -POST /api/mqtt/acl -Content-Type: application/json - -{ - "device_id": "10", - "topic_pattern": "owntracks/10/#", - "permission": "readwrite" -} - -# ACL Regel löschen -DELETE /api/mqtt/acl/{rule_id} -``` - -### Mosquitto Sync - -```http -# Sync Status abrufen -GET /api/mqtt/sync - -# Sync triggern -POST /api/mqtt/sync -``` - -Alle Endpoints erfordern Admin-Authentifizierung (Role: ADMIN). - ---- - -## 🔄 Migration von mosquitto-automation - -Wenn du bereits Devices in `mosquitto-automation` hast: - -### Automatische Migration (TODO) - -```bash -# Script erstellen das aus der alten DB liest -node scripts/migrate-from-mosquitto-automation.js \ - --old-db /pfad/zu/mosquitto-automation/data/devices.db -``` - -### Manuelle Migration - -1. Exportiere Devices aus alter DB: -```sql -SELECT id, name, username, password_hash, permissions -FROM devices -WHERE active = 1; -``` - -2. Erstelle Devices im neuen System über Admin Panel -3. Provisioniere MQTT Credentials manuell -4. Importiere ACL Regeln - ---- - -## 📚 Architektur - -### Komponenten - -``` -┌─────────────────────────────────────────────────────┐ -│ Location Tracker App (Next.js) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ Admin Panel │ │ MQTT Service │ │ API │ │ -│ │ /admin/mqtt │ │ Subscriber │ │ Routes │ │ -│ └──────────────┘ └──────────────┘ └──────────┘ │ -│ │ │ │ │ -│ └──────────────────┴────────────────┘ │ -│ │ │ -│ ┌────▼────┐ │ -│ │ SQLite │ │ -│ │ DB │ │ -│ └────┬────┘ │ -│ │ │ -│ ┌────▼────────┐ │ -│ │ Mosquitto │ │ -│ │ Sync Service│ │ -│ └────┬────────┘ │ -└─────────────────────────┼──────────────────────────┘ - │ - ┌───────────▼───────────┐ - │ Mosquitto Broker │ - │ │ - │ • password.txt │ - │ • acl.txt │ - └───────────┬───────────┘ - │ - ┌───────────▼───────────┐ - │ GPS Tracking │ - │ Devices │ - │ (OwnTracks) │ - └───────────────────────┘ -``` - -### Datei-Struktur - -``` -location-tracker-app/ -├── app/ -│ ├── admin/ -│ │ └── mqtt/ -│ │ └── page.tsx # MQTT Provisioning UI -│ └── api/ -│ └── mqtt/ -│ ├── credentials/ # Credentials Management -│ ├── acl/ # ACL Management -│ └── sync/ # Mosquitto Sync -├── lib/ -│ ├── mqtt-db.ts # MQTT Datenbank Operations -│ ├── mqtt-subscriber.ts # MQTT Message Processing -│ ├── mosquitto-sync.ts # Config File Generation -│ └── startup.ts # Service Initialization -├── scripts/ -│ └── add-mqtt-tables.js # Datenbank Migration -├── docker-compose.yml # Docker Setup -├── Dockerfile # App Container -├── mosquitto.conf # Mosquitto Config -└── instrumentation.ts # Next.js Startup Hook -``` - ---- - -## ✨ Vorteile der Integration - -### Vorher (Separate Systeme) - -``` -GPS Device → MQTT → n8n → HTTP API → location-tracker-app → UI - ↓ - mosquitto-automation (separate) -``` - -**Probleme:** -- n8n als zusätzliche Dependency -- Zwei separate Admin Panels -- Keine zentrale User/Device Verwaltung -- Komplexes Setup - -### Nachher (Integriert) - -``` -GPS Device → MQTT → location-tracker-app → UI - ↓ - (integriertes Provisioning) -``` - -**Vorteile:** -✅ Ein Admin Panel für alles -✅ Direkte MQTT Verarbeitung (schneller) -✅ Einfaches Docker Compose Setup -✅ Zentrale Datenbank -✅ Weniger Dependencies - ---- - -## 🎉 Fertig! - -Die Integration ist komplett. Du kannst jetzt: - -1. **Devices verwalten** unter `/admin/devices` -2. **MQTT Credentials provisionieren** unter `/admin/mqtt` -3. **ACL Regeln definieren** im MQTT Panel -4. **Zu Mosquitto syncen** mit einem Klick -5. **GPS Tracking visualisieren** auf der Hauptseite - -Bei Fragen oder Problemen: Siehe Troubleshooting oder check die Logs. - -Happy Tracking! 🚀📍 diff --git a/N8N_INTEGRATION.md b/N8N_INTEGRATION.md deleted file mode 100644 index 1851d67..0000000 --- a/N8N_INTEGRATION.md +++ /dev/null @@ -1,349 +0,0 @@ -# n8n Integration Anleitung - -## Übersicht - -Die poc-app verwendet nun eine **Dual-Datenbank-Architektur** mit lokalem SQLite-Caching: - -- **database.sqlite** - Benutzerkonten, Geräteverwaltung (kritische Daten, wenige Schreibvorgänge) -- **locations.sqlite** - Standortverfolgung Cache (viele Schreibvorgänge, temporär) - -Diese Architektur bietet: -- **Performance**: Schnelle Abfragen aus lokalem SQLite anstatt NocoDB API -- **Skalierbarkeit**: Unbegrenzter Verlauf ohne Paginierungslimits -- **Resilienz**: Auth-System isoliert von der Tracking-Datenbank -- **Flexibilität**: Einfaches Löschen alter Daten - ---- - -## Erforderliche n8n Workflow-Änderungen - -### Aktueller Flow (ALT) - -``` -MQTT Trigger (owntracks/#) - ↓ -MQTT Location verarbeiten (Parse & Transform) - ↓ -Speichere in NocoDB -``` - -### Neuer Flow (ERFORDERLICH) - -``` -MQTT Trigger (owntracks/#) - ↓ -MQTT Location verarbeiten (Parse & Transform) - ↓ -Speichere in NocoDB - ↓ -[NEU] Push to Next.js Cache (HTTP Request) -``` - ---- - -## Schritt-für-Schritt: HTTP Request Node hinzufügen - -### 1. HTTP Request Node hinzufügen - -Nach dem "Speichere in NocoDB" Node einen neuen **HTTP Request** Node hinzufügen: - -**Node-Konfiguration:** -- **Name**: "Push to Next.js Cache" -- **Methode**: POST -- **URL**: `https://deine-nextjs-domain.com/api/locations/ingest` -- **Authentifizierung**: None (API-Key in Produktion hinzufügen!) -- **Body Content Type**: JSON -- **Specify Body**: Using Fields Below - -### 2. Felder zuordnen - -Im Bereich "Body Parameters" die folgenden Felder zuordnen: - -| Parameter | Wert (Expression) | Beschreibung | -|-----------|-------------------|-------------| -| `latitude` | `{{ $json.latitude }}` | Geografischer Breitengrad | -| `longitude` | `{{ $json.longitude }}` | Geografischer Längengrad | -| `timestamp` | `{{ $json.timestamp }}` | ISO 8601 Zeitstempel | -| `user_id` | `{{ $json.user_id }}` | Benutzer-ID (0 für MQTT) | -| `first_name` | `{{ $json.first_name }}` | Tracker ID | -| `last_name` | `{{ $json.last_name }}` | Quelltyp | -| `username` | `{{ $json.username }}` | Geräte-Benutzername | -| `marker_label` | `{{ $json.marker_label }}` | Anzeige-Label | -| `display_time` | `{{ $json.display_time }}` | Formatierte Zeit | -| `chat_id` | `{{ $json.chat_id }}` | Chat-ID (0 für MQTT) | -| `battery` | `{{ $json.battery }}` | Batterieprozent | -| `speed` | `{{ $json.speed }}` | Geschwindigkeit (m/s) | - -### 3. Fehlerbehandlung (Optional aber empfohlen) - -Einen **Error Trigger** Node hinzufügen, um fehlgeschlagene API-Aufrufe zu behandeln: - -- **Workflow**: Current Workflow -- **Error Type**: All Errors -- **Connected to**: Push to Next.js Cache node - -Einen **Slack/Email** Benachrichtigungs-Node hinzufügen, um über Fehler informiert zu werden. - ---- - -## Beispiel n8n HTTP Request Node (JSON) - -```json -{ - "parameters": { - "method": "POST", - "url": "https://deine-domain.com/api/locations/ingest", - "authentication": "none", - "options": {}, - "bodyParametersJson": "={{ {\n \"latitude\": $json.latitude,\n \"longitude\": $json.longitude,\n \"timestamp\": $json.timestamp,\n \"user_id\": $json.user_id,\n \"first_name\": $json.first_name,\n \"last_name\": $json.last_name,\n \"username\": $json.username,\n \"marker_label\": $json.marker_label,\n \"display_time\": $json.display_time,\n \"chat_id\": $json.chat_id,\n \"battery\": $json.battery,\n \"speed\": $json.speed\n} }}" - }, - "name": "Push to Next.js Cache", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.1, - "position": [1200, 300] -} -``` - ---- - -## Testen - -### 1. Ingest-Endpunkt testen - -```bash -curl -X POST https://deine-domain.com/api/locations/ingest \ - -H "Content-Type: application/json" \ - -d '{ - "latitude": 48.1351, - "longitude": 11.5820, - "timestamp": "2024-01-15T10:30:00Z", - "user_id": 0, - "username": "10", - "marker_label": "Test Gerät", - "battery": 85, - "speed": 2.5 - }' -``` - -Erwartete Antwort: -```json -{ - "success": true, - "inserted": 1, - "message": "Successfully stored 1 location(s)" -} -``` - -### 2. Daten überprüfen - -```bash -curl https://deine-domain.com/api/locations?username=10&timeRangeHours=1 -``` - -### 3. Statistiken prüfen - -```bash -curl https://deine-domain.com/api/locations/ingest -``` - ---- - -## Produktions-Überlegungen - -### 1. API-Key-Authentifizierung hinzufügen - -**Aktualisieren** von `app/api/locations/ingest/route.ts`: - -```typescript -export async function POST(request: NextRequest) { - // API-Key validieren - const apiKey = request.headers.get('x-api-key'); - if (apiKey !== process.env.N8N_API_KEY) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ); - } - - // ... restlicher Code -} -``` - -**n8n HTTP Request Node aktualisieren:** -- Header hinzufügen: `x-api-key` = `dein-secret-key` - -### 2. Automatisches Aufräumen einrichten - -Einen Cron-Job hinzufügen, um alte Daten zu löschen (hält die Datenbankgröße überschaubar): - -```bash -# /etc/crontab -# Tägliche Bereinigung um 2 Uhr morgens - löscht Daten älter als 7 Tage -0 2 * * * cd /pfad/zu/poc-app && node scripts/cleanup-old-locations.js 168 -``` - -Oder verwende einen systemd Timer, PM2 Cron oder ähnliches. - -### 3. Datenbankgröße überwachen - -```bash -# Datenbankstatistiken prüfen -curl https://deine-domain.com/api/locations/ingest - -# Erwartete Ausgabe: -# { -# "total": 5432, -# "oldest": "2024-01-08T10:00:00Z", -# "newest": "2024-01-15T10:30:00Z", -# "sizeKB": 1024 -# } -``` - -### 4. Backup-Strategie - -**database.sqlite** (kritisch): -- Tägliche automatisierte Backups -- 30-Tage Aufbewahrung - -**locations.sqlite** (Cache): -- Optional: Wöchentliche Backups -- Oder keine Backups (Daten existieren in NocoDB) - ---- - -## Migration: Vorhandene NocoDB-Daten importieren - -Falls du bereits Standortdaten in NocoDB hast, kannst du diese importieren: - -### Option 1: CSV aus NocoDB exportieren - -1. Daten aus NocoDB als CSV exportieren -2. In JSON konvertieren -3. In Batches an `/api/locations/ingest` senden (POST) - -### Option 2: Direkter NocoDB API Import (empfohlen) - -Ein Skript `scripts/import-from-nocodb.js` erstellen: - -```javascript -const fetch = require('node-fetch'); -const { locationDb } = require('../lib/db'); - -async function importFromNocoDB() { - // Alle Daten von NocoDB API abrufen - const response = await fetch('https://n8n.example.com/webhook/location'); - const data = await response.json(); - - // Bulk-Insert in locations.sqlite - const count = locationDb.createMany(data.history); - console.log(`Importiert: ${count} Standorte`); -} - -importFromNocoDB().catch(console.error); -``` - -Ausführen: `node scripts/import-from-nocodb.js` - ---- - -## Fehlerbehebung - -### Problem: "directory does not exist" Fehler - -**Lösung**: Init-Skript ausführen: -```bash -cd poc-app -node scripts/init-locations-db.js -``` - -### Problem: n8n gibt 500-Fehler beim Push zu Next.js zurück - -**Prüfen**: -1. Next.js App läuft und ist erreichbar -2. URL in n8n ist korrekt -3. Next.js Logs prüfen: `pm2 logs` oder `docker logs` - -### Problem: Keine Daten im Frontend sichtbar - -**Überprüfen**: -1. Daten sind in SQLite: `curl https://deine-domain.com/api/locations/ingest` -2. API gibt Daten zurück: `curl https://deine-domain.com/api/locations` -3. Browser-Konsole auf Fehler prüfen - -### Problem: Datenbank wird zu groß - -**Lösung**: Cleanup-Skript ausführen: -```bash -node scripts/cleanup-old-locations.js 168 # Behalte 7 Tage -``` - -Oder die Aufbewahrungsdauer im Cron-Job reduzieren. - ---- - -## Architektur-Diagramm - -``` -┌─────────────────┐ -│ OwnTracks App │ -└────────┬────────┘ - │ MQTT - ▼ -┌─────────────────┐ -│ MQTT Broker │ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ n8n Workflow │ -│ │ -│ 1. Parse MQTT │ -│ 2. Save NocoDB │───────────┐ -│ 3. Push Next.js│ │ -└────────┬────────┘ │ - │ │ - │ HTTP POST │ (Backup) - ▼ ▼ -┌─────────────────┐ ┌──────────────┐ -│ Next.js API │ │ NocoDB │ -│ /api/locations │ │ (Cloud DB) │ -│ /ingest │ └──────────────┘ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ locations.sqlite│ (Lokaler Cache) -│ - Schnelle │ -│ Abfragen │ -│ - Auto Cleanup │ -│ - Isoliert │ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ Frontend API │ -│ /api/locations │ -│ (Read-only) │ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ Map UI │ -│ - 24h Verlauf │ -│ - Schnelle │ -│ Filter │ -│ - Echtzeit │ -└─────────────────┘ -``` - ---- - -## Zusammenfassung - -✅ **Dual-Datenbank** isoliert kritische Auth von hochvolumiger Standortverfolgung -✅ **Lokaler Cache** ermöglicht schnelle 24h+ Abfragen ohne NocoDB-Paginierungslimits -✅ **WAL-Modus** bietet Absturzsicherheit und bessere Nebenläufigkeit -✅ **Auto Cleanup** hält die Datenbankgröße überschaubar -✅ **Rückwärtskompatibel** - gleiches API-Antwortformat wie n8n Webhook - -🚀 **Die Next.js App ist jetzt produktionsreif mit unbegrenztem Standortverlauf!** diff --git a/README.md b/README.md index 4489782..37209ff 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTracks Integration, SQLite-Datenbank, Admin-Panel und Authentifizierung. -![Location Tracker Screenshot](./pictures/n8n-MQTT-GPS-Tracking.png) - ## 📋 Inhaltsverzeichnis - [Features](#-features) @@ -43,7 +41,6 @@ Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTr - ⏱️ **System Status** - Live-Uptime, Memory Usage, Runtime Info - 📱 **Device Management** - Geräte hinzufügen, bearbeiten, löschen - 💾 **Datenbank-Wartung**: - - 🔄 Manueller Sync von n8n - 🧹 Cleanup alter Daten (7, 15, 30, 90 Tage) - ⚡ Datenbank-Optimierung (VACUUM) - 📈 Detaillierte Statistiken @@ -61,7 +58,7 @@ Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTr - **Authentifizierung:** NextAuth.js v5 (beta) - **Datenbank:** SQLite (better-sqlite3) - **Passwort-Hashing:** bcryptjs -- **Datenquelle:** n8n Webhook API + lokale SQLite-Cache +- **Datenquelle:** MQTT Broker + lokale SQLite-Cache ### Dual-Database Architektur - **database.sqlite** - User, Geräte (kritische Daten) @@ -249,9 +246,9 @@ Passwort: admin123 In der OwnTracks App: - **Tracker ID (tid):** z.B. `12` - **Topic:** `owntracks/user/12` -- MQTT Broker wie gewohnt +- MQTT Broker konfigurieren (siehe MQTT Setup) -Die n8n-Workflow holt die Daten, und die App synct automatisch alle 5 Sekunden. +Die App empfängt die Daten direkt vom MQTT Broker. ### Zeitfilter verwenden @@ -292,36 +289,29 @@ Für spezifische Zeiträume, z.B. "Route von gestern Abend 18:00 bis heute Morge ```mermaid flowchart TD A[📱 OwnTracks App] -->|MQTT Publish| B[🔌 MQTT Broker] - B -->|Subscribe| C[⚙️ n8n MQTT Trigger] - C -->|Store| D[💾 NocoDB] - D -->|Webhook API| E[🌐 n8n Webhook
/webhook/location] + B -->|Subscribe| C[📡 Next.js MQTT Subscriber] - F[🖥️ Browser Client] -->|GET /api/locations
alle 5 Sek| G[📡 Next.js API Route] + C -->|Store Locations| D[(🗄️ SQLite Cache
locations.sqlite)] - G -->|1. Fetch Fresh Data| E - E -->|JSON Response| G + E[🖥️ Browser Client] -->|GET /api/locations
alle 5 Sek| F[📡 Next.js API Route] - G -->|2. Sync New Locations| H[(🗄️ SQLite Cache
locations.sqlite)] + D -->|Query Filtered Data| F + F -->|JSON Response| E - H -->|3. Query Filtered Data| G - G -->|JSON Response| F + E -->|Render| G[🗺️ React Leaflet Map] - F -->|Render| I[🗺️ React Leaflet Map] - - J[👤 Admin User] -->|Login| K[🔐 NextAuth.js] - K -->|Authenticated| L[📊 Admin Panel] - L -->|CRUD Operations| M[(💼 SQLite DB
database.sqlite)] + H[👤 Admin User] -->|Login| I[🔐 NextAuth.js] + I -->|Authenticated| J[📊 Admin Panel] + J -->|CRUD Operations| K[(💼 SQLite DB
database.sqlite)] style A fill:#4CAF50 style B fill:#FF9800 style C fill:#2196F3 - style D fill:#9C27B0 - style E fill:#F44336 - style G fill:#00BCD4 - style H fill:#FFC107 - style I fill:#8BC34A - style K fill:#E91E63 - style M fill:#FFC107 + style D fill:#FFC107 + style F fill:#00BCD4 + style G fill:#8BC34A + style I fill:#E91E63 + style K fill:#FFC107 ``` ### Komponenten-Übersicht @@ -331,42 +321,39 @@ graph LR subgraph "External Services" A[OwnTracks App] B[MQTT Broker] - C[n8n Automation] - D[NocoDB] end subgraph "Next.js Application" - E[Frontend
React/Leaflet] - F[API Routes] - G[Auth Layer
NextAuth.js] + C[MQTT Subscriber] + D[Frontend
React/Leaflet] + E[API Routes] + F[Auth Layer
NextAuth.js] end subgraph "Data Layer" - H[locations.sqlite
Tracking Data] - I[database.sqlite
Users & Devices] + G[locations.sqlite
Tracking Data] + H[database.sqlite
Users & Devices] end A -->|MQTT| B B -->|Subscribe| C - C -->|Store| D - C -->|Webhook| F + C -->|Write| G - E -->|HTTP| F - F -->|Read/Write| H - F -->|Read/Write| I + D -->|HTTP| E + E -->|Read/Write| G + E -->|Read/Write| H - E -->|Auth| G - G -->|Validate| I + D -->|Auth| F + F -->|Validate| H style A fill:#4CAF50,color:#fff style B fill:#FF9800,color:#fff style C fill:#2196F3,color:#fff - style D fill:#9C27B0,color:#fff + style D fill:#00BCD4,color:#000 style E fill:#00BCD4,color:#000 - style F fill:#00BCD4,color:#000 - style G fill:#E91E63,color:#fff + style F fill:#E91E63,color:#fff + style G fill:#FFC107,color:#000 style H fill:#FFC107,color:#000 - style I fill:#FFC107,color:#000 ``` ### Datenbank-Architektur @@ -410,20 +397,21 @@ erDiagram } ``` -### Auto-Sync Mechanismus +### Auto-Refresh Mechanismus -Die App verwendet einen **Hybrid-Ansatz**: +Die App verwendet einen **Echtzeit-Ansatz**: -1. **Frontend polling** (alle 5 Sek.) → `/api/locations` -2. **API prüft** ob neue Daten in n8n verfügbar -3. **Nur neue Locations** werden in SQLite gespeichert -4. **Duplikate** werden durch UNIQUE Index verhindert -5. **Antwort** kommt aus lokalem SQLite Cache +1. **MQTT Subscriber** empfängt OwnTracks Messages direkt vom Broker +2. **Locations** werden sofort in SQLite gespeichert +3. **Frontend polling** (alle 5 Sek.) → `/api/locations` +4. **API liest** gefilterte Daten aus lokalem SQLite Cache +5. **Duplikate** werden durch UNIQUE Index verhindert **Vorteile:** -- Schnelle Antwortzeiten (SQLite statt n8n) +- Echtzeitdaten ohne Verzögerung +- Schnelle Antwortzeiten (direkter SQLite Zugriff) - Längere Zeiträume abrufbar (24h+) -- Funktioniert auch wenn n8n nicht erreichbar ist (Fallback auf n8n-Daten) +- Keine externen Dependencies (kein n8n nötig) - Duplikate werden automatisch verhindert (UNIQUE Index) ### Datenvalidierung & Normalisierung @@ -431,18 +419,13 @@ Die App verwendet einen **Hybrid-Ansatz**: Die App behandelt spezielle Fälle bei speed/battery korrekt: **speed = 0 Behandlung:** -- n8n sendet `speed: 0` (gültig - Gerät steht still) +- MQTT sendet `speed: 0` (gültig - Gerät steht still) - Wird mit `typeof === 'number'` Check explizit als `0` gespeichert - Wird NICHT zu `null` konvertiert (wichtig für Telemetrie) - Popup zeigt "Speed: 0.0 km/h" an -**n8n Fallback:** -- Wenn DB leer ist, gibt API direkt n8n-Daten zurück -- Alle Filter (username, timeRangeHours) funktionieren auch mit Fallback -- Ermöglicht sofortigen Betrieb ohne DB-Initialisierung - **Debug-Logging:** -- Server-Logs zeigen n8n Sync-Aktivität +- Server-Logs zeigen MQTT Message-Verarbeitung - Browser Console zeigt Daten-Flow (MapView, Popup) - Hilfreich für Troubleshooting @@ -453,14 +436,13 @@ Die App behandelt spezielle Fälle bei speed/battery korrekt: ### Öffentlich **GET /api/locations** -- Location-Daten abrufen (mit Auto-Sync) +- Location-Daten abrufen (aus SQLite Cache) - Query-Parameter: - `username` - Device-Filter (z.B. "10", "11") - **Zeitfilter (wähle eine Methode):** - `timeRangeHours` - Quick Filter (1, 3, 6, 12, 24) - `startTime` & `endTime` - Custom Range (ISO 8601 Format) - `limit` - Max. Anzahl (Standard: 1000) - - `sync=false` - Nur Cache ohne n8n Sync **Beispiele:** ```bash @@ -493,10 +475,6 @@ GET /api/locations?username=10&startTime=2025-11-16T16:00:00.000Z&endTime=2025-1 **DELETE /api/devices/:id** - Gerät löschen (soft delete) -**POST /api/locations/sync** -- Manueller Sync von n8n -- Gibt Anzahl neu eingefügter Locations zurück - **POST /api/locations/cleanup** - Alte Locations löschen - Body: `{ retentionHours }` @@ -562,14 +540,6 @@ node scripts/optimize-db.js - `VACUUM` - Speicherplatz freigeben - `ANALYZE` - Query-Statistiken aktualisieren -### Sync von n8n - -**Via Admin-Panel:** -- `/admin` → Database Maintenance → Sync Now - -**Automatisch:** -- Passiert alle 5 Sekunden beim Abruf der Karte - ### Logs prüfen ```bash @@ -612,9 +582,6 @@ Erstelle `.env.local`: # NextAuth AUTH_SECRET= NEXTAUTH_URL=https://your-domain.com - -# Optional: n8n API URL (Standard in Code definiert) -N8N_API_URL=https://n8n.example.com/webhook/location ``` **Secret generieren:** @@ -781,9 +748,9 @@ node scripts/init-locations-db.js ### Map zeigt keine Daten -1. n8n Webhook erreichbar? `curl https://n8n.example.com/webhook/location` +1. MQTT Broker erreichbar? `mosquitto_sub -h localhost -p 1883 -t '#'` 2. Locations in Datenbank? `/admin` → Database Statistics prüfen -3. Auto-Sync aktiv? Browser Console öffnen +3. MQTT Subscriber läuft? Server-Logs prüfen ### "ENOENT: no such file or directory, open 'data/database.sqlite'" @@ -861,7 +828,7 @@ Alle vollständigen Lizenztexte der verwendeten Bibliotheken finden Sie in den j - **NextAuth.js** - Authentifizierung - **better-sqlite3** - SQLite für Node.js - **Tailwind CSS** - Utility-First CSS -- **n8n** - Workflow Automation (Backend) +- **MQTT.js** - MQTT Client für Node.js - **OwnTracks** - Location Tracking Apps --- diff --git a/app/api/export/csv/route.ts b/app/api/export/csv/route.ts new file mode 100644 index 0000000..f2a9bb3 --- /dev/null +++ b/app/api/export/csv/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationDb, userDb } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { calculateDistance, RateLimitedGeocoder } from "@/lib/geo-utils"; + +/** + * GET /api/export/csv + * + * Export location data as CSV for Lexware digital logbook + * + * Query parameters: + * - username: Filter by device tracker ID + * - startTime: Custom range start (ISO string) + * - endTime: Custom range end (ISO string) + * - includeGeocoding: Whether to include addresses (default: false for preview) + */ +export async function GET(request: NextRequest) { + try { + // Check authentication + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get user's allowed device IDs + const userId = (session.user as any).id; + const role = (session.user as any).role; + const sessionUsername = session.user.name || ''; + + const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername); + + if (userDeviceIds.length === 0) { + return new NextResponse("Keine Geräte verfügbar", { status: 403 }); + } + + const searchParams = request.nextUrl.searchParams; + const username = searchParams.get('username') || undefined; + const startTime = searchParams.get('startTime') || undefined; + const endTime = searchParams.get('endTime') || undefined; + const includeGeocoding = searchParams.get('includeGeocoding') === 'true'; + + // Fetch locations from database + let locations = locationDb.findMany({ + user_id: 0, + username, + startTime, + endTime, + limit: 10000, // High limit for exports + }); + + // Filter by user's allowed devices + locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username)); + + if (locations.length === 0) { + return new NextResponse("Keine Daten im gewählten Zeitraum", { status: 404 }); + } + + // Sort chronologically (oldest first) for proper distance calculation + locations.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + // Initialize geocoder if needed + const geocoder = includeGeocoding ? new RateLimitedGeocoder() : null; + + // Build CSV + const csvRows: string[] = []; + + // Header + csvRows.push('Datum,Uhrzeit,Latitude,Longitude,Adresse,Distanz (km),Geschwindigkeit (km/h),Gerät'); + + // Process each location + for (let i = 0; i < locations.length; i++) { + const loc = locations[i]; + const lat = Number(loc.latitude); + const lon = Number(loc.longitude); + + // Calculate distance from previous point + let distance = 0; + if (i > 0) { + const prevLoc = locations[i - 1]; + distance = calculateDistance( + Number(prevLoc.latitude), + Number(prevLoc.longitude), + lat, + lon + ); + } + + // Geocode if requested + let address = `${lat.toFixed(6)}, ${lon.toFixed(6)}`; + if (geocoder) { + address = await geocoder.geocode(lat, lon); + } + + // Format timestamp (German format) + const date = new Date(loc.timestamp); + const dateStr = date.toLocaleDateString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const timeStr = date.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + // Speed (may be null) + const speed = loc.speed != null ? Number(loc.speed).toFixed(1) : ''; + + // Device name + const deviceName = loc.username || 'Unbekannt'; + + // Build CSV row - properly escape address field + const escapedAddress = address.includes(',') ? `"${address}"` : address; + const distanceStr = distance.toFixed(3).replace('.', ','); // German decimal separator + + csvRows.push( + `${dateStr},${timeStr},${lat.toFixed(6)},${lon.toFixed(6)},${escapedAddress},${distanceStr},${speed},${deviceName}` + ); + } + + const csv = csvRows.join('\n'); + + // Return CSV with proper headers + const filename = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`; + + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + console.error("Error exporting CSV:", error); + return NextResponse.json( + { + error: "Failed to export CSV", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} diff --git a/app/api/locations/ingest/route.ts b/app/api/locations/ingest/route.ts index b0d1af0..500167e 100644 --- a/app/api/locations/ingest/route.ts +++ b/app/api/locations/ingest/route.ts @@ -4,8 +4,8 @@ import { locationDb, Location } from '@/lib/db'; /** * POST /api/locations/ingest * - * Endpoint for n8n to push location data to local SQLite cache. - * This is called AFTER n8n stores the data in NocoDB. + * Endpoint for external systems to push location data to local SQLite cache. + * Can be used for bulk imports or external integrations. * * Expected payload (single location or array): * { diff --git a/app/api/locations/route.ts b/app/api/locations/route.ts index 8b5f599..a016d6d 100644 --- a/app/api/locations/route.ts +++ b/app/api/locations/route.ts @@ -3,15 +3,11 @@ import type { LocationResponse } from "@/types/location"; import { locationDb, Location, deviceDb, userDb } from "@/lib/db"; import { auth } from "@/lib/auth"; -const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location"; - /** * GET /api/locations * - * Hybrid approach: - * 1. Fetch fresh data from n8n webhook - * 2. Store new locations in local SQLite cache - * 3. Return filtered data from SQLite (enables 24h+ history) + * Fetches location data from local SQLite cache. + * The MQTT subscriber automatically writes new locations to the cache. * * Query parameters: * - username: Filter by device tracker ID @@ -19,7 +15,6 @@ const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/ * - startTime: Custom range start (ISO string) * - endTime: Custom range end (ISO string) * - limit: Maximum number of records (default: 1000) - * - sync: Set to 'false' to skip n8n fetch and read only from cache */ export async function GET(request: NextRequest) { try { @@ -58,87 +53,8 @@ export async function GET(request: NextRequest) { const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : 1000; - const sync = searchParams.get('sync') !== 'false'; // Default: true - // Variable to store n8n data as fallback - let n8nData: LocationResponse | null = null; - - // Step 1: Optionally fetch and sync from n8n - if (sync) { - try { - const response = await fetch(N8N_API_URL, { - cache: "no-store", - signal: AbortSignal.timeout(3000), // 3 second timeout - }); - - if (response.ok) { - const data: LocationResponse = await response.json(); - - // Debug: Log first location from n8n - if (data.history && data.history.length > 0) { - console.log('[N8N Debug] First location from n8n:', { - username: data.history[0].username, - speed: data.history[0].speed, - speed_type: typeof data.history[0].speed, - speed_exists: 'speed' in data.history[0], - battery: data.history[0].battery, - battery_type: typeof data.history[0].battery, - battery_exists: 'battery' in data.history[0] - }); - } - - // Normalize data: Ensure speed and battery fields exist (treat 0 explicitly) - if (data.history && Array.isArray(data.history)) { - data.history = data.history.map(loc => { - // Generate display_time in German locale (Europe/Berlin timezone) - const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', { - timeZone: 'Europe/Berlin', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - - return { - ...loc, - display_time: displayTime, - // Explicit handling: 0 is valid, only undefined/null → null - speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null), - battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null), - }; - }); - } - - // Store n8n data for fallback - n8nData = data; - - // Store new locations in SQLite - if (data.history && Array.isArray(data.history) && data.history.length > 0) { - // Get latest timestamp from our DB - const stats = locationDb.getStats(); - const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z'; - - // Filter for only newer locations - const newLocations = data.history.filter(loc => - loc.timestamp > lastLocalTimestamp - ); - - if (newLocations.length > 0) { - const inserted = locationDb.createMany(newLocations as Location[]); - console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`); - } - } - } - } catch (syncError) { - // n8n not reachable - that's ok, we'll use cached data - console.warn('[Location Sync] n8n webhook not reachable, using cache only:', - syncError instanceof Error ? syncError.message : 'Unknown error'); - } - } - - // Step 2: Read from local SQLite with filters + // Read from local SQLite with filters let locations = locationDb.findMany({ user_id: 0, // Always filter for MQTT devices username, @@ -151,38 +67,6 @@ export async function GET(request: NextRequest) { // Filter locations to only include user's devices locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username)); - // Step 3: If DB is empty, use n8n data as fallback - if (locations.length === 0 && n8nData && n8nData.history) { - console.log('[API] DB empty, using n8n data as fallback'); - // Filter n8n data if needed - let filteredHistory = n8nData.history; - - // Filter by user's devices - filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username)); - - if (username) { - filteredHistory = filteredHistory.filter(loc => loc.username === username); - } - - // Apply time filters - if (startTime && endTime) { - // Custom range - filteredHistory = filteredHistory.filter(loc => - loc.timestamp >= startTime && loc.timestamp <= endTime - ); - } else if (timeRangeHours) { - // Quick filter - const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString(); - filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime); - } - - return NextResponse.json({ - ...n8nData, - history: filteredHistory, - total_points: filteredHistory.length, - }); - } - // Normalize locations: Ensure speed, battery, and display_time are correct locations = locations.map(loc => { // Generate display_time if missing or regenerate from timestamp @@ -207,7 +91,7 @@ export async function GET(request: NextRequest) { // Get actual total count from database (not limited by 'limit' parameter) const stats = locationDb.getStats(); - // Step 4: Return data in n8n-compatible format + // Return data in standard format const response: LocationResponse = { success: true, current: locations.length > 0 ? locations[0] : null, diff --git a/app/api/locations/sync/route.ts b/app/api/locations/sync/route.ts deleted file mode 100644 index 113d732..0000000 --- a/app/api/locations/sync/route.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; -import { locationDb, Location } from '@/lib/db'; -import type { LocationResponse } from "@/types/location"; - -const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location"; - -/** - * POST /api/locations/sync (ADMIN only) - * - * Manually sync location data from n8n webhook to local SQLite cache. - * This fetches all available data from n8n and stores only new records. - * - * Useful for: - * - Initial database population - * - Recovery after downtime - * - Manual refresh - */ -export async function POST() { - try { - // ADMIN only - const session = await auth(); - if (!session?.user || (session.user as any).role !== 'ADMIN') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - // Get stats before sync - const statsBefore = locationDb.getStats(); - - // Fetch from n8n webhook - const response = await fetch(N8N_API_URL, { - cache: "no-store", - signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync - }); - - if (!response.ok) { - throw new Error(`n8n webhook returned ${response.status}`); - } - - const data: LocationResponse = await response.json(); - - let insertedCount = 0; - - // Store new locations in SQLite - if (data.history && Array.isArray(data.history) && data.history.length > 0) { - // Get latest timestamp from our DB - const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z'; - - // Filter for only newer locations - const newLocations = data.history.filter(loc => - loc.timestamp > lastLocalTimestamp - ); - - if (newLocations.length > 0) { - insertedCount = locationDb.createMany(newLocations as Location[]); - console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`); - } - } - - // Get stats after sync - const statsAfter = locationDb.getStats(); - - return NextResponse.json({ - success: true, - synced: insertedCount, - n8nTotal: data.total_points || data.history.length, - before: { - total: statsBefore.total, - newest: statsBefore.newest, - }, - after: { - total: statsAfter.total, - newest: statsAfter.newest, - }, - }); - - } catch (error) { - console.error('Sync error:', error); - return NextResponse.json( - { - error: 'Failed to sync locations', - details: error instanceof Error ? error.message : 'Unknown error' - }, - { status: 500 } - ); - } -} diff --git a/app/export/page.tsx b/app/export/page.tsx new file mode 100644 index 0000000..2e203ab --- /dev/null +++ b/app/export/page.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +interface Device { + id: string; + name: string; + color: string; +} + +interface PreviewRow { + datum: string; + uhrzeit: string; + latitude: string; + longitude: string; + adresse: string; + distanz: string; + geschwindigkeit: string; + geraet: string; +} + +export default function ExportPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState("all"); + const [startTime, setStartTime] = useState(""); + const [endTime, setEndTime] = useState(""); + const [previewData, setPreviewData] = useState([]); + const [totalPoints, setTotalPoints] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(""); + + // Redirect if not authenticated + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login"); + } + }, [status, router]); + + // Fetch devices + useEffect(() => { + const fetchDevices = async () => { + try { + const response = await fetch("/api/devices/public"); + if (response.ok) { + const data = await response.json(); + setDevices(data.devices || []); + } + } catch (err) { + console.error("Failed to fetch devices:", err); + } + }; + + if (status === "authenticated") { + fetchDevices(); + } + }, [status]); + + // Set default time range (last 7 days) + useEffect(() => { + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + // Format for datetime-local input (YYYY-MM-DDTHH:mm) + setEndTime(now.toISOString().slice(0, 16)); + setStartTime(sevenDaysAgo.toISOString().slice(0, 16)); + }, []); + + // Generate preview + const handlePreview = async () => { + setLoading(true); + setPreviewData([]); + setTotalPoints(0); + + try { + const params = new URLSearchParams(); + if (selectedDevice !== "all") { + params.set("username", selectedDevice); + } + if (startTime) { + params.set("startTime", new Date(startTime).toISOString()); + } + if (endTime) { + params.set("endTime", new Date(endTime).toISOString()); + } + params.set("limit", "50"); // Preview only first 50 + + const response = await fetch(`/api/locations?${params.toString()}`); + if (!response.ok) throw new Error("Failed to fetch preview"); + + const data = await response.json(); + const locations = data.history || []; + setTotalPoints(locations.length); + + // Sort chronologically + locations.sort((a: any, b: any) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // Build preview rows (without geocoding) + const rows: PreviewRow[] = []; + for (let i = 0; i < Math.min(locations.length, 50); i++) { + const loc = locations[i]; + const lat = Number(loc.latitude); + const lon = Number(loc.longitude); + + // Calculate distance + let distance = 0; + if (i > 0) { + const prevLoc = locations[i - 1]; + distance = calculateDistance( + Number(prevLoc.latitude), + Number(prevLoc.longitude), + lat, + lon + ); + } + + const date = new Date(loc.timestamp); + rows.push({ + datum: date.toLocaleDateString('de-DE'), + uhrzeit: date.toLocaleTimeString('de-DE'), + latitude: lat.toFixed(6), + longitude: lon.toFixed(6), + adresse: `${lat.toFixed(6)}, ${lon.toFixed(6)}`, + distanz: distance.toFixed(3), + geschwindigkeit: loc.speed != null ? Number(loc.speed).toFixed(1) : '-', + geraet: loc.username || 'Unbekannt', + }); + } + + setPreviewData(rows); + } catch (error) { + console.error("Preview error:", error); + alert("Fehler beim Laden der Vorschau"); + } finally { + setLoading(false); + } + }; + + // Export CSV with geocoding + const handleExport = async () => { + if (previewData.length === 0) { + alert("Bitte zuerst Vorschau laden"); + return; + } + + const confirmed = confirm( + `${totalPoints} GPS-Punkte werden exportiert.\n\n` + + `ACHTUNG: Adressauflösung (Geocoding) kann bei vielen Punkten sehr lange dauern (ca. 1 Punkt pro Sekunde).\n\n` + + `Geschätzte Dauer: ca. ${Math.ceil(totalPoints / 60)} Minuten.\n\n` + + `Möchten Sie fortfahren?` + ); + + if (!confirmed) return; + + setExporting(true); + setExportProgress(`Starte Export von ${totalPoints} Punkten...`); + + try { + const params = new URLSearchParams(); + if (selectedDevice !== "all") { + params.set("username", selectedDevice); + } + if (startTime) { + params.set("startTime", new Date(startTime).toISOString()); + } + if (endTime) { + params.set("endTime", new Date(endTime).toISOString()); + } + params.set("includeGeocoding", "true"); + + setExportProgress("Lade Daten und löse Adressen auf..."); + + const response = await fetch(`/api/export/csv?${params.toString()}`); + if (!response.ok) throw new Error("Export failed"); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + setExportProgress("Export erfolgreich abgeschlossen!"); + setTimeout(() => setExportProgress(""), 3000); + } catch (error) { + console.error("Export error:", error); + alert("Fehler beim Exportieren"); + setExportProgress(""); + } finally { + setExporting(false); + } + }; + + // Export CSV without geocoding (faster) + const handleQuickExport = async () => { + if (previewData.length === 0) { + alert("Bitte zuerst Vorschau laden"); + return; + } + + setExporting(true); + setExportProgress("Exportiere ohne Adressauflösung..."); + + try { + const params = new URLSearchParams(); + if (selectedDevice !== "all") { + params.set("username", selectedDevice); + } + if (startTime) { + params.set("startTime", new Date(startTime).toISOString()); + } + if (endTime) { + params.set("endTime", new Date(endTime).toISOString()); + } + params.set("includeGeocoding", "false"); + + const response = await fetch(`/api/export/csv?${params.toString()}`); + if (!response.ok) throw new Error("Export failed"); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fahrtenbuch_schnell_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + setExportProgress("Export erfolgreich abgeschlossen!"); + setTimeout(() => setExportProgress(""), 3000); + } catch (error) { + console.error("Export error:", error); + alert("Fehler beim Exportieren"); + setExportProgress(""); + } finally { + setExporting(false); + } + }; + + if (status === "loading") { + return ( +
+

Laden...

+
+ ); + } + + return ( +
+
+
+

CSV-Export für Fahrtenbuch

+

+ Exportieren Sie Ihre GPS-Tracking-Daten für Lexware oder andere Fahrtenbuch-Software +

+ + {/* Filters */} +
+
+ + +
+ +
+ + setStartTime(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + /> +
+ +
+ + setEndTime(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + /> +
+
+ + {/* Action Buttons */} +
+ + + + + +
+ + {/* Progress */} + {exportProgress && ( +
+

{exportProgress}

+
+ )} + + {/* Info */} + {totalPoints > 0 && ( +
+

+ Gefunden: {totalPoints} GPS-Punkte im gewählten Zeitraum + {totalPoints > 50 && " (Vorschau zeigt erste 50)"} +

+

+ Hinweis: Der vollständige Export mit Adressauflösung dauert ca. {Math.ceil(totalPoints / 60)} Minuten. + Für schnelle Exporte nutzen Sie den Schnell-Export. +

+
+ )} +
+ + {/* Preview Table */} + {previewData.length > 0 && ( +
+

Vorschau (erste 50 Zeilen)

+
+ + + + + + + + + + + + + + + {previewData.map((row, idx) => ( + + + + + + + + + + + ))} + +
DatumUhrzeitLatitudeLongitudeAdresseDistanz (km)Geschw. (km/h)Gerät
{row.datum}{row.uhrzeit}{row.latitude}{row.longitude}{row.adresse}{row.distanz}{row.geschwindigkeit}{row.geraet}
+
+
+ )} +
+
+ ); +} + +// Haversine distance calculation (client-side for preview) +function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} diff --git a/app/map/page.tsx b/app/map/page.tsx index c000b96..3de0f29 100644 --- a/app/map/page.tsx +++ b/app/map/page.tsx @@ -69,12 +69,20 @@ export default function MapPage() { {/* Top row: Title and Admin link */} {/* Controls row - responsive grid */} diff --git a/components/map/MapView.tsx b/components/map/MapView.tsx index 03366a6..d0bfc7a 100644 --- a/components/map/MapView.tsx +++ b/components/map/MapView.tsx @@ -157,7 +157,6 @@ export default function MapView({ selectedDevice, timeFilter, isPaused, filterMo } params.set("limit", "5000"); // Fetch more data for better history - params.set("sync", "false"); // Disable n8n sync (using direct MQTT) // Fetch from local SQLite API (MQTT subscriber writes directly) const response = await fetch(`/api/locations?${params.toString()}`); diff --git a/lib/geo-utils.ts b/lib/geo-utils.ts new file mode 100644 index 0000000..84a693e --- /dev/null +++ b/lib/geo-utils.ts @@ -0,0 +1,111 @@ +/** + * Geo utilities for distance calculation and geocoding + */ + +/** + * Calculate distance between two GPS coordinates using Haversine formula + * @param lat1 Latitude of first point + * @param lon1 Longitude of first point + * @param lat2 Latitude of second point + * @param lon2 Longitude of second point + * @returns Distance in kilometers + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // Earth's radius in kilometers + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + return distance; +} + +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} + +/** + * Reverse geocode coordinates to address using Nominatim (OpenStreetMap) + * @param lat Latitude + * @param lon Longitude + * @returns Address string or coordinates if geocoding fails + */ +export async function reverseGeocode( + lat: number, + lon: number +): Promise { + try { + const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`; + + const response = await fetch(url, { + headers: { + 'User-Agent': 'GPS-Tracker-App/1.0', // Nominatim requires User-Agent + }, + }); + + if (!response.ok) { + throw new Error(`Nominatim API error: ${response.status}`); + } + + const data = await response.json(); + + if (data.error) { + return `${lat.toFixed(6)}, ${lon.toFixed(6)}`; + } + + // Build address from components + const address = data.address; + const parts: string[] = []; + + if (address.road) parts.push(address.road); + if (address.house_number) parts.push(address.house_number); + if (address.postcode) parts.push(address.postcode); + if (address.city || address.town || address.village) { + parts.push(address.city || address.town || address.village); + } + + return parts.length > 0 ? parts.join(', ') : data.display_name; + } catch (error) { + console.error('Geocoding error:', error); + // Fallback to coordinates + return `${lat.toFixed(6)}, ${lon.toFixed(6)}`; + } +} + +/** + * Rate-limited reverse geocoding (max 1 request per second for Nominatim) + */ +export class RateLimitedGeocoder { + private lastRequestTime = 0; + private minInterval = 1000; // 1 second between requests + + async geocode(lat: number, lon: number): Promise { + const now = Date.now(); + const timeSinceLastRequest = now - this.lastRequestTime; + + if (timeSinceLastRequest < this.minInterval) { + const waitTime = this.minInterval - timeSinceLastRequest; + await this.sleep(waitTime); + } + + this.lastRequestTime = Date.now(); + return reverseGeocode(lat, lon); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/pictures/n8n-MQTT-GPS-Tracking.png b/pictures/n8n-MQTT-GPS-Tracking.png deleted file mode 100644 index a0394cc6b6cd81dd40565125e09e5e18bfca472e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61843 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 z 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