Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
Signaturen
|
||||
*.tar
|
||||
*.pdf
|
||||
*.py
|
||||
*.html
|
||||
SVG_renamed
|
||||
29
.env.example
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
97
deploy/portainer-stack.yml
Normal 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
@@ -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
@@ -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
|
||||
109
docker-compose.portainer.yml
Normal 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
@@ -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
@@ -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
@@ -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
92
package.json
Normal 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
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
226
prisma/migrate.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
BIN
public/0.5x/Element 1@0.5x.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/0.75x/Element 1@0.75x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/1.5x/Element 1@1.5x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/1x/Element 1.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/2x/Element 1@2x.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/3x/Element 1@3x.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/4x/Element 1@4x.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/Front_Pepe.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
16
public/SVG/Element 1.svg
Normal 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
|
After Width: | Height: | Size: 264 KiB |
13
public/logo.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
86
src/app/api/admin/categories/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/categories/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
103
src/app/api/admin/icons/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
18
src/app/api/admin/icons/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
91
src/app/api/admin/icons/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
156
src/app/api/admin/settings/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/tenants/[id]/logo/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
43
src/app/api/admin/tenants/[id]/logo/serve/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
178
src/app/api/admin/tenants/[id]/members/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
88
src/app/api/admin/tenants/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
src/app/api/admin/tenants/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
205
src/app/api/admin/trial-reminders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
89
src/app/api/admin/users/[id]/reset-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
135
src/app/api/admin/users/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
101
src/app/api/admin/users/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/change-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
73
src/app/api/auth/forgot-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
55
src/app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal 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 })
|
||||
}
|
||||
33
src/app/api/auth/me/route.ts
Normal 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 })
|
||||
}
|
||||
175
src/app/api/auth/register/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
43
src/app/api/auth/reset-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
49
src/app/api/auth/verify-email/route.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
75
src/app/api/contact/route.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
72
src/app/api/demo/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
37
src/app/api/dictionary/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
80
src/app/api/dictionary/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
56
src/app/api/donate/checkout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
27
src/app/api/donate/config/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
src/app/api/donate/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
74
src/app/api/hose-types/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
74
src/app/api/hose-types/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
src/app/api/icons/[id]/image/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
54
src/app/api/icons/[id]/toggle-visibility/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
66
src/app/api/icons/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
79
src/app/api/icons/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
173
src/app/api/projects/[id]/editing/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
85
src/app/api/projects/[id]/export/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
139
src/app/api/projects/[id]/features/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
57
src/app/api/projects/[id]/journal/check-items/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
101
src/app/api/projects/[id]/journal/entries/[entryId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
31
src/app/api/projects/[id]/journal/entries/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
30
src/app/api/projects/[id]/journal/pendenzen/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
35
src/app/api/projects/[id]/journal/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
168
src/app/api/projects/[id]/journal/send-report/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
121
src/app/api/projects/[id]/plan-image/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
40
src/app/api/projects/[id]/plan-image/serve/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
110
src/app/api/projects/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
94
src/app/api/projects/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
86
src/app/api/rapports/[token]/pdf/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/rapports/[token]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
71
src/app/api/rapports/[token]/send/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
140
src/app/api/rapports/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
17
src/app/api/settings/public/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
102
src/app/api/tenant/delete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
41
src/app/api/tenant/info/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/tenants/[tenantId]/suggestions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
44
src/app/api/tenants/by-slug/[slug]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
185
src/app/api/upgrade-requests/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||