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 })
|
||||||
|
}
|
||||||
|
}
|
||||||