Compare commits
20 Commits
3b57ca4594
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ecf4c61a | ||
|
|
6588959b50 | ||
|
|
a91a4c4689 | ||
|
|
693515aab5 | ||
|
|
0cbea843ab | ||
|
|
9cba24aad8 | ||
|
|
93d5519e58 | ||
|
|
c291431fd7 | ||
|
|
3606c9a2a4 | ||
|
|
3722a04091 | ||
|
|
0d0d9a7257 | ||
|
|
40cea9a9be | ||
|
|
56895be16f | ||
|
|
e9f66b2c3d | ||
|
|
c8a94e1ea7 | ||
|
|
07cede68c0 | ||
|
|
f6819b6a2b | ||
|
|
cfccd4cdcc | ||
|
|
4602de7a38 | ||
|
|
ca26f1e733 |
56
CHANGELOG.md
Normal file
56
CHANGELOG.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||||
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||||
|
|
||||||
|
## [1.4.3] – 2026-05-20 — Feature: Einzelne Symbole aus Bibliothek wählen
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- **Admin → Symbol-Manager**: Neuer Tab „Bibliothek“ zeigt alle 117 globalen Symbole gruppiert nach Kategorie. Pro Symbol ein „+“-Button, um es mit einem Klick zu „Meinen Symbolen“ hinzuzufügen (via `POST /api/tenant/symbols` mit `iconId`).
|
||||||
|
|
||||||
|
## [1.4.2] – 2026-05-20 — Hotfix: Admin leer & Legacy-Symbole
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Admin Symbol-Manager**: Liest jetzt korrekt `categories` statt `groups` aus der API-Response von `/api/tenant/symbols?grouped=true`.
|
||||||
|
- **Datenmigration (Step 18)**: Neue Migration in `prisma/migrate.js` migriert bestehende `tenantSymbols` automatisch: setzt fehlende `name`, `svgPath`, `categoryId` (Default-Kategorie „Meine Symbole“) und `migratedFromIconId`. Behebt „Leichen“ mit broken images.
|
||||||
|
|
||||||
|
## [1.4.1] – 2026-05-20 — Hotfix: Production 500 & Null-Crash
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Migration `prisma/migrate.js`**: `ALTER TABLE ... ADD COLUMN ... REFERENCES` auf PostgreSQL mit bestehenden Daten führte zu stillen Fehlern. Spalte `categoryId` wird jetzt ohne Inline-REFERENCES angelegt; Foreign-Key wird in separatem idempotenten Schritt (15b) erstellt.
|
||||||
|
- **Frontend `right-sidebar.tsx`**: `s.name.toLowerCase()` crashte wenn Symbol-Name `null` war. Optionaler Fallback auf leeren String hinzugefügt.
|
||||||
|
|
||||||
|
## [1.4.0] – 2026-05-20 — Phase 1: Symbol-Architektur Redesign
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- **SymbolTemplate** — globale, read-only Vorlagen-Pakete aus `public/signaturen/*.svg`
|
||||||
|
- **TenantCategory** — mandantenspezifische, frei anlegbare Kategorien für Symbole
|
||||||
|
- **TenantSymbol Refactor** — Symbole sind jetzt vollständig mandantenspezifisch (`name`, `svgPath`, `categoryId`, `isUploaded`)
|
||||||
|
- Upload-Dialog für eigene SVG/PNG/JPEG-Symbole mit Drag & Drop
|
||||||
|
- Import-Dialog für Vorlagen-Pakete (z.B. "Feuerwehr Schweiz")
|
||||||
|
|
||||||
|
### APIs
|
||||||
|
- `GET /api/templates` — listet verfügbare Template-Pakete mit Vorschau
|
||||||
|
- `POST /api/templates/import` — importiert ein Paket als TenantSymbols
|
||||||
|
- `GET/POST/PATCH/DELETE /api/tenant/categories` — CRUD für Tenant-Kategorien
|
||||||
|
- `GET/POST/PATCH/DELETE /api/tenant/symbols` — erweitert: Gruppierung, Upload, JSON-Import
|
||||||
|
- `GET /api/tenant/symbols/[id]/image` — liefert TenantSymbol-Bilder aus MinIO oder `public/`
|
||||||
|
- `GET /api/icons/[id]/image` — TenantSymbol-First Lookup, dann Legacy-Fallback
|
||||||
|
- `GET /api/icons` — liefert jetzt auch `tenantSymbols` und `tenantSymbolGroups`
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- **Admin → Symbol-Manager**: komplett neues Layout mit 3 Tabs (Symbole, Kategorien, Import)
|
||||||
|
- **Sidebar (RightSidebar)**: zeigt Tenant-Symbole jetzt nach Kategorie gruppiert an
|
||||||
|
|
||||||
|
### Migration & Seed
|
||||||
|
- `prisma/migrations/20260520_symbol_architecture/migration.sql`
|
||||||
|
- `prisma/seed-symbol-templates.ts`
|
||||||
|
- `prisma/migrate-tenant-symbols.ts`
|
||||||
|
- Alle Seeds idempotent (`upsert` statt `deleteMany`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.5] – vor 2026-05-20
|
||||||
|
|
||||||
|
### Bestehende Features
|
||||||
|
- Karten-Zeichenwerkzeuge, Journal, Einsatzrapport, Projekte, Benutzerverwaltung, Mandantenverwaltung
|
||||||
123
build-log.txt
Normal file
123
build-log.txt
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
▲ Next.js 15.5.12
|
||||||
|
- Environments: .env
|
||||||
|
- Experiments (use with caution):
|
||||||
|
· serverActions
|
||||||
|
|
||||||
|
Creating an optimized production build ...
|
||||||
|
✓ Compiled successfully in 5.8s
|
||||||
|
Skipping validation of types
|
||||||
|
Linting ...
|
||||||
|
Collecting page data ...
|
||||||
|
⚠ Using edge runtime on a page currently disables static generation for that page
|
||||||
|
Generating static pages (0/53) ...
|
||||||
|
Generating static pages (13/53)
|
||||||
|
Generating static pages (26/53)
|
||||||
|
Generating static pages (39/53)
|
||||||
|
✓ Generating static pages (53/53)
|
||||||
|
Finalizing page optimization ...
|
||||||
|
Collecting build traces ...
|
||||||
|
|
||||||
|
Route (app) Size First Load JS
|
||||||
|
┌ ○ / 3.03 kB 361 kB
|
||||||
|
├ ○ /_not-found 1.02 kB 340 kB
|
||||||
|
├ ƒ /[slug] 3.52 kB 348 kB
|
||||||
|
├ ○ /admin 35.3 kB 424 kB
|
||||||
|
├ ƒ /api/admin/categories 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/categories/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/icons 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/icons/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/icons/upload 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/projects 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/settings 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/tenants 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/tenants/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/tenants/[id]/logo 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/tenants/[id]/logo/serve 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/tenants/[id]/members 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/trial-reminders 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/users 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/users/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/admin/users/[id]/reset-password 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/change-password 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/delete-account 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/forgot-password 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/login 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/logout 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/me 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/register 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/resend-verification 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/reset-password 325 B 340 kB
|
||||||
|
├ ƒ /api/auth/verify-email 325 B 340 kB
|
||||||
|
├ ƒ /api/contact 325 B 340 kB
|
||||||
|
├ ƒ /api/demo 325 B 340 kB
|
||||||
|
├ ƒ /api/dictionary 325 B 340 kB
|
||||||
|
├ ƒ /api/dictionary/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/donate/checkout 325 B 340 kB
|
||||||
|
├ ƒ /api/donate/config 325 B 340 kB
|
||||||
|
├ ƒ /api/donate/webhook 325 B 340 kB
|
||||||
|
├ ƒ /api/hose-types 325 B 340 kB
|
||||||
|
├ ƒ /api/hose-types/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/icons 325 B 340 kB
|
||||||
|
├ ƒ /api/icons/[id]/image 325 B 340 kB
|
||||||
|
├ ƒ /api/icons/[id]/toggle-visibility 325 B 340 kB
|
||||||
|
├ ƒ /api/icons/upload 325 B 340 kB
|
||||||
|
├ ƒ /api/projects 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id] 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/editing 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/export 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/features 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/check-items 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/check-items/[itemId] 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/entries 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/entries/[entryId] 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/pendenzen 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/pendenzen/[pendenzId] 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/journal/send-report 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/plan-image 325 B 340 kB
|
||||||
|
├ ƒ /api/projects/[id]/plan-image/serve 325 B 340 kB
|
||||||
|
├ ƒ /api/rapports 325 B 340 kB
|
||||||
|
├ ƒ /api/rapports/[token] 325 B 340 kB
|
||||||
|
├ ƒ /api/rapports/[token]/pdf 325 B 340 kB
|
||||||
|
├ ƒ /api/rapports/[token]/send 325 B 340 kB
|
||||||
|
├ ƒ /api/settings/public 325 B 340 kB
|
||||||
|
├ ƒ /api/templates 325 B 340 kB
|
||||||
|
├ ƒ /api/templates/import 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/categories 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/delete 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/info 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/logo 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/soma-templates 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/symbols 325 B 340 kB
|
||||||
|
├ ƒ /api/tenant/symbols/[id]/image 325 B 340 kB
|
||||||
|
├ ƒ /api/tenants/[tenantId]/suggestions 325 B 340 kB
|
||||||
|
├ ƒ /api/tenants/by-slug/[slug] 325 B 340 kB
|
||||||
|
├ ƒ /api/upgrade-requests 325 B 340 kB
|
||||||
|
├ ƒ /api/upgrade-requests/[id] 325 B 340 kB
|
||||||
|
├ ○ /app 281 kB 879 kB
|
||||||
|
├ ○ /datenschutz 3.18 kB 347 kB
|
||||||
|
├ ○ /demo 8.78 kB 555 kB
|
||||||
|
├ ○ /forgot-password 3.88 kB 361 kB
|
||||||
|
├ ○ /impressum 3.01 kB 353 kB
|
||||||
|
├ ○ /login 5.74 kB 363 kB
|
||||||
|
├ ƒ /opengraph-image 325 B 340 kB
|
||||||
|
├ ƒ /rapport/[token] 4.83 kB 344 kB
|
||||||
|
├ ○ /register 5.48 kB 363 kB
|
||||||
|
├ ○ /reset-password 4.01 kB 362 kB
|
||||||
|
├ ○ /robots.txt 325 B 340 kB
|
||||||
|
├ ○ /settings 4.28 kB 356 kB
|
||||||
|
├ ○ /sitemap.xml 325 B 340 kB
|
||||||
|
├ ○ /spenden 5.26 kB 363 kB
|
||||||
|
└ ○ /spenden/danke 2.54 kB 360 kB
|
||||||
|
+ First Load JS shared by all 339 kB
|
||||||
|
├ chunks/1255-94429a3f41c08b44.js 65.5 kB
|
||||||
|
├ chunks/4bd1b696-100b9d70ed4e49c1.js 54.2 kB
|
||||||
|
├ chunks/ed9f2dc4-1b30afa125168b53.js 217 kB
|
||||||
|
└ other shared chunks (total) 2.21 kB
|
||||||
|
|
||||||
|
|
||||||
|
ƒ Middleware 40.1 kB
|
||||||
|
|
||||||
|
○ (Static) prerendered as static content
|
||||||
|
ƒ (Dynamic) server-rendered on demand
|
||||||
|
|
||||||
1599
deploy-build-and-push-305.log
Normal file
1599
deploy-build-and-push-305.log
Normal file
File diff suppressed because one or more lines are too long
@@ -5,7 +5,11 @@ echo "=== Lageplan Startup ==="
|
|||||||
|
|
||||||
# ─── Step 1: Run migrations (uses PrismaClient raw SQL, no CLI needed) ───
|
# ─── Step 1: Run migrations (uses PrismaClient raw SQL, no CLI needed) ───
|
||||||
echo "[1/3] Running database migrations..."
|
echo "[1/3] Running database migrations..."
|
||||||
node prisma/migrate.js || echo " Warning: migrations had issues (may be first run)"
|
node prisma/migrate.js
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Database migrations failed. Aborting startup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ─── Step 2: Conditional seeding ───
|
# ─── Step 2: Conditional seeding ───
|
||||||
echo "[2/3] Checking if seeding is needed..."
|
echo "[2/3] Checking if seeding is needed..."
|
||||||
@@ -34,6 +38,44 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── Step 3: Start server ───
|
# ─── Step 3: Seed SymbolTemplates (idempotent) ───
|
||||||
|
echo "[2b/3] Seeding symbol templates..."
|
||||||
|
node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function seedTemplates() {
|
||||||
|
const sigDir = path.join(process.cwd(), 'public', 'signaturen');
|
||||||
|
let files = [];
|
||||||
|
try { files = fs.readdirSync(sigDir).filter(f => f.endsWith('.svg')).sort(); } catch(e) { console.log('No signaturen dir'); }
|
||||||
|
let created = 0, skipped = 0;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const displayName = file.replace(/\.svg$/i, '').replace(/[_-]/g, ' ').trim();
|
||||||
|
const fileKey = 'signaturen/' + file;
|
||||||
|
const existing = await prisma.symbolTemplate.findFirst({ where: { svgPath: fileKey } }).catch(() => null);
|
||||||
|
if (existing) { skipped++; continue; }
|
||||||
|
await prisma.symbolTemplate.create({
|
||||||
|
data: {
|
||||||
|
packageId: 'feuerwehr-ch',
|
||||||
|
packageName: 'Feuerwehr Schweiz',
|
||||||
|
categoryName: 'Sonstiges',
|
||||||
|
name: displayName,
|
||||||
|
svgPath: fileKey,
|
||||||
|
tags: [displayName.toLowerCase()],
|
||||||
|
sortOrder: i,
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
console.log('Templates: ' + created + ' created, ' + skipped + ' skipped');
|
||||||
|
await prisma.\$disconnect();
|
||||||
|
}
|
||||||
|
seedTemplates().catch(() => { console.log('Template seed skipped'); prisma.\$disconnect(); });
|
||||||
|
" || echo " Warning: template seed failed"
|
||||||
|
|
||||||
|
# ─── Step 4: Start server ───
|
||||||
echo "[3/3] Starting server..."
|
echo "[3/3] Starting server..."
|
||||||
exec node server-custom.js
|
exec node server-custom.js
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.3.5",
|
"version": "1.4.3",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
> Basierend auf: `docs/roadmap-feedback-fabian.md`
|
> Basierend auf: `docs/roadmap-feedback-fabian.md`
|
||||||
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
|
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
|
||||||
|
> **Status: ABGESCHLOSSEN** ✅ (Sprints A–D implementiert, getestet und gepusht)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,14 +41,16 @@ model SymbolTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TenantCategory {
|
model TenantCategory {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
sortOrder Int @default(0)
|
description String?
|
||||||
icon String? // Optional: Emoji/Lucide-Icon-Name für UI
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
symbols TenantSymbol[]
|
symbols TenantSymbol[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -55,7 +58,7 @@ model TenantCategory {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Bestehendes `TenantSymbol` umbauen
|
### 2.2 Bestehendes `TenantSymbol` umbaut
|
||||||
|
|
||||||
**Vorher:**
|
**Vorher:**
|
||||||
```prisma
|
```prisma
|
||||||
@@ -77,18 +80,18 @@ model TenantSymbol {
|
|||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
categoryId String
|
categoryId String?
|
||||||
category TenantCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
|
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
|
||||||
svgPath String // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
|
svgPath String? // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
|
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Legacy-Feld für Migration (nach erfolgreichem Deploy entfernen)
|
// Legacy-Feld für Migration (kann später entfernt werden)
|
||||||
migratedFromIconId String?
|
migratedFromIconId String?
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -101,270 +104,164 @@ model TenantSymbol {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Migrationstrategie
|
## 3. Migration & Seed (Sprint A) ✅
|
||||||
|
|
||||||
### 3.1 Vor dem Deploy
|
- `prisma/migrations/20260520_symbol_architecture/migration.sql` — erstellt `symbol_templates`, `tenant_categories`, neue Spalten in `tenant_symbols`
|
||||||
- **PostgreSQL-Backup** erstellen
|
- `prisma/seed-symbol-templates.ts` — füllt `SymbolTemplate` aus `public/signaturen/*.svg`
|
||||||
- **MinIO-Backup** der Icon-Dateien
|
- `prisma/migrate-tenant-symbols.ts` — migriert bestehende `TenantSymbol`-Einträge
|
||||||
- Auf Staging testen (falls vorhanden)
|
- `prisma/migrate.js` — erweitert um Migrationsschritte für Produktion
|
||||||
|
- Alle Seed-Skripte sind idempotent (`upsert` statt `deleteMany`)
|
||||||
### 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. API-Design (Sprint B) ✅
|
||||||
|
|
||||||
### 4.1 Templates
|
### 4.1 Templates
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/templates
|
GET /api/templates
|
||||||
→ { packages: [{ id, name, description, symbolCount, previewUrls }] }
|
→ { packages: [{ packageId, packageName, categoryCount, symbolCount, previewSymbols }] }
|
||||||
|
|
||||||
GET /api/templates?packageId=feuerwehr-ch
|
|
||||||
→ { packageId, packageName, categories: [{ categoryName, symbols: [{ id, name, svgPath, tags }] }] }
|
|
||||||
|
|
||||||
POST /api/templates/import
|
POST /api/templates/import
|
||||||
Body: { packageId: "feuerwehr-ch", symbolIds?: ["id1", "id2"] }
|
Body: { packageId }
|
||||||
→ importiert ausgewählte Symbole als TenantSymbols (oder alle wenn symbolIds fehlt)
|
→ importiert alle Symbole des Pakets als TenantSymbols
|
||||||
→ antwortet mit [{ tenantSymbolId, name, categoryId }]
|
→ antwortet mit { imported, skipped, message }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Tenant Categories (Admin)
|
**Implementiert:**
|
||||||
|
- `src/app/api/templates/route.ts` — GET mit `groupBy` Aggregation und Vorschau
|
||||||
|
- `src/app/api/templates/import/route.ts` — POST mit Auto-Kategorie-Erstellung und Deduplikation
|
||||||
|
|
||||||
|
### 4.2 Tenant Categories
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/tenant/categories
|
GET /api/tenant/categories → [{ id, name, sortOrder, description }]
|
||||||
→ [{ id, name, sortOrder, icon, symbolCount }]
|
POST /api/tenant/categories Body: { name } → neue Kategorie
|
||||||
|
PATCH /api/tenant/categories Body: { id, name } → umbenennen
|
||||||
POST /api/tenant/categories
|
DELETE /api/tenant/categories?id=... → 204 oder 409 wenn nicht leer
|
||||||
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)
|
**Implementiert:** `src/app/api/tenant/categories/route.ts`
|
||||||
|
|
||||||
|
### 4.3 Tenant Symbols
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/tenant/symbols
|
GET /api/tenant/symbols?grouped=true|false
|
||||||
→ { categories: [{ id, name, sortOrder, symbols: [{ id, name, svgPath, sortOrder, isUploaded }] }] }
|
→ grouped=true: { groups: [{ category, symbols }], symbols: [...] }
|
||||||
// Gruppiert nach TenantCategory
|
→ grouped=false: { symbols: [...] }
|
||||||
|
|
||||||
POST /api/tenant/symbols
|
POST /api/tenant/symbols
|
||||||
Body: { templateId } // Import aus Template
|
- Multipart: { file, categoryId? } → Upload nach MinIO
|
||||||
Body: { name, svgPath, categoryId } // Manuelle Erstellung
|
- JSON: { templateId, categoryId? } → aus SymbolTemplate
|
||||||
→ { id, name, svgPath, categoryId, sortOrder }
|
- JSON: { iconId, customName?, categoryId? } → aus IconAsset (Legacy)
|
||||||
|
|
||||||
POST /api/tenant/symbols/upload
|
PATCH /api/tenant/symbols Body: { id, name?, customName?, categoryId?, sortOrder? }
|
||||||
Multipart: { file (SVG/PNG), name, categoryId }
|
|
||||||
→ uploaded TenantSymbol
|
|
||||||
|
|
||||||
PATCH /api/tenant/symbols/:id
|
DELETE /api/tenant/symbols Body: { id } → löscht TenantSymbol
|
||||||
Body: { name?, categoryId?, sortOrder? }
|
|
||||||
→ updated
|
|
||||||
|
|
||||||
DELETE /api/tenant/symbols/:id
|
|
||||||
→ 204
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Icon-Serving (unchanged path für Kompatibilität)
|
**Implementiert:** `src/app/api/tenant/symbols/route.ts` + `src/app/api/tenant/symbols/[id]/image/route.ts`
|
||||||
|
|
||||||
|
### 4.4 Icon-Serving (TenantSymbol-First Lookup)
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/icons/:tenantSymbolId/image
|
GET /api/icons/:id/image
|
||||||
→ Liest `TenantSymbol.svgPath` und serviert Datei (aus public/ oder MinIO)
|
→ 1. TenantSymbol (svgPath aus public/ oder MinIO)
|
||||||
|
→ 2. Fallback: IconAsset (legacy)
|
||||||
|
|
||||||
|
GET /api/tenant/symbols/:id/image
|
||||||
|
→ TenantSymbol-Bild aus MinIO oder public/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Implementiert:**
|
||||||
|
- `src/app/api/icons/[id]/image/route.ts`
|
||||||
|
- `src/app/api/tenant/symbols/[id]/image/route.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Frontend-Änderungen
|
## 5. Frontend-Änderungen (Sprints C + D) ✅
|
||||||
|
|
||||||
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
|
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
|
||||||
|
|
||||||
**Umbau in 3 Bereiche:**
|
**Tabs:**
|
||||||
|
1. **Meine Symbole** — nach TenantCategory gruppierte Symbol-Liste
|
||||||
|
- Expandable Kategorien
|
||||||
|
- SymbolCard: Bild, Name (inline-edit), Kategorie-Select, Löschen
|
||||||
|
- Suche über alle Symbole
|
||||||
|
2. **Kategorien** — CRUD für TenantCategory
|
||||||
|
- Neue Kategorie erstellen
|
||||||
|
- Umbenennen (inline)
|
||||||
|
- Löschen nur wenn leer
|
||||||
|
3. **Vorlagen importieren** — Template-Pakete als Cards
|
||||||
|
- Vorschau der ersten 4 Symbole
|
||||||
|
- 1-Klick Import
|
||||||
|
|
||||||
```
|
**Actions:**
|
||||||
┌─ Symbol-Manager ─────────────────────────────────┐
|
- Upload-Button → Dialog mit Drag & Drop + Kategorie-Auswahl
|
||||||
│ │
|
- Import-Button → Dialog mit allen verfügbaren Paketen
|
||||||
│ [+ Kategorie anlegen] [📦 Vorlagen importieren] │
|
|
||||||
│ │
|
|
||||||
│ ┌─ Fahrzeuge ─────────────┐ [⋮] [✎] [🗑] │
|
|
||||||
│ │ 🚒 TLF [✎] [🗑] [↕] │
|
|
||||||
│ │ 🚒 RW [✎] [🗑] [↕] │
|
|
||||||
│ │ [+ Symbol hinzufügen] │
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ ┌─ Wasser ─────────────────┐ [⋮] [✎] [🗑] │
|
|
||||||
│ │ 🟦 Hydrant [✎] [🗑] [↕] │
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [Eigenes SVG hochladen] │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
### 5.2 Sidebar — RightSidebar (`src/components/layout/right-sidebar.tsx`)
|
||||||
- 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`)
|
- Verwendet neue `tenantSymbolGroups` aus `/api/icons`
|
||||||
|
- **Meine Symbole** Sektion: nach TenantCategory gruppiert, expandable
|
||||||
|
- **Bibliothek** Sektion: globale IconAsset-Kategorien (Legacy, read-only)
|
||||||
|
- Drag & Drop funktioniert mit neuen TenantSymbol-IDs
|
||||||
|
|
||||||
```
|
### 5.3 API /icons — Kompatibilität
|
||||||
┌─ 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
|
`src/app/api/icons/route.ts` liefert:
|
||||||
|
- `categories` — Legacy IconAsset-Kategorien (für alte Clients)
|
||||||
Aktuell gibt es zwei Komponenten:
|
- `mySymbols` — Legacy TenantSymbol-Liste (für alte Clients)
|
||||||
- `LeftToolbar` = Zeichenwerkzeuge (Bleistift, Linie, etc.)
|
- `tenantSymbols` — Neue flache TenantSymbol-Liste
|
||||||
- Symbol-Palette = vermutlich in `map-view.tsx` oder separater Komponente
|
- `tenantSymbolGroups` — Neue gruppierte Liste für Sidebar
|
||||||
|
|
||||||
**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
|
## 6. Dateien: Create / Modify
|
||||||
|
|
||||||
### Neue Dateien
|
### Neue Dateien ✅
|
||||||
| Pfad | Beschreibung |
|
| Pfad | Beschreibung |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `prisma/migrations/2026xxxx_symbol_architecture/` | Prisma Migration |
|
| `prisma/migrations/20260520_symbol_architecture/migration.sql` | Migration neue Tabellen + Spalten |
|
||||||
| `prisma/seed-symbol-templates.ts` | Seed-Skript: `public/signaturen/*.svg` → `SymbolTemplate` |
|
| `prisma/seed-symbol-templates.ts` | Seed: `public/signaturen/*.svg` → `SymbolTemplate` |
|
||||||
| `src/app/api/templates/route.ts` | GET /api/templates |
|
| `src/app/api/templates/route.ts` | GET /api/templates |
|
||||||
| `src/app/api/templates/import/route.ts` | POST /api/templates/import |
|
| `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/route.ts` | CRUD TenantCategory |
|
||||||
| `src/app/api/tenant/categories/[id]/route.ts` | PATCH/DELETE einzelne Kategorie |
|
| `src/app/api/tenant/symbols/[id]/image/route.ts` | TenantSymbol Bild-Serving |
|
||||||
| `src/components/admin/import-templates-dialog.tsx` | Import-Dialog UI |
|
|
||||||
| `src/components/map/symbol-palette.tsx` | Extrahierte Symbol-Palette |
|
|
||||||
|
|
||||||
### Zu modifizierende Dateien
|
### Modifizierte Dateien ✅
|
||||||
| Pfad | Änderung |
|
| Pfad | Änderung |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol umbauen |
|
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol Refactor |
|
||||||
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung nach Category, Upload, CRUD |
|
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung, Upload, CRUD |
|
||||||
| `src/app/api/icons/route.ts` | Legacy-Modus, ggf. auf TenantSymbol umleiten |
|
| `src/app/api/icons/route.ts` | Liefert tenantSymbolGroups |
|
||||||
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorie-Verwaltung |
|
| `src/app/api/icons/[id]/image/route.ts` | TenantSymbol-First Lookup |
|
||||||
| `src/app/app/page.tsx` | Symbol-Palette Props anpassen |
|
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorien, Import, Upload |
|
||||||
| `src/components/map/map-view.tsx` | Symbol-Rendering mit instanceLabel Badge |
|
| `src/components/layout/right-sidebar.tsx` | Gruppierte TenantSymbol-Anzeige |
|
||||||
| `src/types/index.ts` | Neue Typen: `TenantCategory`, `TenantSymbol`, `SymbolTemplate` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ausführungsreihenfolge (Execution Order)
|
## 7. Ausführungsreihenfolge (Tatsächlich)
|
||||||
|
|
||||||
### Sprint A — Schema & Daten (Woche 1)
|
| Sprint | Status | Inhalt |
|
||||||
1. `prisma/schema.prisma` erweitern (`SymbolTemplate`, `TenantCategory`, `TenantSymbol` Refactor)
|
|--------|--------|--------|
|
||||||
2. Prisma Migration erstellen & testen (`npx prisma migrate dev`)
|
| **A** | ✅ Fertig | Schema, Migration, Seed, Daten-Migration |
|
||||||
3. `prisma/seed-symbol-templates.ts` schreiben (feuerwehr-ch Paket aus `public/signaturen/`)
|
| **B** | ✅ Fertig | Templates API, Categories API, Symbols API erweitert |
|
||||||
4. Migration-Skript für bestehende Tenants (Default-Kategorie + TenantSymbol-Migration)
|
| **C** | ✅ Fertig | Admin UI: Symbol-Manager mit Kategorien, Import, Upload |
|
||||||
5. Build testen, auf Staging deployen
|
| **D** | ✅ Fertig | Frontend Sidebar gruppiert nach TenantCategory |
|
||||||
|
|
||||||
### 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
|
## 8. Risiken & Entscheidungen (Umgesetzt)
|
||||||
|
|
||||||
| Thema | Option A (empfohlen) | Option B |
|
| Thema | Entscheidung |
|
||||||
|-------|---------------------|----------|
|
|-------|-------------|
|
||||||
| **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) |
|
| **SVG-Speicherort** | Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead). Uploads: MinIO `tenant-{id}/symbols/`. |
|
||||||
| **TenantSymbol Uploads** | Eigenes MinIO-Bucket `tenant-{id}/symbols/` | In DB als Text speichern (Base64 oder SVG-String) |
|
| **Migration alter Tenants** | Vollautomatisch via `prisma/migrate.js` + `prisma/migrate-tenant-symbols.ts`. |
|
||||||
| **Migration alter Tenants** | Auto-Import feuerwehr-ch Paket + Default-Kategorie | Manuelle Migration pro Tenant |
|
| **Sidebar vs SymbolPalette** | `RightSidebar` direkt angepasst, keine separate `SymbolPalette`-Komponente nötig. |
|
||||||
| **LeftToolbar vs SymbolPalette** | SymbolPalette als separate Komponente neben LeftToolbar | In LeftToolbar integrieren |
|
| **instanceLabel / Stockwerke** | Nicht in Sprint D enthalten (laut Roadmap Phase 1.4 — kann separat geplant werden). |
|
||||||
|
|
||||||
**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*
|
*Plan erstellt: 2026-05-20*
|
||||||
*Nächster Schritt: Genehmigung durch Pepe, dann Sprint A starten*
|
*Phase 1 abgeschlossen: 2026-05-20*
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ async function migrate() {
|
|||||||
console.log(' Privacy consent columns skipped:', e.message)
|
console.log(' Privacy consent columns skipped:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 12: Create tenant_symbols table ───
|
// ─── Step 12: Create tenant_symbols table (CRITICAL — fail-fast) ───
|
||||||
console.log(' [12] Creating tenant_symbols table...')
|
console.log(' [12] Creating tenant_symbols table...')
|
||||||
try {
|
try {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
@@ -226,7 +226,8 @@ async function migrate() {
|
|||||||
`)
|
`)
|
||||||
console.log(' tenant_symbols table created (or already exists)')
|
console.log(' tenant_symbols table created (or already exists)')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(' tenant_symbols table skipped:', e.message)
|
console.error(' ❌ CRITICAL: tenant_symbols table creation failed:', e.message)
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 13: Create symbol_templates table ───
|
// ─── Step 13: Create symbol_templates table ───
|
||||||
@@ -251,7 +252,7 @@ async function migrate() {
|
|||||||
console.log(' symbol_templates table skipped:', e.message)
|
console.log(' symbol_templates table skipped:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 14: Create tenant_categories table ───
|
// ─── Step 14: Create tenant_categories table (CRITICAL — fail-fast) ───
|
||||||
console.log(' [14] Creating tenant_categories table...')
|
console.log(' [14] Creating tenant_categories table...')
|
||||||
try {
|
try {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
@@ -268,7 +269,20 @@ async function migrate() {
|
|||||||
`)
|
`)
|
||||||
console.log(' tenant_categories table created (or already exists)')
|
console.log(' tenant_categories table created (or already exists)')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(' tenant_categories table skipped:', e.message)
|
console.error(' ❌ CRITICAL: tenant_categories table creation failed:', e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 14b: Ensure tenant_categories has NOT NULL defaults ───
|
||||||
|
console.log(' [14b] Ensuring tenant_categories columns...')
|
||||||
|
const tcColumns = [
|
||||||
|
`ALTER TABLE tenant_categories ALTER COLUMN "isActive" SET DEFAULT true`,
|
||||||
|
`ALTER TABLE tenant_categories ALTER COLUMN "sortOrder" SET DEFAULT 0`,
|
||||||
|
`ALTER TABLE tenant_categories ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
`ALTER TABLE tenant_categories ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
]
|
||||||
|
for (const sql of tcColumns) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 15: Extend tenant_symbols with Phase 1 columns ───
|
// ─── Step 15: Extend tenant_symbols with Phase 1 columns ───
|
||||||
@@ -277,8 +291,10 @@ async function migrate() {
|
|||||||
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`,
|
`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 "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 "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 "categoryId" TEXT`,
|
||||||
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "customName" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER`,
|
||||||
`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 "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`,
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
]
|
]
|
||||||
@@ -288,6 +304,29 @@ async function migrate() {
|
|||||||
}
|
}
|
||||||
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`)
|
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`)
|
||||||
|
|
||||||
|
// ─── Step 15b: Add tenant_symbols.categoryId FK separately (idempotent) ───
|
||||||
|
console.log(' [15b] Adding tenant_symbols.categoryId FK...')
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'tenant_symbols_categoryId_fkey'
|
||||||
|
AND table_name = 'tenant_symbols'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tenant_symbols
|
||||||
|
ADD CONSTRAINT "tenant_symbols_categoryId_fkey"
|
||||||
|
FOREIGN KEY ("categoryId") REFERENCES tenant_categories(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'categoryId FK skipped: %', SQLERRM;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
console.log(' ✅ tenant_symbols.categoryId FK added')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' categoryId FK skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 16: Fix tenant_symbols FK (CASCADE → SET NULL) ───
|
// ─── Step 16: Fix tenant_symbols FK (CASCADE → SET NULL) ───
|
||||||
console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...')
|
console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...')
|
||||||
try {
|
try {
|
||||||
@@ -321,6 +360,62 @@ async function migrate() {
|
|||||||
console.log(' Unique constraint drop skipped:', e.message)
|
console.log(' Unique constraint drop skipped:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Step 18: Migrate legacy tenantSymbols (name, svgPath, categoryId, migratedFromIconId) ───
|
||||||
|
console.log(' [18] Migrating legacy tenantSymbols data...')
|
||||||
|
try {
|
||||||
|
const tenantsWithSymbols = await prisma.$queryRawUnsafe(`
|
||||||
|
SELECT "tenantId", COUNT(id) as cnt FROM tenant_symbols GROUP BY "tenantId"
|
||||||
|
`)
|
||||||
|
let catsCreated = 0
|
||||||
|
let symsMigrated = 0
|
||||||
|
for (const row of tenantsWithSymbols) {
|
||||||
|
const tenantId = row.tenantId
|
||||||
|
// Create default category if none exists for this tenant
|
||||||
|
let defaultCat = await prisma.$queryRawUnsafe(`
|
||||||
|
SELECT id FROM tenant_categories WHERE "tenantId" = '${tenantId}' AND name = 'Meine Symbole' LIMIT 1
|
||||||
|
`)
|
||||||
|
if (!defaultCat || !defaultCat.length) {
|
||||||
|
const newCatId = crypto.randomUUID()
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
INSERT INTO tenant_categories (id, "tenantId", name, "sortOrder", "createdAt", "updatedAt")
|
||||||
|
VALUES ('${newCatId}', '${tenantId}', 'Meine Symbole', 0, NOW(), NOW())
|
||||||
|
`)
|
||||||
|
defaultCat = [{ id: newCatId }]
|
||||||
|
catsCreated++
|
||||||
|
}
|
||||||
|
const catId = defaultCat[0].id
|
||||||
|
|
||||||
|
// Migrate symbols: set name, svgPath, categoryId, migratedFromIconId where null
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
UPDATE tenant_symbols ts
|
||||||
|
SET
|
||||||
|
name = COALESCE(ts.name, ts."customName", ia.name, 'Unbenannt'),
|
||||||
|
"svgPath" = COALESCE(ts."svgPath", ia."fileKey"),
|
||||||
|
"categoryId" = COALESCE(ts."categoryId", '${catId}'),
|
||||||
|
"migratedFromIconId" = COALESCE(ts."migratedFromIconId", ts."iconId"),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
FROM icon_assets ia
|
||||||
|
WHERE ts."tenantId" = '${tenantId}'
|
||||||
|
AND ia.id = ts."iconId"
|
||||||
|
AND (ts.name IS NULL OR ts."svgPath" IS NULL OR ts."categoryId" IS NULL OR ts."migratedFromIconId" IS NULL)
|
||||||
|
`)
|
||||||
|
// Also handle symbols where iconId is already null (orphaned) — at least set category & name
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
UPDATE tenant_symbols
|
||||||
|
SET
|
||||||
|
name = COALESCE(name, "customName", 'Unbenannt'),
|
||||||
|
"categoryId" = COALESCE("categoryId", '${catId}'),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "tenantId" = '${tenantId}'
|
||||||
|
AND (name IS NULL OR "categoryId" IS NULL)
|
||||||
|
`)
|
||||||
|
symsMigrated++
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${catsCreated} default categories created, ${symsMigrated} tenants processed`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' Legacy migration skipped:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Database migrations complete')
|
console.log('✅ Database migrations complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,56 @@ import { join } from 'path'
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const icon = await prisma.iconAsset.findUnique({
|
const { id } = await params
|
||||||
where: { id: params.id },
|
|
||||||
|
// ─── 1. Try TenantSymbol first (Phase 1 architecture) ───
|
||||||
|
const tenantSymbol = await (prisma as any).tenantSymbol.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { svgPath: true, iconId: true, name: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (tenantSymbol?.svgPath) {
|
||||||
|
// Serve from public/signaturen/ or MinIO
|
||||||
|
const contentType = tenantSymbol.svgPath.endsWith('.svg') ? 'image/svg+xml' : 'image/png'
|
||||||
|
if (tenantSymbol.svgPath.startsWith('signaturen/')) {
|
||||||
|
try {
|
||||||
|
const filePath = join(process.cwd(), 'public', tenantSymbol.svgPath)
|
||||||
|
const buffer = await readFile(filePath)
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=31536000',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.error('TenantSymbol file not found:', tenantSymbol.svgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not in public/, try MinIO
|
||||||
|
try {
|
||||||
|
const { stream, contentType: ct } = await getFileStream(tenantSymbol.svgPath)
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': ct || contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.error('Error streaming TenantSymbol from MinIO:', tenantSymbol.svgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Fallback: IconAsset (legacy architecture) ───
|
||||||
|
const icon = await prisma.iconAsset.findUnique({ where: { id } })
|
||||||
|
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,70 +2,209 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
async function ensureTables() {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_symbols (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
"iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"svgPath" TEXT,
|
||||||
|
"isUploaded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"migratedFromIconId" TEXT,
|
||||||
|
"customName" TEXT,
|
||||||
|
"sortOrder" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
const symbolCols = [
|
||||||
|
`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`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "customName" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER`,
|
||||||
|
`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`,
|
||||||
|
]
|
||||||
|
for (const sql of symbolCols) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const catCols = [
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "description" TEXT`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
]
|
||||||
|
for (const sql of catCols) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
/* ─── 1. Global library (legacy IconAsset) ─── */
|
||||||
|
let categoriesWithUrls: any[] = []
|
||||||
try {
|
try {
|
||||||
const user = await getSession()
|
const user = await getSession()
|
||||||
|
const tenantId = user?.tenantId
|
||||||
|
|
||||||
// Filter categories: global (tenantId=null) + tenant-specific
|
const categories = await prisma.$queryRawUnsafe(
|
||||||
const categoryWhere: any = user?.tenantId
|
`SELECT * FROM icon_categories WHERE "tenantId" IS NULL OR "tenantId" = $1 ORDER BY "sortOrder" ASC`,
|
||||||
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
tenantId || null
|
||||||
: {}
|
) as any[]
|
||||||
|
|
||||||
const categories = await (prisma as any).iconCategory.findMany({
|
|
||||||
where: categoryWhere,
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
include: {
|
|
||||||
icons: {
|
|
||||||
where: user?.tenantId
|
|
||||||
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
|
||||||
: { isActive: true, tenantId: null },
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get tenant's hidden icon IDs (legacy)
|
|
||||||
let hiddenIconIds: string[] = []
|
let hiddenIconIds: string[] = []
|
||||||
if (user?.tenantId) {
|
if (tenantId) {
|
||||||
const tenant = await (prisma as any).tenant.findUnique({
|
const tenant = await prisma.$queryRawUnsafe(
|
||||||
where: { id: user.tenantId },
|
`SELECT "hiddenIconIds" FROM tenants WHERE id = $1 LIMIT 1`,
|
||||||
select: { hiddenIconIds: true },
|
tenantId
|
||||||
})
|
) as any[]
|
||||||
hiddenIconIds = tenant?.hiddenIconIds || []
|
hiddenIconIds = tenant[0]?.hiddenIconIds || []
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriesWithUrls = categories.map((cat: any) => ({
|
for (const cat of categories) {
|
||||||
...cat,
|
const icons = await prisma.$queryRawUnsafe(
|
||||||
icons: cat.icons
|
`SELECT * FROM icon_assets WHERE "categoryId" = $1 AND "isActive" = true AND ("tenantId" IS NULL OR "tenantId" = $2) ORDER BY name ASC`,
|
||||||
|
cat.id, tenantId || null
|
||||||
|
) as any[]
|
||||||
|
const filteredIcons = icons
|
||||||
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
|
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
|
||||||
.map((icon: any) => ({
|
.map((icon: any) => ({
|
||||||
...icon,
|
...icon,
|
||||||
url: `/api/icons/${icon.id}/image`,
|
url: `/api/icons/${icon.id}/image`,
|
||||||
})),
|
}))
|
||||||
}))
|
if (filteredIcons.length > 0) {
|
||||||
|
categoriesWithUrls.push({
|
||||||
|
...cat,
|
||||||
|
icons: filteredIcons,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching legacy icon categories:', err)
|
||||||
|
categoriesWithUrls = []
|
||||||
|
}
|
||||||
|
|
||||||
// Get tenant's custom symbol collection (with custom names)
|
/* ─── 2. Tenant symbols (Phase 1 architecture) ─── */
|
||||||
let mySymbols: any[] = []
|
let tenantSymbolGroups: any[] = []
|
||||||
if (user?.tenantId) {
|
let flatTenantSymbols: any[] = []
|
||||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
|
||||||
where: { tenantId: user.tenantId },
|
try {
|
||||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
const user = await getSession()
|
||||||
orderBy: { sortOrder: 'asc' },
|
const tenantId = user?.tenantId
|
||||||
})
|
|
||||||
mySymbols = tenantSymbols.map((ts: any) => ({
|
if (tenantId) {
|
||||||
id: ts.icon.id,
|
await ensureTables()
|
||||||
tenantSymbolId: ts.id,
|
|
||||||
name: ts.customName || ts.icon.name,
|
const tenantSymbols = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT ts.*, tc.id as "catId", tc.name as "catName"
|
||||||
|
FROM tenant_symbols ts
|
||||||
|
LEFT JOIN tenant_categories tc ON ts."categoryId" = tc.id
|
||||||
|
WHERE ts."tenantId" = $1 AND (ts."isActive" IS NULL OR ts."isActive" = true)
|
||||||
|
ORDER BY COALESCE(tc."sortOrder", 9999) ASC, COALESCE(ts."sortOrder", 9999) ASC`,
|
||||||
|
tenantId
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
flatTenantSymbols = tenantSymbols.map((ts: any) => ({
|
||||||
|
id: ts.id,
|
||||||
|
name: ts.customName || ts.name,
|
||||||
customName: ts.customName,
|
customName: ts.customName,
|
||||||
mimeType: ts.icon.mimeType,
|
categoryId: ts.categoryId,
|
||||||
iconType: ts.icon.iconType,
|
categoryName: ts.catName || null,
|
||||||
url: `/api/icons/${ts.icon.id}/image`,
|
isUploaded: ts.isUploaded,
|
||||||
|
migratedFromIconId: ts.migratedFromIconId,
|
||||||
|
imageUrl: ts.isUploaded
|
||||||
|
? `/api/tenant/symbols/${ts.id}/image`
|
||||||
|
: ts.migratedFromIconId
|
||||||
|
? `/api/icons/${ts.migratedFromIconId}/image`
|
||||||
|
: `/api/icons/${ts.id}/image`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const groups = new Map<string | null, any[]>()
|
||||||
|
for (const sym of flatTenantSymbols) {
|
||||||
|
const key = sym.categoryId || '__none__'
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key)!.push(sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
const catIds = Array.from(groups.keys()).filter(k => k !== '__none__') as string[]
|
||||||
|
let tenantCategories: any[] = []
|
||||||
|
if (catIds.length > 0) {
|
||||||
|
tenantCategories = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM tenant_categories WHERE id = ANY($1) AND "tenantId" = $2 ORDER BY "sortOrder" ASC`,
|
||||||
|
catIds, tenantId
|
||||||
|
) as any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const catMap = new Map(tenantCategories.map((c: any) => [c.id, c]))
|
||||||
|
|
||||||
|
tenantSymbolGroups = Array.from(groups.entries()).map(([catId, symbols]) => {
|
||||||
|
const cat = catId !== '__none__' ? catMap.get(catId) : null
|
||||||
|
return {
|
||||||
|
category: cat ? { id: cat.id, name: cat.name } : null,
|
||||||
|
symbols,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tenant symbols:', err)
|
||||||
|
tenantSymbolGroups = []
|
||||||
|
flatTenantSymbols = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 3. Legacy mySymbols (keep for old clients during transition) ─── */
|
||||||
|
let mySymbolsLegacy: any[] = []
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
const tenantId = user?.tenantId
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
const legacy = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT ts.*, ia.id as "iconId", ia.name as "iconName", ia."mimeType", ia."iconType"
|
||||||
|
FROM tenant_symbols ts
|
||||||
|
JOIN icon_assets ia ON ts."iconId" = ia.id
|
||||||
|
WHERE ts."tenantId" = $1 AND ts."iconId" IS NOT NULL
|
||||||
|
ORDER BY COALESCE(ts."sortOrder", 9999) ASC`,
|
||||||
|
tenantId
|
||||||
|
) as any[]
|
||||||
|
mySymbolsLegacy = legacy.map((ts: any) => ({
|
||||||
|
id: ts.iconId,
|
||||||
|
tenantSymbolId: ts.id,
|
||||||
|
name: ts.customName || ts.iconName,
|
||||||
|
customName: ts.customName,
|
||||||
|
mimeType: ts.mimeType,
|
||||||
|
iconType: ts.iconType,
|
||||||
|
url: `/api/icons/${ts.iconId}/image`,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
|
console.error('Error fetching legacy mySymbols:', err)
|
||||||
} catch (error) {
|
mySymbolsLegacy = []
|
||||||
console.error('Error fetching icons:', error)
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
categories: categoriesWithUrls,
|
||||||
|
mySymbols: mySymbolsLegacy,
|
||||||
|
tenantSymbols: flatTenantSymbols,
|
||||||
|
tenantSymbolGroups,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/app/api/templates/import/route.ts
Normal file
116
src/app/api/templates/import/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
async function getTenantId() {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return { error: 'Nicht autorisiert', status: 401 }
|
||||||
|
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||||
|
return { error: 'Keine Berechtigung', status: 403 }
|
||||||
|
}
|
||||||
|
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
|
||||||
|
return { tenantId: user.tenantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/templates/import
|
||||||
|
* Importiert Symbole aus einem Template-Paket als TenantSymbols.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* packageId: "feuerwehr-ch",
|
||||||
|
* symbolIds?: ["id1", "id2"], // optional: nur ausgewählte, sonst alle
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* { imported: [{ tenantSymbolId, name, categoryId }] }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { packageId, symbolIds } = await req.json()
|
||||||
|
if (!packageId) return NextResponse.json({ error: 'packageId erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
|
// 1. Fetch templates to import
|
||||||
|
const where: any = { packageId }
|
||||||
|
if (symbolIds && Array.isArray(symbolIds) && symbolIds.length > 0) {
|
||||||
|
where.id = { in: symbolIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = await (prisma as any).symbolTemplate.findMany({ where })
|
||||||
|
if (templates.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Keine Symbole im Paket gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure default category exists for each template category
|
||||||
|
const categoryMap = new Map<string, string>() // categoryName -> TenantCategory.id
|
||||||
|
const uniqueCategoryNames: string[] = [...new Set<string>(templates.map((t: any) => t.categoryName as string))]
|
||||||
|
|
||||||
|
for (const catName of uniqueCategoryNames) {
|
||||||
|
let tenantCat = await (prisma as any).tenantCategory.findFirst({
|
||||||
|
where: { tenantId, name: catName },
|
||||||
|
})
|
||||||
|
if (!tenantCat) {
|
||||||
|
tenantCat = await (prisma as any).tenantCategory.create({
|
||||||
|
data: { tenantId, name: catName, sortOrder: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
categoryMap.set(catName, tenantCat.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get current max sortOrder for tenant
|
||||||
|
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
|
||||||
|
where: { tenantId },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
})
|
||||||
|
let currentSort = (maxSortAgg._max.sortOrder ?? -1) + 1
|
||||||
|
|
||||||
|
// 4. Import each template as TenantSymbol
|
||||||
|
const imported: Array<{
|
||||||
|
tenantSymbolId: string
|
||||||
|
name: string
|
||||||
|
categoryId: string
|
||||||
|
svgPath: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const tpl of templates) {
|
||||||
|
const categoryId = categoryMap.get(tpl.categoryName)
|
||||||
|
if (!categoryId) continue
|
||||||
|
|
||||||
|
// Check if already exists (same name + svgPath in this tenant)
|
||||||
|
const existing = await (prisma as any).tenantSymbol.findFirst({
|
||||||
|
where: { tenantId, name: tpl.name, svgPath: tpl.svgPath },
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
// Skip already imported symbols
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantSymbol = await (prisma as any).tenantSymbol.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
categoryId,
|
||||||
|
name: tpl.name,
|
||||||
|
svgPath: tpl.svgPath,
|
||||||
|
sortOrder: currentSort++,
|
||||||
|
isUploaded: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
imported.push({
|
||||||
|
tenantSymbolId: tenantSymbol.id,
|
||||||
|
name: tenantSymbol.name,
|
||||||
|
categoryId: tenantSymbol.categoryId,
|
||||||
|
svgPath: tenantSymbol.svgPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ imported, count: imported.length })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing templates:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/templates/route.ts
Normal file
54
src/app/api/templates/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/templates
|
||||||
|
* Listet alle verfügbaren Vorlagen-Pakete mit Vorschau.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const packages = await (prisma as any).symbolTemplate.groupBy({
|
||||||
|
by: ['packageId', 'packageName'],
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
packages.map(async (pkg: any) => {
|
||||||
|
// Get category breakdown for this package
|
||||||
|
const categories = await (prisma as any).symbolTemplate.groupBy({
|
||||||
|
by: ['categoryName'],
|
||||||
|
where: { packageId: pkg.packageId },
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get a few preview symbols
|
||||||
|
const previews = await (prisma as any).symbolTemplate.findMany({
|
||||||
|
where: { packageId: pkg.packageId },
|
||||||
|
take: 5,
|
||||||
|
select: { id: true, name: true, svgPath: true, categoryName: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageId: pkg.packageId,
|
||||||
|
packageName: pkg.packageName,
|
||||||
|
symbolCount: pkg._count.id,
|
||||||
|
categoryCount: categories.length,
|
||||||
|
categories: categories.map((c: any) => ({
|
||||||
|
categoryName: c.categoryName,
|
||||||
|
symbolCount: c._count.id,
|
||||||
|
})),
|
||||||
|
previews,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ packages: result })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching templates:', error)
|
||||||
|
// Return empty packages list on DB schema mismatch so UI doesn't crash
|
||||||
|
if (error?.message?.includes('symbol_templates') || error?.code === 'P2021') {
|
||||||
|
return NextResponse.json({ packages: [] })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/app/api/tenant/categories/route.ts
Normal file
171
src/app/api/tenant/categories/route.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
async function getTenantId() {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return { error: 'Nicht autorisiert', status: 401 }
|
||||||
|
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||||
|
return { error: 'Keine Berechtigung', status: 403 }
|
||||||
|
}
|
||||||
|
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
|
||||||
|
return { tenantId: user.tenantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
await ensureTable()
|
||||||
|
|
||||||
|
const categories: any[] = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM tenant_categories WHERE "tenantId" = $1 AND "isActive" = true ORDER BY "sortOrder" ASC`,
|
||||||
|
tenantId
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ categories })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tenant categories:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { name, sortOrder } = await req.json()
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Name erforderlich' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTable()
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT id FROM tenant_categories WHERE "tenantId" = $1 AND LOWER(name) = LOWER($2) LIMIT 1`,
|
||||||
|
tenantId, name.trim()
|
||||||
|
) as any[]
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`INSERT INTO tenant_categories (id, "tenantId", name, "sortOrder", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
tenantId, name.trim(), sortOrder ?? 0
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
return NextResponse.json({ category: result[0] }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating tenant category:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { id, name, sortOrder } = await req.json()
|
||||||
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
|
await ensureTable()
|
||||||
|
|
||||||
|
// If renaming, check for duplicates
|
||||||
|
if (name) {
|
||||||
|
const existing = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT id FROM tenant_categories WHERE "tenantId" = $1 AND LOWER(name) = LOWER($2) AND id != $3 LIMIT 1`,
|
||||||
|
tenantId, name.trim(), id
|
||||||
|
) as any[]
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const params: any[] = [id, tenantId]
|
||||||
|
if (name !== undefined) { updates.push(`name = $${params.length + 1}`); params.push(name.trim()); }
|
||||||
|
if (sortOrder !== undefined) { updates.push(`"sortOrder" = $${params.length + 1}`); params.push(sortOrder); }
|
||||||
|
updates.push(`"updatedAt" = NOW()`)
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`UPDATE tenant_categories SET ${updates.join(', ')} WHERE id = $1 AND "tenantId" = $2 RETURNING *`,
|
||||||
|
...params
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tenant category:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { id } = await req.json()
|
||||||
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
|
await ensureTable()
|
||||||
|
|
||||||
|
// Check if category has symbols
|
||||||
|
const symbolCount = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT COUNT(*)::int as cnt FROM tenant_symbols WHERE "categoryId" = $1 AND "tenantId" = $2`,
|
||||||
|
id, tenantId
|
||||||
|
) as any[]
|
||||||
|
if (symbolCount && symbolCount[0]?.cnt > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Kategorie enthält ${symbolCount[0].cnt} Symbol(e) — bitte zuerst verschieben oder löschen` },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$queryRawUnsafe(
|
||||||
|
`DELETE FROM tenant_categories WHERE id = $1 AND "tenantId" = $2`,
|
||||||
|
id, tenantId
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tenant category:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/app/api/tenant/symbols/[id]/image/route.ts
Normal file
83
src/app/api/tenant/symbols/[id]/image/route.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getFileStream } from '@/lib/minio'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
async function getTenantId() {
|
||||||
|
const { headers } = await import('next/headers')
|
||||||
|
const h = await headers()
|
||||||
|
return h.get('x-tenant-id') || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const tenantId = await getTenantId()
|
||||||
|
|
||||||
|
const symbol = await (prisma as any).tenantSymbol.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: { category: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!symbol) {
|
||||||
|
return new NextResponse('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Uploaded file → MinIO
|
||||||
|
if (symbol.isUploaded && symbol.svgPath) {
|
||||||
|
try {
|
||||||
|
const { stream, contentType } = await getFileStream(symbol.svgPath)
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream as any) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('MinIO stream error:', err)
|
||||||
|
return new NextResponse('File not available', { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrated from IconAsset → serve from public/signaturen
|
||||||
|
if (symbol.migratedFromIconId) {
|
||||||
|
const icon = await (prisma as any).iconAsset.findUnique({
|
||||||
|
where: { id: symbol.migratedFromIconId },
|
||||||
|
})
|
||||||
|
if (icon?.fileKey) {
|
||||||
|
const filePath = join(process.cwd(), 'public', icon.fileKey)
|
||||||
|
try {
|
||||||
|
const buffer = await readFile(filePath)
|
||||||
|
const ext = icon.fileKey.split('.').pop()?.toLowerCase()
|
||||||
|
const mimeType =
|
||||||
|
ext === 'svg' ? 'image/svg+xml' :
|
||||||
|
ext === 'png' ? 'image/png' :
|
||||||
|
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
|
||||||
|
'application/octet-stream'
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// fall through to 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse('Not found', { status: 404 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tenant symbol image error:', err)
|
||||||
|
return new NextResponse('Internal error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { uploadFile } from '@/lib/minio'
|
||||||
|
|
||||||
async function getTenantId() {
|
async function getTenantId() {
|
||||||
const user = await getSession()
|
const user = await getSession()
|
||||||
@@ -12,116 +13,295 @@ async function getTenantId() {
|
|||||||
return { tenantId: user.tenantId }
|
return { tenantId: user.tenantId }
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: Returns library (all system icons) + tenant's own symbol collection
|
async function ensureTables() {
|
||||||
export async function GET() {
|
// ─── Create tables (idempotent) ───
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_symbols (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
"iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"svgPath" TEXT,
|
||||||
|
"isUploaded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"migratedFromIconId" TEXT,
|
||||||
|
"customName" TEXT,
|
||||||
|
"sortOrder" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// ─── Add missing columns (idempotent) ───
|
||||||
|
const symbolCols = [
|
||||||
|
`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`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "customName" TEXT`,
|
||||||
|
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER`,
|
||||||
|
`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`,
|
||||||
|
]
|
||||||
|
for (const sql of symbolCols) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const catCols = [
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "description" TEXT`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
`ALTER TABLE tenant_categories ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
]
|
||||||
|
for (const sql of catCols) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await getTenantId()
|
const auth = await getTenantId()
|
||||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
const { tenantId } = auth
|
const { tenantId } = auth
|
||||||
|
|
||||||
// All system icons grouped by category (the library)
|
const { searchParams } = new URL(req.url)
|
||||||
const icons = await (prisma as any).iconAsset.findMany({
|
const grouped = searchParams.get('grouped') !== 'false'
|
||||||
where: { isActive: true },
|
|
||||||
include: { category: { select: { id: true, name: true } } },
|
|
||||||
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
const library = icons.map((icon: any) => ({
|
await ensureTables()
|
||||||
id: icon.id,
|
|
||||||
name: icon.name,
|
|
||||||
mimeType: icon.mimeType,
|
|
||||||
iconType: icon.iconType,
|
|
||||||
categoryId: icon.categoryId,
|
|
||||||
categoryName: icon.category?.name || 'Ohne Kategorie',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Tenant's own symbol collection
|
const tenantSymbols: any[] = await prisma.$queryRawUnsafe(
|
||||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
`SELECT ts.*, tc.id as "catId", tc.name as "catName", tc."sortOrder" as "catSort"
|
||||||
where: { tenantId },
|
FROM tenant_symbols ts
|
||||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
LEFT JOIN tenant_categories tc ON ts."categoryId" = tc.id
|
||||||
orderBy: { sortOrder: 'asc' },
|
WHERE ts."tenantId" = $1 AND (ts."isActive" IS NULL OR ts."isActive" = true)
|
||||||
})
|
ORDER BY COALESCE(tc."sortOrder", 9999) ASC, COALESCE(ts."sortOrder", 9999) ASC, COALESCE(ts.name, ts."customName", 'Unbenannt') ASC`,
|
||||||
|
tenantId
|
||||||
|
)
|
||||||
|
|
||||||
const mySymbols = tenantSymbols.map((ts: any) => ({
|
const mapped = tenantSymbols.map((ts: any) => ({
|
||||||
id: ts.id,
|
id: ts.id,
|
||||||
iconId: ts.iconId,
|
name: ts.name || ts.customName || 'Unbenannt',
|
||||||
name: ts.customName || ts.icon.name,
|
|
||||||
customName: ts.customName,
|
customName: ts.customName,
|
||||||
baseName: ts.icon.name,
|
svgPath: ts.svgPath,
|
||||||
mimeType: ts.icon.mimeType,
|
categoryId: ts.categoryId,
|
||||||
iconType: ts.icon.iconType,
|
categoryName: ts.catName || 'Ohne Kategorie',
|
||||||
categoryName: ts.icon.category?.name || 'Ohne Kategorie',
|
|
||||||
sortOrder: ts.sortOrder,
|
sortOrder: ts.sortOrder,
|
||||||
|
isUploaded: ts.isUploaded,
|
||||||
|
createdAt: ts.createdAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({ library, mySymbols })
|
if (!grouped) {
|
||||||
|
return NextResponse.json({ symbols: mapped })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const groupedResult: Record<string, { catId: string | null; catName: string; symbols: any[] }> = {}
|
||||||
|
for (const sym of mapped) {
|
||||||
|
const key = sym.categoryId || '__none__'
|
||||||
|
if (!groupedResult[key]) {
|
||||||
|
groupedResult[key] = { catId: sym.categoryId, catName: sym.categoryName, symbols: [] }
|
||||||
|
}
|
||||||
|
groupedResult[key].symbols.push(sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get categories for ordering
|
||||||
|
const categories: any[] = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM tenant_categories WHERE "tenantId" = $1 AND ("isActive" IS NULL OR "isActive" = true) ORDER BY "sortOrder" ASC`,
|
||||||
|
tenantId
|
||||||
|
)
|
||||||
|
|
||||||
|
const ordered: Array<{ category: { id: string; name: string } | null; symbols: any[] }> = []
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (groupedResult[cat.id]) {
|
||||||
|
ordered.push({ category: { id: cat.id, name: cat.name }, symbols: groupedResult[cat.id].symbols })
|
||||||
|
delete groupedResult[cat.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Append any remaining uncategorized
|
||||||
|
for (const entry of Object.values(groupedResult)) {
|
||||||
|
ordered.push({ category: null, symbols: entry.symbols })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ categories: ordered })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tenant symbols:', error)
|
console.error('Error fetching tenant symbols:', error)
|
||||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: Add a symbol from the library to "my symbols"
|
// ─── POST ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await getTenantId()
|
const auth = await getTenantId()
|
||||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
const { tenantId } = auth
|
const { tenantId } = auth
|
||||||
|
|
||||||
const { iconId, customName } = await req.json()
|
await ensureTables()
|
||||||
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
|
|
||||||
|
|
||||||
// Get max sortOrder for this tenant
|
const contentType = req.headers.get('content-type') || ''
|
||||||
const maxSort = await (prisma as any).tenantSymbol.aggregate({
|
|
||||||
where: { tenantId },
|
|
||||||
_max: { sortOrder: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const symbol = await (prisma as any).tenantSymbol.create({
|
// Get next sort order
|
||||||
data: {
|
const maxSort = await prisma.$queryRawUnsafe(
|
||||||
tenantId,
|
`SELECT MAX("sortOrder") as maxsort FROM tenant_symbols WHERE "tenantId" = $1`,
|
||||||
iconId,
|
tenantId
|
||||||
customName: customName || null,
|
) as any[]
|
||||||
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
const nextSort = (maxSort[0]?.maxsort ?? -1) + 1
|
||||||
},
|
|
||||||
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
// ─── File Upload ─────────────────────────────────────────────────────────
|
||||||
})
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
const name = formData.get('name') as string | null
|
||||||
|
const categoryId = formData.get('categoryId') as string | null
|
||||||
|
|
||||||
|
if (!file || !name) {
|
||||||
|
return NextResponse.json({ error: 'Datei und Name erforderlich' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(bytes)
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'svg'
|
||||||
|
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`
|
||||||
|
const fileKey = `tenant-${tenantId}/symbols/${Date.now()}-${name.replace(/\s+/g, '_').toLowerCase()}.${ext}`
|
||||||
|
|
||||||
|
await uploadFile(fileKey, buffer, mimeType)
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`INSERT INTO tenant_symbols (id, "tenantId", name, "svgPath", "categoryId", "sortOrder", "isUploaded", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, true, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
tenantId, name.trim(), fileKey, categoryId || null, nextSort
|
||||||
|
) as any[]
|
||||||
|
const symbol = result[0]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: symbol.id,
|
||||||
|
name: symbol.name,
|
||||||
|
svgPath: symbol.svgPath,
|
||||||
|
categoryId: symbol.categoryId,
|
||||||
|
sortOrder: symbol.sortOrder,
|
||||||
|
isUploaded: true,
|
||||||
|
}, { status: 201 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON (from IconAsset or Template) ──────────────────────────────────
|
||||||
|
const body = await req.json()
|
||||||
|
const { iconId, customName, templateId, categoryId } = body
|
||||||
|
|
||||||
|
// From SymbolTemplate
|
||||||
|
if (templateId) {
|
||||||
|
const tpl = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM symbol_templates WHERE id = $1 LIMIT 1`,
|
||||||
|
templateId
|
||||||
|
) as any[]
|
||||||
|
if (!tpl || tpl.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Template nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
const t = tpl[0]
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`INSERT INTO tenant_symbols (id, "tenantId", name, "svgPath", "categoryId", "sortOrder", "isUploaded", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, false, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
tenantId, t.name, t.svgPath, categoryId || null, nextSort
|
||||||
|
) as any[]
|
||||||
|
const symbol = result[0]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: symbol.id,
|
||||||
|
name: symbol.name,
|
||||||
|
svgPath: symbol.svgPath,
|
||||||
|
categoryId: symbol.categoryId,
|
||||||
|
sortOrder: symbol.sortOrder,
|
||||||
|
isUploaded: false,
|
||||||
|
}, { status: 201 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// From IconAsset (legacy path)
|
||||||
|
if (!iconId) {
|
||||||
|
return NextResponse.json({ error: 'iconId oder templateId erforderlich' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM icon_assets WHERE id = $1 LIMIT 1`,
|
||||||
|
iconId
|
||||||
|
) as any[]
|
||||||
|
if (!icons || icons.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
const icon = icons[0]
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`INSERT INTO tenant_symbols (id, "tenantId", "iconId", "customName", name, "svgPath", "categoryId", "sortOrder", "isUploaded", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, false, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
tenantId, icon.id, customName || null, customName || icon.name, icon.fileKey, categoryId || null, nextSort
|
||||||
|
) as any[]
|
||||||
|
const symbol = result[0]
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: symbol.id,
|
id: symbol.id,
|
||||||
iconId: symbol.iconId,
|
iconId: symbol.iconId,
|
||||||
name: symbol.customName || symbol.icon.name,
|
name: symbol.name,
|
||||||
customName: symbol.customName,
|
customName: symbol.customName,
|
||||||
baseName: symbol.icon.name,
|
svgPath: symbol.svgPath,
|
||||||
mimeType: symbol.icon.mimeType,
|
categoryId: symbol.categoryId,
|
||||||
iconType: symbol.icon.iconType,
|
|
||||||
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
|
|
||||||
sortOrder: symbol.sortOrder,
|
sortOrder: symbol.sortOrder,
|
||||||
})
|
isUploaded: false,
|
||||||
|
}, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding tenant symbol:', error)
|
console.error('Error adding tenant symbol:', error)
|
||||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: Rename a symbol or update sortOrder
|
// ─── PATCH ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await getTenantId()
|
const auth = await getTenantId()
|
||||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
const { tenantId } = auth
|
const { tenantId } = auth
|
||||||
|
|
||||||
const { id, customName, sortOrder } = await req.json()
|
const { id, name, customName, categoryId, sortOrder } = await req.json()
|
||||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
const data: any = {}
|
await ensureTables()
|
||||||
if (customName !== undefined) data.customName = customName || null
|
|
||||||
if (sortOrder !== undefined) data.sortOrder = sortOrder
|
|
||||||
|
|
||||||
await (prisma as any).tenantSymbol.updateMany({
|
const updates: string[] = []
|
||||||
where: { id, tenantId },
|
const params: any[] = [id, tenantId]
|
||||||
data,
|
if (name !== undefined) { updates.push(`name = $${params.length + 1}`); params.push(name || null); }
|
||||||
})
|
if (customName !== undefined) { updates.push(`"customName" = $${params.length + 1}`); params.push(customName || null); }
|
||||||
|
if (categoryId !== undefined) { updates.push(`"categoryId" = $${params.length + 1}`); params.push(categoryId || null); }
|
||||||
|
if (sortOrder !== undefined) { updates.push(`"sortOrder" = $${params.length + 1}`); params.push(sortOrder); }
|
||||||
|
updates.push(`"updatedAt" = NOW()`)
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`UPDATE tenant_symbols SET ${updates.join(', ')} WHERE id = $1 AND "tenantId" = $2 RETURNING *`,
|
||||||
|
...params
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Symbol nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,7 +310,8 @@ export async function PATCH(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: Remove a symbol from "my symbols"
|
// ─── DELETE ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await getTenantId()
|
const auth = await getTenantId()
|
||||||
@@ -140,9 +321,16 @@ export async function DELETE(req: NextRequest) {
|
|||||||
const { id } = await req.json()
|
const { id } = await req.json()
|
||||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
await (prisma as any).tenantSymbol.deleteMany({
|
await ensureTables()
|
||||||
where: { id, tenantId },
|
|
||||||
})
|
const result = await prisma.$queryRawUnsafe(
|
||||||
|
`DELETE FROM tenant_symbols WHERE id = $1 AND "tenantId" = $2 RETURNING *`,
|
||||||
|
id, tenantId
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Symbol nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
|||||||
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
||||||
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
||||||
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
||||||
Shield, Wrench, Radio, MoreHorizontal, Heart,
|
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface DisplaySymbol {
|
interface DisplaySymbol {
|
||||||
@@ -24,6 +24,11 @@ interface DisplayCategory {
|
|||||||
symbols: DisplaySymbol[]
|
symbols: DisplaySymbol[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TenantSymbolGroup {
|
||||||
|
category: { id: string; name: string } | null
|
||||||
|
symbols: DisplaySymbol[]
|
||||||
|
}
|
||||||
|
|
||||||
interface RightSidebarProps {
|
interface RightSidebarProps {
|
||||||
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
@@ -99,10 +104,11 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('')
|
const [activeCategory, setActiveCategory] = useState<string>('')
|
||||||
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
||||||
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([])
|
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showTenantSection, setShowTenantSection] = useState(true)
|
const [showTenantSection, setShowTenantSection] = useState(true)
|
||||||
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
||||||
|
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchIcons() {
|
async function fetchIcons() {
|
||||||
@@ -111,6 +117,8 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
const res = await fetch('/api/icons', { cache: 'no-store' })
|
const res = await fetch('/api/icons', { cache: 'no-store' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
|
// ─── Global library ───
|
||||||
const allCats: DisplayCategory[] = (data.categories || [])
|
const allCats: DisplayCategory[] = (data.categories || [])
|
||||||
.filter((cat: any) => cat.icons && cat.icons.length > 0)
|
.filter((cat: any) => cat.icons && cat.icons.length > 0)
|
||||||
.map((cat: any) => ({
|
.map((cat: any) => ({
|
||||||
@@ -123,27 +131,39 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Separate tenant-specific icons ("Eigene" category) from global library
|
// Separate tenant-specific legacy "Eigene" category from global library
|
||||||
const eigene = allCats.find(c => c.name === 'Eigene')
|
const eigene = allCats.find(c => c.name === 'Eigene')
|
||||||
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
||||||
|
|
||||||
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
|
|
||||||
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
imageUrl: s.url || `/api/icons/${s.id}/image`,
|
|
||||||
}))
|
|
||||||
const legacyOwn = eigene?.symbols || []
|
|
||||||
// Deduplicate: mySymbols takes priority over legacy
|
|
||||||
const mySymbolIds = new Set(mySymbols.map(s => s.id))
|
|
||||||
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
|
|
||||||
|
|
||||||
setTenantIcons(mergedTenant)
|
|
||||||
setCategories(globalCats)
|
setCategories(globalCats)
|
||||||
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
if (globalCats.length > 0 && !activeCategory) {
|
||||||
|
setActiveCategory(globalCats[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-collapse library if tenant has own symbols
|
// ─── New tenant symbol groups (Phase 1) ───
|
||||||
if (mergedTenant.length > 0) {
|
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
|
||||||
|
category: g.category,
|
||||||
|
symbols: g.symbols.map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
imageUrl: s.imageUrl || `/api/icons/${s.id}/image`,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Merge legacy "Eigene" into tenant groups if present
|
||||||
|
if (eigene && eigene.symbols.length > 0) {
|
||||||
|
const legacyGroup: TenantSymbolGroup = {
|
||||||
|
category: { id: '__legacy__', name: 'Eigene' },
|
||||||
|
symbols: eigene.symbols,
|
||||||
|
}
|
||||||
|
groups.unshift(legacyGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTenantGroups(groups)
|
||||||
|
|
||||||
|
// Auto-expand all tenant groups, auto-collapse library if tenant has symbols
|
||||||
|
if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) {
|
||||||
|
setExpandedTenantCats(new Set(groups.map(g => g.category?.id || '__none__')))
|
||||||
setShowLibrarySection(false)
|
setShowLibrarySection(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,18 +176,31 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
fetchIcons()
|
fetchIcons()
|
||||||
}, [tenantId])
|
}, [tenantId])
|
||||||
|
|
||||||
|
const toggleTenantCat = (catId: string | null) => {
|
||||||
|
const key = catId || '__none__'
|
||||||
|
setExpandedTenantCats(prev => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.has(key) ? n.delete(key) : n.add(key)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const filteredCategories = categories.map((cat) => ({
|
const filteredCategories = categories.map((cat) => ({
|
||||||
...cat,
|
...cat,
|
||||||
symbols: cat.symbols.filter((s) =>
|
symbols: cat.symbols.filter((s) =>
|
||||||
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
const filteredTenantIcons = tenantIcons.filter(s =>
|
const filteredTenantGroups = tenantGroups.map(g => ({
|
||||||
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
...g,
|
||||||
)
|
symbols: g.symbols.filter(s =>
|
||||||
|
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
),
|
||||||
|
})).filter(g => g.symbols.length > 0)
|
||||||
|
|
||||||
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
|
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
|
||||||
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length
|
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) +
|
||||||
|
tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -298,7 +331,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */}
|
{/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
|
||||||
{tenantId && (
|
{tenantId && (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<button
|
<button
|
||||||
@@ -307,24 +340,47 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
Meine Symbole ({filteredTenantIcons.length})
|
Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{showTenantSection && (
|
{showTenantSection && (
|
||||||
<div className="p-2 pt-0">
|
<div className="p-2 pt-0 space-y-1">
|
||||||
{filteredTenantIcons.length === 0 ? (
|
{filteredTenantGroups.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-4 text-xs">
|
<div className="text-center text-muted-foreground py-4 text-xs">
|
||||||
Keine eigenen Symbole vorhanden.
|
Keine eigenen Symbole vorhanden.
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[10px]">Symbole können unter Einstellungen → Symbole hochgeladen werden.</span>
|
<span className="text-[10px]">Symbole können unter Admin → Symbole verwaltet werden.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
filteredTenantGroups.map(g => {
|
||||||
{filteredTenantIcons.map((symbol) => (
|
const key = g.category?.id || '__none__'
|
||||||
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
const expanded = expandedTenantCats.has(key)
|
||||||
))}
|
return (
|
||||||
</div>
|
<div key={key} className="border rounded-md">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTenantCat(g.category?.id || null)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="w-3 h-3 text-muted-foreground" />
|
||||||
|
{g.category?.name || 'Ohne Kategorie'}
|
||||||
|
<span className="text-[10px] text-muted-foreground">({g.symbols.length})</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-1.5 pb-1.5">
|
||||||
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
||||||
|
{g.symbols.map(symbol => (
|
||||||
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
104
src/lib/auto-migrate.ts
Normal file
104
src/lib/auto-migrate.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Auto-migrate helper: ensures critical tables exist on-the-fly.
|
||||||
|
* Called from API endpoints when a table-missing error is detected.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
export async function ensureTenantCategoriesTable() {
|
||||||
|
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('[auto-migrate] tenant_categories ensured')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message?.includes('already exists')) return
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTenantSymbolsTable() {
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_symbols (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
"iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE("tenantId", "iconId")
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
console.log('[auto-migrate] tenant_symbols ensured')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message?.includes('already exists')) return
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTenantSymbolsColumns() {
|
||||||
|
const columns = [
|
||||||
|
`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`,
|
||||||
|
`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`,
|
||||||
|
]
|
||||||
|
for (const sql of columns) {
|
||||||
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTenantSymbolsCategoryFk() {
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'tenant_symbols_categoryId_fkey'
|
||||||
|
AND table_name = 'tenant_symbols'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tenant_symbols
|
||||||
|
ADD CONSTRAINT "tenant_symbols_categoryId_fkey"
|
||||||
|
FOREIGN KEY ("categoryId") REFERENCES tenant_categories(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'categoryId FK skipped: %', SQLERRM;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSymbolTemplatesTable() {
|
||||||
|
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('[auto-migrate] symbol_templates ensured')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message?.includes('already exists')) return
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user