Compare commits
16 Commits
f6819b6a2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ecf4c61a | ||
|
|
6588959b50 | ||
|
|
a91a4c4689 | ||
|
|
693515aab5 | ||
|
|
0cbea843ab | ||
|
|
9cba24aad8 | ||
|
|
93d5519e58 | ||
|
|
c291431fd7 | ||
|
|
3606c9a2a4 | ||
|
|
3722a04091 | ||
|
|
0d0d9a7257 | ||
|
|
40cea9a9be | ||
|
|
56895be16f | ||
|
|
e9f66b2c3d | ||
|
|
c8a94e1ea7 | ||
|
|
07cede68c0 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
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) ───
|
||||
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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
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