Compare commits
21 Commits
v1.3.2
...
a53f77c97c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53f77c97c | ||
|
|
fdd928720a | ||
|
|
5adadd246e | ||
|
|
4b92df8fea | ||
|
|
9b96de0a21 | ||
|
|
902e730cd3 | ||
|
|
1f891ab057 | ||
|
|
165109fc65 | ||
|
|
b6fbe38f60 | ||
|
|
053ae3729a | ||
|
|
8207366362 | ||
|
|
29217e883b | ||
|
|
0f635033c2 | ||
|
|
805559efc3 | ||
|
|
5d46200905 | ||
|
|
5c353a0da8 | ||
|
|
ba6f095dc0 | ||
|
|
362a7e4666 | ||
|
|
63a57dcb7c | ||
|
|
62a5a56dea | ||
|
|
eb8566423f |
13
.env.docker
Normal file
13
.env.docker
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Dummy environment for Docker build stage
|
||||||
|
# These values are only needed so Next.js can compile during docker build
|
||||||
|
# Runtime values are injected via docker-compose environment
|
||||||
|
DATABASE_URL=postgresql://lageplan:lageplan_secret@db:5432/lageplan
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=build-time-secret-not-used-at-runtime
|
||||||
|
MINIO_ENDPOINT=minio
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin123
|
||||||
|
MINIO_BUCKET=lageplan-icons
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:9000
|
||||||
@@ -27,3 +27,8 @@ MINIO_PUBLIC_URL=http://localhost:9002
|
|||||||
# Web App
|
# Web App
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# --- CI/CD / Registry (nur für Portainer Deployment) ---
|
||||||
|
# Gitea Registry Login für Watchtower (automatische Image-Updates)
|
||||||
|
GITEA_REGISTRY_USER=adminpepe
|
||||||
|
GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort
|
||||||
|
|||||||
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.purepixel.ch
|
||||||
|
IMAGE: git.purepixel.ch/adminpepe/lageplan
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=,suffix=,format=short
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||||
|
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -49,7 +49,9 @@ prisma/migrations/*
|
|||||||
# Large binary files (do not commit)
|
# Large binary files (do not commit)
|
||||||
*.tar
|
*.tar
|
||||||
*.zip
|
*.zip
|
||||||
*.mp4
|
# Allow MP4s in public folder (used by the app)
|
||||||
|
!public/*.mp4
|
||||||
|
!public/**/*.mp4
|
||||||
lageplan-web.tar
|
lageplan-web.tar
|
||||||
|
|
||||||
# Reference materials (keep locally, not in git)
|
# Reference materials (keep locally, not in git)
|
||||||
@@ -58,4 +60,3 @@ Reglement_*/
|
|||||||
|
|
||||||
# Stack env (contains secrets)
|
# Stack env (contains secrets)
|
||||||
stack.env
|
stack.env
|
||||||
.env.docker
|
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -26,34 +26,41 @@ RUN npm run build
|
|||||||
# Stage 3: Runner
|
# Stage 3: Runner
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
RUN apk add --no-cache openssl
|
RUN apk add --no-cache openssl
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
RUN adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
WORKDIR /app
|
||||||
COPY --from=builder /app/.next/standalone ./
|
# Fast: only chown the /app directory itself, not recursively
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
RUN chown nextjs:nodejs /app
|
||||||
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 --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --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
|
USER nextjs
|
||||||
|
|
||||||
|
# Install only the unbundled runtime deps needed by the custom server.
|
||||||
|
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
|
||||||
|
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
|
||||||
|
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
|
||||||
|
# Ensure all public files (videos, images, etc.) are present in the runtime image
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/.env ./.env
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/prisma ./prisma
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/stripe ./node_modules/stripe
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/next ./node_modules/next
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react ./node_modules/react
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react-dom ./node_modules/react-dom
|
||||||
|
COPY --chown=nextjs:nodejs --from=builder /app/package.json ./package.json
|
||||||
|
COPY --chown=nextjs:nodejs server-custom.js ./server-custom.js
|
||||||
|
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|||||||
139
INCIDENT-REPORT-2026-05-20.md
Normal file
139
INCIDENT-REPORT-2026-05-20.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Vorfall-Bericht: Symbol-Verlust (20. Mai 2026)
|
||||||
|
|
||||||
|
## Was ist passiert?
|
||||||
|
|
||||||
|
Alle Symbole in der App zeigen seit heute Morgen einen 404-Fehler (broken image icon). Die Symbole selbst sind aus der Datenbank gelöscht. Bereits erstellte Zeichnungen sind noch vorhanden, aber die Symbole darin sind ebenfalls broken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chronologie der Ereignisse
|
||||||
|
|
||||||
|
### 1. Phase 1 Planung & Schema-Änderung (vor dem Vorfall)
|
||||||
|
|
||||||
|
Ich habe den Sprint A von **Phase 1 — Symbol-Architektur Redesign** begonnen. Dabei habe ich das Prisma-Schema erweitert:
|
||||||
|
|
||||||
|
- **Neue Tabellen**: `SymbolTemplate`, `TenantCategory`
|
||||||
|
- **Erweiterte Tabelle**: `TenantSymbol` um neue Spalten (`name`, `svgPath`, `isUploaded`, `categoryId`, etc.)
|
||||||
|
|
||||||
|
Diese Änderungen waren ausschließlich schema-seitig — es gab keine Löschoperationen.
|
||||||
|
|
||||||
|
### 2. Der eigentliche Bug (Cascade Delete)
|
||||||
|
|
||||||
|
Das Problem lag **NICHT** in den Schema-Änderungen, sondern in den bestehenden **Seed-Skripten**, die bei jedem Container-Start laufen:
|
||||||
|
|
||||||
|
**Dateien:**
|
||||||
|
- `prisma/seed.js`
|
||||||
|
- `prisma/seed-icons-only.js`
|
||||||
|
|
||||||
|
**Code (VOR dem Fix):**
|
||||||
|
```javascript
|
||||||
|
// Zeile ~73 in seed-icons-only.js
|
||||||
|
const deleted = await prisma.iconAsset.deleteMany({ where: { isSystem: true } })
|
||||||
|
console.log(`Deleted ${deleted.count} old system icons`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Code hat **bei jedem Container-Start** alle System-Icons gelöscht und neu erstellt.
|
||||||
|
|
||||||
|
### 3. Warum das alles zerstört hat
|
||||||
|
|
||||||
|
Im Prisma-Schema gibt es folgende Relation:
|
||||||
|
```prisma
|
||||||
|
model TenantSymbol {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
iconId String
|
||||||
|
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die `onDelete: Cascade`-Regel bedeutet:** Wenn ein `IconAsset` gelöscht wird, werden automatisch alle `TenantSymbol`-Einträge, die darauf verweisen, ebenfalls gelöscht.
|
||||||
|
|
||||||
|
**Ablauf bei jedem Container-Start:**
|
||||||
|
1. `seed-icons-only.js` läuft (weil Icon-Zahl < 100)
|
||||||
|
2. `deleteMany({ isSystem: true })` löscht alle System-Icons
|
||||||
|
3. `onDelete: Cascade` löscht alle `TenantSymbol`-Einträge
|
||||||
|
4. Neue Icons werden mit **neuen UUIDs** erstellt
|
||||||
|
5. Bestehende Zeichnungen verweisen noch auf die **alten gelöschten UUIDs** → 404
|
||||||
|
|
||||||
|
### 4. Warum ist das jetzt aufgefallen?
|
||||||
|
|
||||||
|
Der Container wurde neu gestartet (neues Deployment), und das Seed-Skript ist gelaufen. Dabei wurden alle Symbole gelöscht. Dieser Bug existierte bereits vor meinen Änderungen in den Seed-Skripten, ist aber jetzt zum ersten Mal aufgetreten, weil:
|
||||||
|
- Vorher hat das Skript nur selten gegriffen (Icons waren schon genug da)
|
||||||
|
- Jetzt wurde der Container frisch gestartet, und die Bedingung `ICON_COUNT < 100` war true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auswirkungen
|
||||||
|
|
||||||
|
### Was ist weg?
|
||||||
|
- ❌ Alle `IconAsset`-Einträge (System-Symbole)
|
||||||
|
- ❌ Alle `TenantSymbol`-Einträge (mandantenspezifische Symbol-Aktivierungen)
|
||||||
|
- ❌ Alle Icon-Category-Zuordnungen
|
||||||
|
|
||||||
|
### Was ist noch da?
|
||||||
|
- ✅ Alle Projekte (Zeichnungen)
|
||||||
|
- ✅ Alle Features (Linien, Symbole, Texte in den Zeichnungen)
|
||||||
|
- ✅ Alle Journal-Einträge
|
||||||
|
- ✅ Alle Benutzer, Tenants, etc.
|
||||||
|
|
||||||
|
### Was ist mit den Zeichnungen?
|
||||||
|
Die Zeichnungen sind intakt, aber die Symbole darin zeigen 404. Jedes Symbol in einer Zeichnung speichert in den Properties:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iconId": "alte-gelöschte-uuid",
|
||||||
|
"imageUrl": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Da die `iconId` auf einen gelöschten Eintrag verweist, kann das Bild nicht mehr geladen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix (bereits gepusht)
|
||||||
|
|
||||||
|
**Commit:** `5adadd2`
|
||||||
|
|
||||||
|
**Änderungen:**
|
||||||
|
1. `deleteMany` aus `seed.js` und `seed-icons-only.js` entfernt
|
||||||
|
2. Stattdessen **Upsert-Logik**: Update by `fileKey`, create only if missing
|
||||||
|
3. Dadurch bleiben bestehende IDs erhalten, und es gibt keine Cascade-Löschungen mehr
|
||||||
|
4. `prisma/migrate.js` um neue Phase-1-Tabellen erweitert
|
||||||
|
|
||||||
|
**Das verhindert zukünftige Löschungen, stellt aber bereits gelöschte Daten NICHT wieder her.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wiederherstellung
|
||||||
|
|
||||||
|
Um die Symbole und Zeichnungen wiederherzustellen, gibt es zwei Wege:
|
||||||
|
|
||||||
|
### Option A: Datenbank-Backup (empfohlen)
|
||||||
|
Falls ein `pg_dump` oder Snapshot vor dem 20. Mai existiert, können die Tabellen `icon_assets`, `icon_categories` und `tenant_symbols` daraus restored werden.
|
||||||
|
|
||||||
|
### Option B: Recovery-Skript
|
||||||
|
Ein Skript, das:
|
||||||
|
1. Alle System-Icons aus `public/signaturen/` neu in die DB einspielt
|
||||||
|
2. Für jeden Tenant die Standard-Symbole neu aktiviert
|
||||||
|
3. Die Zeichnungen scannt und die `iconId`-Referenzen auf die neuen IDs updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
1. **Seed-Skripte dürfen niemals `deleteMany` auf verknüpfte Daten ausführen**
|
||||||
|
2. `onDelete: Cascade` ist gefährlich bei Daten, die von Benutzern referenziert werden
|
||||||
|
3. Container-Start-Skripte müssen idempotent sein (mehrfaches Ausführen = gleiches Ergebnis)
|
||||||
|
4. Vor Deployment-Änderungen sollte ein DB-Backup gemacht werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. [x] Recovery-Skript erstellt: `prisma/recover-symbols.js` (Sidebar/Admin)
|
||||||
|
2. [x] Recovery-Skript erstellt: `prisma/recover-features.js` (Zeichnungen)
|
||||||
|
3. [x] Renderer resilient gemacht: broken Symbole zeigen ⚠️ statt leeres Nichts
|
||||||
|
4. [x] `onDelete: Cascade` → `onDelete: SetNull` auf TenantSymbol.icon geändert
|
||||||
|
5. [x] Seed-Skripte auf Upsert umgestellt (Commit 5adadd2)
|
||||||
|
6. [ ] `recover-symbols.js` auf Server ausführen
|
||||||
|
7. [ ] `recover-features.js --dry-run` auf Server ausführen zur Analyse
|
||||||
|
8. [ ] Falls broken Features: User informieren (Symbole manuell neu platzieren)
|
||||||
186
deploy/README.md
186
deploy/README.md
@@ -1,102 +1,168 @@
|
|||||||
# Lageplan — Portainer Deployment
|
# Lageplan — CI/CD & Portainer Deployment
|
||||||
|
|
||||||
## Architektur
|
## Übersicht
|
||||||
|
|
||||||
```
|
```
|
||||||
Browser → :3000 (Web App) → intern: db:5432, minio:9000
|
┌─────────────┐ Push ┌─────────────────┐ Build + Push ┌─────────────────┐
|
||||||
|
│ Dein PC │ ────────────► │ Gitea (git) │ ──────────────────► │ Gitea Registry │
|
||||||
|
│ (VS Code) │ │ git.purepixel │ │ (Docker Image) │
|
||||||
|
└─────────────┘ └─────────────────┘ └────────┬────────┘
|
||||||
|
│
|
||||||
|
│ Pull
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Portainer │
|
||||||
|
│ (Stack Deploy) │
|
||||||
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
1. **Push auf `main`** → Gitea Actions baut Docker Image
|
||||||
|
2. **Image wird gepusht** → Gitea Container Registry (`git.purepixel.ch`)
|
||||||
|
3. **Watchtower (in Portainer)** → prüft alle 60s auf neue Images und startet Container neu
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dateien
|
## Voraussetzungen
|
||||||
|
|
||||||
| Datei | Beschreibung |
|
- Gitea läuft mit Container Registry aktiviert
|
||||||
|-------|-------------|
|
- Gitea Actions Runner ist registriert (`deploy/docker-compose.runner.yml`)
|
||||||
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) |
|
- Portainer Stack ist deployed mit korrekten Environment-Variablen
|
||||||
| `portainer-stack.yml` | Stack YAML für Portainer |
|
|
||||||
| `.env.example` | Environment Variables Vorlage |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 1: Image auf Server laden
|
## Schritt 1: Gitea Container Registry aktivieren
|
||||||
|
|
||||||
```bash
|
In Gitea:
|
||||||
# Kopieren
|
1. **Admin-Konsole** → **Konfiguration** → **Pakete**
|
||||||
scp lageplan-web-v1.0.0.tar user@server:/tmp/
|
2. **Container Registry** auf `Aktiviert` setzen
|
||||||
|
3. Speichern
|
||||||
|
|
||||||
# Auf dem Server laden
|
Oder direkt in der `app.ini`:
|
||||||
docker load -i /tmp/lageplan-web-v1.0.0.tar
|
```ini
|
||||||
|
[packages]
|
||||||
|
ENABLED = true
|
||||||
|
|
||||||
# Prüfen
|
[package.container_registry]
|
||||||
docker images | grep lageplan
|
ENABLED = true
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 2: Stack in Portainer erstellen
|
## Schritt 2: Gitea Actions Runner registrieren
|
||||||
|
|
||||||
|
1. In Gitea: **Admin** → **Actions** → **Runners** → **Neuen Runner erstellen**
|
||||||
|
2. Token kopieren
|
||||||
|
3. In Portainer: Stack `gitea-runner` deployen mit [`deploy/docker-compose.runner.yml`](docker-compose.runner.yml)
|
||||||
|
4. Environment Variable `RUNNER_TOKEN` = das kopierte Token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3: Gitea Access Token erstellen
|
||||||
|
|
||||||
|
Das CI/CD Workflow braucht einen Token um Images in die Registry zu pushen:
|
||||||
|
|
||||||
|
1. Gitea → **Einstellungen** → **Anwendungen** → **Token erstellen**
|
||||||
|
2. Name: `registry-push`
|
||||||
|
3. Berechtigungen: `package:write` (mindestens)
|
||||||
|
4. Token kopieren und als **Repository Secret** hinterlegen:
|
||||||
|
- Repo → **Einstellungen** → **Secrets** → **Neues Secret**
|
||||||
|
- Name: `GITEA_TOKEN`
|
||||||
|
- Wert: das kopierte Token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4: Portainer Stack deployen
|
||||||
|
|
||||||
1. **Portainer** → `Stacks` → `+ Add stack`
|
1. **Portainer** → `Stacks` → `+ Add stack`
|
||||||
2. **Name**: `lageplan`
|
2. **Name**: `lageplan`
|
||||||
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen
|
3. **Build method**: `Repository`
|
||||||
4. **Environment variables** setzen:
|
4. **Git-URL**: `https://git.purepixel.ch/adminpepe/Lageplan.git`
|
||||||
|
5. **Compose path**: `docker-compose.portainer.yml`
|
||||||
|
6. **GitOps updates**: ✅ Aktivieren
|
||||||
|
7. **Mechanism**: `Webhook`
|
||||||
|
8. **Webhook URL kopieren** (für später)
|
||||||
|
|
||||||
| Variable | Wert |
|
### Environment Variables setzen:
|
||||||
|----------|------|
|
|
||||||
| `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`
|
| Variable | Wert | Beschreibung |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `POSTGRES_USER` | `lageplan` | DB User |
|
||||||
|
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* | DB Passwort |
|
||||||
|
| `POSTGRES_DB` | `lageplan` | DB Name |
|
||||||
|
| `NEXTAUTH_URL` | `https://lageplan.ch` | Deine Domain |
|
||||||
|
| `NEXTAUTH_SECRET` | *(openssl rand -base64 32)* | Auth Secret |
|
||||||
|
| `MINIO_ROOT_USER` | `minioadmin` | MinIO User |
|
||||||
|
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* | MinIO Passwort |
|
||||||
|
| `MINIO_BUCKET` | `lageplan-icons` | Bucket Name |
|
||||||
|
| `MINIO_PUBLIC_URL` | `https://s3.deinedomain.ch` | MinIO externe URL |
|
||||||
|
| `GITEA_REGISTRY_USER` | `adminpepe` | Gitea User für Watchtower |
|
||||||
|
| `GITEA_REGISTRY_PASS` | *(Token oder Passwort)* | Gitea Passwort/Token |
|
||||||
|
|
||||||
5. **Deploy the stack**
|
9. **Deploy the stack**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 3: Datenbank initialisieren (einmalig)
|
## Schritt 5: Webhook in Gitea eintragen
|
||||||
|
|
||||||
In Portainer: Container `web` → Console → `/bin/sh`:
|
Damit Portainer bei jedem Push automatisch neu deployed:
|
||||||
|
|
||||||
```bash
|
1. Gitea Repo → **Einstellungen** → **Webhooks** → **Neuer Webhook** → `Gitea`
|
||||||
npx prisma db push
|
2. **Ziel-URL**: Die kopierte Portainer Webhook URL
|
||||||
npx prisma db seed
|
3. **HTTP-Methode**: `POST`
|
||||||
```
|
4. **Trigger**: Nur `Push events` (oder auch `Branch filter: main`)
|
||||||
|
5. **Webhook aktivieren** → Hinzufügen
|
||||||
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
|
## Schritt 6: Erstes Deployment testen
|
||||||
|
|
||||||
- **Web App**: `http://SERVER_IP:3000`
|
1. Lokal einen Push auf `main` machen:
|
||||||
- **Login**: `admin@lageplan.local` / `admin123`
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Test CI/CD"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
2. In Gitea: **Actions** Tab → Build-Job sollte laufen
|
||||||
|
3. Wenn grün → Image wurde in Registry gepusht
|
||||||
|
4. Watchtower (in Portainer) holt neues Image innerhalb von 60s
|
||||||
|
5. App ist unter `NEXTAUTH_URL` erreichbar
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Update
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Gitea Actions startet nicht
|
||||||
|
- Prüfen ob Runner registriert ist: Gitea → Admin → Actions → Runners
|
||||||
|
- Runner muss `Idle` oder `Active` zeigen
|
||||||
|
|
||||||
|
### Image Push schlägt fehl (401 Unauthorized)
|
||||||
|
- `GITEA_TOKEN` Secret im Repo korrekt hinterlegt?
|
||||||
|
- Token hat Berechtigung `package:write`?
|
||||||
|
- Registry in Gitea aktiviert?
|
||||||
|
|
||||||
|
### Watchtower zieht kein neues Image
|
||||||
|
- `GITEA_REGISTRY_USER` und `GITEA_REGISTRY_PASS` in Portainer gesetzt?
|
||||||
|
- Image-Name in `docker-compose.portainer.yml` korrekt?
|
||||||
|
- Watchtower Logs prüfen: Portainer → Container `watchtower` → Logs
|
||||||
|
|
||||||
|
### App startet nicht / DB-Fehler
|
||||||
|
- Environment Variables in Portainer korrekt?
|
||||||
|
- `DATABASE_URL` wird automatisch gebaut, nur `POSTGRES_*` muss gesetzt werden
|
||||||
|
- Bei erstem Start: Prisma Migrations/Seed im Web-Container ausführen:
|
||||||
|
```bash
|
||||||
|
docker exec -it lageplan-web-1 npx prisma db push
|
||||||
|
docker exec -it lageplan-web-1 npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelles Update (falls nötig)
|
||||||
|
|
||||||
|
Wenn Watchtower mal nicht greift:
|
||||||
```bash
|
```bash
|
||||||
# Lokal: neues Image bauen + exportieren
|
# Auf dem Portainer-Host
|
||||||
docker compose build web
|
docker pull git.purepixel.ch/adminpepe/lageplan:latest
|
||||||
docker tag lageplan-web:latest lageplan-web:v1.1.0
|
docker compose -f docker-compose.portainer.yml up -d web
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
23
deploy/docker-compose.runner.yml
Normal file
23
deploy/docker-compose.runner.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
##############################################
|
||||||
|
# Gitea Actions Runner — Portainer Stack
|
||||||
|
#
|
||||||
|
# In Portainer deployen:
|
||||||
|
# 1. Stacks → Add Stack → "gitea-runner"
|
||||||
|
# 2. Diese YAML einfügen
|
||||||
|
# 3. Deploy
|
||||||
|
##############################################
|
||||||
|
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: https://git.purepixel.ch
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
|
||||||
|
GITEA_RUNNER_NAME: lageplan-runner
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- runner_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
runner_data:
|
||||||
@@ -7,3 +7,8 @@ MINIO_BUCKET=lageplan-icons
|
|||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
NEXTAUTH_URL=http://SERVER_IP:3000
|
NEXTAUTH_URL=http://SERVER_IP:3000
|
||||||
NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN
|
NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN
|
||||||
|
MINIO_PUBLIC_URL=http://SERVER_IP:9000
|
||||||
|
|
||||||
|
# Gitea Registry Auth für Watchtower (automatische Image-Updates)
|
||||||
|
GITEA_REGISTRY_USER=adminpepe
|
||||||
|
GITEA_REGISTRY_PASS=HIER_GITEA_TOKEN_ODER_PASSWORT_SETZEN
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
##############################################
|
##############################################
|
||||||
# Gitea — Lightweight Git Server
|
# Gitea — Lightweight Git Server + Container Registry
|
||||||
#
|
#
|
||||||
# Verwendung in Portainer:
|
# Verwendung in Portainer:
|
||||||
# 1. Stacks → Add Stack → "Gitea"
|
# 1. Stacks → Add Stack → "Gitea"
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
# 3. Repository "lageplan" erstellen
|
# 3. Repository "lageplan" erstellen
|
||||||
# 4. Vom PC aus: git init → git remote add origin → git push
|
# 4. Vom PC aus: git init → git remote add origin → git push
|
||||||
#
|
#
|
||||||
|
# Container Registry aktivieren:
|
||||||
|
# 1. Gitea Admin → Konfiguration → Pakete → Container Registry aktivieren
|
||||||
|
# 2. Oder app.ini: [packages] ENABLED = true
|
||||||
|
#
|
||||||
# Daten werden in gitea_data persistiert.
|
# Daten werden in gitea_data persistiert.
|
||||||
##############################################
|
##############################################
|
||||||
|
|
||||||
@@ -27,6 +31,9 @@ services:
|
|||||||
- GITEA__server__ROOT_URL=https://git.purepixel.ch
|
- GITEA__server__ROOT_URL=https://git.purepixel.ch
|
||||||
- GITEA__server__HTTP_PORT=3000
|
- GITEA__server__HTTP_PORT=3000
|
||||||
- GITEA__server__LFS_START_SERVER=true
|
- GITEA__server__LFS_START_SERVER=true
|
||||||
|
# Container Registry aktivieren
|
||||||
|
- GITEA__packages__ENABLED=true
|
||||||
|
- GITEA__package__container_registry__ENABLED=true
|
||||||
volumes:
|
volumes:
|
||||||
- gitea_data:/data
|
- gitea_data:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
@@ -43,4 +50,3 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
lageplan_lageplan-net:
|
lageplan_lageplan-net:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
##############################################
|
##############################################
|
||||||
# Lageplan — Portainer Stack Configuration
|
# Lageplan — Portainer Stack (Watchtower Auto-Update)
|
||||||
#
|
#
|
||||||
# Verwendung in Portainer:
|
# Setup in Portainer:
|
||||||
# 1. Stacks → Add Stack
|
# 1. Stacks → Add Stack → "Repository"
|
||||||
# 2. "Upload" oder diesen Inhalt einfügen
|
# 2. Git-URL: https://git.purepixel.ch/adminpepe/Lageplan.git
|
||||||
# 3. Environment-Variablen setzen (siehe unten)
|
# 3. Compose-Pfad: docker-compose.portainer.yml
|
||||||
# 4. Deploy
|
# 4. "GitOps updates" aktivieren
|
||||||
|
# 5. Environment-Variablen setzen (siehe unten)
|
||||||
|
# 6. Deploy
|
||||||
|
#
|
||||||
|
# Danach: Push auf main → Gitea Actions baut Image →
|
||||||
|
# Watchtower erkennt neues Image und startet Container neu
|
||||||
#
|
#
|
||||||
# Benötigte Environment-Variablen:
|
# Benötigte Environment-Variablen:
|
||||||
# POSTGRES_USER (default: lageplan)
|
# POSTGRES_USER (default: lageplan)
|
||||||
# POSTGRES_PASSWORD (ÄNDERN!)
|
# POSTGRES_PASSWORD (ÄNDERN!)
|
||||||
# POSTGRES_DB (default: lageplan)
|
# POSTGRES_DB (default: lageplan)
|
||||||
# NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32)
|
# NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32)
|
||||||
# NEXTAUTH_URL (z.B. https://lageplan.example.com)
|
# NEXTAUTH_URL (z.B. https://lageplan.ch)
|
||||||
# MINIO_ROOT_USER (default: minioadmin)
|
# MINIO_ROOT_USER (default: minioadmin)
|
||||||
# MINIO_ROOT_PASSWORD (ÄNDERN!)
|
# MINIO_ROOT_PASSWORD (ÄNDERN!)
|
||||||
# MINIO_PUBLIC_URL (z.B. https://s3.example.com)
|
# MINIO_PUBLIC_URL (z.B. https://s3.example.com)
|
||||||
|
# GITEA_REGISTRY_USER (für Watchtower Registry-Auth)
|
||||||
|
# GITEA_REGISTRY_PASS (für Watchtower Registry-Auth)
|
||||||
##############################################
|
##############################################
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -76,8 +83,9 @@ services:
|
|||||||
- lageplan
|
- lageplan
|
||||||
|
|
||||||
# ─── Lageplan Web App ──────────────────────
|
# ─── Lageplan Web App ──────────────────────
|
||||||
|
# Image kommt aus Gitea Container Registry (gebaut via Gitea Actions)
|
||||||
web:
|
web:
|
||||||
image: 192.168.1.183:3100/adminpepe/lageplan:latest
|
image: git.purepixel.ch/adminpepe/lageplan:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
|
||||||
@@ -99,6 +107,25 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- lageplan
|
- lageplan
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
|
# ─── Watchtower (Auto-Restart bei neuen Images) ─
|
||||||
|
# Überwacht nur Container mit Label com.centurylinklabs.watchtower.enable=true
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
WATCHTOWER_POLL_INTERVAL: 60
|
||||||
|
WATCHTOWER_CLEANUP: "true"
|
||||||
|
WATCHTOWER_LABEL_ENABLE: "true"
|
||||||
|
# Gitea Registry Auth
|
||||||
|
REPO_USER: ${GITEA_REGISTRY_USER}
|
||||||
|
REPO_PASS: ${GITEA_REGISTRY_PASS}
|
||||||
|
networks:
|
||||||
|
- lageplan
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
269
docs/roadmap-feedback-fabian.md
Normal file
269
docs/roadmap-feedback-fabian.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Roadmap — Feedback Fabian (Mai 2026)
|
||||||
|
|
||||||
|
> **Quelle**: User-Feedback von Fabian, Führungsoffizier / Übungsleiter Feuerwehr.
|
||||||
|
> **Erhalten**: 20.05.2026
|
||||||
|
> **Status**: Geplant, noch nicht umgesetzt.
|
||||||
|
|
||||||
|
Dieses Dokument ist die strukturierte Roadmap aus Fabians Feedback. Jede KI / jeder Entwickler der an Lageplan arbeitet, soll diese Datei vor Beginn lesen, um Kontext und Priorisierung zu kennen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Originalfeedback (Zusammenfassung)
|
||||||
|
|
||||||
|
Fabian sucht eine Lösung um **Einsätze und Übungen einfach zu skizzieren und zu krokieren**. Lobt die App, gibt aber konkrete Verbesserungsvorschläge aus Anwendersicht:
|
||||||
|
|
||||||
|
1. **Einsatz vs. Übung unterscheiden** — bei Übungen kein Journal nötig, dafür Übungsziele/Auswertung
|
||||||
|
2. **Symbol-Bibliothek erweitern** — Warteraum, Rettungsachse, taktische Zeichen
|
||||||
|
3. **Symbol-Schönheitsfehler** — Treppe/Eingang mit Umriss, Absperrung
|
||||||
|
4. **Stockwerke/Geschosse** — eigene Werte eintragen
|
||||||
|
5. **Symbol-Kategorien aufräumen** — Motorspritze unter Organisation statt Geräte; Hydrant/Leitern uneinheitlich
|
||||||
|
6. **Linien mit Typ** — nach Zeichnen wählen: Rettungsachse (gestrichelt + R), Leitung (blau), Schlauch, etc.
|
||||||
|
7. **Multi-User Live** — eine Person zeichnet Karte, andere schreibt Journal gleichzeitig
|
||||||
|
8. **Rapport-/Lageansicht** — separate Sicht mit Pendenzen, aktueller Stand, wichtige Punkte für Führungsunterstützung (FU), darstellbar auf grossem Bildschirm; nicht alle Journaleinträge sichtbar für alle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasen-Plan
|
||||||
|
|
||||||
|
### Phase 1 — Symbol-Architektur Redesign (3-4 Wochen) ⭐⭐
|
||||||
|
|
||||||
|
**Strategischer Umbau** statt nur Kategorien aufräumen. Begründung:
|
||||||
|
- Fabians Feedback zeigt, dass die aktuelle Kategorisierung uneinheitlich ist
|
||||||
|
- Symbol-Bibliothek ist zu starr — andere Anwender (THW, Sanität, ausländische Wehren) brauchen andere Symbole
|
||||||
|
- Mandantenfähigkeit ist zentrales Versprechen → muss auch für Symbole konsequent durchgezogen werden
|
||||||
|
|
||||||
|
#### 1.1 Neue Symbol-Architektur: Template-Import + 100% mandantenspezifisch
|
||||||
|
|
||||||
|
**Konzept**: Mandant startet mit leerer Bibliothek, importiert beim Onboarding (oder jederzeit) **kuratierte Vorlagen-Pakete** (Feuerwehr CH, THW, Sanität…). Importierte Symbole werden vollständig eigene Mandanten-Daten — umbenennbar, löschbar, kategorisierbar, ergänzbar.
|
||||||
|
|
||||||
|
**Datenmodell**:
|
||||||
|
```
|
||||||
|
TenantCategory (per-tenant, frei definierbar)
|
||||||
|
- id, tenantId, name, sortOrder, icon
|
||||||
|
|
||||||
|
TenantSymbol (per-tenant, ehemals "Meine Symbole")
|
||||||
|
- id, tenantId, categoryId → TenantCategory
|
||||||
|
- name, svgPath, sortOrder, customName
|
||||||
|
|
||||||
|
SymbolTemplate (global, read-only)
|
||||||
|
- id, packageId ("feuerwehr-ch", "thw", "sanitaet")
|
||||||
|
- categoryName, name, svgPath, tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Schema-Migration: `TenantCategory` neu, `TenantSymbol.categoryId` ergänzen, Standard-`Icon` zu `SymbolTemplate` umwandeln
|
||||||
|
- [ ] **Migration der bestehenden Tenants**: Auto-Import des `feuerwehr-ch` Pakets beim ersten Login, sodass nichts kaputt geht
|
||||||
|
- [ ] API: CRUD für `TenantCategory`, erweitert für `TenantSymbol`
|
||||||
|
- [ ] API: `GET /api/templates` — listet verfügbare Pakete
|
||||||
|
- [ ] API: `POST /api/templates/import` — importiert ausgewählte Symbole als TenantSymbols
|
||||||
|
- **Files**: `prisma/schema.prisma`, `src/app/api/tenant/categories/`, `src/app/api/tenant/symbols/`, `src/app/api/templates/`
|
||||||
|
|
||||||
|
#### 1.2 UX: Symbol-Manager im Admin
|
||||||
|
|
||||||
|
**Sidebar / Symbol-Verwaltung**:
|
||||||
|
```
|
||||||
|
┌─ Meine Symbole ─────────────┐
|
||||||
|
│ ┌ Fahrzeuge ─────────┐ │
|
||||||
|
│ │ 🚒 TLF │ │
|
||||||
|
│ │ 🚒 RW │ │
|
||||||
|
│ │ + Symbol │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ ┌ Wasser ────────────┐ │
|
||||||
|
│ │ 🟦 Hydrant │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ [+ Kategorie] │
|
||||||
|
│ [📦 Vorlagen importieren] │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Admin-Tab: Kategorien anlegen/umbenennen/sortieren (Drag & Drop)
|
||||||
|
- [ ] Symbole pro Kategorie verwalten (Drag & Drop zwischen Kategorien)
|
||||||
|
- [ ] Eigene SVG-Uploads direkt einer Kategorie zuordnen
|
||||||
|
- [ ] Import-Dialog: Pakete-Auswahl mit Vorschau, granulare Symbol-Auswahl
|
||||||
|
- [ ] Mehrfach-Import desselben Symbols erlaubt (z.B. "TLF rot" + "TLF blau" auf gleicher SVG-Basis)
|
||||||
|
- **Files**: `src/components/admin/icons-tab.tsx` (umbauen), neuer `import-templates-dialog.tsx`
|
||||||
|
|
||||||
|
#### 1.3 Vorlagen-Pakete kuratieren
|
||||||
|
|
||||||
|
Aus dem aktuellen Symbol-Bestand werden die ersten Pakete:
|
||||||
|
|
||||||
|
- [ ] 📦 **Feuerwehr Schweiz** — taktische Zeichen CH (aktueller Bestand, sauber kategorisiert)
|
||||||
|
- [ ] 📦 **Sanität / Rettungsdienst** — falls Symbole vorhanden, sonst Phase 2
|
||||||
|
- [ ] 📦 **Polizei / Verkehr** — als Stub für später
|
||||||
|
- [ ] Innerhalb der Pakete: **fehlende taktische Zeichen ergänzen**:
|
||||||
|
- Warteraum
|
||||||
|
- Rettungsachse
|
||||||
|
- Sammelplatz
|
||||||
|
- Verletztennest / Eingang Verletzter
|
||||||
|
- Einsatzleitung (EL) / Kommandoposten
|
||||||
|
- Bereitstellungsraum
|
||||||
|
- [ ] **Symbol-Schönheitsfehler im Paket** beheben:
|
||||||
|
- Treppe — Umriss entfernen
|
||||||
|
- Eingang — Umriss entfernen
|
||||||
|
- Absperrung — Seitenlinien aufräumen
|
||||||
|
- **Files**: `prisma/seed/symbol-templates.ts`, `public/icons/`
|
||||||
|
|
||||||
|
#### 1.4 Stockwerke/Geschosse editierbar
|
||||||
|
|
||||||
|
- [ ] Symbol-Property: `instanceLabel` (Freitext, pro Symbol-Instanz auf der Karte)
|
||||||
|
- [ ] Bei Gebäude-Symbolen: optionales Textfeld für Geschosszahl (z.B. "EG+3")
|
||||||
|
- [ ] Anzeige als Badge auf dem Symbol
|
||||||
|
- [ ] Editierbar via Doppelklick oder Sidebar
|
||||||
|
- **Files**: `src/types/`, `map-view.tsx` Symbol-Renderer, ggf. DrawFeature.properties erweitern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1.5 — Quick Polish (parallel oder danach, 1 Woche) 🔥
|
||||||
|
|
||||||
|
Kleinkram der nicht in den Architektur-Umbau passt aber wichtig ist.
|
||||||
|
|
||||||
|
- [ ] Default-Linientyp / -farbe pro Mandant konfigurierbar
|
||||||
|
- [ ] Symbol-Suche im Sidebar (Volltext über Name, Tags, Kategorie)
|
||||||
|
- [ ] Häufig-benutzte Symbole oben anzeigen (Recent / Favoriten)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Übungsmodus (2-3 Wochen) ⭐
|
||||||
|
|
||||||
|
Neuer Projekt-Typ, eigene Logik.
|
||||||
|
|
||||||
|
#### 2.1 Projekt-Typ Auswahl
|
||||||
|
- [ ] Bei "Neues Projekt": Auswahl `Einsatz | Übung`
|
||||||
|
- [ ] DB-Schema: `Project.type: 'einsatz' | 'uebung'`
|
||||||
|
- [ ] UI-Anpassung in `app/page.tsx`
|
||||||
|
|
||||||
|
#### 2.2 Übungs-Metadaten
|
||||||
|
- [ ] Felder: Übungstitel, Datum, **Übungsziele (Liste)**
|
||||||
|
- [ ] Übungsleiter, Teilnehmer (optional)
|
||||||
|
- [ ] Eigene Sidebar-Sektion für Übungsdaten
|
||||||
|
|
||||||
|
#### 2.3 Journal ausblenden bei Übung
|
||||||
|
- [ ] Bei `type === 'uebung'`: Journal-Tab → "Übungsauswertung"
|
||||||
|
- [ ] Karte bleibt voll funktionsfähig
|
||||||
|
- [ ] Krokierung kann **im Voraus** erstellt und mit Übungsleitern geteilt werden (bestehender Share-Link)
|
||||||
|
|
||||||
|
#### 2.4 Übungsauswertung
|
||||||
|
- [ ] Checkliste **Übungsziele** mit Status: `erreicht | teilweise | nicht erreicht`
|
||||||
|
- [ ] Notizen / Erkenntnisse pro Ziel
|
||||||
|
- [ ] Gesamtbewertung / Erkenntnisse
|
||||||
|
- [ ] **PDF-Export** für Debriefing (analog Rapport)
|
||||||
|
- **Files**: neue Komponente `uebungsauswertung-tab.tsx`, neuer PDF-Renderer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Linien mit Typ (1 Woche) ⭐
|
||||||
|
|
||||||
|
Smart Lines mit vordefinierten Stilen.
|
||||||
|
|
||||||
|
#### 3.1 Linientypen definieren
|
||||||
|
- [ ] **Schlauch** (default, aktuelle Linie)
|
||||||
|
- [ ] **Rettungsachse** — gestrichelt, Label "R"
|
||||||
|
- [ ] **Leitung** — blau
|
||||||
|
- [ ] **Absperrung** — rot gestrichelt
|
||||||
|
- [ ] **Frei** — Standard ohne Vorlage
|
||||||
|
|
||||||
|
#### 3.2 UX
|
||||||
|
- [ ] Nach Zeichnen einer Linie: kleines Popup "Was ist das?"
|
||||||
|
- [ ] Auswahl per Klick → Linie wird automatisch gestylt
|
||||||
|
- [ ] In Sidebar pro Linie nachträglich änderbar (Dropdown)
|
||||||
|
- [ ] Tastatur-Shortcut für Typ-Wechsel?
|
||||||
|
|
||||||
|
#### 3.3 Daten-Modell
|
||||||
|
- [ ] `DrawFeature` erweitern: `lineCategory: 'hose' | 'rescue' | 'pipe' | 'barrier' | 'free'`
|
||||||
|
- [ ] Default-Stile als Konstante (Farbe, Strich, Label)
|
||||||
|
- **Files**: `src/types/`, `tool-store.ts`, `map-view.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — Rapport-/Lageansicht (Führungsunterstützung) (2-3 Wochen) ⭐
|
||||||
|
|
||||||
|
Eigenes Modul, hoher Mehrwert für FU.
|
||||||
|
|
||||||
|
#### 4.1 Sichtbarkeits-Stufen pro Journaleintrag
|
||||||
|
- [ ] DB: `JournalEntry.visibility: 'intern' | 'rapport' | 'public'`
|
||||||
|
- [ ] UI: Dropdown beim Erstellen / Editieren
|
||||||
|
- [ ] Migration: bestehende Einträge → `intern` (sicher)
|
||||||
|
|
||||||
|
#### 4.2 Rapport-Modus (Display)
|
||||||
|
- [ ] Neuer Tab/Modus: **Rapport-Ansicht**
|
||||||
|
- [ ] Sektionen:
|
||||||
|
- **Aktuelle Lage** (Freitext, prominentes Feld)
|
||||||
|
- **Pendenzen** (Liste mit Status: offen/erledigt)
|
||||||
|
- **Wichtige Punkte / Befehle**
|
||||||
|
- **Mittel / Personal** (aktueller Stand)
|
||||||
|
- [ ] Grosse Schrift, kontrastreich (Beamer-tauglich)
|
||||||
|
- [ ] Auto-Refresh via Socket.IO
|
||||||
|
|
||||||
|
#### 4.3 Display-Sharing
|
||||||
|
- [ ] Read-only Token-Link (wie bestehende Rapport-URL)
|
||||||
|
- [ ] Vollbild-Modus
|
||||||
|
- [ ] Optional: Auswahl welche Sektionen sichtbar
|
||||||
|
|
||||||
|
#### 4.4 Berechtigungen
|
||||||
|
- [ ] Rolle "Rapport-Viewer" — sieht nur Rapport, nicht Karte / Journal
|
||||||
|
- [ ] Berechtigung pro Projekt vergebbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Multi-User Live-Editing polish (optional, später)
|
||||||
|
|
||||||
|
Realtime-Sync läuft schon via Socket.IO — hier nur Verbesserungen.
|
||||||
|
|
||||||
|
- [ ] **Live-Cursor** anderer User auf Karte (mit Name)
|
||||||
|
- [ ] **Presence-Anzeige** ("Fabian schreibt im Journal", "Pepe zeichnet")
|
||||||
|
- [ ] **Soft-Locks** während Symbol bewegt wird
|
||||||
|
- [ ] **Conflict Resolution** bei gleichzeitigem Edit testen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorisierungs-Matrix
|
||||||
|
|
||||||
|
| Phase | Aufwand | Impact | Priorität | Empfohlene Reihenfolge |
|
||||||
|
|-------|---------|--------|-----------|------------------------|
|
||||||
|
| 1 — Symbol-Architektur Redesign | L (3-4 W) | Sehr Hoch | ⭐⭐ Höchste | **1.** |
|
||||||
|
| 1.5 — Quick Polish | S | Mittel | 🔥 Hoch | parallel zu 1 |
|
||||||
|
| 3 — Linien-Typen | M | Hoch | ⭐ Hoch | **2.** |
|
||||||
|
| 4 — Rapport-Ansicht | L | Sehr Hoch (FU) | ⭐ Sehr Hoch | **3.** |
|
||||||
|
| 2 — Übungsmodus | L | Sehr Hoch | ⭐ Sehr Hoch | **4.** |
|
||||||
|
| 5 — Multi-User polish | M | Mittel | 📅 Mittel | später |
|
||||||
|
|
||||||
|
**Begründung der Reihenfolge**:
|
||||||
|
1. **Phase 1 zuerst** — Architektur-Umbau, weil alles andere darauf aufbaut. Sobald TenantSymbols + Kategorien sauber strukturiert sind, sind 1.3 (fehlende Symbole) und 1.4 (Stockwerke) trivial. Auch Phase 2 (Übungsmodus) profitiert: Übungs-Pakete als eigene Templates möglich.
|
||||||
|
2. **Phase 3 (Linien-Typen)** — kleine Erweiterung, hoher UX-Gewinn, unabhängig von Phase 1.
|
||||||
|
3. **Phase 4 (Rapport-Ansicht)** — USP gegenüber anderen Krokier-Tools, FU-Funktion fehlt überall sonst.
|
||||||
|
4. **Phase 2 (Übungsmodus)** — grosses Update; baut konzeptuell auf Phase 4 auf (Übungsauswertung ≈ Rapport).
|
||||||
|
5. **Phase 5** — Polish, kein User wartet darauf.
|
||||||
|
|
||||||
|
**Risiko-Hinweis Phase 1**:
|
||||||
|
- Schema-Migration von `Icon` (global) zu `SymbolTemplate` + Auto-Import muss **wasserdicht** sein, damit existierende Mandanten ihre Symbole nicht verlieren
|
||||||
|
- Vor Deploy: vollständiges DB-Backup (PostgreSQL) und MinIO-Backup (Icon-Files)
|
||||||
|
- Auf Staging testen falls möglich, sonst kleinen Test-Tenant zuerst migrieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweise für Entwickler / KIs
|
||||||
|
|
||||||
|
- **Stack**: Next.js 15, React 19, MapLibre GL JS, Prisma, PostgreSQL, MinIO, Socket.IO, Zustand
|
||||||
|
- **Sprache**: Schweizerdeutsch / Hochdeutsch (Code englisch, UI deutsch)
|
||||||
|
- **Bei Symbol-Änderungen**: SVGs in `public/icons/` + Symbol-Definitionen + ggf. DB-Migration
|
||||||
|
- **Bei Schema-Änderungen**: Prisma Migration erstellen + DB-Backup vor Deploy
|
||||||
|
- **Vor Deploy**: Build testen (`npx next build`), commit + push, Portainer baut automatisch via Webhook
|
||||||
|
- **Realtime**: bei DB-Änderungen, die alle User sehen müssen, Socket-Event triggern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-Tracking
|
||||||
|
|
||||||
|
> Status pro Aufgabe oben in Checkboxen. Hier kurzer Überblick:
|
||||||
|
|
||||||
|
- [ ] **Phase 1** — nicht gestartet
|
||||||
|
- [ ] **Phase 2** — nicht gestartet
|
||||||
|
- [ ] **Phase 3** — nicht gestartet
|
||||||
|
- [ ] **Phase 4** — nicht gestartet
|
||||||
|
- [ ] **Phase 5** — nicht gestartet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letztes Update**: 2026-05-20
|
||||||
|
**Verantwortlich**: Pepe (adminpepe)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.3.2",
|
"version": "1.3.5",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
"db:migrate:prod": "prisma migrate deploy",
|
"db:migrate:prod": "prisma migrate deploy",
|
||||||
"db:seed": "npx tsx prisma/seed.ts",
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"repair:features:dry-run": "node prisma/repair-features.js --dry-run",
|
||||||
|
"repair:features:apply": "node prisma/repair-features.js --apply",
|
||||||
|
"recover:symbols": "node prisma/recover-symbols.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
|||||||
370
plans/phase-1-symbol-architecture.md
Normal file
370
plans/phase-1-symbol-architecture.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# Phase 1 — Symbol-Architektur Redesign: Detaillierter Plan
|
||||||
|
|
||||||
|
> Basierend auf: `docs/roadmap-feedback-fabian.md`
|
||||||
|
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ziel & Konzept
|
||||||
|
|
||||||
|
**Problem heute:**
|
||||||
|
- `TenantSymbol` verweist auf `IconAsset` (global). Mandant kann zwar `customName` setzen, aber nicht die Kategorie ändern, das SVG bearbeiten oder Symbole aus verschiedenen Paketen frei mischen.
|
||||||
|
- Kategorien (`IconCategory`) sind global mit `tenantId` Override — uneinheitlich.
|
||||||
|
- Keine Möglichkeit, Vorlagen-Pakete (Feuerwehr CH, THW, Sanität) als Unit zu importieren.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- `SymbolTemplate` = globale, read-only Vorlagen (je Paket). Wird einmalig aus bestehenden `public/signaturen/` und `IconAsset` generiert.
|
||||||
|
- `TenantCategory` = pro Mandant, frei anlegbar/umbenennbar/sortierbar.
|
||||||
|
- `TenantSymbol` = pro Mandant, **vollständig eigenständig** (`name`, `svgPath`, `categoryId`). Kein Verweis mehr auf globale `IconAsset`.
|
||||||
|
- Mandant startet mit leerer Bibliothek und importiert Pakete nach Bedarf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodell-Änderungen
|
||||||
|
|
||||||
|
### 2.1 Neue Modelle
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model SymbolTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
packageId String // z.B. "feuerwehr-ch"
|
||||||
|
packageName String // z.B. "Feuerwehr Schweiz"
|
||||||
|
categoryName String // z.B. "Fahrzeuge"
|
||||||
|
name String
|
||||||
|
svgPath String // Relativer Pfad in public/ oder SVG-Inhalt
|
||||||
|
tags String[] @default([])
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
@@index([packageId])
|
||||||
|
@@map("symbol_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TenantCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
icon String? // Optional: Emoji/Lucide-Icon-Name für UI
|
||||||
|
|
||||||
|
symbols TenantSymbol[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("tenant_categories")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Bestehendes `TenantSymbol` umbauen
|
||||||
|
|
||||||
|
**Vorher:**
|
||||||
|
```prisma
|
||||||
|
model TenantSymbol {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
customName String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
iconId String
|
||||||
|
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nachher:**
|
||||||
|
```prisma
|
||||||
|
model TenantSymbol {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
categoryId String
|
||||||
|
category TenantCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
|
||||||
|
svgPath String // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Legacy-Feld für Migration (nach erfolgreichem Deploy entfernen)
|
||||||
|
migratedFromIconId String?
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@map("tenant_symbols")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Hinweis:** `IconAsset`, `IconCategory` und `hiddenIconIds` bleiben vorerst bestehen (read-only Legacy), werden aber nicht mehr für neue Features verwendet. In einer späteren Phase können sie entfernt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Migrationstrategie
|
||||||
|
|
||||||
|
### 3.1 Vor dem Deploy
|
||||||
|
- **PostgreSQL-Backup** erstellen
|
||||||
|
- **MinIO-Backup** der Icon-Dateien
|
||||||
|
- Auf Staging testen (falls vorhanden)
|
||||||
|
|
||||||
|
### 3.2 Schritt-für-Schritt-Migration (in einer Transaktion)
|
||||||
|
|
||||||
|
1. **Neue Tabellen anlegen** (`SymbolTemplate`, `TenantCategory`, neue `TenantSymbol`-Spalten)
|
||||||
|
2. **SymbolTemplate füllen:**
|
||||||
|
- Alle `public/signaturen/*.svg` einlesen
|
||||||
|
- Bestehende `IconAsset` mit `isSystem = true` als `feuerwehr-ch` Paket überführen
|
||||||
|
- Zuordnung: `categoryName` aus `IconCategory.name`
|
||||||
|
3. **TenantCategory pro Tenant anlegen:**
|
||||||
|
- Für jeden Tenant eine Default-Kategorie "Meine Symbole" erstellen
|
||||||
|
- Optional: Weitere Kategorien aus `IconCategory` ableiten (nur wenn `tenantId` gesetzt)
|
||||||
|
4. **TenantSymbol migrieren:**
|
||||||
|
- Für jeden bestehenden `TenantSymbol`-Eintrag:
|
||||||
|
- `name` = `customName || icon.name`
|
||||||
|
- `svgPath` = `icon.fileKey`
|
||||||
|
- `categoryId` = Default-Kategorie des Tenants (oder `icon.categoryId` wenn passend)
|
||||||
|
- `migratedFromIconId` = `iconId` (für Nachvollziehbarkeit)
|
||||||
|
5. **App-Code auf neue Modelle umstellen**
|
||||||
|
6. **Alte Relation `TenantSymbol.icon` und `TenantSymbol.iconId` entfernen** (nach erfolgreichem Live-Test)
|
||||||
|
|
||||||
|
### 3.3 Rollback-Plan
|
||||||
|
Falls etwas schiefgeht: Backup wiederherstellen. Die Migration ist idempotent (neue Tabellen, alte bleiben erhalten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API-Design
|
||||||
|
|
||||||
|
### 4.1 Templates
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/templates
|
||||||
|
→ { packages: [{ id, name, description, symbolCount, previewUrls }] }
|
||||||
|
|
||||||
|
GET /api/templates?packageId=feuerwehr-ch
|
||||||
|
→ { packageId, packageName, categories: [{ categoryName, symbols: [{ id, name, svgPath, tags }] }] }
|
||||||
|
|
||||||
|
POST /api/templates/import
|
||||||
|
Body: { packageId: "feuerwehr-ch", symbolIds?: ["id1", "id2"] }
|
||||||
|
→ importiert ausgewählte Symbole als TenantSymbols (oder alle wenn symbolIds fehlt)
|
||||||
|
→ antwortet mit [{ tenantSymbolId, name, categoryId }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Tenant Categories (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/tenant/categories
|
||||||
|
→ [{ id, name, sortOrder, icon, symbolCount }]
|
||||||
|
|
||||||
|
POST /api/tenant/categories
|
||||||
|
Body: { name, sortOrder?, icon? }
|
||||||
|
→ { id, name, sortOrder, icon }
|
||||||
|
|
||||||
|
PATCH /api/tenant/categories/:id
|
||||||
|
Body: { name?, sortOrder?, icon? }
|
||||||
|
→ updated category
|
||||||
|
|
||||||
|
DELETE /api/tenant/categories/:id
|
||||||
|
→ 204 (nur erlaubt wenn leer, sonst 409)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Tenant Symbols (Admin + Sidebar)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/tenant/symbols
|
||||||
|
→ { categories: [{ id, name, sortOrder, symbols: [{ id, name, svgPath, sortOrder, isUploaded }] }] }
|
||||||
|
// Gruppiert nach TenantCategory
|
||||||
|
|
||||||
|
POST /api/tenant/symbols
|
||||||
|
Body: { templateId } // Import aus Template
|
||||||
|
Body: { name, svgPath, categoryId } // Manuelle Erstellung
|
||||||
|
→ { id, name, svgPath, categoryId, sortOrder }
|
||||||
|
|
||||||
|
POST /api/tenant/symbols/upload
|
||||||
|
Multipart: { file (SVG/PNG), name, categoryId }
|
||||||
|
→ uploaded TenantSymbol
|
||||||
|
|
||||||
|
PATCH /api/tenant/symbols/:id
|
||||||
|
Body: { name?, categoryId?, sortOrder? }
|
||||||
|
→ updated
|
||||||
|
|
||||||
|
DELETE /api/tenant/symbols/:id
|
||||||
|
→ 204
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Icon-Serving (unchanged path für Kompatibilität)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/icons/:tenantSymbolId/image
|
||||||
|
→ Liest `TenantSymbol.svgPath` und serviert Datei (aus public/ oder MinIO)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend-Änderungen
|
||||||
|
|
||||||
|
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
|
||||||
|
|
||||||
|
**Umbau in 3 Bereiche:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Symbol-Manager ─────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [+ Kategorie anlegen] [📦 Vorlagen importieren] │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Fahrzeuge ─────────────┐ [⋮] [✎] [🗑] │
|
||||||
|
│ │ 🚒 TLF [✎] [🗑] [↕] │
|
||||||
|
│ │ 🚒 RW [✎] [🗑] [↕] │
|
||||||
|
│ │ [+ Symbol hinzufügen] │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
│ ┌─ Wasser ─────────────────┐ [⋮] [✎] [🗑] │
|
||||||
|
│ │ 🟦 Hydrant [✎] [🗑] [↕] │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Eigenes SVG hochladen] │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Kategorien per Drag & Drop sortieren (`@dnd-kit` oder native)
|
||||||
|
- Symbole zwischen Kategorien verschieben (Drag & Drop)
|
||||||
|
- Kategorie anlegen/umbenennen/löschen (nur wenn leer)
|
||||||
|
- Symbol umbenennen, Kategorie ändern, löschen
|
||||||
|
- "Vorlagen importieren" öffnet Dialog mit Paket-Vorschau
|
||||||
|
|
||||||
|
### 5.2 Admin — Import-Dialog (`src/components/admin/import-templates-dialog.tsx`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Vorlagen importieren ─────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [📦 Feuerwehr Schweiz] [📦 THW] [📦 Sanität] │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Feuerwehr Schweiz ───────────────────────────┐│
|
||||||
|
│ │ Kategorie: Fahrzeuge (3 Symbole) ││
|
||||||
|
│ │ ☑️ TLF ☑️ RW ☐ DLK ││
|
||||||
|
│ │ Kategorie: Wasser (5 Symbole) ││
|
||||||
|
│ │ ☑️ Hydrant ☑️ Löschwasser ││
|
||||||
|
│ │ ││
|
||||||
|
│ │ [Alle auswählen] [Ausgewählte importieren] ││
|
||||||
|
│ └────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Sidebar / LeftToolbar — Symbol-Palette umbauen
|
||||||
|
|
||||||
|
Aktuell gibt es zwei Komponenten:
|
||||||
|
- `LeftToolbar` = Zeichenwerkzeuge (Bleistift, Linie, etc.)
|
||||||
|
- Symbol-Palette = vermutlich in `map-view.tsx` oder separater Komponente
|
||||||
|
|
||||||
|
**Ziel:** Die Symbol-Palette (die Symbole die auf die Karte gezogen werden) muss nach `TenantCategory` gruppiert werden.
|
||||||
|
|
||||||
|
Da die Symbol-Palette vermutlich inline in `map-view.tsx` oder einer anderen Komponente ist, suchen und extrahieren in eine eigene `SymbolPalette`-Komponente:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/map/symbol-palette.tsx
|
||||||
|
interface SymbolPaletteProps {
|
||||||
|
categories: TenantCategoryWithSymbols[]
|
||||||
|
onSymbolDragStart: (symbol: TenantSymbol) => void
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Collapsible Kategorien (wie aktuell in Symbol-Manager)
|
||||||
|
- Symbole als Grid pro Kategorie
|
||||||
|
- Search-Input oben (Volltext über Name + Tags)
|
||||||
|
- Recent/Favoriten-Sektion (später in Phase 1.5)
|
||||||
|
|
||||||
|
### 5.4 DrawFeature — `instanceLabel` für Stockwerke (Phase 1.4)
|
||||||
|
|
||||||
|
Erweiterung des `properties`-Objekts:
|
||||||
|
```ts
|
||||||
|
interface SymbolProperties {
|
||||||
|
iconId: string
|
||||||
|
scale: number
|
||||||
|
rotation: number
|
||||||
|
instanceLabel?: string // z.B. "EG+3", "Wohngebäude A"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renderer: Badge auf dem Symbol-Overlay (MapLibre Marker oder CSS-Overlay).
|
||||||
|
Edit: Doppelklick auf Symbol → kleiner Inline-Edit oder Dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dateien: Create / Modify / Delete
|
||||||
|
|
||||||
|
### Neue Dateien
|
||||||
|
| Pfad | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| `prisma/migrations/2026xxxx_symbol_architecture/` | Prisma Migration |
|
||||||
|
| `prisma/seed-symbol-templates.ts` | Seed-Skript: `public/signaturen/*.svg` → `SymbolTemplate` |
|
||||||
|
| `src/app/api/templates/route.ts` | GET /api/templates |
|
||||||
|
| `src/app/api/templates/import/route.ts` | POST /api/templates/import |
|
||||||
|
| `src/app/api/tenant/categories/route.ts` | CRUD TenantCategory |
|
||||||
|
| `src/app/api/tenant/categories/[id]/route.ts` | PATCH/DELETE einzelne Kategorie |
|
||||||
|
| `src/components/admin/import-templates-dialog.tsx` | Import-Dialog UI |
|
||||||
|
| `src/components/map/symbol-palette.tsx` | Extrahierte Symbol-Palette |
|
||||||
|
|
||||||
|
### Zu modifizierende Dateien
|
||||||
|
| Pfad | Änderung |
|
||||||
|
|------|----------|
|
||||||
|
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol umbauen |
|
||||||
|
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung nach Category, Upload, CRUD |
|
||||||
|
| `src/app/api/icons/route.ts` | Legacy-Modus, ggf. auf TenantSymbol umleiten |
|
||||||
|
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorie-Verwaltung |
|
||||||
|
| `src/app/app/page.tsx` | Symbol-Palette Props anpassen |
|
||||||
|
| `src/components/map/map-view.tsx` | Symbol-Rendering mit instanceLabel Badge |
|
||||||
|
| `src/types/index.ts` | Neue Typen: `TenantCategory`, `TenantSymbol`, `SymbolTemplate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ausführungsreihenfolge (Execution Order)
|
||||||
|
|
||||||
|
### Sprint A — Schema & Daten (Woche 1)
|
||||||
|
1. `prisma/schema.prisma` erweitern (`SymbolTemplate`, `TenantCategory`, `TenantSymbol` Refactor)
|
||||||
|
2. Prisma Migration erstellen & testen (`npx prisma migrate dev`)
|
||||||
|
3. `prisma/seed-symbol-templates.ts` schreiben (feuerwehr-ch Paket aus `public/signaturen/`)
|
||||||
|
4. Migration-Skript für bestehende Tenants (Default-Kategorie + TenantSymbol-Migration)
|
||||||
|
5. Build testen, auf Staging deployen
|
||||||
|
|
||||||
|
### Sprint B — API (Woche 1-2)
|
||||||
|
6. `GET /api/templates` + `POST /api/templates/import`
|
||||||
|
7. `CRUD /api/tenant/categories`
|
||||||
|
8. `Refactor /api/tenant/symbols` (Gruppierung, Upload, Kategorie-Zuordnung)
|
||||||
|
9. `GET /api/icons/:id/image` an TenantSymbol anpassen
|
||||||
|
|
||||||
|
### Sprint C — Admin UI (Woche 2-3)
|
||||||
|
10. Symbol-Manager: Kategorie-Verwaltung (anlegen/umbenennen/löschen/sortieren)
|
||||||
|
11. Symbol-Manager: Import-Dialog (Paket-Vorschau, granulare Auswahl)
|
||||||
|
12. Symbol-Manager: Eigenen SVG-Upload mit Kategorie-Zuordnung
|
||||||
|
13. Symbol-Manager: Drag & Drop (Kategorien sortieren, Symbole verschieben)
|
||||||
|
|
||||||
|
### Sprint D — Frontend Sidebar & Polish (Woche 3-4)
|
||||||
|
14. `SymbolPalette`-Komponente extrahieren und nach Kategorien gruppieren
|
||||||
|
15. Symbol-Suche in Sidebar (Volltext)
|
||||||
|
16. `instanceLabel` / Stockwerke implementieren (Phase 1.4)
|
||||||
|
17. Häufig-benutzte Symbole (Recent) — Phase 1.5
|
||||||
|
18. End-to-End-Test, Deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risiken & Entscheidungen
|
||||||
|
|
||||||
|
| Thema | Option A (empfohlen) | Option B |
|
||||||
|
|-------|---------------------|----------|
|
||||||
|
| **SVG-Speicherort** | `svgPath` = relativer Pfad in `public/signaturen/` (klein, schnell, kein MinIO nötig für Templates) | `svgPath` = MinIO-Key (konsistent mit Uploads, aber Overhead) |
|
||||||
|
| **TenantSymbol Uploads** | Eigenes MinIO-Bucket `tenant-{id}/symbols/` | In DB als Text speichern (Base64 oder SVG-String) |
|
||||||
|
| **Migration alter Tenants** | Auto-Import feuerwehr-ch Paket + Default-Kategorie | Manuelle Migration pro Tenant |
|
||||||
|
| **LeftToolbar vs SymbolPalette** | SymbolPalette als separate Komponente neben LeftToolbar | In LeftToolbar integrieren |
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead)
|
||||||
|
- Uploads: MinIO `tenant-{id}/symbols/`
|
||||||
|
- Migration: Vollautomatisch beim ersten Login nach Deploy (kein manueller Eingriff)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan erstellt: 2026-05-20*
|
||||||
|
*Nächster Schritt: Genehmigung durch Pepe, dann Sprint A starten*
|
||||||
112
prisma/migrate-tenant-symbols.ts
Normal file
112
prisma/migrate-tenant-symbols.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Post-Migration Script: Migrate existing TenantSymbols to new architecture.
|
||||||
|
*
|
||||||
|
* Run AFTER applying the schema migration (20260520_symbol_architecture).
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Create a default "Meine Symbole" TenantCategory for each tenant that has TenantSymbols
|
||||||
|
* 2. Migrate existing TenantSymbols: set name, svgPath, categoryId, migratedFromIconId
|
||||||
|
* 3. Verify consistency
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node prisma/migrate-tenant-symbols.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Migrating existing TenantSymbols...')
|
||||||
|
|
||||||
|
// Step 1: Get all tenants that have existing tenantSymbols
|
||||||
|
const tenantsWithSymbols = await (prisma as any).tenantSymbol.groupBy({
|
||||||
|
by: ['tenantId'],
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${tenantsWithSymbols.length} tenants with existing symbols`)
|
||||||
|
|
||||||
|
let categoriesCreated = 0
|
||||||
|
let symbolsMigrated = 0
|
||||||
|
|
||||||
|
for (const group of tenantsWithSymbols) {
|
||||||
|
const tenantId = group.tenantId
|
||||||
|
|
||||||
|
// Step 2: Create default category for this tenant
|
||||||
|
let defaultCategory = await (prisma as any).tenantCategory.findFirst({
|
||||||
|
where: { tenantId, name: 'Meine Symbole' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!defaultCategory) {
|
||||||
|
defaultCategory = await (prisma as any).tenantCategory.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: 'Meine Symbole',
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
categoriesCreated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Migrate all tenantSymbols for this tenant
|
||||||
|
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { icon: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const ts of tenantSymbols) {
|
||||||
|
const updates: any = {}
|
||||||
|
|
||||||
|
// name = customName || icon.name
|
||||||
|
if (!ts.name) {
|
||||||
|
updates.name = ts.customName || ts.icon?.name || 'Unbenannt'
|
||||||
|
}
|
||||||
|
|
||||||
|
// svgPath = icon.fileKey
|
||||||
|
if (!ts.svgPath && ts.icon?.fileKey) {
|
||||||
|
updates.svgPath = ts.icon.fileKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryId = default category
|
||||||
|
if (!ts.categoryId) {
|
||||||
|
updates.categoryId = defaultCategory.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// migratedFromIconId = current iconId
|
||||||
|
if (!ts.migratedFromIconId) {
|
||||||
|
updates.migratedFromIconId = ts.iconId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await (prisma as any).tenantSymbol.update({
|
||||||
|
where: { id: ts.id },
|
||||||
|
data: updates,
|
||||||
|
})
|
||||||
|
symbolsMigrated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Done: ${categoriesCreated} categories created, ${symbolsMigrated} symbols migrated`)
|
||||||
|
|
||||||
|
// Verification
|
||||||
|
const unmappedCount = await (prisma as any).tenantSymbol.count({
|
||||||
|
where: { categoryId: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unmappedCount > 0) {
|
||||||
|
console.warn(`⚠️ ${unmappedCount} tenantSymbols still have no categoryId!`)
|
||||||
|
} else {
|
||||||
|
console.log('✅ All tenantSymbols have a category assigned')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
* Database migration script using PrismaClient raw SQL.
|
* Database migration script using PrismaClient raw SQL.
|
||||||
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
|
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
|
||||||
* Safe to run multiple times (all statements are idempotent).
|
* Safe to run multiple times (all statements are idempotent).
|
||||||
|
*
|
||||||
|
* SAFETY RULES:
|
||||||
|
* - NO deleteMany / DELETE / TRUNCATE on icon_assets, icon_categories,
|
||||||
|
* tenant_symbols, or features. These contain user data.
|
||||||
|
* - All operations must be idempotent (safe to re-run).
|
||||||
|
* - In production, destructive operations are blocked.
|
||||||
*/
|
*/
|
||||||
const { PrismaClient } = require('@prisma/client')
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
|
||||||
@@ -121,10 +127,9 @@ async function migrate() {
|
|||||||
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
|
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
// ─── Step 6: Clean up orphan users ───
|
// ─── Step 6: Detect orphan users (log only, no deletion) ───
|
||||||
console.log(' [6/7] Cleaning up orphan users...')
|
console.log(' [6/7] Checking for orphan users...')
|
||||||
try {
|
try {
|
||||||
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
|
|
||||||
const orphans = await prisma.user.findMany({
|
const orphans = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: { not: 'SERVER_ADMIN' },
|
role: { not: 'SERVER_ADMIN' },
|
||||||
@@ -133,22 +138,15 @@ async function migrate() {
|
|||||||
select: { id: true, email: true, name: true },
|
select: { id: true, email: true, name: true },
|
||||||
})
|
})
|
||||||
if (orphans.length > 0) {
|
if (orphans.length > 0) {
|
||||||
console.log(` Found ${orphans.length} orphan user(s):`)
|
console.log(` ⚠️ Found ${orphans.length} orphan user(s) (NOT deleting — manual review required):`)
|
||||||
for (const o of orphans) {
|
for (const o of orphans) {
|
||||||
console.log(` - ${o.email} (${o.name})`)
|
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 {
|
} else {
|
||||||
console.log(' No orphan users found')
|
console.log(' No orphan users found')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(' Orphan cleanup skipped:', e.message)
|
console.log(' Orphan check skipped:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 7: Backfill logoFileKey from logoUrl ───
|
// ─── Step 7: Backfill logoFileKey from logoUrl ───
|
||||||
@@ -222,7 +220,7 @@ async function migrate() {
|
|||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
"iconId" TEXT NOT NULL REFERENCES icon_assets(id) ON DELETE CASCADE,
|
"iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL,
|
||||||
UNIQUE("tenantId", "iconId")
|
UNIQUE("tenantId", "iconId")
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
@@ -231,6 +229,98 @@ async function migrate() {
|
|||||||
console.log(' tenant_symbols table skipped:', e.message)
|
console.log(' tenant_symbols table skipped:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Step 13: Create symbol_templates table ───
|
||||||
|
console.log(' [13] Creating symbol_templates table...')
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS symbol_templates (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"fileKey" TEXT NOT NULL,
|
||||||
|
"originalFilename" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"categoryName" TEXT,
|
||||||
|
"svgPath" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE("fileKey")
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
console.log(' symbol_templates table created (or already exists)')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' symbol_templates table skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 14: Create tenant_categories table ───
|
||||||
|
console.log(' [14] Creating tenant_categories table...')
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_categories (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
console.log(' tenant_categories table created (or already exists)')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' tenant_categories table skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 15: Extend tenant_symbols with Phase 1 columns ───
|
||||||
|
console.log(' [15] Extending tenant_symbols with Phase 1 columns...')
|
||||||
|
const tenantSymbolColumns = [
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "svgPath" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT REFERENCES tenant_categories(id) ON DELETE SET NULL`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
]
|
||||||
|
let tsAdded = 0
|
||||||
|
for (const sql of tenantSymbolColumns) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql); tsAdded++ } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`)
|
||||||
|
|
||||||
|
// ─── Step 16: Fix tenant_symbols FK (CASCADE → SET NULL) ───
|
||||||
|
console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...')
|
||||||
|
try {
|
||||||
|
// Make iconId nullable
|
||||||
|
await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols ALTER COLUMN "iconId" DROP NOT NULL`)
|
||||||
|
// Drop old cascade FK and recreate with SET NULL
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
-- Drop existing FK constraint (name varies)
|
||||||
|
ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_fkey";
|
||||||
|
ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_icon_assets_id_fk";
|
||||||
|
-- Recreate with SET NULL
|
||||||
|
ALTER TABLE tenant_symbols
|
||||||
|
ADD CONSTRAINT "tenant_symbols_iconId_fkey"
|
||||||
|
FOREIGN KEY ("iconId") REFERENCES icon_assets(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'FK fix skipped: %', SQLERRM;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
console.log(' ✅ tenant_symbols.iconId FK is now ON DELETE SET NULL')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' FK fix skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 17: Drop unique constraint on tenant_symbols(tenantId, iconId) ───
|
||||||
|
console.log(' [17] Dropping UNIQUE(tenantId, iconId) on tenant_symbols...')
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_tenantId_iconId_key"`)
|
||||||
|
console.log(' ✅ Unique constraint dropped (duplicates now allowed)')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' Unique constraint drop skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Database migrations complete')
|
console.log('✅ Database migrations complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
prisma/recover-features.js
Normal file
151
prisma/recover-features.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Recovery-Skript für Zeichnungen (Features) nach Symbol-Verlust
|
||||||
|
*
|
||||||
|
* Problem: Features verweisen auf gelöschte iconId-UUIDs.
|
||||||
|
* Die imageUrl ist "/api/icons/{alte-uuid}/image" → 404.
|
||||||
|
*
|
||||||
|
* Lösung:
|
||||||
|
* Die alte UUID→SVG-Zuordnung ist verloren. ABER:
|
||||||
|
* - Alle bestehenden IconAssets wurden per Upsert neu erstellt (gleiche fileKeys)
|
||||||
|
* - Jedes Feature hat noch die alte iconId in properties
|
||||||
|
* - Die Sidebar liefert jetzt NEUE iconIds
|
||||||
|
*
|
||||||
|
* Dieses Skript:
|
||||||
|
* 1. Listet alle broken Symbol-Features (iconId zeigt auf gelöschtes Icon)
|
||||||
|
* 2. Für Features wo imageUrl auf /signaturen/ zeigt: direkt fixbar
|
||||||
|
* 3. Für Features mit /api/icons/{uuid}/image: UUID ist verloren → Renderer zeigt ⚠️
|
||||||
|
* 4. Zählt und listet betroffene Projekte
|
||||||
|
*
|
||||||
|
* Der Renderer wurde angepasst (map-view.tsx): broken Symbole zeigen jetzt
|
||||||
|
* ein ⚠️-Platzhalter statt nichts. User können das Symbol manuell ersetzen.
|
||||||
|
*
|
||||||
|
* Ausführung:
|
||||||
|
* DATABASE_URL=... node prisma/recover-features.js [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const DRY_RUN = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 FEATURE RECOVERY — Analyse & Reparatur')
|
||||||
|
console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN' : '⚡ LIVE'}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// ─── 1. Lade alle aktuellen IconAssets ───
|
||||||
|
const allIcons = await prisma.iconAsset.findMany({
|
||||||
|
select: { id: true, name: true, fileKey: true },
|
||||||
|
})
|
||||||
|
console.log(`📦 ${allIcons.length} IconAssets in der DB`)
|
||||||
|
|
||||||
|
const iconIdSet = new Set(allIcons.map(i => i.id))
|
||||||
|
const fileKeyToIcon = {}
|
||||||
|
for (const icon of allIcons) {
|
||||||
|
fileKeyToIcon[icon.fileKey] = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Lade alle Symbol-Features ───
|
||||||
|
const features = await prisma.feature.findMany({
|
||||||
|
where: { type: 'symbol' },
|
||||||
|
include: { project: { select: { id: true, name: true, tenantId: true } } },
|
||||||
|
})
|
||||||
|
console.log(`🖼️ ${features.length} Symbol-Features gefunden`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
let ok = 0
|
||||||
|
let fixedViaFileKey = 0
|
||||||
|
let broken = 0
|
||||||
|
const brokenByProject = {}
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
const props = feature.properties || {}
|
||||||
|
const iconId = props.iconId || ''
|
||||||
|
const imageUrl = props.imageUrl || ''
|
||||||
|
|
||||||
|
// ─── Check 1: iconId still exists? ───
|
||||||
|
if (iconId && iconIdSet.has(iconId)) {
|
||||||
|
ok++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Check 2: imageUrl points to static /signaturen/ file? ───
|
||||||
|
const sigMatch = imageUrl.match(/\/signaturen\/(.+\.svg)/)
|
||||||
|
if (sigMatch) {
|
||||||
|
const fileKey = `signaturen/${sigMatch[1]}`
|
||||||
|
const matchingIcon = fileKeyToIcon[fileKey]
|
||||||
|
if (matchingIcon) {
|
||||||
|
// Fix: Update iconId to new valid ID
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
iconId: matchingIcon.id,
|
||||||
|
imageUrl: `/api/icons/${matchingIcon.id}/image`,
|
||||||
|
_recovered: true,
|
||||||
|
_oldIconId: iconId,
|
||||||
|
}
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
await prisma.feature.update({
|
||||||
|
where: { id: feature.id },
|
||||||
|
data: { properties: newProps },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fixedViaFileKey++
|
||||||
|
console.log(` ✅ ${feature.id} → ${matchingIcon.name} (via fileKey)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Check 3: imageUrl is /api/icons/{uuid}/image? ───
|
||||||
|
// The uuid in the URL = old iconId = deleted → can't resolve
|
||||||
|
// Mark as broken
|
||||||
|
broken++
|
||||||
|
const projName = feature.project?.name || feature.projectId
|
||||||
|
if (!brokenByProject[projName]) brokenByProject[projName] = []
|
||||||
|
brokenByProject[projName].push({
|
||||||
|
featureId: feature.id,
|
||||||
|
iconId,
|
||||||
|
imageUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
console.log('═══════════════════════════════════════════')
|
||||||
|
console.log(` ✅ OK (iconId existiert): ${ok}`)
|
||||||
|
console.log(` 🔧 Gefixt (via fileKey): ${fixedViaFileKey}`)
|
||||||
|
console.log(` ❌ Broken (UUID verloren): ${broken}`)
|
||||||
|
console.log(` 📊 Total: ${features.length}`)
|
||||||
|
console.log('═══════════════════════════════════════════')
|
||||||
|
|
||||||
|
if (broken > 0) {
|
||||||
|
console.log('')
|
||||||
|
console.log('❌ BROKEN FEATURES pro Projekt:')
|
||||||
|
for (const [projName, items] of Object.entries(brokenByProject)) {
|
||||||
|
console.log(` 📄 "${projName}": ${items.length} Symbole`)
|
||||||
|
for (const item of items) {
|
||||||
|
console.log(` - Feature ${item.featureId} (iconId: ${item.iconId.substring(0, 8)}...)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
console.log('💡 Diese Symbole zeigen jetzt ein ⚠️ auf der Karte.')
|
||||||
|
console.log(' User müssen das Symbol manuell löschen und neu platzieren.')
|
||||||
|
console.log(' (Select-Mode → Symbol anklicken → DEL → neues Symbol aus Sidebar ziehen)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broken === 0 && fixedViaFileKey === 0) {
|
||||||
|
console.log('')
|
||||||
|
console.log('🎉 Alle Symbol-Features sind OK! Keine Reparatur nötig.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log('')
|
||||||
|
console.log('🔍 DRY RUN — Keine Änderungen geschrieben.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => { await prisma.$disconnect() })
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error('❌ Fehler:', e)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
258
prisma/recover-symbols.js
Normal file
258
prisma/recover-symbols.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Recovery-Skript für gelöschte/neu erstellte Symbole
|
||||||
|
*
|
||||||
|
* Stellt sicher, dass alle Icons aus public/signaturen/ als IconAssets
|
||||||
|
* in der DB vorhanden sind und für alle Tenants aktiviert sind.
|
||||||
|
*
|
||||||
|
* Unterstützt Dry-Run und Apply-Modus.
|
||||||
|
*
|
||||||
|
* Ausführung:
|
||||||
|
* Analyse: node prisma/recover-symbols.js --dry-run
|
||||||
|
* Anwenden: node prisma/recover-symbols.js --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const IS_DRY_RUN = process.argv.includes('--dry-run')
|
||||||
|
const IS_APPLY = process.argv.includes('--apply')
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
// ─── Safety Guards ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!IS_DRY_RUN && !IS_APPLY) {
|
||||||
|
console.error('❌ Fehler: Bitte --dry-run oder --apply angeben')
|
||||||
|
console.error('')
|
||||||
|
console.error(' Analyse: node prisma/recover-symbols.js --dry-run')
|
||||||
|
console.error(' Anwenden: node prisma/recover-symbols.js --apply')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_DRY_RUN && IS_APPLY) {
|
||||||
|
console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_APPLY && IS_PROD) {
|
||||||
|
console.log('⚠️ Production-Umgebung erkannt. Fortfahren in 3 Sekunden...')
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(IS_DRY_RUN ? '🔍 DRY-RUN MODUS' : '🔧 APPLY-MODUS')
|
||||||
|
|
||||||
|
// ─── Kategorie-Definitionen ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function findCategory(filename) {
|
||||||
|
for (const def of catDefs) {
|
||||||
|
for (const pattern of def.patterns) {
|
||||||
|
if (filename.includes(pattern)) return def.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Verschiedenes'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function recover() {
|
||||||
|
console.log('\n🚨 SYMBOL RECOVERY')
|
||||||
|
console.log('────────────────────────────────────────')
|
||||||
|
|
||||||
|
const SVG_DIR = path.join(__dirname, '..', 'public', 'signaturen')
|
||||||
|
if (!fs.existsSync(SVG_DIR)) {
|
||||||
|
console.error('❌ SVG-Verzeichnis nicht gefunden:', SVG_DIR)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(SVG_DIR).filter(f => f.endsWith('.svg')).sort()
|
||||||
|
console.log(`\n📁 ${files.length} SVG-Dateien in public/signaturen/ gefunden`)
|
||||||
|
|
||||||
|
// ─── 1. Kategorien Upserten ───
|
||||||
|
console.log('\n[1/4] Kategorien...')
|
||||||
|
const catMap = {}
|
||||||
|
for (const def of catDefs) {
|
||||||
|
const catId = def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
|
||||||
|
if (IS_APPLY) {
|
||||||
|
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.id
|
||||||
|
} else {
|
||||||
|
catMap[def.name] = catId
|
||||||
|
console.log(` 📝 ${def.name} (ID: ${catId})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (IS_APPLY) console.log(` ✅ ${catDefs.length} Kategorien upserted`)
|
||||||
|
|
||||||
|
// ─── 2. IconAssets Upserten ───
|
||||||
|
console.log('\n[2/4] IconAssets...')
|
||||||
|
const iconMap = {} // fileKey -> { id, name }
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
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}` // WICHTIG: Gleiches Format wie Seed!
|
||||||
|
const category = findCategory(file)
|
||||||
|
const categoryId = catMap[category]
|
||||||
|
|
||||||
|
if (IS_APPLY) {
|
||||||
|
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
|
||||||
|
if (existing) {
|
||||||
|
await prisma.iconAsset.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { name, categoryId, mimeType: 'image/svg+xml', isSystem: true, isActive: true, width: 48, height: 48 },
|
||||||
|
})
|
||||||
|
iconMap[fileKey] = { id: existing.id, name }
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
const icon = await prisma.iconAsset.create({
|
||||||
|
data: { name, categoryId, fileKey, mimeType: 'image/svg+xml', isSystem: true, width: 48, height: 48 },
|
||||||
|
})
|
||||||
|
iconMap[fileKey] = { id: icon.id, name }
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dry-Run: Simuliere Lookup
|
||||||
|
const existing = await prisma.iconAsset.findFirst({ where: { fileKey }, select: { id: true, name: true } })
|
||||||
|
if (existing) {
|
||||||
|
iconMap[fileKey] = { id: existing.id, name: existing.name }
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
iconMap[fileKey] = { id: `(neu: ${fileKey})`, name }
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${IS_APPLY ? '✅' : '📝'} ${created} neu, ${updated} aktualisiert, ${files.length} total`)
|
||||||
|
|
||||||
|
// ─── 3. TenantSymbols erstellen ───
|
||||||
|
console.log('\n[3/4] TenantSymbols...')
|
||||||
|
const tenants = await prisma.tenant.findMany({ select: { id: true, name: true } })
|
||||||
|
let tsCreated = 0
|
||||||
|
let tsExisting = 0
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
let tenantNew = 0
|
||||||
|
let tenantExist = 0
|
||||||
|
|
||||||
|
for (const [fileKey, icon] of Object.entries(iconMap)) {
|
||||||
|
if (IS_APPLY) {
|
||||||
|
// Prüfe ob bereits existiert
|
||||||
|
const existing = await prisma.tenantSymbol.findFirst({
|
||||||
|
where: { tenantId: tenant.id, iconId: icon.id },
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
tenantExist++
|
||||||
|
tsExisting++
|
||||||
|
} else {
|
||||||
|
await prisma.tenantSymbol.create({
|
||||||
|
data: { tenantId: tenant.id, iconId: icon.id, isActive: true },
|
||||||
|
})
|
||||||
|
tenantNew++
|
||||||
|
tsCreated++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dry-Run: Prüfe Existenz
|
||||||
|
const existing = await prisma.tenantSymbol.findFirst({
|
||||||
|
where: { tenantId: tenant.id, iconId: icon.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
tenantExist++
|
||||||
|
tsExisting++
|
||||||
|
} else {
|
||||||
|
tenantNew++
|
||||||
|
tsCreated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${IS_APPLY ? '✅' : '📝'} ${tenant.name}: ${tenantNew} neu, ${tenantExist} bereits vorhanden`)
|
||||||
|
}
|
||||||
|
console.log(` Gesamt: ${tsCreated} neu, ${tsExisting} bereits vorhanden`)
|
||||||
|
|
||||||
|
// ─── 4. Feature-Analyse ───
|
||||||
|
console.log('\n[4/4] Feature-Analyse (Zeichnungen)...')
|
||||||
|
const features = await prisma.feature.findMany({
|
||||||
|
where: { type: 'symbol' },
|
||||||
|
select: { id: true, properties: true, projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const validIconIds = new Set(Object.values(iconMap).map(i => i.id))
|
||||||
|
let validRefs = 0
|
||||||
|
let brokenRefs = 0
|
||||||
|
|
||||||
|
for (const f of features) {
|
||||||
|
const iconId = f.properties?.iconId
|
||||||
|
if (!iconId) {
|
||||||
|
validRefs++ // Keine Referenz = nicht kaputt
|
||||||
|
} else if (validIconIds.has(iconId)) {
|
||||||
|
validRefs++
|
||||||
|
} else {
|
||||||
|
brokenRefs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 📊 ${features.length} symbol-Features gefunden`)
|
||||||
|
console.log(` ✅ ${validRefs} gültige/keine Referenzen`)
|
||||||
|
console.log(` ❌ ${brokenRefs} kaputte Referenzen (nicht reparierbar ohne Backup)`)
|
||||||
|
|
||||||
|
if (brokenRefs > 0) {
|
||||||
|
console.log('\n ⚠️ HINWEIS: Kaputte Features können NICHT automatisch repariert werden.')
|
||||||
|
console.log(' Die alten iconId-UUIDs sind verloren.')
|
||||||
|
console.log(' Verwende: npm run repair:features:dry-run')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ RECOVERY ABgeschlossen')
|
||||||
|
if (IS_DRY_RUN) {
|
||||||
|
console.log(' Zum Anwenden: node prisma/recover-symbols.js --apply')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recover()
|
||||||
|
.then(async () => { await prisma.$disconnect() })
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error('❌ Fehler:', e)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
263
prisma/repair-features.js
Normal file
263
prisma/repair-features.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Feature/Symbol-Repair Skript
|
||||||
|
*
|
||||||
|
* Problem: Features (Zeichnungen) speichern Symbol-Referenzen als iconId in
|
||||||
|
* properties JSONB. Nach dem Icon-Verlust zeigen diese auf gelöschte UUIDs.
|
||||||
|
*
|
||||||
|
* Dieses Skript versucht:
|
||||||
|
* 1. Alle symbol-Features zu scannen
|
||||||
|
* 2. Zu prüfen, ob die referenzierte iconId noch existiert
|
||||||
|
* 3. Falls nicht: via migratedFromIconId oder Dateinamen-Matching zu reparieren
|
||||||
|
*
|
||||||
|
* Ausführung:
|
||||||
|
* Dry-Run (nur Analyse, keine Änderungen):
|
||||||
|
* node prisma/repair-features.js --dry-run
|
||||||
|
*
|
||||||
|
* Apply (mit Backup und Reparatur):
|
||||||
|
* node prisma/repair-features.js --apply
|
||||||
|
*
|
||||||
|
* SAFETY:
|
||||||
|
* - In Production (NODE_ENV=production) nur mit --apply möglich
|
||||||
|
* - Vor Apply wird automatisch ein JSON-Backup erstellt
|
||||||
|
* - Keine Features werden gelöscht
|
||||||
|
* - Keine Projekte oder Tenant-Daten werden verändert
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const IS_DRY_RUN = process.argv.includes('--dry-run')
|
||||||
|
const IS_APPLY = process.argv.includes('--apply')
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
// ─── Safety Guards ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!IS_DRY_RUN && !IS_APPLY) {
|
||||||
|
console.error('❌ Fehler: Bitte --dry-run oder --apply angeben')
|
||||||
|
console.error('')
|
||||||
|
console.error(' Analyse-Modus (keine Änderungen):')
|
||||||
|
console.error(' node prisma/repair-features.js --dry-run')
|
||||||
|
console.error('')
|
||||||
|
console.error(' Reparatur-Modus (mit Backup):')
|
||||||
|
console.error(' node prisma/repair-features.js --apply')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_DRY_RUN && IS_APPLY) {
|
||||||
|
console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_APPLY) {
|
||||||
|
console.log('⚠️ REPAIR-MODUS (mit Änderungen)')
|
||||||
|
if (IS_PROD) {
|
||||||
|
console.log(' Production-Umgebung erkannt. Fortfahren in 3 Sekunden...')
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('🔍 DRY-RUN MODUS (keine Änderungen)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Statistics ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalFeatures: 0,
|
||||||
|
validRefs: 0,
|
||||||
|
brokenRefs: 0,
|
||||||
|
repaired: 0,
|
||||||
|
notRepairable: 0,
|
||||||
|
details: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function repair() {
|
||||||
|
console.log('\n🔧 Feature-Repair gestartet')
|
||||||
|
console.log('────────────────────────────────────────')
|
||||||
|
|
||||||
|
// ─── 1. Lade alle gültigen Icons für schnelle Lookup ───
|
||||||
|
console.log('\n[1/5] Lade Icon-Lookup-Tabellen...')
|
||||||
|
|
||||||
|
const allIcons = await prisma.iconAsset.findMany({
|
||||||
|
select: { id: true, name: true, fileKey: true },
|
||||||
|
})
|
||||||
|
const iconById = new Map(allIcons.map(i => [i.id, i]))
|
||||||
|
const iconByFileKey = new Map(allIcons.map(i => [i.fileKey, i]))
|
||||||
|
|
||||||
|
// tenant_symbols mit migratedFromIconId
|
||||||
|
const tenantSymbols = await prisma.tenantSymbol.findMany({
|
||||||
|
select: { id: true, iconId: true, migratedFromIconId: true, customName: true },
|
||||||
|
})
|
||||||
|
const tsByMigratedId = new Map()
|
||||||
|
for (const ts of tenantSymbols) {
|
||||||
|
if (ts.migratedFromIconId) {
|
||||||
|
tsByMigratedId.set(ts.migratedFromIconId, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${allIcons.length} IconAssets geladen`)
|
||||||
|
console.log(` ✅ ${tenantSymbols.length} TenantSymbols geladen`)
|
||||||
|
|
||||||
|
// ─── 2. Lade alle symbol-Features ───
|
||||||
|
console.log('\n[2/5] Scanne Features...')
|
||||||
|
|
||||||
|
const features = await prisma.feature.findMany({
|
||||||
|
where: { type: 'symbol' },
|
||||||
|
include: { project: { select: { id: true, title: true, tenantId: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
stats.totalFeatures = features.length
|
||||||
|
console.log(` 📊 ${features.length} symbol-Features gefunden`)
|
||||||
|
|
||||||
|
// ─── 3. Analysiere jede Feature ───
|
||||||
|
console.log('\n[3/5] Analysiere Symbol-Referenzen...')
|
||||||
|
|
||||||
|
const toRepair = []
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
const props = feature.properties || {}
|
||||||
|
const iconId = props.iconId
|
||||||
|
const imageUrl = props.imageUrl || ''
|
||||||
|
|
||||||
|
// Keine iconId → nicht reparierbar, aber auch nicht unbedingt kaputt
|
||||||
|
if (!iconId) {
|
||||||
|
stats.validRefs++ // Oder neutral
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob iconId noch existiert
|
||||||
|
const icon = iconById.get(iconId)
|
||||||
|
if (icon) {
|
||||||
|
stats.validRefs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Broken reference
|
||||||
|
stats.brokenRefs++
|
||||||
|
|
||||||
|
// Versuche Reparatur-Strategien
|
||||||
|
let repairedId = null
|
||||||
|
let repairMethod = null
|
||||||
|
|
||||||
|
// Strategie 1: via migratedFromIconId
|
||||||
|
const ts = tsByMigratedId.get(iconId)
|
||||||
|
if (ts && ts.iconId && iconById.has(ts.iconId)) {
|
||||||
|
repairedId = ts.iconId
|
||||||
|
repairMethod = 'migratedFromIconId'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategie 2: via imageUrl (extrahiere Dateinamen)
|
||||||
|
if (!repairedId && imageUrl) {
|
||||||
|
const match = imageUrl.match(/\/api\/icons\/([^\/]+)\/image/)
|
||||||
|
if (match) {
|
||||||
|
// Die alte iconId war in der URL, aber die ist ja gelöscht
|
||||||
|
// Wir können hier nicht viel machen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategie 3: via Name-Matching (wenn iconId ein bekannter Name war)
|
||||||
|
// Nicht anwendbar, da iconId eine UUID ist
|
||||||
|
|
||||||
|
if (repairedId) {
|
||||||
|
stats.repaired++
|
||||||
|
toRepair.push({
|
||||||
|
featureId: feature.id,
|
||||||
|
projectId: feature.projectId,
|
||||||
|
projectTitle: feature.project?.title || '(unbekannt)',
|
||||||
|
oldIconId: iconId,
|
||||||
|
newIconId: repairedId,
|
||||||
|
repairMethod,
|
||||||
|
properties: props,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
stats.notRepairable++
|
||||||
|
stats.details.push({
|
||||||
|
featureId: feature.id,
|
||||||
|
projectId: feature.projectId,
|
||||||
|
projectTitle: feature.project?.title || '(unbekannt)',
|
||||||
|
iconId,
|
||||||
|
reason: 'Keine Zuordnung möglich (iconId nicht in migratedFromIconId)',
|
||||||
|
properties: JSON.stringify(props).substring(0, 200),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. Ergebnis-Anzeige ───
|
||||||
|
console.log('\n[4/5] Ergebnis-Zusammenfassung')
|
||||||
|
console.log('────────────────────────────────────────')
|
||||||
|
console.log(` Gesamt geprüfte Features: ${stats.totalFeatures}`)
|
||||||
|
console.log(` ✅ Gültige Referenzen: ${stats.validRefs}`)
|
||||||
|
console.log(` ❌ Kaputte Referenzen: ${stats.brokenRefs}`)
|
||||||
|
console.log(` 🔧 Automatisch reparierbar: ${stats.repaired}`)
|
||||||
|
console.log(` ⚠️ Nicht reparierbar: ${stats.notRepairable}`)
|
||||||
|
|
||||||
|
if (stats.details.length > 0) {
|
||||||
|
console.log('\n Nicht reparierbare Features (erste 10):')
|
||||||
|
for (const d of stats.details.slice(0, 10)) {
|
||||||
|
console.log(` - Feature ${d.featureId} in "${d.projectTitle}"`)
|
||||||
|
console.log(` iconId: ${d.iconId}`)
|
||||||
|
console.log(` Grund: ${d.reason}`)
|
||||||
|
}
|
||||||
|
if (stats.details.length > 10) {
|
||||||
|
console.log(` ... und ${stats.details.length - 10} weitere`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Apply (nur wenn --apply) ───
|
||||||
|
if (IS_APPLY && toRepair.length > 0) {
|
||||||
|
console.log('\n[5/5] Wende Reparaturen an...')
|
||||||
|
|
||||||
|
// Backup erstellen
|
||||||
|
const backupPath = path.join(__dirname, `feature-backup-${Date.now()}.json`)
|
||||||
|
const backup = toRepair.map(r => ({
|
||||||
|
featureId: r.featureId,
|
||||||
|
oldIconId: r.oldIconId,
|
||||||
|
newIconId: r.newIconId,
|
||||||
|
repairMethod: r.repairMethod,
|
||||||
|
oldProperties: r.properties,
|
||||||
|
}))
|
||||||
|
fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2))
|
||||||
|
console.log(` 💾 Backup erstellt: ${backupPath}`)
|
||||||
|
|
||||||
|
// Reparaturen durchführen
|
||||||
|
let applied = 0
|
||||||
|
for (const r of toRepair) {
|
||||||
|
try {
|
||||||
|
const newProps = {
|
||||||
|
...r.properties,
|
||||||
|
iconId: r.newIconId,
|
||||||
|
}
|
||||||
|
await prisma.feature.update({
|
||||||
|
where: { id: r.featureId },
|
||||||
|
data: { properties: newProps },
|
||||||
|
})
|
||||||
|
applied++
|
||||||
|
console.log(` ✅ Feature ${r.featureId}: ${r.oldIconId} → ${r.newIconId} (${r.repairMethod})`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` ❌ Feature ${r.featureId}: Update fehlgeschlagen - ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n ✅ ${applied}/${toRepair.length} Reparaturen erfolgreich angewendet`)
|
||||||
|
} else if (IS_APPLY) {
|
||||||
|
console.log('\n[5/5] Keine Reparaturen notwendig.')
|
||||||
|
} else {
|
||||||
|
console.log('\n[5/5] DRY-RUN abgeschlossen (keine Änderungen).')
|
||||||
|
if (toRepair.length > 0) {
|
||||||
|
console.log(` Zum Anwenden: node prisma/repair-features.js --apply`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Feature-Repair abgeschlossen')
|
||||||
|
}
|
||||||
|
|
||||||
|
repair()
|
||||||
|
.then(async () => { await prisma.$disconnect() })
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error('❌ Fehler:', e)
|
||||||
|
await prisma.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -90,6 +90,7 @@ model Tenant {
|
|||||||
iconCategories IconCategory[]
|
iconCategories IconCategory[]
|
||||||
iconAssets IconAsset[]
|
iconAssets IconAsset[]
|
||||||
tenantSymbols TenantSymbol[]
|
tenantSymbols TenantSymbol[]
|
||||||
|
tenantCategories TenantCategory[]
|
||||||
upgradeRequests UpgradeRequest[]
|
upgradeRequests UpgradeRequest[]
|
||||||
dictionaryEntries DictionaryEntry[]
|
dictionaryEntries DictionaryEntry[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
@@ -389,13 +390,56 @@ model TenantSymbol {
|
|||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
iconId String
|
iconId String?
|
||||||
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
icon IconAsset? @relation(fields: [iconId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
// New fields for Phase 1 Symbol Architecture
|
||||||
|
categoryId String?
|
||||||
|
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
name String? // Display name (migrated from customName || icon.name)
|
||||||
|
svgPath String? // e.g. "signaturen/TLF.svg" or tenant-specific MinIO key
|
||||||
|
isUploaded Boolean @default(false)
|
||||||
|
migratedFromIconId String?
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
@@index([categoryId])
|
||||||
@@map("tenant_symbols")
|
@@map("tenant_symbols")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Symbol Templates (global read-only packages) ─────────
|
||||||
|
|
||||||
|
model SymbolTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
packageId String // e.g. "feuerwehr-ch"
|
||||||
|
packageName String // e.g. "Feuerwehr Schweiz"
|
||||||
|
categoryName String // e.g. "Fahrzeuge"
|
||||||
|
name String
|
||||||
|
svgPath String // relative path in public/ or MinIO key
|
||||||
|
tags String[] @default([])
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
@@index([packageId])
|
||||||
|
@@map("symbol_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tenant Categories (per-tenant, user-managed) ─────────
|
||||||
|
|
||||||
|
model TenantCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
icon String? // Optional emoji or Lucide icon name for UI
|
||||||
|
|
||||||
|
symbols TenantSymbol[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("tenant_categories")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Dictionary (Global + Tenant word library) ────────────
|
// ─── Dictionary (Global + Tenant word library) ────────────
|
||||||
|
|
||||||
model DictionaryEntry {
|
model DictionaryEntry {
|
||||||
|
|||||||
@@ -44,11 +44,10 @@ async function main() {
|
|||||||
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
|
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Delete ALL old system icons (regardless of fileKey pattern)
|
// NOTE: We intentionally do NOT delete old system icons here.
|
||||||
const deleted = await prisma.iconAsset.deleteMany({
|
// TenantSymbol rows reference IconAsset.id via foreign key.
|
||||||
where: { isSystem: true },
|
// Deleting would either break references (tenant symbols become 404s)
|
||||||
})
|
// or cascade-delete tenant symbols. Instead we upsert by fileKey.
|
||||||
console.log(`🗑️ ${deleted.count} old system icons removed`)
|
|
||||||
|
|
||||||
// Upsert global categories (preserves tenant categories)
|
// Upsert global categories (preserves tenant categories)
|
||||||
const catMap = {}
|
const catMap = {}
|
||||||
@@ -90,6 +89,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
for (const file of svgFiles) {
|
for (const file of svgFiles) {
|
||||||
let name = file.replace('.svg', '')
|
let name = file.replace('.svg', '')
|
||||||
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
|
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
|
||||||
@@ -99,7 +99,21 @@ async function main() {
|
|||||||
const category = findCategory(file)
|
const category = findCategory(file)
|
||||||
|
|
||||||
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
|
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
|
||||||
if (!existing) {
|
if (existing) {
|
||||||
|
await prisma.iconAsset.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
categoryId: category.id,
|
||||||
|
mimeType: 'image/svg+xml',
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
await prisma.iconAsset.create({
|
await prisma.iconAsset.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
@@ -115,7 +129,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ FKS Signaturen: ${created} new SVG icons created (${svgFiles.length} total)`)
|
console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
123
prisma/seed-symbol-templates.ts
Normal file
123
prisma/seed-symbol-templates.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Seed-Skript: Erstellt SymbolTemplate-Einträge aus bestehenden IconAsset (isSystem=true)
|
||||||
|
* oder aus dem Dateisystem (public/signaturen/*.svg).
|
||||||
|
*
|
||||||
|
* Ausführen:
|
||||||
|
* npx ts-node prisma/seed-symbol-templates.ts
|
||||||
|
* oder als Teil des Deployments via npx prisma db seed
|
||||||
|
*
|
||||||
|
* Idempotent: bereits existierende Templates werden übersprungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { readdirSync, statSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const SIGNATUREN_DIR = join(process.cwd(), 'public', 'signaturen')
|
||||||
|
const PACKAGE_ID = 'feuerwehr-ch'
|
||||||
|
const PACKAGE_NAME = 'Feuerwehr Schweiz'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht Kategorie-Namen aus bestehenden IconAsset / IconCategory abzuleiten.
|
||||||
|
* Fallback: "Sonstiges"
|
||||||
|
*/
|
||||||
|
async function getCategoryMapping(): Promise<Map<string, string>> {
|
||||||
|
const mapping = new Map<string, string>()
|
||||||
|
|
||||||
|
const iconAssets = await (prisma as any).iconAsset.findMany({
|
||||||
|
where: { isSystem: true },
|
||||||
|
include: { category: { select: { name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const asset of iconAssets) {
|
||||||
|
const fileName = asset.fileKey.replace(/^signaturen\//, '').replace(/\.svg$/i, '')
|
||||||
|
const categoryName = asset.category?.name || 'Sonstiges'
|
||||||
|
mapping.set(fileName.toLowerCase(), categoryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest alle .svg Dateien aus public/signaturen/
|
||||||
|
*/
|
||||||
|
function getSvgFiles(dir: string): string[] {
|
||||||
|
const files: string[] = []
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry)
|
||||||
|
const stat = statSync(fullPath)
|
||||||
|
if (stat.isFile() && entry.endsWith('.svg')) {
|
||||||
|
files.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not read signaturen directory:', (err as Error).message)
|
||||||
|
}
|
||||||
|
return files.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding SymbolTemplate...')
|
||||||
|
|
||||||
|
const categoryMapping = await getCategoryMapping()
|
||||||
|
const svgFiles = getSvgFiles(SIGNATUREN_DIR)
|
||||||
|
|
||||||
|
console.log(`Found ${svgFiles.length} SVG files in public/signaturen/`)
|
||||||
|
console.log(`IconAsset mapping covers ${categoryMapping.size} entries`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < svgFiles.length; i++) {
|
||||||
|
const fileName = svgFiles[i]
|
||||||
|
const nameWithoutExt = fileName.replace(/\.svg$/i, '')
|
||||||
|
const displayName = nameWithoutExt
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
|
||||||
|
const fileKey = `signaturen/${fileName}`
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await (prisma as any).symbolTemplate.findFirst({
|
||||||
|
where: { svgPath: fileKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryName =
|
||||||
|
categoryMapping.get(nameWithoutExt.toLowerCase()) || 'Sonstiges'
|
||||||
|
|
||||||
|
await (prisma as any).symbolTemplate.create({
|
||||||
|
data: {
|
||||||
|
packageId: PACKAGE_ID,
|
||||||
|
packageName: PACKAGE_NAME,
|
||||||
|
categoryName,
|
||||||
|
name: displayName,
|
||||||
|
svgPath: fileKey,
|
||||||
|
tags: [nameWithoutExt.toLowerCase(), categoryName.toLowerCase()],
|
||||||
|
sortOrder: i,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Done: ${created} created, ${skipped} skipped`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -110,21 +110,15 @@ async function main() {
|
|||||||
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
|
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Delete ALL old system icons (regardless of fileKey pattern)
|
// NOTE: We intentionally do NOT delete old system icons here.
|
||||||
const deletedIcons = await prisma.iconAsset.deleteMany({
|
// TenantSymbol rows reference IconAsset.id via foreign key.
|
||||||
where: { isSystem: true },
|
// Deleting would either break references (tenant symbols become 404s)
|
||||||
})
|
// or cascade-delete tenant symbols. Instead we upsert by fileKey.
|
||||||
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
|
|
||||||
|
|
||||||
// Clean up empty global categories
|
// NOTE: We intentionally do NOT delete any icon categories here.
|
||||||
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } })
|
// Tenant-specific categories may reference them, and deleting could
|
||||||
for (const oldCat of oldGlobalCats) {
|
// orphan user data. Empty categories are harmless.
|
||||||
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } })
|
// Create or update global categories
|
||||||
if (remaining === 0) {
|
|
||||||
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create new global categories
|
|
||||||
const catMap = {}
|
const catMap = {}
|
||||||
for (const def of catDefs) {
|
for (const def of catDefs) {
|
||||||
const cat = await prisma.iconCategory.upsert({
|
const cat = await prisma.iconCategory.upsert({
|
||||||
@@ -163,6 +157,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
for (const file of svgFiles) {
|
for (const file of svgFiles) {
|
||||||
// Clean name: remove .svg, remove _de/_DE suffix
|
// Clean name: remove .svg, remove _de/_DE suffix
|
||||||
let name = file.replace('.svg', '')
|
let name = file.replace('.svg', '')
|
||||||
@@ -173,7 +168,21 @@ async function main() {
|
|||||||
const category = findCategory(file)
|
const category = findCategory(file)
|
||||||
|
|
||||||
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
|
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
|
||||||
if (!existing) {
|
if (existing) {
|
||||||
|
await prisma.iconAsset.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
categoryId: category.id,
|
||||||
|
mimeType: 'image/svg+xml',
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
await prisma.iconAsset.create({
|
await prisma.iconAsset.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
@@ -189,7 +198,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ FKS Signaturen: ${created} new icons created (${svgFiles.length} total SVGs)`)
|
console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total SVGs)`)
|
||||||
|
|
||||||
// Create a demo project
|
// Create a demo project
|
||||||
const demoProject = await prisma.project.upsert({
|
const demoProject = await prisma.project.upsert({
|
||||||
|
|||||||
BIN
public/Pepe_Avatar.mp4
Normal file
BIN
public/Pepe_Avatar.mp4
Normal file
Binary file not shown.
BIN
remote.txt
Normal file
BIN
remote.txt
Normal file
Binary file not shown.
@@ -50,7 +50,8 @@ export default function AppPage() {
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
|
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
|
||||||
const [isAuditOpen, setIsAuditOpen] = useState(false)
|
const [isAuditOpen, setIsAuditOpen] = useState(false)
|
||||||
|
const [presentationLocked, setPresentationLocked] = useState(false)
|
||||||
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||||
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
|
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
|
||||||
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
|
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
|
||||||
@@ -375,7 +376,7 @@ export default function AppPage() {
|
|||||||
|
|
||||||
const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false
|
const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false
|
||||||
// User can only edit if they have the role AND they hold the editing lock (or no one is editing)
|
// User can only edit if they have the role AND they hold the editing lock (or no one is editing)
|
||||||
const canEdit = roleCanEdit && (isEditingByMe || !editingBy)
|
const canEdit = !presentationLocked && roleCanEdit && (isEditingByMe || !editingBy)
|
||||||
const isReadOnly = !!editingBy && !isEditingByMe
|
const isReadOnly = !!editingBy && !isEditingByMe
|
||||||
|
|
||||||
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
||||||
@@ -806,6 +807,14 @@ export default function AppPage() {
|
|||||||
userRole={user?.role}
|
userRole={user?.role}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onStartTour={() => { resetOnboardingTour(); setShowTour(true) }}
|
onStartTour={() => { resetOnboardingTour(); setShowTour(true) }}
|
||||||
|
presentationLocked={presentationLocked}
|
||||||
|
onTogglePresentationLock={() => {
|
||||||
|
const next = !presentationLocked
|
||||||
|
setPresentationLocked(next)
|
||||||
|
if (next && isEditingByMe) {
|
||||||
|
handleStopEditing()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Offline banner */}
|
{/* Offline banner */}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
MapPin,
|
MapPin,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||||
import type { Project, DrawFeature } from '@/types'
|
import type { Project, DrawFeature } from '@/types'
|
||||||
@@ -66,6 +68,8 @@ interface TopbarProps {
|
|||||||
userRole?: string
|
userRole?: string
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
onStartTour?: () => void
|
onStartTour?: () => void
|
||||||
|
presentationLocked?: boolean
|
||||||
|
onTogglePresentationLock?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({
|
export function Topbar({
|
||||||
@@ -89,6 +93,8 @@ export function Topbar({
|
|||||||
userRole,
|
userRole,
|
||||||
onLogout,
|
onLogout,
|
||||||
onStartTour,
|
onStartTour,
|
||||||
|
presentationLocked,
|
||||||
|
onTogglePresentationLock,
|
||||||
}: TopbarProps) {
|
}: TopbarProps) {
|
||||||
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
||||||
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
||||||
@@ -172,6 +178,16 @@ export function Topbar({
|
|||||||
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
|
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={presentationLocked ? 'default' : 'outline'}
|
||||||
|
className={`h-9 md:h-10 px-2 md:px-3 text-sm ${presentationLocked ? 'bg-amber-600 hover:bg-amber-700 text-white border-amber-600' : ''}`}
|
||||||
|
onClick={onTogglePresentationLock}
|
||||||
|
title={presentationLocked ? 'Präsentationsmodus deaktivieren' : 'Präsentationsmodus aktivieren'}
|
||||||
|
>
|
||||||
|
{presentationLocked ? <Lock className="w-5 h-5 md:mr-1" /> : <Unlock className="w-5 h-5 md:mr-1" />}
|
||||||
|
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
|
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
|
||||||
|
|||||||
@@ -1670,13 +1670,56 @@ export function MapView({
|
|||||||
inner.style.transform = `rotate(${rotation}deg)`
|
inner.style.transform = `rotate(${rotation}deg)`
|
||||||
inner.style.transition = 'transform 0.1s'
|
inner.style.transition = 'transform 0.1s'
|
||||||
|
|
||||||
if (imgSrc) {
|
// Try primary image source, with fallback chain for broken/deleted icons
|
||||||
inner.style.backgroundImage = `url("${imgSrc}")`
|
const applyImage = (src: string) => {
|
||||||
|
inner.style.backgroundImage = `url("${src}")`
|
||||||
inner.style.backgroundSize = 'contain'
|
inner.style.backgroundSize = 'contain'
|
||||||
inner.style.backgroundRepeat = 'no-repeat'
|
inner.style.backgroundRepeat = 'no-repeat'
|
||||||
inner.style.backgroundPosition = 'center'
|
inner.style.backgroundPosition = 'center'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (imgSrc) {
|
||||||
|
applyImage(imgSrc)
|
||||||
|
// If image fails to load (404 from deleted icon), try fallback
|
||||||
|
const testImg = new Image()
|
||||||
|
testImg.onload = () => {} // OK
|
||||||
|
testImg.onerror = () => {
|
||||||
|
// Fallback 1: Try API endpoint with iconId
|
||||||
|
if (iconId) {
|
||||||
|
const fallbackUrl = `/api/icons/${iconId}/image`
|
||||||
|
if (fallbackUrl !== imgSrc) {
|
||||||
|
const test2 = new Image()
|
||||||
|
test2.onload = () => applyImage(fallbackUrl)
|
||||||
|
test2.onerror = () => {
|
||||||
|
// Fallback 2: Show broken symbol indicator
|
||||||
|
inner.style.backgroundImage = 'none'
|
||||||
|
inner.style.display = 'flex'
|
||||||
|
inner.style.alignItems = 'center'
|
||||||
|
inner.style.justifyContent = 'center'
|
||||||
|
inner.style.border = '2px dashed #ef4444'
|
||||||
|
inner.style.borderRadius = '4px'
|
||||||
|
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
|
||||||
|
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
|
||||||
|
}
|
||||||
|
test2.src = fallbackUrl
|
||||||
|
} else {
|
||||||
|
inner.style.backgroundImage = 'none'
|
||||||
|
inner.style.display = 'flex'
|
||||||
|
inner.style.alignItems = 'center'
|
||||||
|
inner.style.justifyContent = 'center'
|
||||||
|
inner.style.border = '2px dashed #ef4444'
|
||||||
|
inner.style.borderRadius = '4px'
|
||||||
|
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
|
||||||
|
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testImg.src = imgSrc
|
||||||
|
} else if (iconId) {
|
||||||
|
// No imageUrl at all — try loading via API
|
||||||
|
applyImage(`/api/icons/${iconId}/image`)
|
||||||
|
}
|
||||||
|
|
||||||
wrapper.appendChild(inner)
|
wrapper.appendChild(inner)
|
||||||
|
|
||||||
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode
|
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode
|
||||||
|
|||||||
Reference in New Issue
Block a user