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