16 Commits

Author SHA1 Message Date
Pepe Ziberi
08ecf4c61a fix(schema): ensureTables() fügt jetzt fehlende Spalten nachträglich hinzu – verhindert altes Schema-Problem
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11m16s
2026-05-21 15:42:22 +02:00
Pepe Ziberi
6588959b50 fix(tenant-symbols): isActive Filter auf NULL tolerant – zeigt Symbole auch ohne isActive-Wert
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-21 15:36:13 +02:00
Pepe Ziberi
a91a4c4689 fix(migrate): customName + sortOrder Spalten zu tenant_symbols hinzugefügt
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-21 15:34:11 +02:00
Pepe Ziberi
693515aab5 fix(tenant-symbols): POST /api/tenant/symbols iconAsset-Legacy-Pfad auf Raw-SQL umgestellt
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-21 15:26:14 +02:00
Pepe Ziberi
0cbea843ab fix(symbol-groups): API + Frontend auf category-Objekt-Format umgestellt – zeigt jetzt alle Kategorien korrekt
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-21 15:25:12 +02:00
Pepe Ziberi
9cba24aad8 fix(icons): /api/icons auf Raw-SQL umgestellt – zeigt jetzt alle Tenant-Kategorien in der App
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11m39s
2026-05-21 15:00:47 +02:00
Pepe Ziberi
93d5519e58 fix(tenant-symbols): kompletter Endpoint auf Raw-SQL umgestellt
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-21 14:58:19 +02:00
Pepe Ziberi
c291431fd7 fix(categories): kompletter Endpoint auf Raw-SQL umgestellt – unabhängig von Prisma-Client-Modellen
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10m51s
2026-05-21 14:31:54 +02:00
Pepe Ziberi
3606c9a2a4 fix(auto-migrate): tenant_categories on-the-fly erstellung + migrate.js fail-fast
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10m41s
2026-05-21 13:40:34 +02:00
Pepe Ziberi
3722a04091 fix(categories): POST/PATCH/DELETE mit Fallback bei fehlendem Schema
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11m44s
2026-05-21 08:51:54 +02:00
Pepe Ziberi
0d0d9a7257 fix(symbol-manager): Select.Item empty string replaced with '__none__' placeholder
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m56s
2026-05-21 08:16:20 +02:00
Pepe Ziberi
40cea9a9be refactor(symbol-manager): remove template import, focus on library UX
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m43s
2026-05-21 07:41:17 +02:00
Pepe Ziberi
56895be16f fix(symbol-arch): robuste API-Endpoints + docker-entrypoint Migration/Seed
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m59s
2026-05-21 07:05:49 +02:00
Pepe Ziberi
e9f66b2c3d feat(1.4.3): add 'Bibliothek' tab to admin for single-icon selection
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m6s
2026-05-20 23:36:00 +02:00
Pepe Ziberi
c8a94e1ea7 hotfix(1.4.2): admin empty + legacy symbol migration
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m44s
2026-05-20 23:06:25 +02:00
Pepe Ziberi
07cede68c0 hotfix(1.4.1): fix migration FK failure + null toLowerCase crash
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 18m13s
2026-05-20 22:08:40 +02:00
13 changed files with 2481 additions and 442 deletions

View File

@@ -3,6 +3,23 @@
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

123
build-log.txt Normal file
View 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

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,11 @@ echo "=== Lageplan Startup ==="
# ─── Step 1: Run migrations (uses PrismaClient raw SQL, no CLI needed) ───
echo "[1/3] Running database migrations..."
node prisma/migrate.js || echo " Warning: migrations had issues (may be first run)"
node prisma/migrate.js
if [ $? -ne 0 ]; then
echo "❌ Database migrations failed. Aborting startup."
exit 1
fi
# ─── Step 2: Conditional seeding ───
echo "[2/3] Checking if seeding is needed..."
@@ -34,6 +38,44 @@ else
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..."
exec node server-custom.js

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.4.0",
"version": "1.4.3",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

View File

@@ -212,7 +212,7 @@ async function migrate() {
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...')
try {
await prisma.$executeRawUnsafe(`
@@ -226,7 +226,8 @@ async function migrate() {
`)
console.log(' tenant_symbols table created (or already exists)')
} 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 ───
@@ -251,7 +252,7 @@ async function migrate() {
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...')
try {
await prisma.$executeRawUnsafe(`
@@ -268,7 +269,20 @@ async function migrate() {
`)
console.log(' tenant_categories table created (or already exists)')
} 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 ───
@@ -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 "svgPath" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT REFERENCES tenant_categories(id) ON DELETE SET NULL`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "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`,
]
@@ -288,6 +304,29 @@ async function migrate() {
}
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) ───
console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...')
try {
@@ -321,6 +360,62 @@ async function migrate() {
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')
}

View File

@@ -2,65 +2,134 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
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() {
/* ─── 1. Global library (legacy IconAsset) ─── */
let categoriesWithUrls: any[] = []
try {
const user = await getSession()
const tenantId = user?.tenantId
/* ─── 1. Global library (legacy IconAsset) ─── */
const categoryWhere: any = tenantId
? { OR: [{ tenantId: null }, { tenantId }] }
: {}
const categories = await (prisma as any).iconCategory.findMany({
where: categoryWhere,
orderBy: { sortOrder: 'asc' },
include: {
icons: {
where: tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId }] }
: { isActive: true, tenantId: null },
orderBy: { name: 'asc' },
},
},
})
const categories = await prisma.$queryRawUnsafe(
`SELECT * FROM icon_categories WHERE "tenantId" IS NULL OR "tenantId" = $1 ORDER BY "sortOrder" ASC`,
tenantId || null
) as any[]
let hiddenIconIds: string[] = []
if (tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: tenantId },
select: { hiddenIconIds: true },
})
hiddenIconIds = tenant?.hiddenIconIds || []
const tenant = await prisma.$queryRawUnsafe(
`SELECT "hiddenIconIds" FROM tenants WHERE id = $1 LIMIT 1`,
tenantId
) as any[]
hiddenIconIds = tenant[0]?.hiddenIconIds || []
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
for (const cat of categories) {
const icons = await prisma.$queryRawUnsafe(
`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))
.map((icon: any) => ({
...icon,
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 = []
}
/* ─── 2. Tenant symbols (Phase 1 architecture) ─── */
let tenantSymbolGroups: any[] = []
let flatTenantSymbols: any[] = []
/* ─── 2. Tenant symbols (Phase 1 architecture) ─── */
let tenantSymbolGroups: any[] = []
let flatTenantSymbols: any[] = []
try {
const user = await getSession()
const tenantId = user?.tenantId
if (tenantId) {
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: { category: true },
orderBy: [{ category: { sortOrder: 'asc' } }, { sortOrder: 'asc' }],
})
await ensureTables()
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,
categoryId: ts.categoryId,
categoryName: ts.category?.name || null,
categoryName: ts.catName || null,
isUploaded: ts.isUploaded,
migratedFromIconId: ts.migratedFromIconId,
imageUrl: ts.isUploaded
@@ -70,64 +139,72 @@ export async function GET() {
: `/api/icons/${ts.id}/image`,
}))
// Group by category for sidebar display
const groups = new Map<string | null, any[]>()
for (const sym of flatTenantSymbols) {
const key = sym.categoryId || null
const key = sym.categoryId || '__none__'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(sym)
}
// Fetch categories that have symbols but may not be in the symbol list (empty ones are omitted)
const catIds = Array.from(groups.keys()).filter(Boolean) as string[]
const tenantCategories = catIds.length
? await (prisma as any).tenantCategory.findMany({
where: { id: { in: catIds }, tenantId },
orderBy: { sortOrder: 'asc' },
})
: []
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 ? catMap.get(catId) : null
const cat = catId !== '__none__' ? catMap.get(catId) : null
return {
categoryId: catId,
categoryName: cat ? (cat as any).name || 'Kategorie' : 'Ohne Kategorie',
sortOrder: cat ? (cat as any).sortOrder ?? 999 : 999,
category: cat ? { id: cat.id, name: cat.name } : null,
symbols,
}
})
tenantSymbolGroups.sort((a, b) => a.sortOrder - b.sortOrder)
}
} 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
/* ─── 3. Legacy mySymbols (keep for old clients during transition) ─── */
let mySymbolsLegacy: any[] = []
if (tenantId) {
const legacy = await (prisma as any).tenantSymbol.findMany({
where: { tenantId, iconId: { not: null } },
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
orderBy: { sortOrder: 'asc' },
})
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.icon.id,
id: ts.iconId,
tenantSymbolId: ts.id,
name: ts.customName || ts.icon.name,
name: ts.customName || ts.iconName,
customName: ts.customName,
mimeType: ts.icon.mimeType,
iconType: ts.icon.iconType,
url: `/api/icons/${ts.icon.id}/image`,
mimeType: ts.mimeType,
iconType: ts.iconType,
url: `/api/icons/${ts.iconId}/image`,
}))
}
return NextResponse.json({
categories: categoriesWithUrls,
mySymbols: mySymbolsLegacy, // legacy shape for old clients
tenantSymbols: flatTenantSymbols, // new flat list
tenantSymbolGroups, // grouped by TenantCategory
})
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
} catch (err) {
console.error('Error fetching legacy mySymbols:', err)
mySymbolsLegacy = []
}
return NextResponse.json({
categories: categoriesWithUrls,
mySymbols: mySymbolsLegacy,
tenantSymbols: flatTenantSymbols,
tenantSymbolGroups,
})
}

View File

@@ -43,8 +43,12 @@ export async function GET() {
)
return NextResponse.json({ packages: result })
} catch (error) {
} 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 })
}
}

View File

@@ -12,38 +12,37 @@ async function getTenantId() {
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 ────────────────────────────────────────────────────────────────────
/**
* GET /api/tenant/categories
* Liefert alle Tenant-Kategorien des aktuellen Mandanten mit Symbol-Zählung.
*/
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
const categories = await (prisma as any).tenantCategory.findMany({
where: { tenantId },
include: {
_count: {
select: { symbols: true },
},
},
orderBy: { sortOrder: 'asc' },
})
await ensureTable()
const result = categories.map((cat: any) => ({
id: cat.id,
name: cat.name,
sortOrder: cat.sortOrder,
icon: cat.icon,
symbolCount: cat._count.symbols,
createdAt: cat.createdAt,
}))
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: result })
return NextResponse.json({ categories })
} catch (error) {
console.error('Error fetching tenant categories:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
@@ -52,41 +51,36 @@ export async function GET() {
// ─── POST ───────────────────────────────────────────────────────────────────
/**
* POST /api/tenant/categories
* Legt eine neue Kategorie an.
*
* Body: { name: string, sortOrder?: number, icon?: string }
*/
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, icon } = await req.json()
const { name, sortOrder } = await req.json()
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Name erforderlich' }, { status: 400 })
}
// Check for duplicate name within tenant
const existing = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: { equals: name, mode: 'insensitive' } },
})
if (existing) {
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 category = await (prisma as any).tenantCategory.create({
data: {
tenantId,
name: name.trim(),
sortOrder: sortOrder ?? 0,
icon: icon || null,
},
})
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 }, { status: 201 })
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 })
@@ -95,46 +89,40 @@ export async function POST(req: NextRequest) {
// ─── PATCH ──────────────────────────────────────────────────────────────────
/**
* PATCH /api/tenant/categories
* Aktualisiert eine Kategorie.
*
* Body: { id: string, name?: string, sortOrder?: number, icon?: string }
*/
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, icon } = await req.json()
const { id, name, sortOrder } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const data: any = {}
if (name !== undefined) data.name = name.trim()
if (sortOrder !== undefined) data.sortOrder = sortOrder
if (icon !== undefined) data.icon = icon || null
await ensureTable()
// If renaming, check for duplicates
if (name) {
const existing = await (prisma as any).tenantCategory.findFirst({
where: {
tenantId,
name: { equals: name.trim(), mode: 'insensitive' },
id: { not: id },
},
})
if (existing) {
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 category = await (prisma as any).tenantCategory.updateMany({
where: { id, tenantId },
data,
})
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()`)
if (category.count === 0) {
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 })
}
@@ -147,12 +135,6 @@ export async function PATCH(req: NextRequest) {
// ─── DELETE ─────────────────────────────────────────────────────────────────
/**
* DELETE /api/tenant/categories
* Löscht eine Kategorie (nur wenn leer).
*
* Body: { id: string }
*/
export async function DELETE(req: NextRequest) {
try {
const auth = await getTenantId()
@@ -162,20 +144,24 @@ export async function DELETE(req: NextRequest) {
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 as any).tenantSymbol.count({
where: { categoryId: id, tenantId },
})
if (symbolCount > 0) {
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} Symbol(e) — bitte zuerst verschieben oder löschen` },
{ error: `Kategorie enthält ${symbolCount[0].cnt} Symbol(e) — bitte zuerst verschieben oder löschen` },
{ status: 409 }
)
}
await (prisma as any).tenantCategory.deleteMany({
where: { id, tenantId },
})
await prisma.$queryRawUnsafe(
`DELETE FROM tenant_categories WHERE id = $1 AND "tenantId" = $2`,
id, tenantId
)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -13,13 +13,68 @@ async function getTenantId() {
return { tenantId: user.tenantId }
}
async function ensureTables() {
// ─── 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 ────────────────────────────────────────────────────────────────────
/**
* GET /api/tenant/symbols
* Liefert die Symbole des Mandanten gruppiert nach TenantCategory.
* Optional: ?grouped=true (default) oder ?grouped=false (flache Liste)
*/
export async function GET(req: NextRequest) {
try {
const auth = await getTenantId()
@@ -29,13 +84,16 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const grouped = searchParams.get('grouped') !== 'false'
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: {
category: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ category: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { name: 'asc' }],
})
await ensureTables()
const tenantSymbols: any[] = await prisma.$queryRawUnsafe(
`SELECT ts.*, tc.id as "catId", tc.name as "catName", tc."sortOrder" as "catSort"
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, COALESCE(ts.name, ts."customName", 'Unbenannt') ASC`,
tenantId
)
const mapped = tenantSymbols.map((ts: any) => ({
id: ts.id,
@@ -43,7 +101,7 @@ export async function GET(req: NextRequest) {
customName: ts.customName,
svgPath: ts.svgPath,
categoryId: ts.categoryId,
categoryName: ts.category?.name || 'Ohne Kategorie',
categoryName: ts.catName || 'Ohne Kategorie',
sortOrder: ts.sortOrder,
isUploaded: ts.isUploaded,
createdAt: ts.createdAt,
@@ -54,29 +112,31 @@ export async function GET(req: NextRequest) {
}
// Group by category
const groupedResult: Record<string, any[]> = {}
const groupedResult: Record<string, { catId: string | null; catName: string; symbols: any[] }> = {}
for (const sym of mapped) {
const catName = sym.categoryName
if (!groupedResult[catName]) groupedResult[catName] = []
groupedResult[catName].push(sym)
const key = sym.categoryId || '__none__'
if (!groupedResult[key]) {
groupedResult[key] = { catId: sym.categoryId, catName: sym.categoryName, symbols: [] }
}
groupedResult[key].symbols.push(sym)
}
// Sort categories by sortOrder from category
const categories = await (prisma as any).tenantCategory.findMany({
where: { tenantId },
orderBy: { sortOrder: 'asc' },
})
// 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<{ categoryId: string | null; categoryName: string; symbols: any[] }> = []
const ordered: Array<{ category: { id: string; name: string } | null; symbols: any[] }> = []
for (const cat of categories) {
if (groupedResult[cat.name]) {
ordered.push({ categoryId: cat.id, categoryName: cat.name, symbols: groupedResult[cat.name] })
delete groupedResult[cat.name]
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 [catName, symbols] of Object.entries(groupedResult)) {
ordered.push({ categoryId: null, categoryName: catName, symbols })
for (const entry of Object.values(groupedResult)) {
ordered.push({ category: null, symbols: entry.symbols })
}
return NextResponse.json({ categories: ordered })
@@ -88,27 +148,23 @@ export async function GET(req: NextRequest) {
// ─── POST ───────────────────────────────────────────────────────────────────
/**
* POST /api/tenant/symbols
* Erstellt ein neues TenantSymbol (manuell oder aus Template/IconAsset).
*
* Body (manuell aus IconAsset):
* { iconId: string, customName?: string, categoryId?: string }
*
* Body (aus Template-Import):
* { templateId: string, categoryId?: string }
*
* Body (manueller Upload):
* FormData mit: file (SVG/PNG), 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
await ensureTables()
const contentType = req.headers.get('content-type') || ''
// Get next sort order
const maxSort = await prisma.$queryRawUnsafe(
`SELECT MAX("sortOrder") as maxsort FROM tenant_symbols WHERE "tenantId" = $1`,
tenantId
) as any[]
const nextSort = (maxSort[0]?.maxsort ?? -1) + 1
// ─── File Upload ─────────────────────────────────────────────────────────
if (contentType.includes('multipart/form-data')) {
const formData = await req.formData()
@@ -120,11 +176,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Datei und Name erforderlich' }, { status: 400 })
}
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
where: { tenantId },
_max: { sortOrder: true },
})
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const ext = file.name.split('.').pop()?.toLowerCase() || 'svg'
@@ -133,16 +184,13 @@ export async function POST(req: NextRequest) {
await uploadFile(fileKey, buffer, mimeType)
const symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
name: name.trim(),
svgPath: fileKey,
categoryId: categoryId || null,
sortOrder: (maxSortAgg._max.sortOrder ?? -1) + 1,
isUploaded: true,
},
})
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,
@@ -158,31 +206,24 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { iconId, customName, templateId, categoryId } = body
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
where: { tenantId },
_max: { sortOrder: true },
})
const nextSort = (maxSortAgg._max.sortOrder ?? -1) + 1
// From SymbolTemplate
if (templateId) {
const tpl = await (prisma as any).symbolTemplate.findUnique({
where: { id: templateId },
})
if (!tpl) {
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 symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
name: tpl.name,
svgPath: tpl.svgPath,
categoryId: categoryId || null,
sortOrder: nextSort,
isUploaded: false,
},
})
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,
@@ -199,26 +240,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'iconId oder templateId erforderlich' }, { status: 400 })
}
const icon = await prisma.iconAsset.findUnique({
where: { id: iconId },
include: { category: true },
})
if (!icon) {
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 symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
iconId: icon.id,
customName: customName || null,
name: customName || icon.name,
svgPath: icon.fileKey,
categoryId: categoryId || null,
sortOrder: nextSort,
isUploaded: false,
},
})
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({
id: symbol.id,
@@ -238,12 +275,6 @@ export async function POST(req: NextRequest) {
// ─── PATCH ──────────────────────────────────────────────────────────────────
/**
* PATCH /api/tenant/symbols
* Aktualisiert ein TenantSymbol.
*
* Body: { id: string, name?: string, customName?: string, categoryId?: string, sortOrder?: number }
*/
export async function PATCH(req: NextRequest) {
try {
const auth = await getTenantId()
@@ -253,18 +284,22 @@ export async function PATCH(req: NextRequest) {
const { id, name, customName, categoryId, sortOrder } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const data: any = {}
if (name !== undefined) data.name = name || null
if (customName !== undefined) data.customName = customName || null
if (categoryId !== undefined) data.categoryId = categoryId || null
if (sortOrder !== undefined) data.sortOrder = sortOrder
await ensureTables()
const result = await (prisma as any).tenantSymbol.updateMany({
where: { id, tenantId },
data,
})
const updates: string[] = []
const params: any[] = [id, tenantId]
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()`)
if (result.count === 0) {
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 })
}
@@ -277,12 +312,6 @@ export async function PATCH(req: NextRequest) {
// ─── DELETE ─────────────────────────────────────────────────────────────────
/**
* DELETE /api/tenant/symbols
* Löscht ein TenantSymbol.
*
* Body: { id: string }
*/
export async function DELETE(req: NextRequest) {
try {
const auth = await getTenantId()
@@ -292,11 +321,14 @@ export async function DELETE(req: NextRequest) {
const { id } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const result = await (prisma as any).tenantSymbol.deleteMany({
where: { id, tenantId },
})
await ensureTables()
if (result.count === 0) {
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 })
}

View File

@@ -32,9 +32,8 @@ import {
LayoutGrid,
ImageIcon,
FolderOpen,
Download,
AlertCircle,
Package,
Library,
} from 'lucide-react'
/* ─── Types ─── */
@@ -62,14 +61,6 @@ interface SymbolGroup {
symbols: TenantSymbol[]
}
interface TemplatePackage {
packageId: string
packageName: string
categoryCount: number
symbolCount: number
previewSymbols: { name: string; svgPath: string }[]
}
/* ─── Component ─── */
export function SymbolManager() {
const { toast } = useToast()
@@ -79,12 +70,11 @@ export function SymbolManager() {
const [categories, setCategories] = useState<TenantCategory[]>([])
const [symbolGroups, setSymbolGroups] = useState<SymbolGroup[]>([])
const [flatSymbols, setFlatSymbols] = useState<TenantSymbol[]>([])
const [templates, setTemplates] = useState<TemplatePackage[]>([])
/* -- UI state -- */
const [search, setSearch] = useState('')
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'import'>('symbols')
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'library'>('library')
/* -- Symbol editing -- */
const [editingSymbolId, setEditingSymbolId] = useState<string | null>(null)
@@ -102,18 +92,19 @@ export function SymbolManager() {
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
/* -- Import dialog -- */
const [importOpen, setImportOpen] = useState(false)
const [importingPkg, setImportingPkg] = useState<string | null>(null)
/* -- Library -- */
const [libraryIcons, setLibraryIcons] = useState<any[]>([])
const [librarySearch, setLibrarySearch] = useState('')
const [libraryLoading, setLibraryLoading] = useState(false)
const [addingIconId, setAddingIconId] = useState<string | null>(null)
/* ─── Fetch ─── */
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [catRes, symRes, tplRes] = await Promise.all([
const [catRes, symRes] = await Promise.all([
fetch('/api/tenant/categories'),
fetch('/api/tenant/symbols?grouped=true'),
fetch('/api/templates'),
])
if (catRes.ok) {
const c = await catRes.json()
@@ -121,12 +112,10 @@ export function SymbolManager() {
}
if (symRes.ok) {
const s = await symRes.json()
setSymbolGroups(s.groups || [])
setFlatSymbols(s.symbols || [])
}
if (tplRes.ok) {
const t = await tplRes.json()
setTemplates(t.packages || [])
setSymbolGroups(s.categories || [])
// Flatten all symbols from categories for ungrouped / category-management views
const all = (s.categories || []).flatMap((c: any) => c.symbols || [])
setFlatSymbols(all)
}
} catch {
toast({ title: 'Fehler beim Laden', variant: 'destructive' })
@@ -272,28 +261,44 @@ export function SymbolManager() {
toast({ title: `${success} Datei(en) hochgeladen` })
}
/* ─── Import ─── */
const importPackage = async (packageId: string) => {
setImportingPkg(packageId)
/* ─── Library ─── */
const fetchLibrary = useCallback(async () => {
setLibraryLoading(true)
try {
const res = await fetch('/api/templates/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packageId }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: data.error || 'Import fehlgeschlagen', variant: 'destructive' })
} else {
toast({ title: `${data.imported} Symbole importiert` })
await fetchData()
setImportOpen(false)
setActiveTab('symbols')
const res = await fetch('/api/icons')
if (res.ok) {
const data = await res.json()
setLibraryIcons(data.categories || [])
}
} catch {
toast({ title: 'Import fehlgeschlagen', variant: 'destructive' })
toast({ title: 'Fehler beim Laden der Bibliothek', variant: 'destructive' })
}
setImportingPkg(null)
setLibraryLoading(false)
}, [toast])
useEffect(() => {
if (activeTab === 'library') fetchLibrary()
}, [activeTab, fetchLibrary])
const addFromLibrary = async (iconId: string, name: string) => {
setAddingIconId(iconId)
try {
const res = await fetch('/api/tenant/symbols', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iconId }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
toast({ title: err.error || 'Fehler', variant: 'destructive' })
} else {
toast({ title: `'${name}' hinzugefügt` })
await fetchData()
}
} catch {
toast({ title: 'Fehler beim Hinzufügen', variant: 'destructive' })
}
setAddingIconId(null)
}
/* ─── Derived data ─── */
@@ -324,7 +329,7 @@ export function SymbolManager() {
{([
{ key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid },
{ key: 'categories', label: 'Kategorien', icon: FolderOpen },
{ key: 'import', label: 'Vorlagen importieren', icon: Download },
{ key: 'library', label: 'Bibliothek', icon: Library },
] as const).map(t => (
<button
key={t.key}
@@ -343,9 +348,6 @@ export function SymbolManager() {
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="w-4 h-4 mr-1.5" /> Upload
</Button>
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<Download className="w-4 h-4 mr-1.5" /> Import
</Button>
</div>
</div>
@@ -411,15 +413,15 @@ export function SymbolManager() {
<div className="text-center py-12 border rounded-lg">
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{search ? 'Keine Symbole gefunden.' : 'Noch keine Symbole vorhanden.'}
{search ? 'Keine Symbole gefunden.' : 'Noch keine Symbole. Füge Symbole aus der Bibliothek hinzu oder lade eigene hoch.'}
</p>
<div className="flex justify-center gap-2 mt-3">
<Button size="sm" variant="outline" onClick={() => setActiveTab('library')}>
<Library className="w-4 h-4 mr-1" /> Bibliothek durchsuchen
</Button>
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="w-4 h-4 mr-1" /> Hochladen
</Button>
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<Download className="w-4 h-4 mr-1" /> Importieren
</Button>
</div>
</div>
)}
@@ -514,57 +516,69 @@ export function SymbolManager() {
</div>
)}
{/* ===== TAB: Import ===== */}
{activeTab === 'import' && (
{/* ===== TAB: Bibliothek ===== */}
{activeTab === 'library' && (
<div className="space-y-4">
{templates.length === 0 ? (
<div className="text-center py-12 border rounded-lg text-muted-foreground">
<Package className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p className="text-sm">Keine Vorlagen-Pakete verfügbar.</p>
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="In Bibliothek suchen..."
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
className="pl-9"
/>
</div>
{libraryLoading ? (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Bibliothek laden...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map(pkg => (
<div key={pkg.packageId} className="border rounded-lg p-4 space-y-3 hover:border-primary/30 transition-colors">
<div className="flex items-start justify-between">
<div>
<h4 className="font-medium text-sm">{pkg.packageName}</h4>
<p className="text-xs text-muted-foreground mt-0.5">
{pkg.categoryCount} Kategorien · {pkg.symbolCount} Symbole
</p>
<div className="space-y-4">
{libraryIcons.map((cat: any) => {
const filtered = (cat.icons || []).filter((icon: any) =>
!librarySearch || (icon.name || '').toLowerCase().includes(librarySearch.toLowerCase())
)
if (filtered.length === 0) return null
return (
<div key={cat.id} className="border rounded-lg">
<div className="px-3 py-2 bg-muted/30 border-b">
<span className="font-medium text-sm">{cat.name}</span>
<span className="text-xs text-muted-foreground ml-2">({filtered.length})</span>
</div>
<Package className="w-5 h-5 text-muted-foreground" />
</div>
{/* Preview */}
<div className="flex gap-2">
{pkg.previewSymbols.slice(0, 4).map((p, i) => (
<div key={i} className="w-10 h-10 border rounded flex items-center justify-center bg-muted/30">
<img
src={`/signaturen/${p.svgPath.replace(/^.*[\\/]/, '')}`}
alt={p.name}
className="w-8 h-8 object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="px-3 py-3">
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
{filtered.map((icon: any) => (
<div key={icon.id} className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30">
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={icon.url}
alt={icon.name}
className="w-10 h-10 object-contain"
draggable={false}
onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg' }}
/>
</div>
<p className="text-[11px] text-center truncate" title={icon.name}>{icon.name}</p>
<button
onClick={() => addFromLibrary(icon.id, icon.name)}
disabled={addingIconId === icon.id}
className="absolute top-1 right-1 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-primary/90 disabled:opacity-50"
title="Zu Meinen Symbolen hinzufügen"
>
{addingIconId === icon.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
</button>
</div>
))}
</div>
))}
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={() => importPackage(pkg.packageId)}
disabled={importingPkg === pkg.packageId}
>
{importingPkg === pkg.packageId ? (
<Loader2 className="w-4 h-4 animate-spin mr-1.5" />
) : (
<Download className="w-4 h-4 mr-1.5" />
)}
{importingPkg === pkg.packageId ? 'Importiere...' : 'Importieren'}
</Button>
</div>
))}
)
})}
</div>
)}
</div>
@@ -631,57 +645,6 @@ export function SymbolManager() {
</DialogContent>
</Dialog>
{/* ===== IMPORT DIALOG ===== */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Aus Vorlagen importieren</DialogTitle>
<DialogDescription>
Wähle ein Vorlagen-Paket aus, das als Mandanten-Symbole importiert werden soll.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{templates.map(pkg => (
<div key={pkg.packageId} className="flex items-center gap-3 border rounded-lg p-3">
<div className="flex -space-x-2">
{pkg.previewSymbols.slice(0, 3).map((p, i) => (
<div key={i} className="w-9 h-9 border-2 border-background rounded bg-muted/30 flex items-center justify-center">
<img
src={`/signaturen/${p.svgPath.replace(/^.*[\\/]/, '')}`}
alt=""
className="w-7 h-7 object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
))}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{pkg.packageName}</p>
<p className="text-xs text-muted-foreground">{pkg.symbolCount} Symbole · {pkg.categoryCount} Kategorien</p>
</div>
<Button
size="sm"
onClick={() => importPackage(pkg.packageId)}
disabled={importingPkg === pkg.packageId}
>
{importingPkg === pkg.packageId ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</Button>
</div>
))}
{templates.length === 0 && (
<div className="text-center py-6 text-sm text-muted-foreground">
Keine Vorlagen verfügbar.
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -756,14 +719,14 @@ function SymbolCard({
{/* Category select */}
<Select
value={sym.categoryId || ''}
onValueChange={onMoveCategory}
value={sym.categoryId || '__none__'}
onValueChange={(val) => onMoveCategory(val === '__none__' ? '' : val)}
>
<SelectTrigger className="h-5 text-[10px] border-0 bg-transparent px-0 mt-0.5 hover:bg-muted/50 rounded">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ohne Kategorie</SelectItem>
<SelectItem value="__none__">Ohne Kategorie</SelectItem>
{categories.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}

View File

@@ -25,8 +25,7 @@ interface DisplayCategory {
}
interface TenantSymbolGroup {
categoryId: string | null
categoryName: string
category: { id: string; name: string } | null
symbols: DisplaySymbol[]
}
@@ -143,8 +142,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// ─── New tenant symbol groups (Phase 1) ───
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
categoryId: g.categoryId,
categoryName: g.categoryName,
category: g.category,
symbols: g.symbols.map((s: any) => ({
id: s.id,
name: s.name,
@@ -155,8 +153,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// Merge legacy "Eigene" into tenant groups if present
if (eigene && eigene.symbols.length > 0) {
const legacyGroup: TenantSymbolGroup = {
categoryId: '__legacy__',
categoryName: 'Eigene',
category: { id: '__legacy__', name: 'Eigene' },
symbols: eigene.symbols,
}
groups.unshift(legacyGroup)
@@ -166,7 +163,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// 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.categoryId || '__none__')))
setExpandedTenantCats(new Set(groups.map(g => g.category?.id || '__none__')))
setShowLibrarySection(false)
}
}
@@ -191,13 +188,13 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
const filteredCategories = categories.map((cat) => ({
...cat,
symbols: cat.symbols.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
}))
const filteredTenantGroups = tenantGroups.map(g => ({
...g,
symbols: g.symbols.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
})).filter(g => g.symbols.length > 0)
@@ -357,17 +354,17 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
</div>
) : (
filteredTenantGroups.map(g => {
const key = g.categoryId || '__none__'
const key = g.category?.id || '__none__'
const expanded = expandedTenantCats.has(key)
return (
<div key={key} className="border rounded-md">
<button
onClick={() => toggleTenantCat(g.categoryId)}
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.categoryName}
{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' : ''}`} />

104
src/lib/auto-migrate.ts Normal file
View 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
}
}