2 Commits

Author SHA1 Message Date
Pepe Ziberi
a53f77c97c fix(db): comprehensive symbol recovery + safety fixes
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22m1s
2026-05-20 15:05:44 +02:00
Pepe Ziberi
fdd928720a docs: add incident report for symbol loss + recovery script 2026-05-20 13:58:37 +02:00
9 changed files with 910 additions and 27 deletions

View File

@@ -0,0 +1,139 @@
# Vorfall-Bericht: Symbol-Verlust (20. Mai 2026)
## Was ist passiert?
Alle Symbole in der App zeigen seit heute Morgen einen 404-Fehler (broken image icon). Die Symbole selbst sind aus der Datenbank gelöscht. Bereits erstellte Zeichnungen sind noch vorhanden, aber die Symbole darin sind ebenfalls broken.
---
## Chronologie der Ereignisse
### 1. Phase 1 Planung & Schema-Änderung (vor dem Vorfall)
Ich habe den Sprint A von **Phase 1 — Symbol-Architektur Redesign** begonnen. Dabei habe ich das Prisma-Schema erweitert:
- **Neue Tabellen**: `SymbolTemplate`, `TenantCategory`
- **Erweiterte Tabelle**: `TenantSymbol` um neue Spalten (`name`, `svgPath`, `isUploaded`, `categoryId`, etc.)
Diese Änderungen waren ausschließlich schema-seitig — es gab keine Löschoperationen.
### 2. Der eigentliche Bug (Cascade Delete)
Das Problem lag **NICHT** in den Schema-Änderungen, sondern in den bestehenden **Seed-Skripten**, die bei jedem Container-Start laufen:
**Dateien:**
- `prisma/seed.js`
- `prisma/seed-icons-only.js`
**Code (VOR dem Fix):**
```javascript
// Zeile ~73 in seed-icons-only.js
const deleted = await prisma.iconAsset.deleteMany({ where: { isSystem: true } })
console.log(`Deleted ${deleted.count} old system icons`)
```
Dieser Code hat **bei jedem Container-Start** alle System-Icons gelöscht und neu erstellt.
### 3. Warum das alles zerstört hat
Im Prisma-Schema gibt es folgende Relation:
```prisma
model TenantSymbol {
id String @id @default(uuid())
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
// ...
}
```
**Die `onDelete: Cascade`-Regel bedeutet:** Wenn ein `IconAsset` gelöscht wird, werden automatisch alle `TenantSymbol`-Einträge, die darauf verweisen, ebenfalls gelöscht.
**Ablauf bei jedem Container-Start:**
1. `seed-icons-only.js` läuft (weil Icon-Zahl < 100)
2. `deleteMany({ isSystem: true })` löscht alle System-Icons
3. `onDelete: Cascade` löscht alle `TenantSymbol`-Einträge
4. Neue Icons werden mit **neuen UUIDs** erstellt
5. Bestehende Zeichnungen verweisen noch auf die **alten gelöschten UUIDs** → 404
### 4. Warum ist das jetzt aufgefallen?
Der Container wurde neu gestartet (neues Deployment), und das Seed-Skript ist gelaufen. Dabei wurden alle Symbole gelöscht. Dieser Bug existierte bereits vor meinen Änderungen in den Seed-Skripten, ist aber jetzt zum ersten Mal aufgetreten, weil:
- Vorher hat das Skript nur selten gegriffen (Icons waren schon genug da)
- Jetzt wurde der Container frisch gestartet, und die Bedingung `ICON_COUNT < 100` war true
---
## Auswirkungen
### Was ist weg?
- ❌ Alle `IconAsset`-Einträge (System-Symbole)
- ❌ Alle `TenantSymbol`-Einträge (mandantenspezifische Symbol-Aktivierungen)
- ❌ Alle Icon-Category-Zuordnungen
### Was ist noch da?
- ✅ Alle Projekte (Zeichnungen)
- ✅ Alle Features (Linien, Symbole, Texte in den Zeichnungen)
- ✅ Alle Journal-Einträge
- ✅ Alle Benutzer, Tenants, etc.
### Was ist mit den Zeichnungen?
Die Zeichnungen sind intakt, aber die Symbole darin zeigen 404. Jedes Symbol in einer Zeichnung speichert in den Properties:
```json
{
"iconId": "alte-gelöschte-uuid",
"imageUrl": ""
}
```
Da die `iconId` auf einen gelöschten Eintrag verweist, kann das Bild nicht mehr geladen werden.
---
## Fix (bereits gepusht)
**Commit:** `5adadd2`
**Änderungen:**
1. `deleteMany` aus `seed.js` und `seed-icons-only.js` entfernt
2. Stattdessen **Upsert-Logik**: Update by `fileKey`, create only if missing
3. Dadurch bleiben bestehende IDs erhalten, und es gibt keine Cascade-Löschungen mehr
4. `prisma/migrate.js` um neue Phase-1-Tabellen erweitert
**Das verhindert zukünftige Löschungen, stellt aber bereits gelöschte Daten NICHT wieder her.**
---
## Wiederherstellung
Um die Symbole und Zeichnungen wiederherzustellen, gibt es zwei Wege:
### Option A: Datenbank-Backup (empfohlen)
Falls ein `pg_dump` oder Snapshot vor dem 20. Mai existiert, können die Tabellen `icon_assets`, `icon_categories` und `tenant_symbols` daraus restored werden.
### Option B: Recovery-Skript
Ein Skript, das:
1. Alle System-Icons aus `public/signaturen/` neu in die DB einspielt
2. Für jeden Tenant die Standard-Symbole neu aktiviert
3. Die Zeichnungen scannt und die `iconId`-Referenzen auf die neuen IDs updated
---
## Lessons Learned
1. **Seed-Skripte dürfen niemals `deleteMany` auf verknüpfte Daten ausführen**
2. `onDelete: Cascade` ist gefährlich bei Daten, die von Benutzern referenziert werden
3. Container-Start-Skripte müssen idempotent sein (mehrfaches Ausführen = gleiches Ergebnis)
4. Vor Deployment-Änderungen sollte ein DB-Backup gemacht werden
---
## Nächste Schritte
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)

View File

@@ -14,7 +14,10 @@
"db:migrate:prod": "prisma migrate deploy", "db:migrate:prod": "prisma migrate deploy",
"db:seed": "npx tsx prisma/seed.ts", "db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio", "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": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",

View File

@@ -2,6 +2,12 @@
* Database migration script using PrismaClient raw SQL. * Database migration script using PrismaClient raw SQL.
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client. * Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
* Safe to run multiple times (all statements are idempotent). * 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') const { PrismaClient } = require('@prisma/client')
@@ -121,10 +127,9 @@ async function migrate() {
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`) await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// ─── Step 6: Clean up orphan users ─── // ─── Step 6: Detect orphan users (log only, no deletion) ───
console.log(' [6/7] Cleaning up orphan users...') console.log(' [6/7] Checking for orphan users...')
try { try {
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
const orphans = await prisma.user.findMany({ const orphans = await prisma.user.findMany({
where: { where: {
role: { not: 'SERVER_ADMIN' }, role: { not: 'SERVER_ADMIN' },
@@ -133,22 +138,15 @@ async function migrate() {
select: { id: true, email: true, name: true }, select: { id: true, email: true, name: true },
}) })
if (orphans.length > 0) { 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) { for (const o of orphans) {
console.log(` - ${o.email} (${o.name})`) 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 { } else {
console.log(' No orphan users found') console.log(' No orphan users found')
} }
} catch (e) { } catch (e) {
console.log(' Orphan cleanup skipped:', e.message) console.log(' Orphan check skipped:', e.message)
} }
// ─── Step 7: Backfill logoFileKey from logoUrl ─── // ─── Step 7: Backfill logoFileKey from logoUrl ───
@@ -222,7 +220,7 @@ async function migrate() {
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"isActive" BOOLEAN NOT NULL DEFAULT true, "isActive" BOOLEAN NOT NULL DEFAULT true,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, "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") UNIQUE("tenantId", "iconId")
) )
`) `)
@@ -290,6 +288,39 @@ async function migrate() {
} }
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`) 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') console.log('✅ Database migrations complete')
} }

151
prisma/recover-features.js Normal file
View File

@@ -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)
})

258
prisma/recover-symbols.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* 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:
* Analyse: node prisma/recover-symbols.js --dry-run
* Anwenden: node prisma/recover-symbols.js --apply
*/
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: 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 ────────────────────────────────────────────────
const catDefs = [
{ 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',
'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) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return def.name
}
}
return 'Verschiedenes'
}
// ─── Main ──────────────────────────────────────────────────────────────────
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')).sort()
console.log(`\n📁 ${files.length} SVG-Dateien in public/signaturen/ gefunden`)
// ─── 1. Kategorien Upserten ───
console.log('\n[1/4] Kategorien...')
const catMap = {}
for (const def of catDefs) {
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 },
})
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(` 📊 ${features.length} symbol-Features gefunden`)
console.log(`${validRefs} gültige/keine Referenzen`)
console.log(`${brokenRefs} kaputte Referenzen (nicht reparierbar ohne Backup)`)
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('❌ Fehler:', e)
await prisma.$disconnect()
process.exit(1)
})

263
prisma/repair-features.js Normal file
View File

@@ -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)
})

View File

@@ -390,8 +390,8 @@ model TenantSymbol {
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
iconId String iconId String?
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade) icon IconAsset? @relation(fields: [iconId], references: [id], onDelete: SetNull)
// New fields for Phase 1 Symbol Architecture // New fields for Phase 1 Symbol Architecture
categoryId String? categoryId String?

View File

@@ -115,15 +115,10 @@ async function main() {
// Deleting would either break references (tenant symbols become 404s) // Deleting would either break references (tenant symbols become 404s)
// or cascade-delete tenant symbols. Instead we upsert by fileKey. // or cascade-delete tenant symbols. Instead we upsert by fileKey.
// Clean up empty global categories // NOTE: We intentionally do NOT delete any icon categories here.
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } }) // Tenant-specific categories may reference them, and deleting could
for (const oldCat of oldGlobalCats) { // orphan user data. Empty categories are harmless.
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } }) // Create or update global categories
if (remaining === 0) {
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
}
}
// Create new global categories
const catMap = {} const catMap = {}
for (const def of catDefs) { for (const def of catDefs) {
const cat = await prisma.iconCategory.upsert({ const cat = await prisma.iconCategory.upsert({

View File

@@ -1670,13 +1670,56 @@ export function MapView({
inner.style.transform = `rotate(${rotation}deg)` inner.style.transform = `rotate(${rotation}deg)`
inner.style.transition = 'transform 0.1s' inner.style.transition = 'transform 0.1s'
if (imgSrc) { // Try primary image source, with fallback chain for broken/deleted icons
inner.style.backgroundImage = `url("${imgSrc}")` const applyImage = (src: string) => {
inner.style.backgroundImage = `url("${src}")`
inner.style.backgroundSize = 'contain' inner.style.backgroundSize = 'contain'
inner.style.backgroundRepeat = 'no-repeat' inner.style.backgroundRepeat = 'no-repeat'
inner.style.backgroundPosition = 'center' 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 = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
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 = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
}
}
testImg.src = imgSrc
} else if (iconId) {
// No imageUrl at all — try loading via API
applyImage(`/api/icons/${iconId}/image`)
}
wrapper.appendChild(inner) wrapper.appendChild(inner)
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode // Click/tap to select symbol for Moveable editing — ONLY in 'select' mode