diff --git a/INCIDENT-REPORT-2026-05-20.md b/INCIDENT-REPORT-2026-05-20.md index f77d45f..5236f66 100644 --- a/INCIDENT-REPORT-2026-05-20.md +++ b/INCIDENT-REPORT-2026-05-20.md @@ -129,6 +129,11 @@ Ein Skript, das: ## Nächste Schritte -1. [ ] Recovery-Skript erstellen oder Backup einspielen -2. [ ] Alle bestehenden Zeichnungen auf Korrektheit prüfen -3. [ ] Optional: `onDelete: Cascade` auf `onDelete: SetNull` ändern, um zukünftige Probleme zu vermeiden +1. [x] Recovery-Skript erstellt: `prisma/recover-symbols.js` (Sidebar/Admin) +2. [x] Recovery-Skript erstellt: `prisma/recover-features.js` (Zeichnungen) +3. [x] Renderer resilient gemacht: broken Symbole zeigen ⚠️ statt leeres Nichts +4. [x] `onDelete: Cascade` → `onDelete: SetNull` auf TenantSymbol.icon geändert +5. [x] Seed-Skripte auf Upsert umgestellt (Commit 5adadd2) +6. [ ] `recover-symbols.js` auf Server ausführen +7. [ ] `recover-features.js --dry-run` auf Server ausführen zur Analyse +8. [ ] Falls broken Features: User informieren (Symbole manuell neu platzieren) diff --git a/package.json b/package.json index c8732b4..cc27272 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "db:migrate:prod": "prisma migrate deploy", "db:seed": "npx tsx prisma/seed.ts", "db:studio": "prisma studio", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "repair:features:dry-run": "node prisma/repair-features.js --dry-run", + "repair:features:apply": "node prisma/repair-features.js --apply", + "recover:symbols": "node prisma/recover-symbols.js" }, "dependencies": { "@dnd-kit/core": "^6.1.0", diff --git a/prisma/migrate.js b/prisma/migrate.js index d6b2cd3..2af066d 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -2,6 +2,12 @@ * Database migration script using PrismaClient raw SQL. * Does NOT require the Prisma CLI (npx prisma) — only the runtime client. * Safe to run multiple times (all statements are idempotent). + * + * SAFETY RULES: + * - NO deleteMany / DELETE / TRUNCATE on icon_assets, icon_categories, + * tenant_symbols, or features. These contain user data. + * - All operations must be idempotent (safe to re-run). + * - In production, destructive operations are blocked. */ const { PrismaClient } = require('@prisma/client') @@ -121,10 +127,9 @@ async function migrate() { await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`) } catch (e) { /* ignore */ } - // ─── Step 6: Clean up orphan users ─── - console.log(' [6/7] Cleaning up orphan users...') + // ─── Step 6: Detect orphan users (log only, no deletion) ─── + console.log(' [6/7] Checking for orphan users...') try { - // Find users who are NOT SERVER_ADMIN and have NO tenant membership const orphans = await prisma.user.findMany({ where: { role: { not: 'SERVER_ADMIN' }, @@ -133,22 +138,15 @@ async function migrate() { select: { id: true, email: true, name: true }, }) if (orphans.length > 0) { - console.log(` Found ${orphans.length} orphan user(s):`) + console.log(` ⚠️ Found ${orphans.length} orphan user(s) (NOT deleting — manual review required):`) for (const o of orphans) { console.log(` - ${o.email} (${o.name})`) } - // Delete orphan users and their related data - await prisma.user.deleteMany({ - where: { - id: { in: orphans.map(o => o.id) }, - }, - }) - console.log(` 🗑️ ${orphans.length} orphan user(s) removed`) } else { console.log(' No orphan users found') } } catch (e) { - console.log(' Orphan cleanup skipped:', e.message) + console.log(' Orphan check skipped:', e.message) } // ─── Step 7: Backfill logoFileKey from logoUrl ─── @@ -222,7 +220,7 @@ async function migrate() { 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 NOT NULL REFERENCES icon_assets(id) ON DELETE CASCADE, + "iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL, UNIQUE("tenantId", "iconId") ) `) @@ -290,6 +288,39 @@ async function migrate() { } console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`) + // ─── Step 16: Fix tenant_symbols FK (CASCADE → SET NULL) ─── + console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...') + try { + // Make iconId nullable + await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols ALTER COLUMN "iconId" DROP NOT NULL`) + // Drop old cascade FK and recreate with SET NULL + await prisma.$executeRawUnsafe(` + DO $$ BEGIN + -- Drop existing FK constraint (name varies) + ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_fkey"; + ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_icon_assets_id_fk"; + -- Recreate with SET NULL + ALTER TABLE tenant_symbols + ADD CONSTRAINT "tenant_symbols_iconId_fkey" + FOREIGN KEY ("iconId") REFERENCES icon_assets(id) ON DELETE SET NULL; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'FK fix skipped: %', SQLERRM; + END $$; + `) + console.log(' ✅ tenant_symbols.iconId FK is now ON DELETE SET NULL') + } catch (e) { + console.log(' FK fix skipped:', e.message) + } + + // ─── Step 17: Drop unique constraint on tenant_symbols(tenantId, iconId) ─── + console.log(' [17] Dropping UNIQUE(tenantId, iconId) on tenant_symbols...') + try { + await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_tenantId_iconId_key"`) + console.log(' ✅ Unique constraint dropped (duplicates now allowed)') + } catch (e) { + console.log(' Unique constraint drop skipped:', e.message) + } + console.log('✅ Database migrations complete') } diff --git a/prisma/recover-features.js b/prisma/recover-features.js new file mode 100644 index 0000000..f34e333 --- /dev/null +++ b/prisma/recover-features.js @@ -0,0 +1,151 @@ +/** + * Recovery-Skript für Zeichnungen (Features) nach Symbol-Verlust + * + * Problem: Features verweisen auf gelöschte iconId-UUIDs. + * Die imageUrl ist "/api/icons/{alte-uuid}/image" → 404. + * + * Lösung: + * Die alte UUID→SVG-Zuordnung ist verloren. ABER: + * - Alle bestehenden IconAssets wurden per Upsert neu erstellt (gleiche fileKeys) + * - Jedes Feature hat noch die alte iconId in properties + * - Die Sidebar liefert jetzt NEUE iconIds + * + * Dieses Skript: + * 1. Listet alle broken Symbol-Features (iconId zeigt auf gelöschtes Icon) + * 2. Für Features wo imageUrl auf /signaturen/ zeigt: direkt fixbar + * 3. Für Features mit /api/icons/{uuid}/image: UUID ist verloren → Renderer zeigt ⚠️ + * 4. Zählt und listet betroffene Projekte + * + * Der Renderer wurde angepasst (map-view.tsx): broken Symbole zeigen jetzt + * ein ⚠️-Platzhalter statt nichts. User können das Symbol manuell ersetzen. + * + * Ausführung: + * DATABASE_URL=... node prisma/recover-features.js [--dry-run] + */ + +const { PrismaClient } = require('@prisma/client') +const prisma = new PrismaClient() + +const DRY_RUN = process.argv.includes('--dry-run') + +async function main() { + console.log('🔧 FEATURE RECOVERY — Analyse & Reparatur') + console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN' : '⚡ LIVE'}`) + console.log('') + + // ─── 1. Lade alle aktuellen IconAssets ─── + const allIcons = await prisma.iconAsset.findMany({ + select: { id: true, name: true, fileKey: true }, + }) + console.log(`📦 ${allIcons.length} IconAssets in der DB`) + + const iconIdSet = new Set(allIcons.map(i => i.id)) + const fileKeyToIcon = {} + for (const icon of allIcons) { + fileKeyToIcon[icon.fileKey] = icon + } + + // ─── 2. Lade alle Symbol-Features ─── + const features = await prisma.feature.findMany({ + where: { type: 'symbol' }, + include: { project: { select: { id: true, name: true, tenantId: true } } }, + }) + console.log(`🖼️ ${features.length} Symbol-Features gefunden`) + console.log('') + + let ok = 0 + let fixedViaFileKey = 0 + let broken = 0 + const brokenByProject = {} + + for (const feature of features) { + const props = feature.properties || {} + const iconId = props.iconId || '' + const imageUrl = props.imageUrl || '' + + // ─── Check 1: iconId still exists? ─── + if (iconId && iconIdSet.has(iconId)) { + ok++ + continue + } + + // ─── Check 2: imageUrl points to static /signaturen/ file? ─── + const sigMatch = imageUrl.match(/\/signaturen\/(.+\.svg)/) + if (sigMatch) { + const fileKey = `signaturen/${sigMatch[1]}` + const matchingIcon = fileKeyToIcon[fileKey] + if (matchingIcon) { + // Fix: Update iconId to new valid ID + const newProps = { + ...props, + iconId: matchingIcon.id, + imageUrl: `/api/icons/${matchingIcon.id}/image`, + _recovered: true, + _oldIconId: iconId, + } + if (!DRY_RUN) { + await prisma.feature.update({ + where: { id: feature.id }, + data: { properties: newProps }, + }) + } + fixedViaFileKey++ + console.log(` ✅ ${feature.id} → ${matchingIcon.name} (via fileKey)`) + continue + } + } + + // ─── Check 3: imageUrl is /api/icons/{uuid}/image? ─── + // The uuid in the URL = old iconId = deleted → can't resolve + // Mark as broken + broken++ + const projName = feature.project?.name || feature.projectId + if (!brokenByProject[projName]) brokenByProject[projName] = [] + brokenByProject[projName].push({ + featureId: feature.id, + iconId, + imageUrl, + }) + } + + console.log('') + console.log('═══════════════════════════════════════════') + console.log(` ✅ OK (iconId existiert): ${ok}`) + console.log(` 🔧 Gefixt (via fileKey): ${fixedViaFileKey}`) + console.log(` ❌ Broken (UUID verloren): ${broken}`) + console.log(` 📊 Total: ${features.length}`) + console.log('═══════════════════════════════════════════') + + if (broken > 0) { + console.log('') + console.log('❌ BROKEN FEATURES pro Projekt:') + for (const [projName, items] of Object.entries(brokenByProject)) { + console.log(` 📄 "${projName}": ${items.length} Symbole`) + for (const item of items) { + console.log(` - Feature ${item.featureId} (iconId: ${item.iconId.substring(0, 8)}...)`) + } + } + console.log('') + console.log('💡 Diese Symbole zeigen jetzt ein ⚠️ auf der Karte.') + console.log(' User müssen das Symbol manuell löschen und neu platzieren.') + console.log(' (Select-Mode → Symbol anklicken → DEL → neues Symbol aus Sidebar ziehen)') + } + + if (broken === 0 && fixedViaFileKey === 0) { + console.log('') + console.log('🎉 Alle Symbol-Features sind OK! Keine Reparatur nötig.') + } + + if (DRY_RUN) { + console.log('') + console.log('🔍 DRY RUN — Keine Änderungen geschrieben.') + } +} + +main() + .then(async () => { await prisma.$disconnect() }) + .catch(async (e) => { + console.error('❌ Fehler:', e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/prisma/recover-symbols.js b/prisma/recover-symbols.js index eed6cd5..e7cb536 100644 --- a/prisma/recover-symbols.js +++ b/prisma/recover-symbols.js @@ -1,15 +1,14 @@ /** - * Recovery-Skript für gelöschte Symbole - * - * ACHTUNG: Dieses Skript kann die Symbole in der Sidebar wiederherstellen, - * aber BESTEHENDE ZEICHNUNGEN (Features) bleiben broken, weil die alten - * iconId-UUIDs in den Feature-Properties für immer verloren sind. - * + * Recovery-Skript für gelöschte/neu erstellte Symbole + * + * Stellt sicher, dass alle Icons aus public/signaturen/ als IconAssets + * in der DB vorhanden sind und für alle Tenants aktiviert sind. + * + * Unterstützt Dry-Run und Apply-Modus. + * * Ausführung: - * node prisma/recover-symbols.js - * - * Umgebung: - * DATABASE_URL muss gesetzt sein (oder .env.docker geladen werden) + * Analyse: node prisma/recover-symbols.js --dry-run + * Anwenden: node prisma/recover-symbols.js --apply */ const { PrismaClient } = require('@prisma/client') @@ -18,200 +17,242 @@ const path = require('path') const prisma = new PrismaClient() -const SVG_DIR = path.join(__dirname, '..', 'public', 'signaturen') +const IS_DRY_RUN = process.argv.includes('--dry-run') +const IS_APPLY = process.argv.includes('--apply') +const IS_PROD = process.env.NODE_ENV === 'production' + +// ─── Safety Guards ───────────────────────────────────────────────────────── + +if (!IS_DRY_RUN && !IS_APPLY) { + console.error('❌ Fehler: Bitte --dry-run oder --apply angeben') + console.error('') + console.error(' Analyse: node prisma/recover-symbols.js --dry-run') + console.error(' Anwenden: node prisma/recover-symbols.js --apply') + process.exit(1) +} + +if (IS_DRY_RUN && IS_APPLY) { + console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden') + process.exit(1) +} + +if (IS_APPLY && IS_PROD) { + console.log('⚠️ Production-Umgebung erkannt. Fortfahren in 3 Sekunden...') + await new Promise(r => setTimeout(r, 3000)) +} + +console.log(IS_DRY_RUN ? '🔍 DRY-RUN MODUS' : '🔧 APPLY-MODUS') + +// ─── Kategorie-Definitionen ──────────────────────────────────────────────── -// Kategorie-Definitionen (wie im Original-Seed) const catDefs = [ - { - name: 'Wasser', - description: 'Wasser, Hydranten, Leitungen', - sortOrder: 2, + { name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1, + patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] }, + { name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2, patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung', - 'Oberflurhydrant', 'Unterflurhydrant', 'Innenhydrant', 'Wasserloeschposten', 'Wasserversorgung', - 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser', 'Schieber'], - }, - { - name: 'Feuer', - description: 'Brand, Feuerwehr, Entwicklung', - sortOrder: 3, - patterns: ['Feuer', 'Brandherd', 'Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale'], - }, - { - name: 'Gefahr / Stoffe', - description: 'Chemie, Gas, Biologie, Strom', - sortOrder: 4, - patterns: ['Chemie', 'Chemikalien', 'Biologische', 'Gas', 'Elektrizitaet', 'Gefaehrliche', - 'Explosion', 'Gefahr_durch_Loeschen'], - }, - { - name: 'Technik / Gebäude', - description: 'Lifte, Türen, Treppen, Tableaus', - sortOrder: 5, - patterns: ['Lift', 'Treppe', 'Eingang', 'Tableau', 'Entrauchung', 'Sprinkler', 'Kamin', - 'Brandmeldezentrale', 'Brandschutztueren', 'Elektrotableau', 'Fernsignaltableau', - 'Luefter', 'Decke_eingestuerzt', 'AnzahlGeschosse'], - }, - { - name: 'Gebäude / Schäden', - description: 'Gebäude, Zerstörung, Schäden', - sortOrder: 6, - patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Bruecke'], - }, - { - name: 'Einsatzführung', - description: 'Führung, Organisation, Kommunikation', - sortOrder: 7, - patterns: ['Einsatzleiter', 'ADL', 'KP_', 'Kontrollstelle', 'Informationszentrum', - 'Medienkontaktstelle', 'Sanitaet', 'Sanitaetshilfsstelle', 'Totensammelstelle', - 'Materialdepot', 'Schluesseldepot', 'Sammelstelle', 'Sammelplatz', 'Beobachtungsposten'], - }, - { - name: 'Fahrzeuge / Geräte', - description: 'Feuerwehrfahrzeuge, Leitern, Geräte', - sortOrder: 8, - patterns: ['TLF', 'HRF', 'Anhaengeleiter', 'Leiter', 'Sprungretter', 'Kleinloeschgeraet', - 'SWHP', 'MS_de'], - }, - { - name: 'Kommunikation / Sonstiges', - description: 'Funk, Wind, Massstab, etc.', - sortOrder: 9, - patterns: ['Funk', 'Windrichtung', 'Nordrichtung', 'Massstab', 'Helikopter', 'Armee', - 'Zivilschutz', 'Rettungen', 'Unfall', 'Rutschgebiet', 'Ueberschwemmung', - 'Abschnitt', 'Absperrung', 'Achtung', 'Anmarsch'], - }, + 'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung', + 'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser', + 'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] }, + { name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3, + patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri', + 'Elektrotableau', 'Achtung', 'Leitungsdaehte'] }, + { name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4, + patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle', + 'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] }, + { name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5, + patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] }, + { name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6, + patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt', + 'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke', + 'Rutschgebiet', 'Strasse', 'Bahnlin'] }, + { name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7, + patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle', + 'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau', + 'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] }, + { name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8, + patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] }, + { name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9, + patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung', + 'VertikaleEntwicklung', 'Unfall'] }, + { name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10, + patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] }, ] function findCategory(filename) { - const base = filename.replace(/\.svg$/i, '') - for (const cat of catDefs) { - for (const p of cat.patterns) { - if (base.toLowerCase().includes(p.toLowerCase())) return cat.name + for (const def of catDefs) { + for (const pattern of def.patterns) { + if (filename.includes(pattern)) return def.name } } - return 'Sonstiges' + return 'Verschiedenes' } -async function recover() { - console.log('🚨 SYMBOL RECOVERY STARTED') - console.log('') - console.log('⚠️ WARNUNG: Bestehende Zeichnungen bleiben broken!') - console.log(' Die alten iconId-UUIDs in den Feature-Properties sind verloren.') - console.log(' Benutzer müssen Symbole in Zeichnungen manuell neu setzen.') - console.log('') +// ─── Main ────────────────────────────────────────────────────────────────── - // ─── 1. Lade SVG-Dateien ─── +async function recover() { + console.log('\n🚨 SYMBOL RECOVERY') + console.log('────────────────────────────────────────') + + const SVG_DIR = path.join(__dirname, '..', 'public', 'signaturen') if (!fs.existsSync(SVG_DIR)) { console.error('❌ SVG-Verzeichnis nicht gefunden:', SVG_DIR) process.exit(1) } - const files = fs.readdirSync(SVG_DIR).filter(f => f.endsWith('.svg')) - console.log(`📁 ${files.length} SVG-Dateien gefunden`) + const files = fs.readdirSync(SVG_DIR).filter(f => f.endsWith('.svg')).sort() + console.log(`\n📁 ${files.length} SVG-Dateien in public/signaturen/ gefunden`) - // ─── 2. Erstelle Icon-Categories ─── - console.log('\n📂 Erstelle Icon-Categories...') - const categoryMap = {} + // ─── 1. Kategorien Upserten ─── + console.log('\n[1/4] Kategorien...') + const catMap = {} for (const def of catDefs) { - const cat = await prisma.iconCategory.upsert({ - where: { name: def.name }, - update: {}, - create: { - name: def.name, - description: def.description, - sortOrder: def.sortOrder, - isGlobal: true, - }, - }) - categoryMap[def.name] = cat.id - console.log(` ✅ ${cat.name}`) - } - // Sonstiges-Kategorie - const sonstiges = await prisma.iconCategory.upsert({ - where: { name: 'Sonstiges' }, - update: {}, - create: { name: 'Sonstiges', sortOrder: 99, isGlobal: true }, - }) - categoryMap['Sonstiges'] = sonstiges.id - - // ─── 3. Erstelle IconAssets ─── - console.log('\n🖼️ Erstelle IconAssets...') - const iconMap = {} // fileKey -> icon.id - for (const file of files) { - const filePath = path.join(SVG_DIR, file) - const stats = fs.statSync(filePath) - const fileKey = `lageplan-icons/symbols/${file}` - const name = file.replace(/\.svg$/i, '').replace(/_/g, ' ') - const catName = findCategory(file) - const categoryId = categoryMap[catName] - - const icon = await prisma.iconAsset.upsert({ - where: { fileKey }, - update: { - name, - mimeType: 'image/svg+xml', - isSystem: true, - isActive: true, - iconType: 'STANDARD', - categoryId, - }, - create: { - name, - fileKey, - mimeType: 'image/svg+xml', - isSystem: true, - isActive: true, - iconType: 'STANDARD', - categoryId, - }, - }) - iconMap[fileKey] = icon.id - } - console.log(` ✅ ${files.length} IconAssets erstellt/aktualisiert`) - - // ─── 4. Aktiviere alle Icons für alle Tenants ─── - console.log('\n🏢 Aktiviere Symbole für alle Tenants...') - const tenants = await prisma.tenant.findMany({ select: { id: true, name: true } }) - let tsCount = 0 - for (const tenant of tenants) { - for (const [fileKey, iconId] of Object.entries(iconMap)) { - await prisma.tenantSymbol.upsert({ - where: { - tenantId_iconId: { - tenantId: tenant.id, - iconId: iconId, - }, - }, - update: { isActive: true }, - create: { - tenantId: tenant.id, - iconId: iconId, - isActive: true, - }, + const catId = def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() + if (IS_APPLY) { + const cat = await prisma.iconCategory.upsert({ + where: { id: catId }, + update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true }, + create: { id: catId, name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true, tenantId: null }, }) - tsCount++ + catMap[def.name] = cat.id + } else { + catMap[def.name] = catId + console.log(` 📝 ${def.name} (ID: ${catId})`) + } + } + if (IS_APPLY) console.log(` ✅ ${catDefs.length} Kategorien upserted`) + + // ─── 2. IconAssets Upserten ─── + console.log('\n[2/4] IconAssets...') + const iconMap = {} // fileKey -> { id, name } + let created = 0 + let updated = 0 + + for (const file of files) { + let name = file.replace('.svg', '') + name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '') + name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim() + + const fileKey = `signaturen/${file}` // WICHTIG: Gleiches Format wie Seed! + const category = findCategory(file) + const categoryId = catMap[category] + + if (IS_APPLY) { + const existing = await prisma.iconAsset.findFirst({ where: { fileKey } }) + if (existing) { + await prisma.iconAsset.update({ + where: { id: existing.id }, + data: { name, categoryId, mimeType: 'image/svg+xml', isSystem: true, isActive: true, width: 48, height: 48 }, + }) + iconMap[fileKey] = { id: existing.id, name } + updated++ + } else { + const icon = await prisma.iconAsset.create({ + data: { name, categoryId, fileKey, mimeType: 'image/svg+xml', isSystem: true, width: 48, height: 48 }, + }) + iconMap[fileKey] = { id: icon.id, name } + created++ + } + } else { + // Dry-Run: Simuliere Lookup + const existing = await prisma.iconAsset.findFirst({ where: { fileKey }, select: { id: true, name: true } }) + if (existing) { + iconMap[fileKey] = { id: existing.id, name: existing.name } + updated++ + } else { + iconMap[fileKey] = { id: `(neu: ${fileKey})`, name } + created++ + } + } + } + console.log(` ${IS_APPLY ? '✅' : '📝'} ${created} neu, ${updated} aktualisiert, ${files.length} total`) + + // ─── 3. TenantSymbols erstellen ─── + console.log('\n[3/4] TenantSymbols...') + const tenants = await prisma.tenant.findMany({ select: { id: true, name: true } }) + let tsCreated = 0 + let tsExisting = 0 + + for (const tenant of tenants) { + let tenantNew = 0 + let tenantExist = 0 + + for (const [fileKey, icon] of Object.entries(iconMap)) { + if (IS_APPLY) { + // Prüfe ob bereits existiert + const existing = await prisma.tenantSymbol.findFirst({ + where: { tenantId: tenant.id, iconId: icon.id }, + }) + if (existing) { + tenantExist++ + tsExisting++ + } else { + await prisma.tenantSymbol.create({ + data: { tenantId: tenant.id, iconId: icon.id, isActive: true }, + }) + tenantNew++ + tsCreated++ + } + } else { + // Dry-Run: Prüfe Existenz + const existing = await prisma.tenantSymbol.findFirst({ + where: { tenantId: tenant.id, iconId: icon.id }, + select: { id: true }, + }) + if (existing) { + tenantExist++ + tsExisting++ + } else { + tenantNew++ + tsCreated++ + } + } + } + console.log(` ${IS_APPLY ? '✅' : '📝'} ${tenant.name}: ${tenantNew} neu, ${tenantExist} bereits vorhanden`) + } + console.log(` Gesamt: ${tsCreated} neu, ${tsExisting} bereits vorhanden`) + + // ─── 4. Feature-Analyse ─── + console.log('\n[4/4] Feature-Analyse (Zeichnungen)...') + const features = await prisma.feature.findMany({ + where: { type: 'symbol' }, + select: { id: true, properties: true, projectId: true }, + }) + + const validIconIds = new Set(Object.values(iconMap).map(i => i.id)) + let validRefs = 0 + let brokenRefs = 0 + + for (const f of features) { + const iconId = f.properties?.iconId + if (!iconId) { + validRefs++ // Keine Referenz = nicht kaputt + } else if (validIconIds.has(iconId)) { + validRefs++ + } else { + brokenRefs++ } - console.log(` ✅ ${tenant.name}: ${Object.keys(iconMap).length} Symbole aktiviert`) } - // ─── 5. Zeichnungen (Features) — Hinweis ─── - console.log('\n⚠️ FEATURES / ZEICHNUNGEN:') - const featureCount = await prisma.feature.count({ where: { type: 'symbol' } }) - console.log(` ${featureCount} Symbol-Features in der Datenbank gefunden.`) - console.log(` Diese zeigen auf GELÖSCHTE iconId-UUIDs und bleiben BROKEN.`) - console.log(` Es gibt KEINE Möglichkeit, die alten UUIDs wiederherzustellen.`) - console.log(` Benutzer müssen Symbole in ihren Zeichnungen manuell neu setzen.`) + console.log(` 📊 ${features.length} symbol-Features gefunden`) + console.log(` ✅ ${validRefs} gültige/keine Referenzen`) + console.log(` ❌ ${brokenRefs} kaputte Referenzen (nicht reparierbar ohne Backup)`) - console.log('\n✅ RECOVERY ABGESCHLOSSEN') - console.log(' - Symbole sind jetzt in der Sidebar verfügbar') - console.log(' - Admin → Symbol-Manager zeigt alle Symbole an') - console.log(' - Zeichnungen müssen manuell repariert werden') + if (brokenRefs > 0) { + console.log('\n ⚠️ HINWEIS: Kaputte Features können NICHT automatisch repariert werden.') + console.log(' Die alten iconId-UUIDs sind verloren.') + console.log(' Verwende: npm run repair:features:dry-run') + } + + console.log('\n✅ RECOVERY ABgeschlossen') + if (IS_DRY_RUN) { + console.log(' Zum Anwenden: node prisma/recover-symbols.js --apply') + } } recover() .then(async () => { await prisma.$disconnect() }) .catch(async (e) => { - console.error('Recovery error:', e) + console.error('❌ Fehler:', e) await prisma.$disconnect() process.exit(1) }) diff --git a/prisma/repair-features.js b/prisma/repair-features.js new file mode 100644 index 0000000..6c7411a --- /dev/null +++ b/prisma/repair-features.js @@ -0,0 +1,263 @@ +/** + * Feature/Symbol-Repair Skript + * + * Problem: Features (Zeichnungen) speichern Symbol-Referenzen als iconId in + * properties JSONB. Nach dem Icon-Verlust zeigen diese auf gelöschte UUIDs. + * + * Dieses Skript versucht: + * 1. Alle symbol-Features zu scannen + * 2. Zu prüfen, ob die referenzierte iconId noch existiert + * 3. Falls nicht: via migratedFromIconId oder Dateinamen-Matching zu reparieren + * + * Ausführung: + * Dry-Run (nur Analyse, keine Änderungen): + * node prisma/repair-features.js --dry-run + * + * Apply (mit Backup und Reparatur): + * node prisma/repair-features.js --apply + * + * SAFETY: + * - In Production (NODE_ENV=production) nur mit --apply möglich + * - Vor Apply wird automatisch ein JSON-Backup erstellt + * - Keine Features werden gelöscht + * - Keine Projekte oder Tenant-Daten werden verändert + */ + +const { PrismaClient } = require('@prisma/client') +const fs = require('fs') +const path = require('path') + +const prisma = new PrismaClient() + +const IS_DRY_RUN = process.argv.includes('--dry-run') +const IS_APPLY = process.argv.includes('--apply') +const IS_PROD = process.env.NODE_ENV === 'production' + +// ─── Safety Guards ───────────────────────────────────────────────────────── + +if (!IS_DRY_RUN && !IS_APPLY) { + console.error('❌ Fehler: Bitte --dry-run oder --apply angeben') + console.error('') + console.error(' Analyse-Modus (keine Änderungen):') + console.error(' node prisma/repair-features.js --dry-run') + console.error('') + console.error(' Reparatur-Modus (mit Backup):') + console.error(' node prisma/repair-features.js --apply') + process.exit(1) +} + +if (IS_DRY_RUN && IS_APPLY) { + console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden') + process.exit(1) +} + +if (IS_APPLY) { + console.log('⚠️ REPAIR-MODUS (mit Änderungen)') + if (IS_PROD) { + console.log(' Production-Umgebung erkannt. Fortfahren in 3 Sekunden...') + await new Promise(r => setTimeout(r, 3000)) + } +} else { + console.log('🔍 DRY-RUN MODUS (keine Änderungen)') +} + +// ─── Statistics ──────────────────────────────────────────────────────────── + +const stats = { + totalFeatures: 0, + validRefs: 0, + brokenRefs: 0, + repaired: 0, + notRepairable: 0, + details: [], +} + +// ─── Main ────────────────────────────────────────────────────────────────── + +async function repair() { + console.log('\n🔧 Feature-Repair gestartet') + console.log('────────────────────────────────────────') + + // ─── 1. Lade alle gültigen Icons für schnelle Lookup ─── + console.log('\n[1/5] Lade Icon-Lookup-Tabellen...') + + const allIcons = await prisma.iconAsset.findMany({ + select: { id: true, name: true, fileKey: true }, + }) + const iconById = new Map(allIcons.map(i => [i.id, i])) + const iconByFileKey = new Map(allIcons.map(i => [i.fileKey, i])) + + // tenant_symbols mit migratedFromIconId + const tenantSymbols = await prisma.tenantSymbol.findMany({ + select: { id: true, iconId: true, migratedFromIconId: true, customName: true }, + }) + const tsByMigratedId = new Map() + for (const ts of tenantSymbols) { + if (ts.migratedFromIconId) { + tsByMigratedId.set(ts.migratedFromIconId, ts) + } + } + + console.log(` ✅ ${allIcons.length} IconAssets geladen`) + console.log(` ✅ ${tenantSymbols.length} TenantSymbols geladen`) + + // ─── 2. Lade alle symbol-Features ─── + console.log('\n[2/5] Scanne Features...') + + const features = await prisma.feature.findMany({ + where: { type: 'symbol' }, + include: { project: { select: { id: true, title: true, tenantId: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + stats.totalFeatures = features.length + console.log(` 📊 ${features.length} symbol-Features gefunden`) + + // ─── 3. Analysiere jede Feature ─── + console.log('\n[3/5] Analysiere Symbol-Referenzen...') + + const toRepair = [] + + for (const feature of features) { + const props = feature.properties || {} + const iconId = props.iconId + const imageUrl = props.imageUrl || '' + + // Keine iconId → nicht reparierbar, aber auch nicht unbedingt kaputt + if (!iconId) { + stats.validRefs++ // Oder neutral + continue + } + + // Prüfe ob iconId noch existiert + const icon = iconById.get(iconId) + if (icon) { + stats.validRefs++ + continue + } + + // ❌ Broken reference + stats.brokenRefs++ + + // Versuche Reparatur-Strategien + let repairedId = null + let repairMethod = null + + // Strategie 1: via migratedFromIconId + const ts = tsByMigratedId.get(iconId) + if (ts && ts.iconId && iconById.has(ts.iconId)) { + repairedId = ts.iconId + repairMethod = 'migratedFromIconId' + } + + // Strategie 2: via imageUrl (extrahiere Dateinamen) + if (!repairedId && imageUrl) { + const match = imageUrl.match(/\/api\/icons\/([^\/]+)\/image/) + if (match) { + // Die alte iconId war in der URL, aber die ist ja gelöscht + // Wir können hier nicht viel machen + } + } + + // Strategie 3: via Name-Matching (wenn iconId ein bekannter Name war) + // Nicht anwendbar, da iconId eine UUID ist + + if (repairedId) { + stats.repaired++ + toRepair.push({ + featureId: feature.id, + projectId: feature.projectId, + projectTitle: feature.project?.title || '(unbekannt)', + oldIconId: iconId, + newIconId: repairedId, + repairMethod, + properties: props, + }) + } else { + stats.notRepairable++ + stats.details.push({ + featureId: feature.id, + projectId: feature.projectId, + projectTitle: feature.project?.title || '(unbekannt)', + iconId, + reason: 'Keine Zuordnung möglich (iconId nicht in migratedFromIconId)', + properties: JSON.stringify(props).substring(0, 200), + }) + } + } + + // ─── 4. Ergebnis-Anzeige ─── + console.log('\n[4/5] Ergebnis-Zusammenfassung') + console.log('────────────────────────────────────────') + console.log(` Gesamt geprüfte Features: ${stats.totalFeatures}`) + console.log(` ✅ Gültige Referenzen: ${stats.validRefs}`) + console.log(` ❌ Kaputte Referenzen: ${stats.brokenRefs}`) + console.log(` 🔧 Automatisch reparierbar: ${stats.repaired}`) + console.log(` ⚠️ Nicht reparierbar: ${stats.notRepairable}`) + + if (stats.details.length > 0) { + console.log('\n Nicht reparierbare Features (erste 10):') + for (const d of stats.details.slice(0, 10)) { + console.log(` - Feature ${d.featureId} in "${d.projectTitle}"`) + console.log(` iconId: ${d.iconId}`) + console.log(` Grund: ${d.reason}`) + } + if (stats.details.length > 10) { + console.log(` ... und ${stats.details.length - 10} weitere`) + } + } + + // ─── 5. Apply (nur wenn --apply) ─── + if (IS_APPLY && toRepair.length > 0) { + console.log('\n[5/5] Wende Reparaturen an...') + + // Backup erstellen + const backupPath = path.join(__dirname, `feature-backup-${Date.now()}.json`) + const backup = toRepair.map(r => ({ + featureId: r.featureId, + oldIconId: r.oldIconId, + newIconId: r.newIconId, + repairMethod: r.repairMethod, + oldProperties: r.properties, + })) + fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2)) + console.log(` 💾 Backup erstellt: ${backupPath}`) + + // Reparaturen durchführen + let applied = 0 + for (const r of toRepair) { + try { + const newProps = { + ...r.properties, + iconId: r.newIconId, + } + await prisma.feature.update({ + where: { id: r.featureId }, + data: { properties: newProps }, + }) + applied++ + console.log(` ✅ Feature ${r.featureId}: ${r.oldIconId} → ${r.newIconId} (${r.repairMethod})`) + } catch (e) { + console.error(` ❌ Feature ${r.featureId}: Update fehlgeschlagen - ${e.message}`) + } + } + console.log(`\n ✅ ${applied}/${toRepair.length} Reparaturen erfolgreich angewendet`) + } else if (IS_APPLY) { + console.log('\n[5/5] Keine Reparaturen notwendig.') + } else { + console.log('\n[5/5] DRY-RUN abgeschlossen (keine Änderungen).') + if (toRepair.length > 0) { + console.log(` Zum Anwenden: node prisma/repair-features.js --apply`) + } + } + + console.log('\n✅ Feature-Repair abgeschlossen') +} + +repair() + .then(async () => { await prisma.$disconnect() }) + .catch(async (e) => { + console.error('❌ Fehler:', e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4799ce1..6913cf8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -390,8 +390,8 @@ model TenantSymbol { tenantId String tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - iconId String - icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade) + iconId String? + icon IconAsset? @relation(fields: [iconId], references: [id], onDelete: SetNull) // New fields for Phase 1 Symbol Architecture categoryId String? diff --git a/prisma/seed.js b/prisma/seed.js index 456eb7e..5f9e388 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -115,15 +115,10 @@ async function main() { // Deleting would either break references (tenant symbols become 404s) // or cascade-delete tenant symbols. Instead we upsert by fileKey. - // Clean up empty global categories - const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } }) - for (const oldCat of oldGlobalCats) { - const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } }) - if (remaining === 0) { - await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {}) - } - } - // Create new global categories + // NOTE: We intentionally do NOT delete any icon categories here. + // Tenant-specific categories may reference them, and deleting could + // orphan user data. Empty categories are harmless. + // Create or update global categories const catMap = {} for (const def of catDefs) { const cat = await prisma.iconCategory.upsert({ diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 5f394be..969ffe7 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -1670,13 +1670,56 @@ export function MapView({ inner.style.transform = `rotate(${rotation}deg)` inner.style.transition = 'transform 0.1s' - if (imgSrc) { - inner.style.backgroundImage = `url("${imgSrc}")` + // Try primary image source, with fallback chain for broken/deleted icons + const applyImage = (src: string) => { + inner.style.backgroundImage = `url("${src}")` inner.style.backgroundSize = 'contain' inner.style.backgroundRepeat = 'no-repeat' inner.style.backgroundPosition = 'center' } + if (imgSrc) { + applyImage(imgSrc) + // If image fails to load (404 from deleted icon), try fallback + const testImg = new Image() + testImg.onload = () => {} // OK + testImg.onerror = () => { + // Fallback 1: Try API endpoint with iconId + if (iconId) { + const fallbackUrl = `/api/icons/${iconId}/image` + if (fallbackUrl !== imgSrc) { + const test2 = new Image() + test2.onload = () => applyImage(fallbackUrl) + test2.onerror = () => { + // Fallback 2: Show broken symbol indicator + inner.style.backgroundImage = 'none' + inner.style.display = 'flex' + inner.style.alignItems = 'center' + inner.style.justifyContent = 'center' + inner.style.border = '2px dashed #ef4444' + inner.style.borderRadius = '4px' + inner.style.backgroundColor = 'rgba(239,68,68,0.1)' + inner.innerHTML = '⚠️' + } + test2.src = fallbackUrl + } else { + inner.style.backgroundImage = 'none' + inner.style.display = 'flex' + inner.style.alignItems = 'center' + inner.style.justifyContent = 'center' + inner.style.border = '2px dashed #ef4444' + inner.style.borderRadius = '4px' + inner.style.backgroundColor = 'rgba(239,68,68,0.1)' + inner.innerHTML = '⚠️' + } + } + } + testImg.src = imgSrc + } else if (iconId) { + // No imageUrl at all — try loading via API + applyImage(`/api/icons/${iconId}/image`) + } + wrapper.appendChild(inner) // Click/tap to select symbol for Moveable editing — ONLY in 'select' mode