Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
.git
Signaturen
*.tar
*.pdf
*.py
*.html
SVG_renamed

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# ===========================================
# Lageplan App - Environment Configuration
# ===========================================
# Copy this file to .env and adjust values
# Database
POSTGRES_USER=lageplan
POSTGRES_PASSWORD=lageplan_secret
POSTGRES_DB=lageplan
DB_PORT=5432
# Database URL (for Prisma - uses Docker service name)
DATABASE_URL=postgresql://lageplan:lageplan_secret@localhost:5432/lageplan
# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-super-secret-key-change-in-production-min-32-chars
# MinIO Object Storage
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123
MINIO_BUCKET=lageplan-icons
MINIO_API_PORT=9002
MINIO_CONSOLE_PORT=9003
MINIO_PUBLIC_URL=http://localhost:9002
# Web App
WEB_PORT=3000
NODE_ENV=development

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next/
out/
build
dist
# Production
build
# Misc
.DS_Store
*.pem
Thumbs.db
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea
.vscode
*.swp
*.swo
# Prisma
prisma/migrations/*
!prisma/migrations/.gitkeep
# Large binary files (do not commit)
*.tar
*.zip
*.mp4
lageplan-web.tar
# Reference materials (keep locally, not in git)
Signaturen/
Reglement_*/
# Stack env (contains secrets)
stack.env
.env.docker

61
Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# Stage 2: Builder
FROM node:20-alpine AS builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma Client
RUN npx prisma generate
# Copy docker-specific env for build (DATABASE_URL pointing to 'db' service)
COPY .env.docker .env
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.env ./.env
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --from=builder /app/node_modules/stripe ./node_modules/stripe
COPY --from=builder /app/package.json ./package.json
RUN npm install --omit=dev socket.io@4.7.4 @react-pdf/renderer@3.4.2 qrcode@1.5.3 --no-save
COPY server-custom.js ./server-custom.js
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chown -R nextjs:nodejs /app/node_modules
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["sh", "docker-entrypoint.sh"]

246
README.md Normal file
View File

@@ -0,0 +1,246 @@
# Lageplan - Feuerwehr Krokier-App
Digitale Lageplan-Applikation für Schweizer Feuerwehren zur Einsatzdokumentation und taktischen Lagedarstellung. Multi-Tenant SaaS mit Karte, Journal, Symbolbibliothek und Druckexport.
## Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ MapView │ │ Journal │ │ Admin │ │Landing │ │
│ │(MapLibre)│ │ View │ │ Panel │ │ Page │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ └──────────────┴─────────────┴─────────────┘ │
│ │ fetch() │
├─────────────────────────┼───────────────────────────────┤
│ Next.js 14 Server │
│ ┌──────────────────────┼──────────────────────────┐ │
│ │ API Routes (/api/*) │ │
│ │ ┌────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Auth │ │ Projects │ │ Admin (Users, │ │ │
│ │ │ (JWT) │ │ Features │ │ Tenants, Icons) │ │ │
│ │ └───┬────┘ │ Journal │ └────────┬─────────┘ │ │
│ │ │ └────┬─────┘ │ │ │
│ │ └────────────┼─────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────┼────────────────────────┐ │ │
│ │ │ Prisma ORM + Tenant Guard │ │ │
│ │ └────────────────┼────────────────────────┘ │ │
│ └───────────────────┼─────────────────────────────┘ │
├───────────────────────┼─────────────────────────────────┤
│ ┌────────────┐ ┌────┴───────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ MinIO │ │ SMTP (optional) │ │
│ │ (Prisma) │ │ (S3 Icons) │ │ (Nodemailer) │ │
│ └────────────┘ └────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Tech Stack
| Schicht | Technologie | Zweck |
|---------|------------|-------|
| **Frontend** | Next.js 14, React 18, TypeScript | SSR + SPA |
| **UI** | TailwindCSS, shadcn/ui, Lucide Icons | Styling + Komponenten |
| **Karte** | MapLibre GL JS | OSM + Satellit Tiles |
| **Drag & Drop** | react-dnd + TouchBackend | Symbol-Platzierung (Desktop + Mobile) |
| **Datenbank** | PostgreSQL 16 + Prisma ORM | Relationale Daten |
| **Object Storage** | MinIO (S3-kompatibel) | Icon-Upload |
| **Auth** | JWT (jose) + httpOnly Cookies | Session-Management |
| **Validierung** | Zod | Input-Validierung auf API-Ebene |
| **E-Mail** | Nodemailer | Passwort-Reset, Kontaktformular |
| **PDF** | jsPDF + html2canvas | Kartenexport |
| **Container** | Docker Compose | Deployment |
## Features
### Karte
- **OSM + Satellitenansicht** — Layer-Toggle oben rechts
- **Zeichenwerkzeuge** — Punkt, Linie, Polygon, Rechteck, Kreis, Pfeil, Freihand, Text, Gefahrenzone
- **Symbolbibliothek** — 22+ FKS/BABS-konforme Feuerwehr-Symbole in 9 Kategorien
- **Drag & Drop + Tap-to-Place** — Symbole auf Karte platzieren (Desktop + Touch)
- **Symbol-Bearbeitung** — Rechtsklick: Skalierung (0.3x-4x), Rotation (0-360°)
- **Messwerkzeug** — Distanzmessung mit Höhenprofil (Open-Meteo API), Druckverlust-Berechnung für Feuerwehrschläuche
- **GPS-Standort** — Automatische Geolocation bei App-Start
- **Kartenposition persistent** — Bleibt beim Tab-Wechsel und Projekt-Erstellung erhalten
- **Offline-Tiles** — Service Worker cached OSM-Tiles
### Einsatz-Journal
- **Zeitprotokoll** — Einträge mit Zeitstempel, Was, Wer, Erledigt-Status
- **SOMA-Checkliste** — Vordefinierte Prüfpunkte (Ja/Ok Spalten), aus Templates initialisiert
- **Pendenzen** — Offene Aufgaben mit Was/Wer/Wann
- **Druckansicht** — Journal als formatiertes Dokument drucken
### Einsatz-Verwaltung
- **Erstellen/Laden/Löschen** — Über Menü → "Einsätze verwalten"
- **Auto-Save** — Alle 30 Sekunden automatisch in DB + localStorage
- **Export** — PNG, PDF (mit Metadaten-Header), GeoJSON
- **Sperren** — Projekte können gesperrt werden (nur SERVER_ADMIN kann entsperren)
### Admin-Bereich (`/admin`)
- **Benutzer** — CRUD, Rollen zuweisen, Passwort zurücksetzen
- **Tenants** — Organisationen verwalten, Mitglieder, Abo-Status
- **Symbole** — Upload (PNG/SVG/JPEG/WebP, max 5MB), Kategorien
- **Schlauchtypen** — Konfigurierbare Druckverlust-Parameter
- **System** — SMTP-Einstellungen, Kontakt-E-Mail, Test-E-Mail
### Sicherheit & Multi-Tenancy
- **4 Rollen** — SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER
- **Tenant-Isolation** — Jeder Tenant sieht nur eigene Projekte/Benutzer
- **JWT httpOnly Cookies** — Kein Token im localStorage
- **Zod-Validierung** — Alle API-Inputs validiert
- **IDOR-Schutz** — Sub-Ressourcen (Journal, CheckItems) werden gegen Projekt-Zugehörigkeit geprüft
- **Passwort-Hashing** — bcrypt mit 12 Rounds
- **Reset-Token** — Kryptographisch sicher (32 Bytes), 1h Ablauf, nie in API-Response exponiert
## Projekt-Struktur
```
lageplan/
├── docker-compose.yml # PostgreSQL, MinIO, Web-App
├── Dockerfile # Multi-Stage Build (deps → builder → runner)
├── docker-entrypoint.sh # DB-Migration + Seed beim Container-Start
├── prisma/
│ ├── schema.prisma # 15 Models, 4 Enums
│ └── seed.js # Demo-Daten (Tenants, Users, Symbole, Projekt)
├── public/
│ └── sw.js # Service Worker für Offline-Tile-Caching
├── src/
│ ├── app/
│ │ ├── page.tsx # Landing Page (/)
│ │ ├── login/page.tsx # Login
│ │ ├── register/page.tsx # Registrierung (erstellt Tenant + User)
│ │ ├── reset-password/ # Passwort-Reset
│ │ ├── admin/page.tsx # Admin-Panel
│ │ ├── app/page.tsx # Haupt-App (Karte + Journal)
│ │ └── api/
│ │ ├── auth/ # login, logout, me, register, forgot/reset-password
│ │ ├── projects/ # CRUD + features, journal, export
│ │ ├── admin/ # users, tenants, icons, categories, settings
│ │ ├── icons/ # Public icon listing + image serving
│ │ ├── hose-types/ # Schlauchtyp-Konfiguration
│ │ ├── contact/ # Kontaktformular
│ │ └── tenants/ # Public tenant info by slug
│ ├── components/
│ │ ├── map/
│ │ │ └── map-view.tsx # MapLibre GL Karte (~1650 Zeilen)
│ │ ├── journal/
│ │ │ └── journal-view.tsx # Einsatz-Journal
│ │ ├── layout/
│ │ │ ├── topbar.tsx # Header mit Menü, Speichern, Einsatz-Verwaltung
│ │ │ ├── left-toolbar.tsx # Zeichenwerkzeuge
│ │ │ └── right-sidebar.tsx # Symbole + Karte/Journal Tabs
│ │ ├── dialogs/ # ProjectDialog, TextDialog, LineLabelDialog, HoseSettings
│ │ ├── providers/
│ │ │ └── auth-provider.tsx # React Context für Auth-State
│ │ └── ui/ # shadcn/ui Basis-Komponenten
│ └── lib/
│ ├── auth.ts # JWT erstellen/verifizieren, Login, Rollen-Checks
│ ├── tenant.ts # Tenant-Filter, Projekt-Zugriffsprüfung
│ ├── db.ts # Prisma Client Singleton
│ ├── minio.ts # MinIO Upload/Download/Delete
│ ├── email.ts # SMTP aus DB laden, E-Mail senden
│ ├── validations.ts # Zod Schemas (Login, Project, Feature, Icon)
│ ├── fw-symbols.ts # 22 eingebaute SVG Feuerwehr-Symbole
│ └── utils.ts # Hilfsfunktionen (cn, formatDateTime)
```
## Datenbank-Schema (Prisma)
### Kern-Models
- **Tenant** — Organisation (Feuerwehr), mit Abo-Plan, Limits, Logo
- **User** — E-Mail/Passwort, Rolle, Reset-Token
- **TenantMembership** — User ↔ Tenant Zuordnung (M:N)
- **Project** — Einsatz mit Titel, Ort, mapCenter/mapZoom, isLocked
- **Feature** — GeoJSON-Zeichnung (Geometrie + Properties als JSON)
### Journal-Models
- **JournalEntry** — Zeitprotokoll-Eintrag (time, what, who, done)
- **JournalCheckItem** — SOMA-Checkliste (label, confirmed, ok)
- **JournalPendenz** — Offene Aufgabe (what, who, whenHow, done)
- **JournalCheckTemplate** — Vorlagen für Checklisten-Punkte
### Konfigurations-Models
- **IconCategory** — Symbolkategorie (Feuer, Wasser, Gefahrstoffe, ...)
- **IconAsset** — Hochgeladenes Symbol (fileKey → MinIO)
- **HoseType** — Schlauchtyp mit Druckverlust-Parametern
- **SystemSetting** — Key-Value Store (SMTP, Kontakt-E-Mail)
### Rollen & Berechtigungen
| Aktion | SERVER_ADMIN | TENANT_ADMIN | OPERATOR | VIEWER |
|--------|:---:|:---:|:---:|:---:|
| Alle Projekte sehen | ✓ | - | - | - |
| Tenant-Projekte sehen | ✓ | ✓ | ✓ | ✓ |
| Zeichnen/Bearbeiten | ✓ | ✓ | ✓ | - |
| Projekt erstellen | ✓ | ✓ | ✓ | - |
| Projekt löschen | ✓ | ✓ | Eigene | - |
| Benutzer verwalten | Alle | Eigener Tenant | - | - |
| Tenants verwalten | ✓ | - | - | - |
| System-Einstellungen | ✓ | - | - | - |
## Schnellstart
### Voraussetzungen
- Docker & Docker Compose
### Installation
```bash
cp .env.example .env
docker compose up -d
```
App: **http://localhost:3000**
### Standard-Logins (nach Seed)
| Benutzer | E-Mail | Passwort | Rolle |
|----------|--------|----------|-------|
| Admin | admin@lageplan.local | admin123 | SERVER_ADMIN |
| Editor | editor@demo.local | editor123 | OPERATOR |
| Viewer | viewer@demo.local | viewer123 | VIEWER |
## Umgebungsvariablen
| Variable | Beschreibung | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL Connection String | siehe .env.example |
| `NEXTAUTH_SECRET` | JWT Secret (**min. 32 Zeichen!**) | - |
| `MINIO_ENDPOINT` | MinIO Host | localhost |
| `MINIO_PORT` | MinIO API Port | 9000 |
| `MINIO_ACCESS_KEY` | MinIO Zugangsdaten | minioadmin |
| `MINIO_SECRET_KEY` | MinIO Passwort | minioadmin123 |
| `MINIO_BUCKET` | Bucket Name | lageplan-icons |
| `MINIO_PUBLIC_URL` | Öffentliche MinIO URL | http://localhost:9002 |
## Docker Services
| Service | Port | Beschreibung |
|---------|------|-------------|
| `web` | 3000 | Next.js App |
| `db` | 5432 | PostgreSQL 16 |
| `minio` | 9002 (API), 9003 (Console) | MinIO Object Storage |
## Produktion
```bash
# 1. Sichere Secrets setzen
NEXTAUTH_SECRET=$(openssl rand -base64 32)
POSTGRES_PASSWORD=$(openssl rand -base64 16)
# 2. .env anpassen
# 3. Starten
docker compose up -d
# 4. Optional: Reverse Proxy (nginx/traefik) für HTTPS
```
## Security-Hinweise
- `NEXTAUTH_SECRET` **muss** in Produktion gesetzt werden (min. 32 Zeichen)
- SMTP-Passwörter werden in der DB als `isSecret: true` markiert
- Reset-Tokens werden **nie** in API-Responses exponiert (nur in Server-Logs wenn kein SMTP)
- Alle Sub-Ressourcen-Zugriffe (Journal-Einträge, CheckItems, Pendenzen) prüfen Projekt-Zugehörigkeit
- Cookie: `httpOnly`, `secure` (in Produktion), `sameSite: lax`
- Datei-Uploads: Nur PNG/SVG/JPEG/WebP, max 5MB, UUID-Dateinamen

111
deploy/README.md Normal file
View File

@@ -0,0 +1,111 @@
# Lageplan — Portainer Deployment
## Architektur
```
Browser → :3000 (Web App) → intern: db:5432, minio:9000
```
Nur **ein Port (3000)** muss exponiert werden. DB und MinIO laufen rein intern im Docker-Netzwerk. Icons werden über die Web-App gestreamt — kein direkter MinIO-Zugriff nötig.
---
## Dateien
| Datei | Beschreibung |
|-------|-------------|
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) |
| `portainer-stack.yml` | Stack YAML für Portainer |
| `.env.example` | Environment Variables Vorlage |
---
## Schritt 1: Image auf Server laden
```bash
# Kopieren
scp lageplan-web-v1.0.0.tar user@server:/tmp/
# Auf dem Server laden
docker load -i /tmp/lageplan-web-v1.0.0.tar
# Prüfen
docker images | grep lageplan
```
---
## Schritt 2: Stack in Portainer erstellen
1. **Portainer**`Stacks``+ Add stack`
2. **Name**: `lageplan`
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen
4. **Environment variables** setzen:
| Variable | Wert |
|----------|------|
| `POSTGRES_USER` | `lageplan` |
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* |
| `POSTGRES_DB` | `lageplan` |
| `MINIO_ROOT_USER` | `minioadmin` |
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* |
| `MINIO_BUCKET` | `lageplan-icons` |
| `WEB_PORT` | `3000` |
| `NEXTAUTH_URL` | `http://SERVER_IP:3000` |
| `NEXTAUTH_SECRET` | *(langer zufälliger String)* |
> **Tipp**: Secret generieren: `openssl rand -base64 32`
5. **Deploy the stack**
---
## Schritt 3: Datenbank initialisieren (einmalig)
In Portainer: Container `web` → Console → `/bin/sh`:
```bash
npx prisma db push
npx prisma db seed
```
Oder per SSH:
```bash
docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed
```
---
## Schritt 4: Zugriff
- **Web App**: `http://SERVER_IP:3000`
- **Login**: `admin@lageplan.local` / `admin123`
---
## Update
```bash
# Lokal: neues Image bauen + exportieren
docker compose build web
docker tag lageplan-web:latest lageplan-web:v1.1.0
docker save lageplan-web:v1.1.0 -o deploy/lageplan-web-v1.1.0.tar
# Server: laden
docker load -i lageplan-web-v1.1.0.tar
# Portainer: Stack → Editor → Image-Tag ändern → Update the stack
```
---
## Ports
| Service | Port | Extern? |
|---------|------|---------|
| Web App | 3000 | ✅ Ja (`WEB_PORT`) |
| PostgreSQL | 5432 | ❌ Nur intern |
| MinIO API | 9000 | ❌ Nur intern |
| MinIO Console | 9001 | ❌ Nur intern |

View File

@@ -0,0 +1,97 @@
# =============================================================
# Lageplan - Portainer Stack
# =============================================================
# Deployment:
# 1. Image auf Server laden: docker load -i lageplan-web-v1.0.0.tar
# 2. In Portainer: Stacks → Add Stack → Web editor
# 3. Diese YAML einfügen + Environment Variables setzen
# =============================================================
services:
# --- PostgreSQL Database ---
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- lageplan-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
# --- MinIO Object Storage (S3-kompatibel, für Symbole/Icons) ---
# Kein externer Port nötig — Icons werden über die Web-App gestreamt
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
networks:
- lageplan-net
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
# --- MinIO Bucket Initialisierung (einmalig) ---
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
networks:
- lageplan-net
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb myminio/${MINIO_BUCKET} --ignore-existing;
mc anonymous set download myminio/${MINIO_BUCKET};
echo 'Bucket ready';
exit 0;
"
# --- Lageplan Web App (Next.js) ---
web:
image: lageplan-web:v1.0.0
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
MINIO_ENDPOINT: minio
MINIO_PORT: "9000"
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${MINIO_BUCKET}
MINIO_USE_SSL: "false"
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
networks:
- lageplan-net
volumes:
postgres_data:
driver: local
minio_data:
driver: local
networks:
lageplan-net:
driver: bridge

9
deploy/portainer.env Normal file
View File

@@ -0,0 +1,9 @@
POSTGRES_USER=lageplan
POSTGRES_PASSWORD=HIER_SICHERES_PASSWORT_SETZEN
POSTGRES_DB=lageplan
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=HIER_SICHERES_PASSWORT_SETZEN
MINIO_BUCKET=lageplan-icons
WEB_PORT=3000
NEXTAUTH_URL=http://SERVER_IP:3000
NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN

45
docker-compose.gitea.yml Normal file
View File

@@ -0,0 +1,45 @@
##############################################
# Gitea — Lightweight Git Server
#
# Verwendung in Portainer:
# 1. Stacks → Add Stack → "Gitea"
# 2. Diesen Inhalt einfügen
# 3. Deploy
#
# Danach:
# 1. http://192.168.1.183:3100 öffnen
# 2. Erstinstallation: Admin-User anlegen
# 3. Repository "lageplan" erstellen
# 4. Vom PC aus: git init → git remote add origin → git push
#
# Daten werden in gitea_data persistiert.
##############################################
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__ROOT_URL=http://192.168.1.183:3100
- GITEA__server__HTTP_PORT=3000
- GITEA__server__LFS_START_SERVER=true
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3100:3000"
- "2222:22"
networks:
- lageplan_lageplan-net
volumes:
gitea_data:
networks:
lageplan_lageplan-net:
external: true

View File

@@ -0,0 +1,109 @@
##############################################
# Lageplan — Portainer Stack Configuration
#
# Verwendung in Portainer:
# 1. Stacks → Add Stack
# 2. "Upload" oder diesen Inhalt einfügen
# 3. Environment-Variablen setzen (siehe unten)
# 4. Deploy
#
# Benötigte Environment-Variablen:
# POSTGRES_USER (default: lageplan)
# POSTGRES_PASSWORD (ÄNDERN!)
# POSTGRES_DB (default: lageplan)
# NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32)
# NEXTAUTH_URL (z.B. https://lageplan.example.com)
# MINIO_ROOT_USER (default: minioadmin)
# MINIO_ROOT_PASSWORD (ÄNDERN!)
# MINIO_PUBLIC_URL (z.B. https://s3.example.com)
##############################################
services:
# ─── PostgreSQL ────────────────────────────
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-lageplan}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lageplan_secret}
POSTGRES_DB: ${POSTGRES_DB:-lageplan}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lageplan}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lageplan
# ─── MinIO (S3-kompatibler Objektspeicher) ─
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123}
volumes:
- minio_data:/data
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lageplan
# ─── MinIO Bucket Init ─────────────────────
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin123};
mc mb myminio/$${MINIO_BUCKET:-lageplan-icons} --ignore-existing;
mc anonymous set download myminio/$${MINIO_BUCKET:-lageplan-icons};
echo 'Bucket initialized';
exit 0;
"
networks:
- lageplan
# ─── Lageplan Web App ──────────────────────
web:
image: lageplan-web:latest
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production}
MINIO_ENDPOINT: minio
MINIO_PORT: "9000"
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin123}
MINIO_BUCKET: ${MINIO_BUCKET:-lageplan-icons}
MINIO_USE_SSL: "false"
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
networks:
- lageplan
volumes:
postgres_data:
minio_data:
networks:
lageplan:
driver: bridge

89
docker-compose.yml Normal file
View File

@@ -0,0 +1,89 @@
version: '3.8'
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: lageplan-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-lageplan}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lageplan_secret}
POSTGRES_DB: ${POSTGRES_DB:-lageplan}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lageplan}"]
interval: 5s
timeout: 5s
retries: 5
# MinIO Object Storage (S3-compatible)
minio:
image: minio/minio:latest
container_name: lageplan-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123}
volumes:
- minio_data:/data
ports:
- "${MINIO_API_PORT:-9002}:9000"
- "${MINIO_CONSOLE_PORT:-9003}:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
# MinIO Bucket Initialization
minio-init:
image: minio/mc:latest
container_name: lageplan-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123};
mc mb myminio/${MINIO_BUCKET:-lageplan-icons} --ignore-existing;
mc anonymous set download myminio/${MINIO_BUCKET:-lageplan-icons};
echo 'Bucket initialized successfully';
exit 0;
"
# Next.js Web Application
web:
build:
context: .
dockerfile: Dockerfile
container_name: lageplan-web
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-super-secret-key-change-in-production}
MINIO_ENDPOINT: minio
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin123}
MINIO_BUCKET: ${MINIO_BUCKET:-lageplan-icons}
MINIO_USE_SSL: "false"
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
volumes:
- ./prisma:/app/prisma
volumes:
postgres_data:
minio_data:

39
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/sh
set -e
echo "=== Lageplan Startup ==="
# ─── Step 1: Run migrations (uses PrismaClient raw SQL, no CLI needed) ───
echo "[1/3] Running database migrations..."
node prisma/migrate.js || echo " Warning: migrations had issues (may be first run)"
# ─── Step 2: Conditional seeding ───
echo "[2/3] Checking if seeding is needed..."
TENANT_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.tenant.count().then(c => { console.log(c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
" 2>/dev/null || echo "0")
if [ "$TENANT_COUNT" = "0" ] || [ -z "$TENANT_COUNT" ]; then
echo " Empty database — running full seed..."
node prisma/seed.js || echo " Warning: seed failed"
else
echo " $TENANT_COUNT tenant(s) found — skipping full seed"
# Only refresh icons if count doesn't match expected 117 SVGs
ICON_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.iconAsset.count({ where: { isSystem: true, fileKey: { endsWith: '.svg' } } }).then(c => { console.log(c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
" 2>/dev/null || echo "0")
if [ "$ICON_COUNT" -lt 100 ] 2>/dev/null || [ "$ICON_COUNT" = "0" ] || [ -z "$ICON_COUNT" ]; then
echo " $ICON_COUNT SVG icons found (expected 117) — refreshing..."
node prisma/seed-icons-only.js || echo " Warning: icon seed failed"
else
echo " $ICON_COUNT SVG icons present — skipping icon seed"
fi
fi
# ─── Step 3: Start server ───
echo "[3/3] Starting server..."
exec node server-custom.js

74
next.config.js Normal file
View File

@@ -0,0 +1,74 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(self)',
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com http://localhost:9000 http://minio:9000",
"font-src 'self' data:",
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
],
},
]
},
serverExternalPackages: ['minio', 'stripe', '@react-pdf/renderer', '@react-pdf/layout', '@react-pdf/pdfkit', '@react-pdf/font'],
typescript: {
ignoreBuildErrors: true,
},
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '9000',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'minio',
port: '9000',
pathname: '/**',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '16mb',
},
},
}
module.exports = nextConfig

10685
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

92
package.json Normal file
View File

@@ -0,0 +1,92 @@
{
"name": "lageplan",
"version": "1.0.0",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:prod": "prisma migrate deploy",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio",
"postinstall": "prisma generate"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^5.9.0",
"@radix-ui/react-alert-dialog": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"@react-pdf/renderer": "^3.4.4",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"html2canvas": "^1.4.1",
"jose": "^5.2.0",
"jspdf": "^2.5.1",
"lucide-react": "^0.312.0",
"maplibre-gl": "^4.0.0",
"minio": "^7.1.3",
"next": "^15.1.0",
"nodemailer": "^6.9.8",
"qrcode": "^1.5.4",
"rdndmb-html5-to-touch": "^9.0.0",
"react": "^19.0.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dnd-multi-backend": "^9.0.0",
"react-dnd-touch-backend": "^16.0.1",
"react-dom": "^19.0.0",
"react-moveable": "^0.56.0",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4",
"stripe": "^20.3.1",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
"prisma": {
"seed": "node prisma/seed.js"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^9.0.7",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.4.33",
"prisma": "^5.9.0",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions",
"last 2 Edge versions"
]
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

226
prisma/migrate.js Normal file
View File

@@ -0,0 +1,226 @@
/**
* Database migration script using PrismaClient raw SQL.
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
* Safe to run multiple times (all statements are idempotent).
*/
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function migrate() {
console.log('🔧 Running database migrations...')
// ─── Step 1: Ensure enum values exist ───
console.log(' [1/7] Ensuring enum values...')
const enumMigrations = [
// Role enum
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'SERVER_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'SERVER_ADMIN'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'TENANT_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'TENANT_ADMIN'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'OPERATOR' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'OPERATOR'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'VIEWER' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'VIEWER'; END IF; END$$;`,
// DictionaryScope enum
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'DictionaryScope') THEN CREATE TYPE "DictionaryScope" AS ENUM ('GLOBAL', 'TENANT'); END IF; END$$;`,
]
for (const sql of enumMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* enum might already exist */ }
}
// ─── Step 2: Migrate old enum data ───
console.log(' [2/7] Migrating old data...')
const dataMigrations = [
`UPDATE users SET role = 'SERVER_ADMIN' WHERE role = 'ADMIN'`,
`UPDATE users SET role = 'OPERATOR' WHERE role = 'EDITOR'`,
`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`,
]
for (const sql of dataMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might not exist yet */ }
}
// ─── Step 3: Add missing columns (idempotent) ───
console.log(' [3/7] Adding missing columns...')
const columnMigrations = [
// Tenants
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoUrl" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoFileKey" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "hiddenIconIds" TEXT[] DEFAULT '{}'`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "journalSuggestions" TEXT[] DEFAULT '{}'`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactEmail" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactPhone" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "address" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "notes" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxUsers" INTEGER DEFAULT 5`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxProjects" INTEGER DEFAULT 10`,
// Users
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN DEFAULT true`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerificationToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`,
// Icon categories
`ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "isGlobal" BOOLEAN DEFAULT false`,
`ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "tenantId" TEXT`,
// Projects
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planImageKey" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingStartedAt" TIMESTAMP`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingHeartbeat" TIMESTAMP`,
// Journal
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
]
let added = 0
for (const sql of columnMigrations) {
try {
await prisma.$executeRawUnsafe(sql)
added++
} catch (e) {
// Table might not exist yet — that's OK, prisma db push or first seed will create it
}
}
console.log(` ${added}/${columnMigrations.length} column migrations executed`)
// ─── Step 4: Create new tables ───
console.log(' [4/7] Creating new tables...')
const tableMigrations = [
// Dictionary entries table
`CREATE TABLE IF NOT EXISTS dictionary_entries (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
word TEXT NOT NULL,
scope "DictionaryScope" NOT NULL DEFAULT 'GLOBAL',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tenantId" TEXT REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE(word, "tenantId")
)`,
// Rapports table
`CREATE TABLE IF NOT EXISTS rapports (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"reportNumber" TEXT NOT NULL,
token TEXT NOT NULL UNIQUE DEFAULT gen_random_uuid(),
data JSONB NOT NULL DEFAULT '{}',
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
"createdById" TEXT REFERENCES users(id) ON DELETE SET NULL,
UNIQUE("tenantId", "reportNumber")
)`,
]
for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
}
// ─── Step 5: Set safe defaults ───
console.log(' [5/7] Setting defaults...')
try {
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
} catch (e) { /* ignore */ }
// ─── Step 6: Clean up orphan users ───
console.log(' [6/7] Cleaning up orphan users...')
try {
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
const orphans = await prisma.user.findMany({
where: {
role: { not: 'SERVER_ADMIN' },
memberships: { none: {} },
},
select: { id: true, email: true, name: true },
})
if (orphans.length > 0) {
console.log(` Found ${orphans.length} orphan user(s):`)
for (const o of orphans) {
console.log(` - ${o.email} (${o.name})`)
}
// Delete orphan users and their related data
await prisma.user.deleteMany({
where: {
id: { in: orphans.map(o => o.id) },
},
})
console.log(` 🗑️ ${orphans.length} orphan user(s) removed`)
} else {
console.log(' No orphan users found')
}
} catch (e) {
console.log(' Orphan cleanup skipped:', e.message)
}
// ─── Step 7: Backfill logoFileKey from logoUrl ───
console.log(' [7/7] Backfilling logoFileKey...')
try {
// Extract fileKey from MinIO URLs: the path after the bucket name
// e.g. http://localhost:9002/lageplan-icons/logos/tenant-xxx.png → logos/tenant-xxx.png
const tenants = await prisma.tenant.findMany({
where: { logoUrl: { not: null }, logoFileKey: null },
select: { id: true, logoUrl: true },
})
for (const t of tenants) {
if (!t.logoUrl) continue
// Try to extract the fileKey from the URL
const match = t.logoUrl.match(/logos\/[^?]+/)
if (match) {
await prisma.tenant.update({
where: { id: t.id },
data: { logoFileKey: match[0] },
})
}
}
if (tenants.length > 0) console.log(` Backfilled ${tenants.length} logo fileKey(s)`)
} catch (e) {
console.log(' Logo backfill skipped:', e.message)
}
// ─── Step 8: Drop unique constraint on rapports(tenantId, reportNumber) ───
console.log(' [8] Dropping rapports unique constraint on (tenantId, reportNumber)...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" DROP CONSTRAINT IF EXISTS "rapports_tenantId_reportNumber_key"`)
console.log(' Constraint dropped (or did not exist)')
} catch (e) {
console.log(' Constraint drop skipped:', e.message)
}
// ─── Step 9: Add einsatzNr column to projects ───
console.log(' [9] Adding einsatzNr column to projects...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "einsatzNr" TEXT`)
console.log(' einsatzNr column added (or already exists)')
} catch (e) {
console.log(' einsatzNr column skipped:', e.message)
}
// ─── Step 10: Make rapports.tenantId nullable ───
console.log(' [10] Making rapports.tenantId nullable...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" ALTER COLUMN "tenantId" DROP NOT NULL`)
console.log(' rapports.tenantId is now nullable')
} catch (e) {
console.log(' rapports.tenantId nullable skipped:', e.message)
}
// ─── Step 11: Add privacy consent columns to tenants ───
console.log(' [11] Adding privacy consent columns to tenants...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAccepted" BOOLEAN DEFAULT false`)
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAcceptedAt" TIMESTAMP`)
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "adminAccessAccepted" BOOLEAN DEFAULT false`)
console.log(' Privacy consent columns added')
} catch (e) {
console.log(' Privacy consent columns skipped:', e.message)
}
console.log('✅ Database migrations complete')
}
migrate()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error('Migration error:', e.message)
await prisma.$disconnect()
process.exit(1)
})

414
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,414 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
SERVER_ADMIN
TENANT_ADMIN
OPERATOR
VIEWER
}
enum IconType {
STANDARD
RETTUNG
GEFAHRSTOFF
FEUER
WASSER
FAHRZEUG
}
enum ItemKind {
SYMBOL
LINE
POLYGON
RECTANGLE
CIRCLE
ARROW
TEXT
}
enum SubscriptionPlan {
FREE
PRO
}
enum DictionaryScope {
GLOBAL
TENANT
}
enum SubscriptionStatus {
ACTIVE
TRIAL
SUSPENDED
EXPIRED
CANCELLED
}
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
description String?
isActive Boolean @default(true)
contactEmail String?
contactPhone String?
address String?
logoUrl String?
logoFileKey String?
// Subscription
plan SubscriptionPlan @default(FREE)
subscriptionStatus SubscriptionStatus @default(ACTIVE)
trialEndsAt DateTime?
subscriptionEndsAt DateTime?
maxUsers Int @default(5)
maxProjects Int @default(10)
notes String?
hiddenIconIds String[] @default([])
journalSuggestions String[] @default([])
// Privacy consent
privacyAccepted Boolean @default(false)
privacyAcceptedAt DateTime?
adminAccessAccepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TenantMembership[]
projects Project[]
hoseTypes HoseType[]
checkTemplates JournalCheckTemplate[]
iconCategories IconCategory[]
iconAssets IconAsset[]
upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[]
rapports Rapport[]
@@map("tenants")
}
// ─── System Settings (Key-Value, encrypted for secrets) ──────
model SystemSetting {
id String @id @default(uuid())
key String @unique
value String
isSecret Boolean @default(false)
category String @default("general")
updatedAt DateTime @updatedAt
@@map("system_settings")
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String
role Role @default(OPERATOR)
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
resetToken String? @unique
resetTokenExpiry DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TenantMembership[]
projects Project[]
iconAssets IconAsset[]
upgradeRequests UpgradeRequest[]
rapports Rapport[]
@@map("users")
}
model TenantMembership {
id String @id @default(uuid())
role Role @default(OPERATOR)
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, tenantId])
@@map("tenant_memberships")
}
model Project {
id String @id @default(uuid())
einsatzNr String?
title String
location String?
description String?
einsatzleiter String?
journalfuehrer String?
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
mapZoom Float @default(15)
isLocked Boolean @default(false)
// Live editing lock (session-based for same-account multi-device)
editingById String?
editingUserName String?
editingSessionId String?
editingStartedAt DateTime?
editingHeartbeat DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
features Feature[]
items Item[]
journalEntries JournalEntry[]
journalCheckItems JournalCheckItem[]
journalPendenzen JournalPendenz[]
rapports Rapport[]
@@map("projects")
}
model Feature {
id String @id @default(uuid())
type String
geometry Json
properties Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("features")
}
model IconCategory {
id String @id @default(uuid())
name String
description String?
sortOrder Int @default(0)
isGlobal Boolean @default(false)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
icons IconAsset[]
@@map("icon_categories")
}
model IconAsset {
id String @id @default(uuid())
name String
fileKey String
mimeType String
width Int?
height Int?
isSystem Boolean @default(false)
isActive Boolean @default(true)
iconType IconType @default(STANDARD)
tags String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId String?
category IconCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("icon_assets")
}
model HoseType {
id String @id @default(uuid())
name String @unique
diameterMm Int
lengthPerPieceM Int @default(10)
flowRateLpm Float
frictionCoeff Float
description String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("hose_types")
}
// ─── Journal ───────────────────────────────────────────────
model JournalEntry {
id String @id @default(uuid())
time DateTime @default(now())
what String
who String?
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
isCorrected Boolean @default(false)
correctionOfId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_entries")
}
model JournalCheckItem {
id String @id @default(uuid())
label String
confirmed Boolean @default(false)
confirmedAt DateTime?
ok Boolean @default(false)
okAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_check_items")
}
model JournalPendenz {
id String @id @default(uuid())
what String
who String?
whenHow String?
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_pendenzen")
}
model JournalCheckTemplate {
id String @id @default(uuid())
label String
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("journal_check_templates")
}
model Item {
id String @id @default(uuid())
kind ItemKind
geometry Json
style Json @default("{}")
properties Json @default("{}")
isVisible Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
iconId String?
@@map("items")
}
// ─── Upgrade Requests ─────────────────────────────────────
enum UpgradeRequestStatus {
PENDING
APPROVED
REJECTED
}
model UpgradeRequest {
id String @id @default(uuid())
requestedPlan SubscriptionPlan
currentPlan SubscriptionPlan
message String?
status UpgradeRequestStatus @default(PENDING)
adminNote String?
processedAt DateTime?
processedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
requestedById String
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
@@map("upgrade_requests")
}
// ─── Dictionary (Global + Tenant word library) ────────────
model DictionaryEntry {
id String @id @default(uuid())
word String
scope DictionaryScope @default(GLOBAL)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([word, tenantId])
@@map("dictionary_entries")
}
// ─── Rapport (PDF reports with public token access) ───────
model Rapport {
id String @id @default(uuid())
reportNumber String
token String @unique @default(uuid())
data Json
generatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@map("rapports")
}

127
prisma/seed-icons-only.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* Seed SVG icons and categories ONLY — does NOT touch tenants, users, or projects.
* Used when upgrading an existing database to new SVG icons.
*/
const { PrismaClient } = require('@prisma/client')
const fs = require('fs')
const path = require('path')
const prisma = new PrismaClient()
async function main() {
console.log('🎨 Seeding SVG icons only...')
const catDefs = [
{ name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1,
patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] },
{ name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2,
patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung',
'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung',
'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser',
'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] },
{ name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3,
patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri',
'Elektrotableau', 'Achtung', 'Leitungsdaehte'] },
{ name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4,
patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle',
'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] },
{ name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5,
patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] },
{ name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6,
patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt',
'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke',
'Rutschgebiet', 'Strasse', 'Bahnlin'] },
{ name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7,
patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle',
'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau',
'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] },
{ name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8,
patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] },
{ name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9,
patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung',
'VertikaleEntwicklung', 'Unfall'] },
{ name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10,
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deleted = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deleted.count} old system icons removed`)
// Upsert global categories (preserves tenant categories)
const catMap = {}
for (const def of catDefs) {
const catId = def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
const cat = await prisma.iconCategory.upsert({
where: { id: catId },
update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true },
create: {
id: catId,
name: def.name,
description: def.description,
sortOrder: def.sortOrder,
isGlobal: true,
tenantId: null,
},
})
catMap[def.name] = cat
}
console.log(`${Object.keys(catMap).length} icon categories upserted`)
function findCategory(filename) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return catMap[def.name]
}
}
return catMap['Verschiedenes']
}
// Load SVG icons from public/signaturen/
const signaturenDir = path.join(process.cwd(), 'public', 'signaturen')
let svgFiles = []
try {
svgFiles = fs.readdirSync(signaturenDir).filter(f => f.endsWith('.svg')).sort()
} catch (e) {
console.warn('⚠️ public/signaturen/ not found')
return
}
let created = 0
for (const file of svgFiles) {
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
const fileKey = `signaturen/${file}`
const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) {
await prisma.iconAsset.create({
data: {
name,
categoryId: category.id,
fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
created++
}
}
console.log(`✅ FKS Signaturen: ${created} new SVG icons created (${svgFiles.length} total)`)
}
main()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

291
prisma/seed.js Normal file
View File

@@ -0,0 +1,291 @@
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Starting seed...')
// Create default tenant
const defaultTenant = await prisma.tenant.upsert({
where: { slug: 'demo-feuerwehr' },
update: {},
create: {
name: 'Demo Feuerwehr',
slug: 'demo-feuerwehr',
description: 'Standard-Mandant für Demonstrationszwecke',
},
})
console.log('✅ Default tenant created:', defaultTenant.name)
// Create default users
const adminPassword = await bcrypt.hash('admin123', 12)
const editorPassword = await bcrypt.hash('editor123', 12)
const viewerPassword = await bcrypt.hash('viewer123', 12)
const admin = await prisma.user.upsert({
where: { email: 'admin@lageplan.local' },
update: { role: 'SERVER_ADMIN' },
create: {
email: 'admin@lageplan.local',
name: 'Server Admin',
password: adminPassword,
role: 'SERVER_ADMIN',
},
})
const editor = await prisma.user.upsert({
where: { email: 'editor@lageplan.local' },
update: { role: 'TENANT_ADMIN' },
create: {
email: 'editor@lageplan.local',
name: 'Einsatzleiter',
password: editorPassword,
role: 'TENANT_ADMIN',
},
})
const viewer = await prisma.user.upsert({
where: { email: 'viewer@lageplan.local' },
update: { role: 'OPERATOR' },
create: {
email: 'viewer@lageplan.local',
name: 'Bediener',
password: viewerPassword,
role: 'OPERATOR',
},
})
console.log('✅ Users created:', { admin: admin.email, editor: editor.email, viewer: viewer.email })
// Create tenant memberships
for (const u of [editor, viewer]) {
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: u.id, tenantId: defaultTenant.id } },
update: {},
create: {
userId: u.id,
tenantId: defaultTenant.id,
role: u.id === editor.id ? 'TENANT_ADMIN' : 'OPERATOR',
},
})
}
console.log('✅ Tenant memberships created')
// ─── FKS Icon Categories & Symbols (Bildquelle: Feuerwehr Koordination Schweiz FKS) ───
const fs = require('fs')
const path = require('path')
// Category definitions with file-name patterns for auto-assignment
const catDefs = [
{ name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1,
patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] },
{ name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2,
patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung',
'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung',
'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser',
'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] },
{ name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3,
patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri',
'Elektrotableau', 'Achtung', 'Leitungsdaehte'] },
{ name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4,
patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle',
'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] },
{ name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5,
patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] },
{ name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6,
patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt',
'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke',
'Rutschgebiet', 'Strasse', 'Bahnlin'] },
{ name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7,
patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle',
'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau',
'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] },
{ name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8,
patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] },
{ name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9,
patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung',
'VertikaleEntwicklung', 'Unfall'] },
{ name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10,
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deletedIcons = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
// Clean up empty global categories
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } })
for (const oldCat of oldGlobalCats) {
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } })
if (remaining === 0) {
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
}
}
// Create new global categories
const catMap = {}
for (const def of catDefs) {
const cat = await prisma.iconCategory.upsert({
where: { id: def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() },
update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true },
create: {
id: def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(),
name: def.name,
description: def.description,
sortOrder: def.sortOrder,
isGlobal: true,
tenantId: null,
},
})
catMap[def.name] = cat
}
console.log(`${Object.keys(catMap).length} icon categories created`)
// Assign icon to category based on filename pattern matching
function findCategory(filename) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return catMap[def.name]
}
}
return catMap['Verschiedenes']
}
// Load SVG icons from public/signaturen/
const signaturenDir = path.join(process.cwd(), 'public', 'signaturen')
let svgFiles = []
try {
svgFiles = fs.readdirSync(signaturenDir).filter(f => f.endsWith('.svg')).sort()
} catch (e) {
console.warn('⚠️ public/signaturen/ not found, skipping icon seed')
}
let created = 0
for (const file of svgFiles) {
// Clean name: remove .svg, remove _de/_DE suffix
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
const fileKey = `signaturen/${file}`
const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) {
await prisma.iconAsset.create({
data: {
name,
categoryId: category.id,
fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
created++
}
}
console.log(`✅ FKS Signaturen: ${created} new icons created (${svgFiles.length} total SVGs)`)
// Create a demo project
const demoProject = await prisma.project.upsert({
where: { id: 'demo-project-001' },
update: {},
create: {
id: 'demo-project-001',
title: 'Demo Einsatz - Wohnungsbrand',
location: 'Musterstrasse 42, 8000 Zürich',
description: 'Beispiel-Einsatz zur Demonstration der Funktionen',
ownerId: editor.id,
tenantId: defaultTenant.id,
mapCenter: { lng: 8.5417, lat: 47.3769 },
mapZoom: 17,
},
})
console.log('✅ Demo project created:', demoProject.title)
// Create default hose types (Swiss fire department standards)
const hoseTypes = [
{
name: '55mm Transportleitung',
diameterMm: 55,
lengthPerPieceM: 10,
flowRateLpm: 500,
frictionCoeff: 0.034,
description: 'Standard Transportleitung, 3er Verteiler (1500/3 = 500 l/min)',
isDefault: true,
sortOrder: 1,
},
{
name: '75mm Zubringerleitung',
diameterMm: 75,
lengthPerPieceM: 20,
flowRateLpm: 1500,
frictionCoeff: 0.012,
description: 'Zubringerleitung vom Hydranten zum Verteiler',
isDefault: false,
sortOrder: 2,
},
]
for (const ht of hoseTypes) {
await prisma.hoseType.upsert({
where: { name: ht.name },
update: {
diameterMm: ht.diameterMm,
lengthPerPieceM: ht.lengthPerPieceM,
flowRateLpm: ht.flowRateLpm,
frictionCoeff: ht.frictionCoeff,
description: ht.description,
isDefault: ht.isDefault,
sortOrder: ht.sortOrder,
},
create: ht,
})
}
console.log('✅ Hose types created:', hoseTypes.length)
// ─── Journal Check Templates (SOMA) ─────────────────────
const somaTemplates = [
{ label: 'Stopp Zutritt / Ex-Gefahr', sortOrder: 1 },
{ label: 'Alarmierung Gesamt-Fw', sortOrder: 2 },
{ label: 'Alarmierung Ofw Wohlen', sortOrder: 3 },
{ label: 'Alarmierung Stp Baden', sortOrder: 4 },
{ label: 'Alarmierung Ambulanz', sortOrder: 5 },
{ label: 'Alarmierung BWL', sortOrder: 6 },
{ label: 'Alarmierung BL/Meister', sortOrder: 7 },
{ label: 'Brunnenchef Villmergen', sortOrder: 8 },
{ label: 'Berieselung Tank / Anl', sortOrder: 9 },
{ label: 'Druckerhöhung', sortOrder: 10 },
]
for (const tpl of somaTemplates) {
await prisma.journalCheckTemplate.upsert({
where: { id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() },
update: { label: tpl.label, sortOrder: tpl.sortOrder },
create: {
id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(),
label: tpl.label,
sortOrder: tpl.sortOrder,
},
})
}
console.log('✅ SOMA templates created:', somaTemplates.length)
console.log('🎉 Seed completed successfully!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

156
prisma/seed.ts Normal file
View File

@@ -0,0 +1,156 @@
import { PrismaClient, Role } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Starting seed...')
// Create default users
const adminPassword = await bcrypt.hash('admin123', 12)
const editorPassword = await bcrypt.hash('editor123', 12)
const viewerPassword = await bcrypt.hash('viewer123', 12)
const admin = await prisma.user.upsert({
where: { email: 'admin@lageplan.local' },
update: {},
create: {
email: 'admin@lageplan.local',
name: 'Administrator',
password: adminPassword,
role: Role.ADMIN,
},
})
const editor = await prisma.user.upsert({
where: { email: 'editor@lageplan.local' },
update: {},
create: {
email: 'editor@lageplan.local',
name: 'Einsatzleiter',
password: editorPassword,
role: Role.EDITOR,
},
})
const viewer = await prisma.user.upsert({
where: { email: 'viewer@lageplan.local' },
update: {},
create: {
email: 'viewer@lageplan.local',
name: 'Beobachter',
password: viewerPassword,
role: Role.VIEWER,
},
})
console.log('✅ Users created:', { admin: admin.email, editor: editor.email, viewer: viewer.email })
// Create icon categories
const categories = [
{ name: 'Feuer', description: 'Brand- und Feuersymbole', sortOrder: 1 },
{ name: 'Wasser', description: 'Wasser- und Überflutungssymbole', sortOrder: 2 },
{ name: 'Gefahrstoffe', description: 'Chemie- und Gefahrstoffsymbole', sortOrder: 3 },
{ name: 'Verkehr', description: 'Verkehrs- und Unfallsymbole', sortOrder: 4 },
{ name: 'Personen', description: 'Personen- und Rettungssymbole', sortOrder: 5 },
{ name: 'Fahrzeuge', description: 'Einsatzfahrzeuge und Geräte', sortOrder: 6 },
{ name: 'Infrastruktur', description: 'Gebäude und Infrastruktursymbole', sortOrder: 7 },
{ name: 'Taktik', description: 'Taktische Zeichen und Symbole', sortOrder: 8 },
{ name: 'Eigene', description: 'Benutzerdefinierte Symbole', sortOrder: 99 },
]
for (const cat of categories) {
await prisma.iconCategory.upsert({
where: { name: cat.name },
update: { description: cat.description, sortOrder: cat.sortOrder },
create: cat,
})
}
console.log('✅ Icon categories created:', categories.length)
// Get category IDs for system icons
const feuerCat = await prisma.iconCategory.findUnique({ where: { name: 'Feuer' } })
const wasserCat = await prisma.iconCategory.findUnique({ where: { name: 'Wasser' } })
const gefahrstoffeCat = await prisma.iconCategory.findUnique({ where: { name: 'Gefahrstoffe' } })
const verkehrCat = await prisma.iconCategory.findUnique({ where: { name: 'Verkehr' } })
const personenCat = await prisma.iconCategory.findUnique({ where: { name: 'Personen' } })
const fahrzeugeCat = await prisma.iconCategory.findUnique({ where: { name: 'Fahrzeuge' } })
const infrastrukturCat = await prisma.iconCategory.findUnique({ where: { name: 'Infrastruktur' } })
const taktikCat = await prisma.iconCategory.findUnique({ where: { name: 'Taktik' } })
// Create system icons (these use inline SVG data URIs - in production, upload to MinIO)
const systemIcons = [
{ name: 'Brand', categoryId: feuerCat!.id, fileKey: 'system/fire.svg' },
{ name: 'Rauch', categoryId: feuerCat!.id, fileKey: 'system/smoke.svg' },
{ name: 'Explosion', categoryId: feuerCat!.id, fileKey: 'system/explosion.svg' },
{ name: 'Brandstelle', categoryId: feuerCat!.id, fileKey: 'system/fire-location.svg' },
{ name: 'Überflutung', categoryId: wasserCat!.id, fileKey: 'system/flood.svg' },
{ name: 'Wasserschaden', categoryId: wasserCat!.id, fileKey: 'system/water-damage.svg' },
{ name: 'Hydrant', categoryId: wasserCat!.id, fileKey: 'system/hydrant.svg' },
{ name: 'Gefahrstoff', categoryId: gefahrstoffeCat!.id, fileKey: 'system/hazmat.svg' },
{ name: 'Radioaktiv', categoryId: gefahrstoffeCat!.id, fileKey: 'system/radioactive.svg' },
{ name: 'Giftig', categoryId: gefahrstoffeCat!.id, fileKey: 'system/toxic.svg' },
{ name: 'Unfall', categoryId: verkehrCat!.id, fileKey: 'system/accident.svg' },
{ name: 'Strassensperre', categoryId: verkehrCat!.id, fileKey: 'system/road-block.svg' },
{ name: 'Verletzte Person', categoryId: personenCat!.id, fileKey: 'system/injured.svg' },
{ name: 'Vermisste Person', categoryId: personenCat!.id, fileKey: 'system/missing.svg' },
{ name: 'Sammelplatz', categoryId: personenCat!.id, fileKey: 'system/assembly-point.svg' },
{ name: 'Löschfahrzeug', categoryId: fahrzeugeCat!.id, fileKey: 'system/fire-truck.svg' },
{ name: 'Rettungswagen', categoryId: fahrzeugeCat!.id, fileKey: 'system/ambulance.svg' },
{ name: 'Kommandoposten', categoryId: taktikCat!.id, fileKey: 'system/command-post.svg' },
{ name: 'Einsatzleitung', categoryId: taktikCat!.id, fileKey: 'system/incident-command.svg' },
{ name: 'Absperrung', categoryId: taktikCat!.id, fileKey: 'system/barrier.svg' },
{ name: 'Gebäude', categoryId: infrastrukturCat!.id, fileKey: 'system/building.svg' },
{ name: 'Eingang', categoryId: infrastrukturCat!.id, fileKey: 'system/entrance.svg' },
]
for (const icon of systemIcons) {
const existing = await prisma.iconAsset.findFirst({
where: { fileKey: icon.fileKey },
})
if (!existing) {
await prisma.iconAsset.create({
data: {
name: icon.name,
categoryId: icon.categoryId,
fileKey: icon.fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
}
}
console.log('✅ System icons created:', systemIcons.length)
// Create a demo project
const demoProject = await prisma.project.upsert({
where: { id: 'demo-project-001' },
update: {},
create: {
id: 'demo-project-001',
title: 'Demo Einsatz - Wohnungsbrand',
location: 'Musterstrasse 42, 8000 Zürich',
description: 'Beispiel-Einsatz zur Demonstration der Funktionen',
ownerId: editor.id,
mapCenter: { lng: 8.5417, lat: 47.3769 },
mapZoom: 17,
},
})
console.log('✅ Demo project created:', demoProject.title)
console.log('🎉 Seed completed successfully!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

0
public/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/1x/Element 1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/2x/Element 1@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
public/3x/Element 1@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
public/4x/Element 1@4x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
public/Front_Pepe.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

16
public/SVG/Element 1.svg Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 751.13 754.43">
<defs>
<style>
.cls-1 {
fill: #e41313;
}
</style>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<g>
<path class="cls-1" d="M750.46,590.12c-.14,84.87-80.94,164.13-164.84,164.16l-417.85.15c-41.23.02-80.97-17.81-110.92-45.41C22.8,677.64.27,633.05.24,586.16l-.24-422.7C5.62,75.03,77.43,4.25,166.02,0h420.61c62.96,3.69,119.27,41.03,146.88,97.42,12.43,24.66,17.66,51.06,17.62,78.93l-.67,413.76ZM370.18,625.88c68.31,1.54,132.18-29.97,171.03-85.28,19.04-27.11,29.77-59.07,31.22-92.31,2.32-53.09-17.94-99.1-49.2-140.66-16.25-21.6-34.32-40.41-53.32-59.63l-43.23-43.74c-12.68-12.83-21.88-27.8-28.68-44.35-10.42-25.91-11.61-52.65-2.26-80.45-22.44,9.87-41.94,24.28-58.9,41.82-32.89,34-50.93,79.43-49.03,126.79.86,21.44,6.18,40.62,13.88,60.14,3.95,10.01,6.17,20.15,5.8,30.69-.57,16.13-12.7,28.03-28.8,28.13-9.24.06-17.69-3.81-23.17-10.35-5.88-7.01-7.61-16.24-6.35-25.41,2.35-17.02-3.73-32.8-18.26-42-69.49,73.43-78.46,184.2-13.01,262.78,37.6,45.14,92.49,72.48,152.3,73.83Z"/>
<path class="cls-1" d="M490.63,521.64c-17.79,22.43-41.28,37.71-67.84,46.68-59.05,19.93-124.13,1.45-163.53-46.85-25.25-30.95-37.66-72.35-28.81-112.76,30.31,30.19,79.51,27.67,110.75,2.18,36.69-29.94,30.9-75.91,14.6-116.82-6.76-16.96-10.43-34.03-11.76-52.32s2.8-36.52,9.95-52.81c11.14,34.99,32.99,58.35,58.82,82.14,7.59,6.99,15.55,12.82,22.13,20.89,20.88,25.59,29.46,58.1,25.52,90.79-3.27,27.11-12.78,51.87-30.01,73.44,41.77-13.36,65.73-42.83,67.49-87.7,28.98,50.16,29.42,106.82-7.32,153.13Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

13
public/logo.svg Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 751.13 754.43">
<!-- Generator: Adobe Illustrator 30.3.0, SVG Export Plug-In . SVG Version: 2.1.3 Build 157) -->
<defs>
<style>
.st0 {
fill: #e41313;
}
</style>
</defs>
<path class="st0" d="M750.46,590.12c-.14,84.87-80.94,164.13-164.84,164.16l-417.85.15c-41.23.02-80.97-17.81-110.92-45.41C22.8,677.64.27,633.05.24,586.16l-.24-422.7C5.62,75.03,77.43,4.25,166.02,0h420.61c62.96,3.69,119.27,41.03,146.88,97.42,12.43,24.66,17.66,51.06,17.62,78.93l-.67,413.76ZM370.18,625.88c68.31,1.54,132.18-29.97,171.03-85.28,19.04-27.11,29.77-59.07,31.22-92.31,2.32-53.09-17.94-99.1-49.2-140.66-16.25-21.6-34.32-40.41-53.32-59.63l-43.23-43.74c-12.68-12.83-21.88-27.8-28.68-44.35-10.42-25.91-11.61-52.65-2.26-80.45-22.44,9.87-41.94,24.28-58.9,41.82-32.89,34-50.93,79.43-49.03,126.79.86,21.44,6.18,40.62,13.88,60.14,3.95,10.01,6.17,20.15,5.8,30.69-.57,16.13-12.7,28.03-28.8,28.13-9.24.06-17.69-3.81-23.17-10.35-5.88-7.01-7.61-16.24-6.35-25.41,2.35-17.02-3.73-32.8-18.26-42-69.49,73.43-78.46,184.2-13.01,262.78,37.6,45.14,92.49,72.48,152.3,73.83Z"/>
<path class="st0" d="M490.63,521.64c-17.79,22.43-41.28,37.71-67.84,46.68-59.05,19.93-124.13,1.45-163.53-46.85-25.25-30.95-37.66-72.35-28.81-112.76,30.31,30.19,79.51,27.67,110.75,2.18,36.69-29.94,30.9-75.91,14.6-116.82-6.76-16.96-10.43-34.03-11.76-52.32s2.8-36.52,9.95-52.81c11.14,34.99,32.99,58.35,58.82,82.14,7.59,6.99,15.55,12.82,22.13,20.89,20.88,25.59,29.46,58.1,25.52,90.79-3.27,27.11-12.78,51.87-30.01,73.44,41.77-13.36,65.73-42.83,67.49-87.7,28.98,50.16,29.42,106.82-7.32,153.13Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

28
public/manifest.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "Lageplan Feuerwehr Krokier-App",
"short_name": "Lageplan",
"description": "Digitale Einsatzdokumentation für Schweizer Feuerwehren. Lagepläne erstellen, Journal führen, Rapporte generieren.",
"start_url": "/app",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#dc2626",
"lang": "de-CH",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/logo.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/logo-icon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"prefer_related_applications": false
}

94
public/sw.js Normal file
View File

@@ -0,0 +1,94 @@
const TILE_CACHE = 'lageplan-tiles-v2'
const STATIC_CACHE = 'lageplan-static-v2'
const APP_CACHE = 'lageplan-app-v2'
// Pre-cache essential app shell on install
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_CACHE).then((cache) =>
cache.addAll([
'/app',
'/logo.svg',
'/logo-icon.png',
'/manifest.json',
]).catch(() => {})
)
)
self.skipWaiting()
})
// Cache strategy: Network First for API, Cache First for tiles, Stale While Revalidate for static assets
self.addEventListener('fetch', (event) => {
const url = event.request.url
const { pathname } = new URL(url)
// Skip non-GET requests
if (event.request.method !== 'GET') return
// API requests: network only (don't cache dynamic data)
if (pathname.startsWith('/api/')) return
// Cache map tiles from OpenStreetMap (Cache First)
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com')) {
event.respondWith(
caches.open(TILE_CACHE).then((cache) =>
cache.match(event.request).then((cached) => {
if (cached) return cached
return fetch(event.request).then((response) => {
if (response.ok) {
cache.put(event.request, response.clone())
}
return response
}).catch(() => new Response('', { status: 503 }))
})
)
)
return
}
// Static assets (JS, CSS, images): Stale While Revalidate
if (pathname.match(/\.(js|css|png|jpg|jpeg|svg|ico|woff2?)$/)) {
event.respondWith(
caches.open(STATIC_CACHE).then((cache) =>
cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) cache.put(event.request, response.clone())
return response
}).catch(() => cached || new Response('', { status: 503 }))
return cached || fetchPromise
})
)
)
return
}
// App pages: Network First with cache fallback
if (pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) {
event.respondWith(
fetch(event.request).then((response) => {
if (response.ok) {
const clone = response.clone()
caches.open(APP_CACHE).then((cache) => cache.put(event.request, clone))
}
return response
}).catch(() =>
caches.match(event.request).then((cached) => cached || caches.match('/app'))
)
)
return
}
})
// Clean old caches on activation
self.addEventListener('activate', (event) => {
const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE]
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => !currentCaches.includes(k))
.map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
)
})

70
scripts/convert-icons.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* Copy SVG icons from FEUKOS source to public/signaturen/
* Filters to German (_de) and universal (no _fr/_it suffix) files only
*/
const fs = require('fs')
const path = require('path')
const SOURCE_DIR = path.join(__dirname, '..', 'Signaturen', 'Markierungsmoglichkeiten', 'Markierungsmöglichkeiten Icons', 'SVG')
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'signaturen')
// Only German (_de) and universal (no _fr/_it suffix) files
function isRelevant(filename) {
if (!filename.endsWith('.svg')) return false
if (filename.match(/_fr\.svg$/i)) return false
if (filename.match(/_it\.svg$/i)) return false
return true
}
// Clean up name: remove _de/_DE suffix, replace underscores/hyphens with spaces
function cleanName(filename) {
let name = filename.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
return name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
}
function copyIcons() {
// Ensure output dir exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
}
// Clean old SVGs/PNGs from output dir
const oldFiles = fs.readdirSync(OUTPUT_DIR).filter(f => f.endsWith('.svg') || f.endsWith('.png'))
for (const f of oldFiles) {
fs.unlinkSync(path.join(OUTPUT_DIR, f))
}
console.log(`🗑️ Removed ${oldFiles.length} old files from ${OUTPUT_DIR}`)
const files = fs.readdirSync(SOURCE_DIR).filter(isRelevant).sort()
console.log(`📂 Found ${files.length} relevant SVG files`)
let copied = 0
for (const file of files) {
const inputPath = path.join(SOURCE_DIR, file)
const outputPath = path.join(OUTPUT_DIR, file)
try {
fs.copyFileSync(inputPath, outputPath)
copied++
} catch (err) {
console.error(` ❌ Failed: ${file}:`, err.message)
}
}
console.log(`\n🎉 Done! Copied ${copied}/${files.length} SVG icons to ${OUTPUT_DIR}`)
// Generate a manifest file for the seed script
const manifest = files.map(f => ({
file: f,
name: cleanName(f),
originalFile: f,
}))
fs.writeFileSync(
path.join(OUTPUT_DIR, 'manifest.json'),
JSON.stringify(manifest, null, 2)
)
console.log(`📋 Manifest written with ${manifest.length} entries`)
}
copyIcons()

74
server-custom.js Normal file
View File

@@ -0,0 +1,74 @@
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const { Server } = require('socket.io')
const dev = process.env.NODE_ENV !== 'production'
const hostname = process.env.HOSTNAME || '0.0.0.0'
const port = parseInt(process.env.PORT || '3000', 10)
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const httpServer = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error('Error occurred handling', req.url, err)
res.statusCode = 500
res.end('internal server error')
}
})
const io = new Server(httpServer, {
cors: { origin: '*' },
path: '/socket.io',
})
// Make io globally accessible for API routes
global.__io = io
io.on('connection', (socket) => {
console.log(`[Socket.io] Client connected: ${socket.id}`)
// Client joins a project room
socket.on('join-project', (projectId) => {
socket.join(`project:${projectId}`)
console.log(`[Socket.io] ${socket.id} joined project:${projectId}`)
})
// Client leaves a project room
socket.on('leave-project', (projectId) => {
socket.leave(`project:${projectId}`)
console.log(`[Socket.io] ${socket.id} left project:${projectId}`)
})
// Features updated — broadcast to all others in the project room
socket.on('features-updated', (data) => {
const { projectId, features } = data
socket.to(`project:${projectId}`).emit('features-changed', { features })
})
// Editing lock changed — broadcast to all others in the project room
socket.on('editing-changed', (data) => {
const { projectId, editing, editingBy, sessionId } = data
socket.to(`project:${projectId}`).emit('editing-status', { editing, editingBy, sessionId })
})
// Journal entry added/updated — broadcast to all others
socket.on('journal-updated', (data) => {
const { projectId } = data
socket.to(`project:${projectId}`).emit('journal-changed', { projectId })
})
socket.on('disconnect', () => {
console.log(`[Socket.io] Client disconnected: ${socket.id}`)
})
})
httpServer.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})

96
src/app/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useAuth } from '@/components/providers/auth-provider'
import { Loader2 } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
interface TenantInfo {
name: string
slug: string
logoUrl: string | null
}
export default function TenantPortalPage() {
const { slug } = useParams<{ slug: string }>()
const { user, loading } = useAuth()
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [tenant, setTenant] = useState<TenantInfo | null>(null)
useEffect(() => {
// Skip known non-tenant routes
const reserved = ['app', 'admin', 'login', 'register', 'api', 'forgot-password', 'reset-password', 'spenden', 'demo']
if (reserved.includes(slug)) {
return
}
// Fetch tenant info (public)
fetch(`/api/tenants/by-slug/${slug}`)
.then(async res => {
const data = await res.json()
if (res.ok && data?.tenant) {
setTenant(data.tenant)
} else if (res.status === 403 && data?.reason === 'suspended') {
// Tenant exists but is suspended
if (data.tenant) setTenant(data.tenant)
setError('Dieser Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator für weitere Informationen.')
} else {
setError('Mandant nicht gefunden.')
}
})
.catch(() => setError('Fehler beim Laden.'))
}, [slug])
useEffect(() => {
const reserved = ['app', 'admin', 'login', 'register', 'api', 'forgot-password', 'reset-password', 'spenden', 'demo']
if (reserved.includes(slug)) return
if (loading || !tenant) return
if (!user) {
// Not logged in → redirect to login with slug context
router.push(`/login?redirect=/${slug}&tenant=${slug}`)
return
}
// Check if user belongs to this tenant
if (user.tenantSlug === slug) {
router.push('/app')
} else {
setError('Sie haben keinen Zugang zu diesem Mandanten.')
}
}, [slug, user, loading, router, tenant])
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="text-center">
{tenant?.logoUrl ? (
<img src={tenant.logoUrl} alt={tenant.name} className="w-20 h-20 object-contain mx-auto mb-4 rounded-lg" />
) : (
<LogoRound size={56} className="mx-auto mb-4" />
)}
<h1 className="text-xl font-bold text-white mb-2">Zugang verweigert</h1>
<p className="text-gray-400 mb-6">{error}</p>
<button onClick={() => router.push(`/login?tenant=${slug}`)} className="text-red-400 hover:text-red-300 text-sm underline">
Zur Anmeldung
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{tenant?.logoUrl ? (
<div className="flex flex-col items-center gap-4">
<img src={tenant.logoUrl} alt={tenant.name} className="w-20 h-20 object-contain rounded-lg animate-pulse" />
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
)}
</div>
)
}

1854
src/app/admin/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
sortOrder: z.number().int().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only edit their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
const body = await req.json()
const data = updateSchema.parse(body)
const updated = await db.iconCategory.update({
where: { id: params.id },
data,
})
return NextResponse.json({ category: updated })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only delete their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
// Global categories (isGlobal=true, tenantId=null) cannot be deleted
if (category.isGlobal || (!category.tenantId && category.isSystem !== false)) {
return NextResponse.json({ error: 'Globale Kategorien können nicht gelöscht werden. Blenden Sie sie stattdessen aus.' }, { status: 403 })
}
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
// Disconnect all icons from this category (set categoryId to null) so they are NOT deleted
await db.$executeRawUnsafe(
`UPDATE icon_assets SET "categoryId" = NULL WHERE "categoryId" = $1`,
params.id
)
await db.iconCategory.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const categorySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).nullable().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let where: any = {}
if (user.role !== 'SERVER_ADMIN') {
// TENANT_ADMIN: show global categories (tenantId=null) + their own tenant categories
where = {
OR: [
{ tenantId: null },
...(user.tenantId ? [{ tenantId: user.tenantId }] : []),
],
}
}
// SERVER_ADMIN: show all categories (no filter)
const categories = await db.iconCategory.findMany({
where,
orderBy: { sortOrder: 'asc' },
include: {
_count: { select: { icons: true } },
},
})
return NextResponse.json({ categories })
} catch (error) {
console.error('Error fetching categories:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const data = categorySchema.parse(body)
const maxOrder = await db.iconCategory.aggregate({
_max: { sortOrder: true },
})
// SERVER_ADMIN creates global categories; TENANT_ADMIN creates tenant-specific ones
const isGlobal = user.role === 'SERVER_ADMIN'
const tenantId = user.role === 'SERVER_ADMIN' ? null : (user.tenantId || null)
const category = await db.iconCategory.create({
data: {
name: data.name,
description: data.description,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
isGlobal,
tenantId,
} as any,
})
return NextResponse.json({ category }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { deleteFile } from '@/lib/minio'
import { getSession, isAdmin, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
categoryId: z.string().uuid().optional(),
iconType: z.enum(['STANDARD', 'RETTUNG', 'GEFAHRSTOFF', 'FEUER', 'WASSER', 'FAHRZEUG']).optional(),
tags: z.array(z.string()).optional(),
isActive: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = updateSchema.parse(body)
// TENANT_ADMIN can only edit their own icons
if (!isServerAdmin(user.role)) {
const existing = await (prisma as any).iconAsset.findUnique({ where: { id: params.id } })
if (existing && existing.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für dieses Symbol' }, { status: 403 })
}
}
const icon = await (prisma as any).iconAsset.update({
where: { id: params.id },
data: {
...(data.name && { name: data.name }),
...(data.categoryId && { categoryId: data.categoryId }),
...(data.iconType && { iconType: data.iconType }),
...(data.tags && { tags: data.tags }),
...(data.isActive !== undefined && { isActive: data.isActive }),
},
include: {
category: true,
},
})
return NextResponse.json({ icon })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const icon = await (prisma as any).iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN can only delete their own tenant icons, not global/system ones
if (!isServerAdmin(user.role)) {
if (!icon.tenantId || icon.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Globale Symbole können nicht gelöscht werden. Sie können nur Ihre eigenen Symbole löschen.' }, { status: 403 })
}
}
// Delete from MinIO if not a system icon
if (icon.fileKey && !icon.fileKey.startsWith('system:') && !icon.isSystem) {
try {
await deleteFile(icon.fileKey)
} catch (e) {
console.error('Error deleting file from MinIO:', e)
}
}
await (prisma as any).iconAsset.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
export async function GET() {
try {
const icons = await db.iconAsset.findMany({
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
include: {
category: true,
},
})
return NextResponse.json({ icons })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { getSession, isAdmin } from '@/lib/auth'
import { v4 as uuidv4 } from 'uuid'
const ALLOWED_TYPES = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
const categoryId = formData.get('categoryId') as string
const iconType = (formData.get('iconType') as string) || 'STANDARD'
const name = formData.get('name') as string
if (!file || !categoryId || !name) {
return NextResponse.json(
{ error: 'Datei, Kategorie und Name sind erforderlich' },
{ status: 400 }
)
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien erlaubt' },
{ status: 400 }
)
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: 'Datei zu groß (max. 5MB)' },
{ status: 400 }
)
}
// Check category exists
const category = await (prisma as any).iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
// Generate safe filename
const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
const safeFileName = `${uuidv4()}.${ext}`
const fileKey = `icons/${safeFileName}`
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null)
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
const icon = await (prisma as any).iconAsset.create({
data: {
name: name.trim(),
fileKey,
mimeType: file.type,
categoryId,
iconType: iconType as any,
isSystem: false,
isActive: true,
tenantId,
ownerId: user.id,
},
include: {
category: true,
},
})
return NextResponse.json({ icon }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { getSmtpConfig, saveSmtpConfig, testSmtpConnection, sendEmail } from '@/lib/email'
import { getStripeConfig, saveStripeConfig } from '@/lib/stripe'
// GET SMTP settings (mask password)
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const smtp = await getSmtpConfig()
// Load contact email
let contactEmail = 'app@lageplan.ch'
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'contact_email' } })
if (setting) contactEmail = setting.value
} catch {}
// Load Stripe settings
const stripeConfig = await getStripeConfig()
// Load demo project ID
let demoProjectId = ''
try {
const demoSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'demo_project_id' } })
if (demoSetting) demoProjectId = demoSetting.value
} catch {}
// Load registration notification email
let notifyRegistrationEmail = ''
try {
const nrSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
if (nrSetting) notifyRegistrationEmail = nrSetting.value
} catch {}
// Load default symbol scale
let defaultSymbolScale = '1.5'
try {
const scaleSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (scaleSetting) defaultSymbolScale = scaleSetting.value
} catch {}
return NextResponse.json({
smtp: smtp ? { ...smtp, pass: smtp.pass ? '••••••••' : '' } : null,
contactEmail,
notifyRegistrationEmail,
demoProjectId,
defaultSymbolScale,
stripe: stripeConfig ? {
publicKey: stripeConfig.publicKey || '',
secretKey: stripeConfig.secretKey ? '••••••••' : '',
webhookSecret: stripeConfig.webhookSecret ? '••••••••' : '',
} : null,
})
} catch (error) {
console.error('Error fetching settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Save SMTP settings
export async function PUT(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { action, smtp, testEmail } = body
if (action === 'save_smtp') {
// Don't overwrite password if masked
const config: any = { ...smtp }
if (config.pass === '••••••••') {
delete config.pass
}
await saveSmtpConfig(config)
return NextResponse.json({ success: true, message: 'SMTP-Einstellungen gespeichert' })
}
if (action === 'test_smtp') {
const result = await testSmtpConnection()
return NextResponse.json(result)
}
if (action === 'send_test_email') {
if (!testEmail) {
return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
}
try {
await sendEmail(
testEmail,
'Lageplan - Test E-Mail',
`<h2>Test E-Mail</h2><p>Diese E-Mail wurde von der Lageplan-Applikation gesendet.</p><p>SMTP-Konfiguration funktioniert korrekt.</p><p><small>Gesendet am ${new Date().toLocaleString('de-CH')}</small></p>`
)
return NextResponse.json({ success: true, message: `Test-E-Mail an ${testEmail} gesendet` })
} catch (error) {
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Senden fehlgeschlagen' })
}
}
if (action === 'save_stripe') {
const { stripe: stripeData } = body
if (!stripeData) return NextResponse.json({ error: 'Stripe-Daten fehlen' }, { status: 400 })
await saveStripeConfig({
secretKey: stripeData.secretKey,
publicKey: stripeData.publicKey,
webhookSecret: stripeData.webhookSecret,
})
return NextResponse.json({ success: true, message: 'Stripe-Einstellungen gespeichert' })
}
if (action === 'save_demo_project') {
const { demoProjectId } = body
await (prisma as any).systemSetting.upsert({
where: { key: 'demo_project_id' },
update: { value: demoProjectId || '' },
create: { key: 'demo_project_id', value: demoProjectId || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true, message: 'Demo-Projekt gespeichert' })
}
if (action === 'save_contact_email') {
const { contactEmail } = body
if (!contactEmail) return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key: 'contact_email' },
update: { value: contactEmail },
create: { key: 'contact_email', value: contactEmail, isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
if (action === 'save_setting') {
const { key, value } = body
if (!key) return NextResponse.json({ error: 'Key erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key },
update: { value: value || '' },
create: { key, value: value || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
return NextResponse.json({ error: 'Unbekannte Aktion' }, { status: 400 })
} catch (error) {
console.error('Error updating settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { uploadFile, getFileUrl, deleteFile } from '@/lib/minio'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('logo') as File
if (!file) {
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
if (!validTypes.includes(file.type)) {
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
}
// Max 2MB
if (file.size > 2 * 1024 * 1024) {
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `logos/tenant-${params.id}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Store fileKey for proxy-based serving, logoUrl for backward compat
const logoServeUrl = `/api/admin/tenants/${params.id}/logo/serve`
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
})
return NextResponse.json({ logoUrl: logoServeUrl })
} catch (error) {
console.error('Logo upload error:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// Get current logo
const tenant = await (prisma as any).tenant.findUnique({ where: { id: params.id } })
if (tenant?.logoUrl) {
try {
const urlParts = tenant.logoUrl.split('/')
const fileKey = `logos/${urlParts[urlParts.length - 1]}`
await deleteFile(fileKey)
} catch {}
}
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoUrl: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Logo delete error:', error)
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
// Serve tenant logo via proxy (no direct MinIO URL needed)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
select: { logoFileKey: true, logoUrl: true },
})
// Try logoFileKey first, fallback to extracting from logoUrl
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) {
return NextResponse.json({ error: 'Kein Logo vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(fileKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving logo:', error)
return NextResponse.json({ error: 'Logo konnte nicht geladen werden' }, { status: 500 })
}
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
function generateTempPassword(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
let pw = ''
for (let i = 0; i < 10; i++) pw += chars[crypto.randomInt(chars.length)]
return pw
}
// Create a NEW user and add as member to tenant
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { name, email, role } = await req.json()
if (!name || !email) {
return NextResponse.json({ error: 'Name und E-Mail sind erforderlich' }, { status: 400 })
}
// Check tenant user limit
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: { _count: { select: { memberships: true } } },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// User limit removed — no restriction on number of users
// Check if user with this email already exists
const existingUser = await (prisma as any).user.findUnique({ where: { email: email.toLowerCase() } })
if (existingUser) {
// Check if already member of this tenant
const existingMembership = await (prisma as any).tenantMembership.findUnique({
where: { userId_tenantId: { userId: existingUser.id, tenantId: params.id } },
})
if (existingMembership) {
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail ist bereits Mitglied dieses Mandanten.' }, { status: 400 })
}
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail existiert bereits im System. Bitte verwenden Sie eine andere E-Mail-Adresse.' }, { status: 400 })
}
// Generate temp password
const tempPassword = generateTempPassword()
const hashedPassword = await bcrypt.hash(tempPassword, 12)
// Create user + membership in transaction
const result = await (prisma as any).$transaction(async (tx: any) => {
const newUser = await tx.user.create({
data: {
email: email.toLowerCase(),
name,
password: hashedPassword,
role: role || 'OPERATOR',
},
})
const membership = await tx.tenantMembership.create({
data: {
userId: newUser.id,
tenantId: params.id,
role: role || 'OPERATOR',
},
include: { user: { select: { id: true, email: true, name: true, role: true } } },
})
return { user: newUser, membership }
})
// Send welcome email (best effort)
let emailSent = false
try {
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXTAUTH_URL || 'https://app.lageplan.ch'
const html = `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Willkommen bei Lageplan</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${name}</strong>,</p>
<p>Sie wurden als Benutzer für <strong>${tenant.name}</strong> eingerichtet.</p>
<div style="background:#f3f4f6;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:14px;color:#6b7280;">Ihre Zugangsdaten:</p>
<table style="width:100%;">
<tr><td style="padding:4px 0;font-weight:bold;width:100px;">E-Mail:</td><td>${email.toLowerCase()}</td></tr>
<tr><td style="padding:4px 0;font-weight:bold;">Passwort:</td><td style="font-family:monospace;font-size:16px;letter-spacing:1px;">${tempPassword}</td></tr>
</table>
</div>
<p style="color:#dc2626;font-weight:bold;font-size:14px;">Bitte ändern Sie Ihr Passwort nach der ersten Anmeldung.</p>
<a href="${appUrl}/login" style="display:inline-block;background:#dc2626;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin-top:12px;">
Jetzt anmelden
</a>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
<p style="font-size:12px;color:#9ca3af;">Diese E-Mail wurde automatisch von Lageplan gesendet. Gehostet in der Schweiz.</p>
</div>
</div>
`
await sendEmail(email.toLowerCase(), `Willkommen bei Lageplan — Ihre Zugangsdaten für ${tenant.name}`, html)
emailSent = true
}
} catch (e) {
console.error('Welcome email failed:', e)
}
return NextResponse.json({
membership: result.membership,
tempPassword,
emailSent,
}, { status: 201 })
} catch (error) {
console.error('Error creating member:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Remove a user from a tenant (and delete the user)
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const membershipId = searchParams.get('membershipId')
const deleteUser = searchParams.get('deleteUser') === 'true'
if (!membershipId) {
return NextResponse.json({ error: 'membershipId erforderlich' }, { status: 400 })
}
// Get the membership to find the userId
const membership = await (prisma as any).tenantMembership.findUnique({
where: { id: membershipId },
})
// Delete membership
await (prisma as any).tenantMembership.delete({ where: { id: membershipId } })
// Optionally delete the user too (if they have no other memberships)
if (deleteUser && membership) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: membership.userId },
})
if (otherMemberships === 0) {
// Reassign projects to tenant admin before deleting
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: params.id,
userId: { not: membership.userId },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || user.id
await (prisma as any).project.updateMany({
where: { ownerId: membership.userId },
data: { ownerId: newOwnerId },
})
await (prisma as any).user.delete({ where: { id: membership.userId } })
}
}
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error removing member:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht entfernt werden — es gibt noch abhängige Daten.'
: 'Interner Fehler'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true, role: true, createdAt: true, lastLoginAt: true } } },
orderBy: { createdAt: 'asc' },
},
_count: { select: { projects: true, memberships: true } },
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error fetching tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const {
name, description, isActive, contactEmail, contactPhone, address,
plan, subscriptionStatus, trialEndsAt, subscriptionEndsAt,
maxUsers, maxProjects, notes, logoUrl,
} = body
const tenant = await (prisma as any).tenant.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(contactEmail !== undefined && { contactEmail }),
...(contactPhone !== undefined && { contactPhone }),
...(address !== undefined && { address }),
...(logoUrl !== undefined && { logoUrl }),
...(plan !== undefined && { plan }),
...(subscriptionStatus !== undefined && { subscriptionStatus }),
...(trialEndsAt !== undefined && { trialEndsAt: trialEndsAt ? new Date(trialEndsAt) : null }),
...(subscriptionEndsAt !== undefined && { subscriptionEndsAt: subscriptionEndsAt ? new Date(subscriptionEndsAt) : null }),
...(maxUsers !== undefined && { maxUsers }),
...(maxProjects !== undefined && { maxProjects }),
...(notes !== undefined && { notes }),
},
})
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error updating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
await (prisma as any).tenant.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const createTenantSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
description: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenants = await (prisma as any).tenant.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { memberships: true, projects: true },
},
},
})
return NextResponse.json({ tenants })
} catch (error) {
console.error('Error fetching tenants:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createTenantSchema.parse(body)
const existing = await (prisma as any).tenant.findUnique({ where: { slug: data.slug } })
if (existing) {
return NextResponse.json({ error: 'Slug bereits vergeben' }, { status: 400 })
}
const tenant = await (prisma as any).tenant.create({
data: {
name: data.name,
slug: data.slug,
description: data.description,
},
})
return NextResponse.json({ tenant }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,205 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
// POST: Check all tenants in TRIAL status and send reminder emails
// Can be called manually by SERVER_ADMIN or via cron
export async function POST(req: NextRequest) {
try {
// Auth: only SERVER_ADMIN or internal cron (via secret header)
const cronSecret = req.headers.get('x-cron-secret')
const expectedSecret = process.env.CRON_SECRET
const isCron = expectedSecret && cronSecret === expectedSecret
if (!isCron) {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
}
const smtpConfig = await getSmtpConfig()
if (!smtpConfig) {
return NextResponse.json({ error: 'SMTP nicht konfiguriert', sent: 0 }, { status: 200 })
}
const now = new Date()
// Find all tenants in TRIAL status with trialEndsAt set
const trialTenants = await (prisma as any).tenant.findMany({
where: {
subscriptionStatus: 'TRIAL',
trialEndsAt: { not: null },
isActive: true,
},
include: {
memberships: {
where: { role: 'TENANT_ADMIN' },
include: { user: { select: { email: true, name: true } } },
},
},
})
const results: { tenant: string; daysLeft: number; emailsSent: number; action: string }[] = []
for (const tenant of trialTenants) {
const trialEnd = new Date(tenant.trialEndsAt)
const diffMs = trialEnd.getTime() - now.getTime()
const daysLeft = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
// Determine which reminder to send (only specific days to avoid spam)
// Send at: 14 days, 7 days, 3 days, 1 day before, and on expiry day
const reminderDays = [14, 7, 3, 1, 0]
if (!reminderDays.includes(daysLeft)) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'skipped' })
continue
}
// Check if we already sent a reminder for this day (using SystemSetting as log)
const reminderKey = `trial_reminder_${tenant.id}_${daysLeft}`
const existing = await (prisma as any).systemSetting.findUnique({
where: { key: reminderKey },
})
if (existing) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'already_sent' })
continue
}
// Get admin emails for this tenant
const adminEmails = tenant.memberships
.filter((m: any) => m.user?.email)
.map((m: any) => ({ email: m.user.email, name: m.user.name }))
if (adminEmails.length === 0) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'no_admins' })
continue
}
// Build email content based on days left
let subject: string
let heading: string
let bodyText: string
let urgencyColor: string
if (daysLeft <= 0) {
subject = `Ihre Testphase bei Lageplan ist abgelaufen`
heading = 'Testphase abgelaufen'
bodyText = `Ihre 45-tägige Testphase für <strong>${tenant.name}</strong> ist heute abgelaufen. Ihr Konto wurde automatisch auf den <strong>Free-Plan</strong> umgestellt. Einige Funktionen sind nun eingeschränkt.`
urgencyColor = '#dc2626'
} else if (daysLeft === 1) {
subject = `Lageplan: Ihre Testphase endet morgen`
heading = 'Noch 1 Tag'
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet <strong>morgen</strong>. Danach wechselt Ihr Konto automatisch zum Free-Plan.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 3) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in <strong>${daysLeft} Tagen</strong>. Upgraden Sie rechtzeitig, um alle Funktionen zu behalten.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 7) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in ${daysLeft} Tagen. Nutzen Sie die verbleibende Zeit, um alle Funktionen auszuprobieren.`
urgencyColor = '#ca8a04'
} else {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> läuft noch ${daysLeft} Tage. Viel Spass beim Testen!`
urgencyColor = '#2563eb'
}
const trialEndStr = trialEnd.toLocaleDateString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: ${urgencyColor}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">${heading}</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">${bodyText}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Organisation</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${tenant.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Ablaufdatum</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${trialEndStr}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">Testversion (Trial)</td>
</tr>
</table>
${daysLeft > 0 ? `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Sie müssen nichts tun — nach Ablauf der Testphase wechselt Ihr Konto automatisch zum kostenlosen Free-Plan.
Für erweiterte Funktionen können Sie jederzeit upgraden.
</p>
` : `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Ihre Daten bleiben erhalten. Sie können jederzeit auf einen kostenpflichtigen Plan upgraden, um alle Funktionen wieder freizuschalten.
</p>
`}
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">
Lageplan — Digitale Lagepläne für die Feuerwehr<br/>
Diese E-Mail wurde automatisch gesendet.
</p>
</div>
</div>
`
let emailsSent = 0
for (const admin of adminEmails) {
try {
await sendEmail(admin.email, subject, html)
emailsSent++
} catch (e) {
console.error(`Failed to send trial reminder to ${admin.email}:`, e)
}
}
// Log that we sent this reminder (prevent duplicates)
await (prisma as any).systemSetting.create({
data: {
key: reminderKey,
value: JSON.stringify({
sentAt: now.toISOString(),
daysLeft,
emailsSent,
recipients: adminEmails.map((a: any) => a.email),
}),
isSecret: false,
category: 'trial_reminders',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent, action: 'sent' })
// If trial has expired, downgrade to FREE
if (daysLeft <= 0) {
await (prisma as any).tenant.update({
where: { id: tenant.id },
data: {
subscriptionStatus: 'EXPIRED',
plan: 'FREE',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'downgraded_to_free' })
}
}
return NextResponse.json({
success: true,
processed: trialTenants.length,
results,
})
} catch (error) {
console.error('Trial reminder error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: only reset users in same tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
}
// Generate reset token
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours for admin-initiated
await (prisma as any).user.update({
where: { id: params.id },
data: { resetToken, resetTokenExpiry },
})
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
// Try to send email
const smtpConfig = await getSmtpConfig()
let emailSent = false
if (smtpConfig) {
try {
await sendEmail(
target.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${target.name},</p>
<p>Ein Administrator hat eine Passwort-Zurücksetzung für Ihr Konto angefordert.</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Neues Passwort setzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 24 Stunden gültig.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
emailSent = true
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
}
}
return NextResponse.json({
success: true,
emailSent,
resetUrl: emailSent ? undefined : resetUrl,
message: emailSent
? `Reset-Link wurde an ${target.email} gesendet.`
: 'SMTP nicht konfiguriert. Reset-Link wurde generiert.',
})
} catch (error) {
console.error('Admin reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(100).optional(),
password: z.string().min(6).optional(),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
emailVerified: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN: can only edit users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const data = updateUserSchema.parse(body)
// TENANT_ADMIN cannot promote to SERVER_ADMIN
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const updateData: any = {}
if (data.email) updateData.email = data.email
if (data.name) updateData.name = data.name
if (data.role) updateData.role = data.role
if (data.password) updateData.password = await hashPassword(data.password)
if (data.emailVerified !== undefined) updateData.emailVerified = data.emailVerified
const user = await (prisma as any).user.update({
where: { id: params.id },
data: updateData,
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
})
return NextResponse.json({ user })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: can only delete users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// Prevent deleting yourself
if (session.id === params.id) {
return NextResponse.json({ error: 'Eigenen Account kann nicht gelöscht werden' }, { status: 400 })
}
// Reassign projects to tenant admin before deleting
for (const membership of (target.memberships || [])) {
// Find a TENANT_ADMIN in the same tenant (not the user being deleted)
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: membership.tenantId,
userId: { not: params.id },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || session.id
// Reassign all projects owned by this user in this tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id, tenantId: membership.tenantId },
data: { ownerId: newOwnerId },
})
}
// Also reassign any projects without a tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id },
data: { ownerId: session.id },
})
await (prisma as any).user.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error deleting user:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht gelöscht werden — es gibt noch abhängige Daten.'
: 'Interner Fehler beim Löschen des Benutzers'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(6),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']),
tenantId: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const where = user.role === 'SERVER_ADMIN' ? {} : {
memberships: { some: { tenantId: user.tenantId } },
}
const users = await (prisma as any).user.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
memberships: {
include: { tenant: { select: { id: true, name: true, slug: true } } },
},
_count: { select: { projects: true } },
},
})
return NextResponse.json({ users })
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createUserSchema.parse(body)
// Only SERVER_ADMIN can create SERVER_ADMIN users
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existing = await (prisma as any).user.findUnique({ where: { email: data.email } })
if (existing) {
return NextResponse.json({ error: 'E-Mail bereits vergeben' }, { status: 400 })
}
const hashedPassword = await hashPassword(data.password)
const tenantId = data.tenantId || session.tenantId
const user = await (prisma as any).user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
role: data.role as any,
...(tenantId && data.role !== 'SERVER_ADMIN' ? {
memberships: {
create: { tenantId, role: data.role as any },
},
} : {}),
},
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
},
})
return NextResponse.json({ user }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
}
if (newPassword.length < 6) {
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const dbUser = await (prisma as any).user.findUnique({
where: { id: user.id },
select: { password: true },
})
if (!dbUser) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
const isValid = await bcrypt.compare(currentPassword, dbUser.password)
if (!isValid) {
return NextResponse.json({ error: 'Aktuelles Kennwort ist falsch' }, { status: 400 })
}
const hashedPassword = await bcrypt.hash(newPassword, 12)
await (prisma as any).user.update({
where: { id: user.id },
data: { password: hashedPassword },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Change password error:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(req: NextRequest) {
try {
const { email } = await req.json()
if (!email) {
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
}
const user = await (prisma as any).user.findUnique({ where: { email } })
// Always return success to prevent email enumeration
if (!user) {
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
}
// Generate reset token (32 bytes hex = 64 chars)
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await (prisma as any).user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
})
// Try to send email
const smtpConfig = await getSmtpConfig()
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
if (smtpConfig) {
try {
await sendEmail(
user.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${user.name},</p>
<p>Sie haben eine Passwort-Zurücksetzung angefordert. Klicken Sie auf den folgenden Link:</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Passwort zurücksetzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig.</p>
<p style="color: #666; font-size: 14px;">Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
// Fall through to show token directly
}
}
// No SMTP configured or email failed → log token server-side only, never expose to client
console.log(`[Password Reset] No SMTP configured. Reset URL: ${resetUrl}`)
return NextResponse.json({
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet. (SMTP nicht konfiguriert — siehe Server-Logs)',
})
} catch (error) {
console.error('Forgot password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { login, createToken } from '@/lib/auth'
import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = loginSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten' },
{ status: 400 }
)
}
const { email, password } = validated.data
const result = await login(email, password)
if (!result.success || !result.user) {
return NextResponse.json(
{ error: result.error || 'Login fehlgeschlagen' },
{ status: 401 }
)
}
// Update lastLoginAt
try {
await (prisma as any).user.update({
where: { id: result.user.id },
data: { lastLoginAt: new Date() },
})
} catch {}
const token = await createToken(result.user)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
})
return NextResponse.json({ user: result.user })
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST() {
;(await cookies()).delete('auth-token')
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
const user = await getSession()
if (!user) {
return NextResponse.json({ user: null, tenant: null })
}
// Enrich with tenant subscription info for non-server-admins
let tenant: any = null
if (user.tenantId) {
tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
trialEndsAt: true,
subscriptionEndsAt: true,
maxUsers: true,
maxProjects: true,
logoUrl: true,
},
})
}
return NextResponse.json({ user, tenant })
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
import { sendEmail } from '@/lib/email'
import { randomBytes } from 'crypto'
import { z } from 'zod'
const registerSchema = z.object({
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
name: z.string().min(2, 'Name zu kurz').max(200),
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = registerSchema.parse(body)
// Check if email already exists
const existingUser = await (prisma as any).user.findUnique({
where: { email: data.email },
include: { memberships: true },
})
if (existingUser) {
// If the user is an orphan (no memberships) or never verified their email,
// clean them up so they can re-register
const isOrphan = !existingUser.memberships || existingUser.memberships.length === 0
const isUnverified = existingUser.emailVerified === false
if (isOrphan || isUnverified) {
// Force-delete orphan/unverified user and all their remaining data
try {
await (prisma as any).upgradeRequest.deleteMany({ where: { requestedById: existingUser.id } })
await (prisma as any).iconAsset.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).project.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).tenantMembership.deleteMany({ where: { userId: existingUser.id } })
await (prisma as any).user.delete({ where: { id: existingUser.id } })
console.log(`[Register] Cleaned up orphan/unverified user: ${data.email}`)
} catch (cleanupErr) {
console.error('[Register] Failed to cleanup existing user:', cleanupErr)
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert. Bitte kontaktieren Sie den Administrator.' }, { status: 400 })
}
} else {
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert.' }, { status: 400 })
}
}
// Generate slug from organization name
let slug = data.organizationName
.toLowerCase()
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
// Ensure slug is unique
const existingTenant = await (prisma as any).tenant.findUnique({ where: { slug } })
if (existingTenant) {
slug = `${slug}-${Date.now().toString(36)}`
}
// Hash password
const hashedPassword = await hashPassword(data.password)
// Generate email verification token
const verificationToken = randomBytes(32).toString('hex')
// Create tenant (no trial, directly ACTIVE) with privacy consent
const tenant = await (prisma as any).tenant.create({
data: {
name: data.organizationName,
slug,
plan: 'FREE',
subscriptionStatus: 'ACTIVE',
maxUsers: 5,
maxProjects: 10,
contactEmail: data.email,
privacyAccepted: body.privacyAccepted === true,
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
adminAccessAccepted: body.adminAccessAccepted === true,
},
})
// Create user as TENANT_ADMIN with email not yet verified
const user = await (prisma as any).user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
role: 'TENANT_ADMIN',
emailVerified: false,
emailVerificationToken: verificationToken,
},
})
// Create tenant membership
await (prisma as any).tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
role: 'TENANT_ADMIN',
},
})
// Send verification email
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = `https://${baseUrl}`
}
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
try {
await sendEmail(
data.email,
'E-Mail-Adresse bestätigen — Lageplan',
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">E-Mail bestätigen</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${data.name}</strong>,</p>
<p>Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto für <strong>${data.organizationName}</strong> zu aktivieren.</p>
<div style="text-align:center;margin:24px 0;">
<a href="${verifyUrl}" style="background:#dc2626;color:white;padding:12px 32px;text-decoration:none;border-radius:8px;font-weight:600;display:inline-block;">
E-Mail bestätigen
</a>
</div>
<p style="color:#666;font-size:13px;">Falls der Button nicht funktioniert, kopieren Sie diesen Link:<br/>
<a href="${verifyUrl}" style="word-break:break-all;">${verifyUrl}</a></p>
</div>
</div>`
)
} catch (e) {
console.warn('Failed to send verification email:', e)
}
// Notify server admin about new registration (#13)
try {
const adminSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
const adminEmail = adminSetting?.value
if (adminEmail) {
await sendEmail(
adminEmail,
`Neue Registrierung — ${data.organizationName}`,
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#1e293b;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Neue Registrierung</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p><strong>Organisation:</strong> ${data.organizationName}</p>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>E-Mail:</strong> ${data.email}</p>
<p><strong>Mandant-Slug:</strong> ${slug}</p>
<p><strong>Datum:</strong> ${new Date().toLocaleString('de-CH')}</p>
</div>
</div>`
)
}
} catch (e) {
console.warn('Failed to send registration notification:', e)
}
return NextResponse.json({
success: true,
message: 'Registrierung erfolgreich! Bitte bestätigen Sie Ihre E-Mail-Adresse.',
tenantSlug: tenant.slug,
requiresVerification: true,
}, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
console.error('Registration error:', error)
return NextResponse.json({ error: 'Registrierung fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
const { token, password } = await req.json()
if (!token || !password) {
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const user = await (prisma as any).user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gt: new Date() },
},
})
if (!user) {
return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link. Bitte fordern Sie einen neuen Link an.' }, { status: 400 })
}
const hashedPassword = await hashPassword(password)
await (prisma as any).user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
})
return NextResponse.json({ success: true, message: 'Passwort wurde erfolgreich geändert.' })
} catch (error) {
console.error('Reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
function getBaseUrl(req: NextRequest): string {
// Use NEXTAUTH_URL if set, ensure it has a protocol
if (process.env.NEXTAUTH_URL) {
const url = process.env.NEXTAUTH_URL.trim()
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `https://${url}`
}
const proto = req.headers.get('x-forwarded-proto') || 'https'
const host = req.headers.get('host') || 'localhost:3000'
return `${proto}://${host}`
}
export async function GET(req: NextRequest) {
const base = getBaseUrl(req)
try {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Find user by verification token
const user = await (prisma as any).user.findFirst({
where: { emailVerificationToken: token },
})
if (!user) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Mark email as verified
await (prisma as any).user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailVerificationToken: null,
},
})
// Redirect to login with success message
return NextResponse.redirect(`${base}/login?verified=true`)
} catch (error) {
console.error('Email verification error:', error)
return NextResponse.redirect(`${base}/login?error=verification-failed`)
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
message: z.string().min(1).max(5000),
})
// Get contact email from SystemSettings, fallback to app@lageplan.ch
async function getContactEmail(): Promise<string> {
try {
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'contact_email' },
})
return setting?.value || 'app@lageplan.ch'
} catch {
return 'app@lageplan.ch'
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = contactSchema.parse(body)
const contactEmail = await getContactEmail()
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
// Send via SMTP
const html = `
<h2>Neue Kontaktanfrage — Lageplan</h2>
<table style="border-collapse:collapse;width:100%;max-width:500px;">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">Name</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">E-Mail</td><td style="padding:8px;border-bottom:1px solid #eee;"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
</table>
<h3 style="margin-top:20px;">Nachricht</h3>
<div style="background:#f9f9f9;padding:16px;border-radius:8px;white-space:pre-wrap;">${escapeHtml(data.message)}</div>
<p style="margin-top:20px;font-size:12px;color:#999;">Gesendet über das Kontaktformular auf lageplan.ch</p>
`
await sendEmail(contactEmail, `Kontaktanfrage von ${data.name}`, html)
} else {
// No SMTP configured — store in SystemSettings as fallback log
const logKey = `contact_msg_${Date.now()}`
await (prisma as any).systemSetting.create({
data: {
key: logKey,
value: JSON.stringify({ name: data.name, email: data.email, message: data.message, date: new Date().toISOString() }),
isSecret: false,
category: 'contact_messages',
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Bitte füllen Sie alle Felder korrekt aus.' }, { status: 400 })
}
console.error('Contact form error:', error)
return NextResponse.json({ error: 'Senden fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

72
src/app/api/demo/route.ts Normal file
View File

@@ -0,0 +1,72 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET demo project data — no auth required
export async function GET() {
try {
// Find demo project ID from system settings
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'demo_project_id' },
})
if (!setting?.value) {
return NextResponse.json({ error: 'Keine Demo konfiguriert' }, { status: 404 })
}
const project = await (prisma as any).project.findUnique({
where: { id: setting.value },
})
if (!project) {
return NextResponse.json({ error: 'Demo-Projekt nicht gefunden' }, { status: 404 })
}
const [features, journalEntries, journalCheckItems, journalPendenzen] = await Promise.all([
(prisma as any).feature.findMany({
where: { projectId: project.id },
orderBy: { createdAt: 'asc' },
}),
(prisma as any).journalEntry.findMany({
where: { projectId: project.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}).catch(() => []),
(prisma as any).journalCheckItem.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
(prisma as any).journalPendenz.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
])
return NextResponse.json({
project: {
id: project.id,
title: project.title,
location: project.location,
description: project.description,
einsatzleiter: project.einsatzleiter || '',
journalfuehrer: project.journalfuehrer || '',
mapCenter: project.mapCenter || { lng: 8.2275, lat: 47.3497 },
mapZoom: project.mapZoom || 15,
},
features: features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
journal: {
entries: journalEntries,
checkItems: journalCheckItems,
pendenzen: journalPendenzen,
},
})
} catch (error) {
console.error('[Demo API] Error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// DELETE: Remove a dictionary word
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const entry = await (prisma as any).dictionaryEntry.findUnique({
where: { id: params.id },
})
if (!entry) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
}
// Only SERVER_ADMIN can delete global words
if (entry.scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter löschen' }, { status: 403 })
}
// TENANT_ADMIN can only delete their own tenant's words
if (entry.scope === 'TENANT' && entry.tenantId !== user.tenantId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).dictionaryEntry.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch dictionary words (global + tenant-specific merged)
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Global words
const globalWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
orderBy: { word: 'asc' },
})
// Tenant words (if user has a tenant)
let tenantWords: any[] = []
if (user.tenantId) {
tenantWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: user.tenantId },
orderBy: { word: 'asc' },
})
}
// Merge: tenant words override global (by word)
const tenantWordSet = new Set(tenantWords.map((w: any) => w.word.toLowerCase()))
const merged = [
...tenantWords.map((w: any) => ({ ...w, source: 'tenant' })),
...globalWords
.filter((w: any) => !tenantWordSet.has(w.word.toLowerCase()))
.map((w: any) => ({ ...w, source: 'global' })),
].sort((a, b) => a.word.localeCompare(b.word, 'de'))
return NextResponse.json({ words: merged })
} catch (error) {
console.error('Error fetching dictionary:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Add a word to dictionary
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { word, scope } = body
if (!word?.trim()) {
return NextResponse.json({ error: 'Wort erforderlich' }, { status: 400 })
}
// Only SERVER_ADMIN can add global words
if (scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter hinzufügen' }, { status: 403 })
}
const tenantId = scope === 'TENANT' ? user.tenantId : null
const entry = await (prisma as any).dictionaryEntry.create({
data: {
word: word.trim(),
scope: scope || 'GLOBAL',
tenantId,
},
})
return NextResponse.json(entry)
} catch (error: any) {
if (error?.code === 'P2002') {
return NextResponse.json({ error: 'Wort existiert bereits' }, { status: 409 })
}
console.error('Error adding dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe } from '@/lib/stripe'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { amount, name, message } = await req.json()
if (!amount || amount < 1 || amount > 1000) {
return NextResponse.json({ error: 'Ungültiger Betrag' }, { status: 400 })
}
const stripe = await getStripe()
if (!stripe) {
return NextResponse.json({ error: 'Stripe ist nicht konfiguriert. Bitte kontaktiere den Administrator.' }, { status: 503 })
}
const amountInCents = Math.round(amount * 100)
const origin = req.headers.get('origin') || req.nextUrl.origin
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card', 'twint'],
mode: 'payment',
line_items: [
{
price_data: {
currency: 'chf',
product_data: {
name: 'Spende für Lageplan',
description: `Freiwillige Spende von CHF ${amount} für die Weiterentwicklung`,
},
unit_amount: amountInCents,
},
quantity: 1,
},
],
metadata: {
donor_name: name || 'Anonym',
donor_message: message || '',
type: 'donation',
},
success_url: `${origin}/spenden/danke?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/spenden`,
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Stripe checkout error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Checkout fehlgeschlagen' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
// Public endpoint — returns only the publishable key
export async function GET() {
try {
const keys = await (prisma as any).systemSetting.findMany({
where: {
key: { in: ['stripe_secret_key', 'stripe_public_key', 'stripe_webhook_secret'] },
},
})
const secretKey = keys.find((k: any) => k.key === 'stripe_secret_key')?.value
const publicKey = keys.find((k: any) => k.key === 'stripe_public_key')?.value
if (!secretKey || !publicKey) {
return NextResponse.json({ configured: false })
}
return NextResponse.json({ configured: true, publicKey })
} catch (error: any) {
console.error('[Stripe Config] Error:', error?.message || error)
return NextResponse.json({ configured: false })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe, getStripeConfig } from '@/lib/stripe'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const stripe = await getStripe()
const config = await getStripeConfig()
if (!stripe || !config) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 })
}
const body = await req.text()
const sig = req.headers.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
let event
try {
if (config.webhookSecret) {
event = stripe.webhooks.constructEvent(body, sig, config.webhookSecret)
} else {
// No webhook secret configured — parse event directly (not recommended for production)
event = JSON.parse(body)
}
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const metadata = session.metadata || {}
if (metadata.type === 'donation') {
// Store donation record
try {
await (prisma as any).systemSetting.create({
data: {
key: `donation_${Date.now()}`,
value: JSON.stringify({
amount: (session.amount_total || 0) / 100,
currency: session.currency,
donor: metadata.donor_name || 'Anonym',
message: metadata.donor_message || '',
paymentId: session.payment_intent,
date: new Date().toISOString(),
}),
isSecret: false,
category: 'donations',
},
})
} catch (e) {
console.error('Failed to store donation record:', e)
}
console.log(`[Donation] CHF ${(session.amount_total || 0) / 100} from ${metadata.donor_name || 'Anonym'}`)
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// PUT: Update a hose type
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, isActive, sortOrder } = body
// If setting as default, unset other defaults
if (isDefault) {
await prisma.hoseType.updateMany({
where: { isDefault: true, id: { not: params.id } },
data: { isDefault: false },
})
}
const hoseType = await prisma.hoseType.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(diameterMm !== undefined && { diameterMm: parseInt(diameterMm) }),
...(lengthPerPieceM !== undefined && { lengthPerPieceM: parseInt(lengthPerPieceM) }),
...(flowRateLpm !== undefined && { flowRateLpm: parseFloat(flowRateLpm) }),
...(frictionCoeff !== undefined && { frictionCoeff: parseFloat(frictionCoeff) }),
...(description !== undefined && { description }),
...(isDefault !== undefined && { isDefault }),
...(isActive !== undefined && { isActive }),
...(sortOrder !== undefined && { sortOrder }),
},
})
return NextResponse.json({ hoseType })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error updating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// DELETE: Delete a hose type
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await prisma.hoseType.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error deleting hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// GET: List all hose types (global + tenant-specific)
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Show global hose types (tenantId=null) + tenant-specific ones
const where: any = { isActive: true }
if (user.role !== 'SERVER_ADMIN' && user.tenantId) {
where.OR = [{ tenantId: null }, { tenantId: user.tenantId }]
delete where.isActive
where.AND = [{ isActive: true }]
}
const hoseTypes = await (prisma as any).hoseType.findMany({
where: user.role === 'SERVER_ADMIN'
? { isActive: true }
: { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] },
orderBy: { sortOrder: 'asc' },
})
return NextResponse.json({ hoseTypes })
} catch (error) {
console.error('Error fetching hose types:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new hose type (admin only)
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, sortOrder } = body
if (!name || !diameterMm || !flowRateLpm || !frictionCoeff) {
return NextResponse.json({ error: 'Name, Durchmesser, Durchfluss und Reibungskoeffizient sind erforderlich' }, { status: 400 })
}
if (isDefault) {
await (prisma as any).hoseType.updateMany({ where: { isDefault: true }, data: { isDefault: false } })
}
const hoseType = await (prisma as any).hoseType.create({
data: {
name,
diameterMm: parseInt(diameterMm),
lengthPerPieceM: parseInt(lengthPerPieceM) || 10,
flowRateLpm: parseFloat(flowRateLpm),
frictionCoeff: parseFloat(frictionCoeff),
description: description || null,
isDefault: isDefault || false,
sortOrder: sortOrder || 0,
tenantId: user.role === 'SERVER_ADMIN' ? null : user.tenantId || null,
},
})
return NextResponse.json({ hoseType }, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: 'Ein Schlauchtyp mit diesem Namen existiert bereits' }, { status: 409 })
}
console.error('Error creating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
import { readFile } from 'fs/promises'
import { join } from 'path'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const icon = await prisma.iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// Serve system icons from public/signaturen/
if (icon.isSystem && icon.fileKey.startsWith('signaturen/')) {
const contentType = icon.mimeType || (icon.fileKey.endsWith('.svg') ? 'image/svg+xml' : 'image/png')
try {
const filePath = join(process.cwd(), 'public', icon.fileKey)
const buffer = await readFile(filePath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
try {
const altPath = join(process.cwd(), icon.fileKey)
const buffer = await readFile(altPath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
console.error('System icon file not found:', icon.fileKey)
}
}
}
// Stream file from MinIO through the app (no external MinIO access needed)
try {
const { stream, contentType } = await getFileStream(icon.fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (streamErr) {
console.error('Error streaming icon from MinIO:', streamErr)
return NextResponse.json({ error: 'Icon-Datei nicht gefunden' }, { status: 404 })
}
} catch (error) {
console.error('Error fetching icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// Toggle icon visibility for the current tenant (adds/removes from hiddenIconIds)
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
const iconId = params.id
// Get current tenant
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
const hiddenIds: string[] = tenant.hiddenIconIds || []
const isHidden = hiddenIds.includes(iconId)
// Toggle: if hidden, unhide; if visible, hide
const updatedHiddenIds = isHidden
? hiddenIds.filter((id: string) => id !== iconId)
: [...hiddenIds, iconId]
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { hiddenIconIds: updatedHiddenIds },
})
return NextResponse.json({
success: true,
isHidden: !isHidden,
message: isHidden ? 'Symbol eingeblendet' : 'Symbol ausgeblendet',
})
} catch (error) {
console.error('Error toggling icon visibility:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET() {
try {
const user = await getSession()
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
const iconFilter: any = { isActive: true }
if (user?.tenantId) {
iconFilter.OR = [
{ tenantId: null },
{ tenantId: user.tenantId },
]
delete iconFilter.isActive
iconFilter.AND = [{ isActive: true }]
} else {
// Server admin or no tenant: show all global icons
iconFilter.tenantId = null
}
// Filter categories: global (tenantId=null) + tenant-specific
const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: {}
const categories = await (prisma as any).iconCategory.findMany({
where: categoryWhere,
orderBy: { sortOrder: 'asc' },
include: {
icons: {
where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true },
orderBy: { name: 'asc' },
},
},
})
// Get tenant's hidden icon IDs
let hiddenIconIds: string[] = []
if (user?.tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
hiddenIconIds = tenant?.hiddenIconIds || []
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({
...icon,
url: `/api/icons/${icon.id}/image`,
})),
}))
return NextResponse.json({ categories: categoriesWithUrls })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { v4 as uuidv4 } from 'uuid'
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
const name = formData.get('name') as string | null
const categoryId = formData.get('categoryId') as string | null
if (!file || !name || !categoryId) {
return NextResponse.json(
{ error: 'Datei, Name und Kategorie sind erforderlich' },
{ status: 400 }
)
}
const allowedTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien sind erlaubt' },
{ status: 400 }
)
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'Datei ist zu gross (max. 5MB)' },
{ status: 400 }
)
}
const category = await prisma.iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
const fileExtension = file.name.split('.').pop() || 'png'
const fileKey = `icons/${user.id}/${uuidv4()}.${fileExtension}`
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
const iconAsset = await prisma.iconAsset.create({
data: {
name,
categoryId,
ownerId: user.id,
fileKey,
mimeType: file.type,
isSystem: false,
},
})
return NextResponse.json({ icon: iconAsset }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
const HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1000 // 2 minutes
function isEditingExpired(heartbeat: Date | null): boolean {
if (!heartbeat) return true
return Date.now() - new Date(heartbeat).getTime() > HEARTBEAT_TIMEOUT_MS
}
// GET: Check editing status of a project
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const sessionId = req.nextUrl.searchParams.get('sessionId') || ''
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingUserName: true,
editingSessionId: true,
editingStartedAt: true,
editingHeartbeat: true,
isLocked: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Auto-release if heartbeat expired
if (project.editingById && isEditingExpired(project.editingHeartbeat)) {
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({
editing: false,
editingBy: null,
isMe: false,
isLocked: project.isLocked,
})
}
// isMe is true only if sessionId matches (same tab/device)
const isMe = !!sessionId && project.editingSessionId === sessionId
return NextResponse.json({
editing: !!project.editingById,
editingBy: project.editingById ? {
id: project.editingById,
name: project.editingUserName,
since: project.editingStartedAt,
} : null,
isMe,
isLocked: project.isLocked,
})
} catch (error) {
console.error('Error checking editing status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Start editing / heartbeat / stop editing
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { action, sessionId } = body // 'start', 'heartbeat', 'stop'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingSessionId: true,
editingHeartbeat: true,
isLocked: true,
tenantId: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (action === 'start') {
if (!sessionId) {
return NextResponse.json({ error: 'sessionId fehlt' }, { status: 400 })
}
// Check if another session is editing (and heartbeat not expired)
if (project.editingSessionId && project.editingSessionId !== sessionId && !isEditingExpired(project.editingHeartbeat)) {
return NextResponse.json({
error: 'Projekt wird gerade von jemand anderem bearbeitet',
editingBy: project.editingById,
}, { status: 409 })
}
// Start editing
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: user.id,
editingUserName: user.name,
editingSessionId: sessionId,
editingStartedAt: new Date(),
editingHeartbeat: new Date(),
},
})
return NextResponse.json({ success: true, action: 'started' })
}
if (action === 'heartbeat') {
// Only the current session can send heartbeats
if (project.editingSessionId !== sessionId) {
return NextResponse.json({ error: 'Sie sind nicht der aktuelle Bearbeiter' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: { editingHeartbeat: new Date() },
})
return NextResponse.json({ success: true, action: 'heartbeat' })
}
if (action === 'stop') {
// Only the current session or a SERVER_ADMIN can stop editing
if (project.editingSessionId !== sessionId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({ success: true, action: 'stopped' })
}
return NextResponse.json({ error: 'Ungültige Aktion' }, { status: 400 })
} catch (error) {
console.error('Error managing editing lock:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation check
const projectCheck = await getProjectWithTenantCheck(params.id, user)
if (!projectCheck) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
features: true,
owner: {
select: { name: true },
},
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (format === 'geojson') {
const geojson = {
type: 'FeatureCollection',
properties: {
title: project.title,
location: project.location,
description: project.description,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
owner: project.owner?.name ?? null,
},
features: project.features.map((f: any) => ({
type: 'Feature',
id: f.id,
geometry: f.geometry,
properties: {
type: f.type,
...f.properties as object,
},
})),
}
return new NextResponse(JSON.stringify(geojson, null, 2), {
headers: {
'Content-Type': 'application/geo+json',
'Content-Disposition': `attachment; filename="${project.title.replace(/[^a-z0-9]/gi, '_')}.geojson"`,
},
})
}
// Default: return project data for client-side PDF/PNG generation
return NextResponse.json({
project: {
...project,
features: project.features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties,
})),
},
})
} catch (error) {
console.error('Error exporting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { featureSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const features = await (prisma as any).feature.findMany({
where: { projectId: params.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({ features })
} catch (error) {
console.error('Error fetching features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const validated = featureSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const feature = await (prisma as any).feature.create({
data: {
projectId: params.id,
type: validated.data.type,
geometry: validated.data.geometry,
properties: validated.data.properties || {},
},
})
return NextResponse.json({ feature }, { status: 201 })
} catch (error) {
console.error('Error creating feature:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
const exists = await (prisma as any).project.findUnique({ where: { id: params.id }, select: { id: true, tenantId: true, ownerId: true } })
if (!exists) {
console.warn(`[Features PUT] Project ${params.id} not in DB`)
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
console.warn(`[Features PUT] Access denied: user=${user.id} tenant=${user.tenantId}, project owner=${exists.ownerId} tenant=${exists.tenantId}`)
return NextResponse.json({ error: 'Keine Berechtigung für dieses Projekt' }, { status: 403 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const { features } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> }
await (prisma as any).feature.deleteMany({
where: { projectId: params.id },
})
if (features && features.length > 0) {
await (prisma as any).feature.createMany({
data: features.map((f: any) => ({
projectId: params.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
})
}
const updatedFeatures = await (prisma as any).feature.findMany({
where: { projectId: params.id },
})
return NextResponse.json({ features: updatedFeatures })
} catch (error) {
console.error('Error updating features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Toggle confirmed/ok on a check item
export async function PUT(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.label !== undefined) data.label = body.label
if (body.confirmed !== undefined) {
data.confirmed = body.confirmed
data.confirmedAt = body.confirmed ? new Date() : null
}
if (body.ok !== undefined) {
data.ok = body.ok
data.okAt = body.ok ? new Date() : null
}
const item = await (prisma as any).journalCheckItem.update({
where: { id: params.itemId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating check item:', error)
return NextResponse.json({ error: 'Failed to update check item' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
await (prisma as any).journalCheckItem.delete({ where: { id: params.itemId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting check item:', error)
return NextResponse.json({ error: 'Failed to delete check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add check item (or initialize from templates)
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
// If 'initFromTemplates' is true, create check items from templates (only if none exist)
if (body.initFromTemplates) {
const existing = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
})
if (existing.length > 0) {
return NextResponse.json(existing)
}
const templates = await (prisma as any).journalCheckTemplate.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
})
const items = await Promise.all(
templates.map((tpl: any, i: number) =>
(prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: tpl.label,
sortOrder: i,
},
})
)
)
return NextResponse.json(items)
}
// Single item creation
const item = await (prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: body.label || '',
sortOrder: body.sortOrder || 0,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating check item:', error)
return NextResponse.json({ error: 'Failed to create check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a journal entry — only toggle done status allowed directly
export async function PUT(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
const body = await req.json()
// Only done toggle is allowed as direct edit
if (body.done !== undefined) {
const entry = await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { done: body.done, doneAt: body.done ? new Date() : null },
})
return NextResponse.json(entry)
}
return NextResponse.json({ error: 'Direkte Bearbeitung nicht erlaubt. Bitte Korrektur erstellen.' }, { status: 400 })
} catch (error) {
console.error('Error updating journal entry:', error)
return NextResponse.json({ error: 'Failed to update entry' }, { status: 500 })
}
}
// POST: Create a correction for a journal entry (replaces DELETE)
// Marks the original as corrected (strikethrough) and creates a new correction entry below it
export async function POST(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
// Prevent double-correction or correcting a correction entry
if (existing.isCorrected) {
return NextResponse.json({ error: 'Dieser Eintrag wurde bereits korrigiert.' }, { status: 400 })
}
if (existing.correctionOfId) {
return NextResponse.json({ error: 'Ein Korrektureintrag kann nicht nochmals korrigiert werden.' }, { status: 400 })
}
const body = await req.json()
const correctionText = body.what || ''
if (!correctionText.trim()) {
return NextResponse.json({ error: 'Korrekturtext ist erforderlich' }, { status: 400 })
}
// Mark original as corrected
await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { isCorrected: true },
})
// Create correction entry with same time, placed right after the original
const correction = await (prisma as any).journalEntry.create({
data: {
time: existing.time,
what: `[Korrektur] ${correctionText}`,
who: body.who || existing.who || user.name,
sortOrder: existing.sortOrder + 1,
correctionOfId: existing.id,
projectId: params.id,
},
})
return NextResponse.json({ original: existing, correction })
} catch (error) {
console.error('Error creating correction:', error)
return NextResponse.json({ error: 'Failed to create correction' }, { status: 500 })
}
}
// DELETE: Not allowed — entries cannot be deleted, only corrected
export async function DELETE() {
return NextResponse.json(
{ error: 'Journal-Einträge können nicht gelöscht werden. Bitte erstellen Sie eine Korrektur.' },
{ status: 403 }
)
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new journal entry
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const entry = await (prisma as any).journalEntry.create({
data: {
projectId: params.id,
time: body.time ? new Date(body.time) : new Date(),
what: body.what || '',
who: body.who || null,
done: body.done || false,
},
})
return NextResponse.json(entry)
} catch (error) {
console.error('Error creating journal entry:', error)
return NextResponse.json({ error: 'Failed to create entry' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a pendenz
export async function PUT(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.what !== undefined) data.what = body.what
if (body.who !== undefined) data.who = body.who
if (body.whenHow !== undefined) data.whenHow = body.whenHow
if (body.done !== undefined) {
data.done = body.done
data.doneAt = body.done ? new Date() : null
}
const item = await (prisma as any).journalPendenz.update({
where: { id: params.pendenzId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating pendenz:', error)
return NextResponse.json({ error: 'Failed to update pendenz' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
await (prisma as any).journalPendenz.delete({ where: { id: params.pendenzId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting pendenz:', error)
return NextResponse.json({ error: 'Failed to delete pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new pendenz
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const item = await (prisma as any).journalPendenz.create({
data: {
projectId: params.id,
what: body.what || '',
who: body.who || null,
whenHow: body.whenHow || null,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating pendenz:', error)
return NextResponse.json({ error: 'Failed to create pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// GET all journal data for a project (entries, check items, pendenzen)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const [entries, checkItems, pendenzen] = await Promise.all([
(prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
(prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
(prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
])
return NextResponse.json({ entries, checkItems, pendenzen })
} catch (error) {
console.error('Error fetching journal:', error)
return NextResponse.json({ error: 'Failed to fetch journal' }, { status: 500 })
}
}

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { sendEmail } from '@/lib/email'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Load tenant logo
let tenantLogoUrl = ''
let tenantName = ''
if ((project as any).tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: (project as any).tenantId },
select: { logoUrl: true, name: true },
})
if (tenant?.logoUrl) tenantLogoUrl = tenant.logoUrl
if (tenant?.name) tenantName = tenant.name
}
const body = await req.json()
const { recipientEmail } = body
if (!recipientEmail) {
return NextResponse.json({ error: 'Empfänger-E-Mail erforderlich' }, { status: 400 })
}
// Load journal data
const entries = await (prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }],
})
const checkItems = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
const pendenzen = await (prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
// Build HTML report
const formatTime = (d: Date) => new Date(d).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const formatDate = (d: Date) => new Date(d).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const p = project as any
let entriesHtml = ''
for (const e of entries) {
const correctedStyle = e.isCorrected ? 'text-decoration:line-through;opacity:0.5;' : ''
const correctionStyle = e.correctionOfId ? 'color:#b45309;font-style:italic;' : ''
const doneIcon = e.done ? '✅' : ''
const doneAtStr = e.done && e.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(erledigt ${formatTime(e.doneAt)})</span>` : ''
entriesHtml += `
<tr style="${correctedStyle}${correctionStyle}">
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;white-space:nowrap;">${formatTime(e.time)}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${e.what}${e.isCorrected ? ' <span style="color:#ef4444;font-size:11px;">(korrigiert)</span>' : ''}${doneAtStr}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${e.who || ''}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${doneIcon}</td>
</tr>`
}
let checkHtml = ''
for (const c of checkItems) {
const confirmedTime = c.confirmed && c.confirmedAt ? ` <span style="font-size:10px;color:#666;">${formatTime(c.confirmedAt)}</span>` : ''
checkHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${c.label}${confirmedTime}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.confirmed ? '✅' : ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.ok ? '✅' : ''}</td>
</tr>`
}
let pendHtml = ''
for (const p of pendenzen) {
const pendDoneAt = p.done && p.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(${formatTime(p.doneAt)})</span>` : ''
pendHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${p.what}${pendDoneAt}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.who || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.whenHow || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${p.done ? '✅' : ''}</td>
</tr>`
}
const logoHtml = tenantLogoUrl
? `<img src="${tenantLogoUrl}" alt="${tenantName}" style="height:40px;max-width:120px;object-fit:contain;margin-right:16px;border-radius:4px;" />`
: ''
const html = `
<div style="font-family:sans-serif;max-width:800px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;display:flex;align-items:center;">
${logoHtml}
<div>
<h1 style="margin:0;font-size:22px;">Einsatzrapport</h1>
<p style="margin:4px 0 0;opacity:0.9;">${p.title || 'Ohne Titel'}${tenantName ? `${tenantName}` : ''}</p>
</div>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<div style="display:flex;gap:24px;margin-bottom:20px;flex-wrap:wrap;">
<div><strong>Standort:</strong> ${p.location || ''}</div>
<div><strong>Datum:</strong> ${p.createdAt ? formatDate(p.createdAt) : ''}</div>
</div>
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Journal-Einträge</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Zeit</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Wer</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Ok</th>
</tr>
</thead>
<tbody>${entriesHtml || '<tr><td colspan="4" style="padding:12px;text-align:center;color:#999;">Keine Einträge</td></tr>'}</tbody>
</table>
${checkItems.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">SOMA Checkliste</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Punkt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Bestätigt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Ok</th>
</tr>
</thead>
<tbody>${checkHtml}</tbody>
</table>` : ''}
${pendenzen.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Pendenzen</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wer</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wann/Wie</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Erledigt</th>
</tr>
</thead>
<tbody>${pendHtml}</tbody>
</table>` : ''}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;" />
<p style="color:#999;font-size:11px;">Gesendet von Lageplan am ${new Date().toLocaleString('de-CH')} durch ${user.name || user.email}</p>
</div>
</div>`
await sendEmail(
recipientEmail,
`Einsatzrapport — ${p.title || 'Ohne Titel'}`,
html
)
return NextResponse.json({ success: true, message: `Rapport an ${recipientEmail} gesendet` })
} catch (error) {
console.error('Error sending report:', error)
return NextResponse.json({ error: 'Fehler beim Senden des Rapports' }, { status: 500 })
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { uploadFile, deleteFile, getFileUrl } from '@/lib/minio'
// POST: Upload a plan image for a project
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const formData = await req.formData()
const file = formData.get('file') as File | null
const boundsStr = formData.get('bounds') as string | null
if (!file) {
return NextResponse.json({ error: 'Keine Datei angegeben' }, { status: 400 })
}
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Nur PNG, JPEG, WebP oder SVG erlaubt' }, { status: 400 })
}
// Delete old plan image if exists
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `plans/${params.id}/${Date.now()}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Parse bounds or use default (current map view)
let bounds = null
if (boundsStr) {
try { bounds = JSON.parse(boundsStr) } catch {}
}
// Update project
await (prisma as any).project.update({
where: { id: params.id },
data: {
planImageKey: fileKey,
planBounds: bounds,
},
})
const url = await getFileUrl(fileKey)
return NextResponse.json({
success: true,
planImageUrl: url,
planImageKey: fileKey,
planBounds: bounds,
})
} catch (error) {
console.error('Error uploading plan image:', error)
return NextResponse.json({ error: 'Fehler beim Hochladen' }, { status: 500 })
}
}
// DELETE: Remove the plan image
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
await (prisma as any).project.update({
where: { id: params.id },
data: { planImageKey: null, planBounds: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting plan image:', error)
return NextResponse.json({ error: 'Fehler beim Löschen' }, { status: 500 })
}
}
// PATCH: Update plan bounds (repositioning)
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
if (!body.bounds) return NextResponse.json({ error: 'Bounds erforderlich' }, { status: 400 })
await (prisma as any).project.update({
where: { id: params.id },
data: { planBounds: body.bounds },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating plan bounds:', error)
return NextResponse.json({ error: 'Fehler beim Aktualisieren' }, { status: 500 })
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getFileStream } from '@/lib/minio'
// Serve plan image (authenticated users only)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: { planImageKey: true },
})
if (!project?.planImageKey) {
return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(project.planImageKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving plan image:', error)
return NextResponse.json({ error: 'Fehler beim Laden des Plans' }, { status: 500 })
}
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const projectBase = await getProjectWithTenantCheck(params.id, user)
if (!projectBase) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Re-fetch with includes
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
owner: {
select: { id: true, name: true, email: true },
},
features: true,
},
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error fetching project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const body = await request.json()
const validated = projectSchema.partial().safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const project = await (prisma as any).project.update({
where: { id: params.id },
data: validated.data,
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error updating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Only owner, tenant admin, or server admin can delete
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN' && existingProject.ownerId !== user.id) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getTenantFilter } from '@/lib/tenant'
export async function GET() {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation: each user only sees their tenant's projects
const tenantFilter = getTenantFilter(user)
const projects = await (prisma as any).project.findMany({
where: tenantFilter,
orderBy: { updatedAt: 'desc' },
include: {
owner: {
select: { id: true, name: true, email: true },
},
_count: {
select: { features: true },
},
},
})
return NextResponse.json({ projects })
} catch (error) {
console.error('Error fetching projects:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const validated = projectSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
// Generate unique Einsatz-Nr: E-YYYY-NNNN (auto-increment per tenant per year)
const year = new Date().getFullYear()
const einsatzPrefix = `E-${year}-`
let einsatzNr = `${einsatzPrefix}0001`
try {
const lastProject = await (prisma as any).project.findFirst({
where: {
tenantId: user.tenantId || undefined,
einsatzNr: { startsWith: einsatzPrefix },
},
orderBy: { einsatzNr: 'desc' },
select: { einsatzNr: true },
})
if (lastProject?.einsatzNr) {
const lastNum = parseInt(lastProject.einsatzNr.split('-').pop())
if (!isNaN(lastNum)) einsatzNr = `${einsatzPrefix}${String(lastNum + 1).padStart(4, '0')}`
}
} catch {}
// Always set tenantId from the logged-in user's tenant
const project = await (prisma as any).project.create({
data: {
...validated.data,
einsatzNr,
ownerId: user.id,
tenantId: user.tenantId || null,
mapCenter: validated.data.mapCenter || { lng: 8.5417, lat: 47.3769 },
mapZoom: validated.data.mapZoom || 15,
},
})
return NextResponse.json({ project }, { status: 201 })
} catch (error) {
console.error('Error creating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import React from 'react'
// Convert a MinIO/internal logo URL to a base64 data URI server-side
async function resolveLogoDataUri(rapport: any): Promise<string> {
try {
const logoUrl = rapport.data?.logoUrl
// Already a data URI — use as-is
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
// Try to load logo from MinIO via tenant's logoFileKey / logoUrl
const tenantId = rapport.tenantId
if (!tenantId) return ''
const tenant = await (prisma as any).tenant.findUnique({
where: { id: tenantId },
select: { logoFileKey: true, logoUrl: true },
})
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) return ''
const { getFileStream } = await import('@/lib/minio')
const { stream, contentType } = await getFileStream(fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return `data:${contentType};base64,${buffer.toString('base64')}`
} catch (e) {
console.warn('[Rapport PDF] Could not resolve logo:', e)
return ''
}
}
// GET: Generate and serve PDF for a rapport (public, token-based)
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
try {
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
tenant: { select: { name: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
// Ensure logo is a valid data URI for PDF rendering
const pdfData = { ...rapport.data }
const resolvedLogo = await resolveLogoDataUri(rapport)
if (resolvedLogo) {
pdfData.logoUrl = resolvedLogo
} else if (pdfData.logoUrl && !pdfData.logoUrl.startsWith('data:')) {
// Remove non-data-URI logo URLs — @react-pdf can't fetch them
pdfData.logoUrl = ''
}
// Dynamic import to avoid issues during build when @react-pdf/renderer isn't installed
const { renderToBuffer } = await import('@react-pdf/renderer')
const { RapportDocument } = await import('@/lib/rapport-pdf')
const buffer = await renderToBuffer(
React.createElement(RapportDocument, { data: pdfData })
)
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="Rapport-${rapport.reportNumber}.pdf"`,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error: any) {
console.error('Error generating rapport PDF:', error)
const msg = error?.message || String(error)
return NextResponse.json({ error: 'PDF-Generierung fehlgeschlagen', detail: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// Resolve tenant logo to a base64 data URI (avoids exposing internal MinIO URLs)
async function resolveLogoForClient(rapport: any): Promise<string> {
try {
const logoUrl = rapport.data?.logoUrl
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
const tenantId = rapport.tenantId
if (!tenantId) return ''
const tenant = await (prisma as any).tenant.findUnique({
where: { id: tenantId },
select: { logoFileKey: true, logoUrl: true },
})
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) return ''
const { getFileStream } = await import('@/lib/minio')
const { stream, contentType } = await getFileStream(fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return `data:${contentType};base64,${buffer.toString('base64')}`
} catch (e) {
console.warn('[Rapport] Could not resolve logo:', e)
return ''
}
}
// GET: Public access to rapport by token (no auth required)
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
try {
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
project: { select: { title: true, location: true } },
tenant: { select: { name: true } },
createdBy: { select: { name: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
// Resolve logo to data URI so the client never sees internal MinIO URLs
const rapportData = { ...rapport.data }
const resolvedLogo = await resolveLogoForClient(rapport)
if (resolvedLogo) {
rapportData.logoUrl = resolvedLogo
} else if (rapportData.logoUrl && !rapportData.logoUrl.startsWith('data:')) {
rapportData.logoUrl = ''
}
return NextResponse.json({
id: rapport.id,
reportNumber: rapport.reportNumber,
data: rapportData,
generatedAt: rapport.generatedAt,
project: rapport.project,
tenant: rapport.tenant,
createdBy: rapport.createdBy,
})
} catch (error) {
console.error('Error fetching rapport by token:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email'
// POST: Send rapport link via email
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { email } = await req.json()
if (!email) return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
tenant: { select: { name: true } },
project: { select: { title: true, location: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
const pdfUrl = `${baseUrl}/api/rapports/${rapport.token}/pdf`
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #1a1a1a; color: white; padding: 20px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Einsatzrapport</h2>
<p style="margin: 4px 0 0; font-size: 13px; opacity: 0.8;">${rapport.tenant?.name || ''}</p>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<table style="width: 100%; font-size: 14px; margin-bottom: 16px;">
<tr><td style="color: #6b7280; padding: 4px 0;">Rapport-Nr.</td><td style="font-weight: 600;">${rapport.reportNumber}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Einsatz</td><td style="font-weight: 600;">${rapport.project?.title || '—'}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Standort</td><td>${rapport.project?.location || '—'}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Erstellt</td><td>${new Date(rapport.createdAt).toLocaleString('de-CH')}</td></tr>
</table>
<div style="margin: 20px 0;">
<a href="${rapportUrl}" style="display: inline-block; background: #1a1a1a; color: white; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; margin-right: 8px;">
Rapport ansehen
</a>
<a href="${pdfUrl}" style="display: inline-block; background: white; color: #1a1a1a; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; border: 1px solid #d1d5db;">
PDF herunterladen
</a>
</div>
<p style="font-size: 12px; color: #9ca3af; margin-top: 16px;">
Dieser Link ist öffentlich zugänglich — keine Anmeldung nötig.<br/>
Gesendet von ${user.name || user.email} via app.lageplan.ch
</p>
</div>
</div>
`
await sendEmail(
email,
`Einsatzrapport ${rapport.reportNumber}${rapport.project?.title || 'Lageplan'}`,
html
)
return NextResponse.json({ success: true, message: `Rapport an ${email} gesendet` })
} catch (error) {
console.error('Error sending rapport email:', error)
return NextResponse.json({ error: 'E-Mail konnte nicht gesendet werden' }, { status: 500 })
}
}

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
import QRCode from 'qrcode'
// Helper: create a rapport record and return JSON response
async function createRapport(projectId: string, data: any, tenantId: string, userId: string, req: NextRequest) {
// Einsatz-Nummer: one unique number per project (not per rapport)
const existingRapport = await (prisma as any).rapport.findFirst({
where: { projectId },
orderBy: { createdAt: 'asc' },
select: { reportNumber: true },
})
let reportNumber: string
if (existingRapport?.reportNumber) {
reportNumber = existingRapport.reportNumber
} else {
const year = new Date().getFullYear()
const prefix = `${year}-`
const lastRapport = await (prisma as any).rapport.findFirst({
where: { tenantId, reportNumber: { startsWith: prefix } },
orderBy: { reportNumber: 'desc' },
select: { reportNumber: true },
})
let nextNum = 1
if (lastRapport?.reportNumber) {
const lastNum = parseInt(lastRapport.reportNumber.split('-')[1])
if (!isNaN(lastNum)) nextNum = lastNum + 1
}
reportNumber = `${prefix}${String(nextNum).padStart(4, '0')}`
}
data.reportNumber = reportNumber
const rapport = await (prisma as any).rapport.create({
data: { reportNumber, data, projectId, tenantId, createdById: userId },
})
// Generate QR code
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
let qrCodeDataUri = ''
try {
qrCodeDataUri = await QRCode.toDataURL(rapportUrl, { width: 200, margin: 1, color: { dark: '#1a1a1a', light: '#ffffff' } })
} catch (e) {
console.warn('QR code generation failed:', e)
}
if (qrCodeDataUri) {
data.qrCodeUrl = qrCodeDataUri
await (prisma as any).rapport.update({ where: { id: rapport.id }, data: { data } })
}
return NextResponse.json({
id: rapport.id,
reportNumber: rapport.reportNumber,
token: rapport.token,
qrCodeUrl: qrCodeDataUri,
})
}
// GET: List rapports for a project
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { searchParams } = new URL(req.url)
const projectId = searchParams.get('projectId')
if (!projectId) {
return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 })
}
const rapports = await (prisma as any).rapport.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
reportNumber: true,
token: true,
generatedAt: true,
createdAt: true,
createdBy: { select: { name: true } },
},
})
return NextResponse.json({ rapports })
} catch (error) {
console.error('Error fetching rapports:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new rapport
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let body: any
try {
body = await req.json()
} catch (parseErr: any) {
console.error('[Rapport] Body parse error:', parseErr?.message)
return NextResponse.json({ error: 'Request zu gross oder ungültig: ' + (parseErr?.message || 'Body konnte nicht gelesen werden') }, { status: 400 })
}
const { projectId, data } = body || {}
if (!projectId || !data) {
console.error('[Rapport] Missing fields — projectId:', !!projectId, 'data:', !!data, 'body keys:', Object.keys(body || {}))
return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 })
}
// Resolve tenantId: project → user session → membership lookup
const project = await (prisma as any).project.findUnique({
where: { id: projectId },
select: { tenantId: true },
})
let tenantId = project?.tenantId || user.tenantId || null
if (!tenantId) {
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: user.id },
select: { tenantId: true },
})
tenantId = membership?.tenantId || null
}
console.log('[Rapport] Creating rapport — project:', projectId, 'tenant:', tenantId || '(none)')
return await createRapport(projectId, data, tenantId, user.id, req)
} catch (error: any) {
console.error('[Rapport] Error:', error?.message || error)
if (error?.code === 'P2002') {
return NextResponse.json({ error: 'Rapportnummer existiert bereits' }, { status: 409 })
}
return NextResponse.json({ error: error?.message || 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET: Public app settings (no auth required, non-sensitive values only)
export async function GET() {
try {
let defaultSymbolScale = 1.5
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (setting) defaultSymbolScale = parseFloat(setting.value) || 1.5
} catch {}
return NextResponse.json({ defaultSymbolScale })
} catch {
return NextResponse.json({ defaultSymbolScale: 1.5 })
}
}

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// POST: Tenant self-deletion — TENANT_ADMIN deletes own organization with all data
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { confirmText } = body
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
// Verify user is TENANT_ADMIN
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur der Organisations-Administrator kann die Organisation löschen' }, { status: 403 })
}
// Get tenant info for confirmation
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, name: true, slug: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// Require confirmation text to match tenant name
if (confirmText !== tenant.name) {
return NextResponse.json({ error: `Bitte geben Sie "${tenant.name}" zur Bestätigung ein` }, { status: 400 })
}
console.log(`[Tenant Delete] User ${user.id} deleting tenant ${tenant.id} (${tenant.name})`)
// Delete everything in order (respecting foreign keys)
// 1. Rapports
await (prisma as any).rapport.deleteMany({ where: { tenantId: tenant.id } })
// 2. Journal entries (via projects)
const projectIds = await (prisma as any).project.findMany({
where: { tenantId: tenant.id },
select: { id: true },
})
const pIds = projectIds.map((p: any) => p.id)
if (pIds.length > 0) {
await (prisma as any).journalEntry.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalCheckItem.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalPendenz.deleteMany({ where: { projectId: { in: pIds } } })
}
// 3. Projects
await (prisma as any).project.deleteMany({ where: { tenantId: tenant.id } })
// 4. Icon assets & categories
await (prisma as any).iconAsset.deleteMany({ where: { tenantId: tenant.id } })
await (prisma as any).iconCategory.deleteMany({ where: { tenantId: tenant.id } })
// 5. Hose types, check templates, dictionary entries
try { await (prisma as any).hoseType.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).journalCheckTemplate.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).dictionaryEntry.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).upgradeRequest.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
// 6. Get all user IDs from memberships
const memberships = await (prisma as any).tenantMembership.findMany({
where: { tenantId: tenant.id },
select: { userId: true },
})
const userIds = memberships.map((m: any) => m.userId)
// 7. Delete memberships
await (prisma as any).tenantMembership.deleteMany({ where: { tenantId: tenant.id } })
// 8. Delete users that have no other memberships
for (const uid of userIds) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: uid },
})
if (otherMemberships === 0) {
try {
await (prisma as any).user.delete({ where: { id: uid } })
} catch (e) {
console.warn(`[Tenant Delete] Could not delete user ${uid}:`, e)
}
}
}
// 9. Delete tenant itself
await (prisma as any).tenant.delete({ where: { id: tenant.id } })
console.log(`[Tenant Delete] Tenant ${tenant.name} (${tenant.id}) fully deleted`)
return NextResponse.json({ success: true, message: 'Organisation und alle Daten wurden gelöscht' })
} catch (error: any) {
console.error('[Tenant Delete] Error:', error?.message || error)
return NextResponse.json({ error: error?.message || 'Löschung fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) {
return NextResponse.json({ tenant: null })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true,
privacyAcceptedAt: true,
adminAccessAccepted: true,
createdAt: true,
_count: {
select: {
memberships: true,
projects: true,
},
},
},
})
return NextResponse.json({ tenant })
} catch (error: any) {
console.error('[Tenant Info] Error:', error?.message)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch journal suggestions for a tenant (global + tenant dictionary merged)
export async function GET(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Fetch from new Dictionary model (global + tenant)
const [globalWords, tenantWords, tenant] = await Promise.all([
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
select: { word: true },
}).catch(() => []),
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: params.tenantId },
select: { word: true },
}).catch(() => []),
(prisma as any).tenant.findUnique({
where: { id: params.tenantId },
select: { journalSuggestions: true },
}),
])
// Merge: dictionary entries + legacy journalSuggestions (backward compat)
const wordSet = new Set<string>()
for (const w of tenantWords) wordSet.add(w.word)
for (const w of globalWords) wordSet.add(w.word)
if (tenant?.journalSuggestions) {
for (const s of tenant.journalSuggestions) wordSet.add(s)
}
const suggestions = Array.from(wordSet).sort((a, b) => a.localeCompare(b, 'de'))
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error fetching suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// PUT: Replace all journal suggestions for a tenant (admin only)
export async function PUT(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN can only edit their own tenant
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== params.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const suggestions: string[] = Array.isArray(body.suggestions)
? body.suggestions.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
: []
await (prisma as any).tenant.update({
where: { id: params.tenantId },
data: { journalSuggestions: suggestions },
})
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error updating suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// Public endpoint: get tenant info by slug (logo, name)
export async function GET(
req: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { slug: params.slug },
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
isActive: true,
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
if (!tenant.isActive) {
return NextResponse.json({
error: 'Mandant gesperrt',
reason: 'suspended',
tenant: { name: tenant.name, slug: tenant.slug, logoUrl: tenant.logoUrl },
}, { status: 403 })
}
return NextResponse.json({
tenant: {
name: tenant.name,
slug: tenant.slug,
logoUrl: tenant.logoUrl,
},
})
} catch (error) {
console.error('Error fetching tenant by slug:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const processSchema = z.object({
action: z.enum(['approve', 'reject']),
adminNote: z.string().max(500).optional(),
// Only for approve: override limits
maxUsers: z.number().min(1).max(1000).optional(),
maxProjects: z.number().min(1).max(10000).optional(),
})
// PATCH: Approve or reject an upgrade request (SERVER_ADMIN only)
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const validated = processSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json({ error: 'Ungültige Eingabe', details: validated.error.flatten() }, { status: 400 })
}
// Get the request
const upgradeReq = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { id: true, name: true, plan: true, contactEmail: true } },
requestedBy: { select: { name: true, email: true } },
},
})
if (!upgradeReq) {
return NextResponse.json({ error: 'Anfrage nicht gefunden' }, { status: 404 })
}
if (upgradeReq.status !== 'PENDING') {
return NextResponse.json({ error: 'Anfrage wurde bereits bearbeitet' }, { status: 400 })
}
const planLabels: Record<string, string> = {
FREE: 'Free', PRO: 'Pro',
}
const planLimits: Record<string, { maxUsers: number; maxProjects: number }> = {
FREE: { maxUsers: 5, maxProjects: 10 },
PRO: { maxUsers: 999, maxProjects: 9999 },
}
if (validated.data.action === 'approve') {
const limits = planLimits[upgradeReq.requestedPlan] || planLimits.PRO
// Update tenant plan
await (prisma as any).tenant.update({
where: { id: upgradeReq.tenantId },
data: {
plan: upgradeReq.requestedPlan,
subscriptionStatus: 'ACTIVE',
trialEndsAt: null,
maxUsers: validated.data.maxUsers || limits.maxUsers,
maxProjects: validated.data.maxProjects || limits.maxProjects,
},
})
// Update request status
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'APPROVED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send approval email to requester
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade bestätigt — ${planLabels[upgradeReq.requestedPlan]} Plan aktiviert`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #16a34a; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">✓ Upgrade bestätigt</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihr Upgrade für <strong>${upgradeReq.tenant.name}</strong> wurde bestätigt und ist ab sofort aktiv.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Neuer Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #16a34a;">${planLabels[upgradeReq.requestedPlan]}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Benutzer</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxUsers || limits.maxUsers}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Projekte</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxProjects || limits.maxProjects}</td>
</tr>
</table>
${validated.data.adminNote ? `<p style="margin: 16px 0 0; padding: 12px; background: #f0fdf4; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Hinweis:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Vielen Dank für Ihr Vertrauen. Bei Fragen stehen wir Ihnen gerne zur Verfügung.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send approval email:', e)
}
}
} else {
// Reject
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'REJECTED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send rejection email
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade-Anfrage — Rückmeldung`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #6b7280; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Upgrade-Anfrage</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihre Upgrade-Anfrage für <strong>${upgradeReq.tenant.name}</strong> auf den <strong>${planLabels[upgradeReq.requestedPlan]}</strong>-Plan konnte leider nicht bestätigt werden.
</p>
${validated.data.adminNote ? `<p style="margin: 0 0 16px; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Begründung:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 0; font-size: 13px; color: #6b7280;">
Bei Fragen kontaktieren Sie uns bitte unter app@lageplan.ch. Sie können jederzeit eine neue Anfrage stellen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send rejection email:', e)
}
}
}
// Return updated request
const updated = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
requestedBy: { select: { name: true, email: true } },
},
})
return NextResponse.json({ request: updated })
} catch (error) {
console.error('Error processing upgrade request:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

Some files were not shown because too many files have changed in this diff Show More