/** * 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') const prisma = new PrismaClient() async function migrate() { console.log('πŸ”§ Running database migrations...') // ─── Step 1: Ensure enum values exist ─── console.log(' [1/7] Ensuring enum values...') const enumMigrations = [ // Role enum `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'SERVER_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'SERVER_ADMIN'; END IF; END$$;`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'TENANT_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'TENANT_ADMIN'; END IF; END$$;`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'OPERATOR' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'OPERATOR'; END IF; END$$;`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'VIEWER' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'VIEWER'; END IF; END$$;`, // DictionaryScope enum `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'DictionaryScope') THEN CREATE TYPE "DictionaryScope" AS ENUM ('GLOBAL', 'TENANT'); END IF; END$$;`, ] for (const sql of enumMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* enum might already exist */ } } // ─── Step 2: Migrate old enum data ─── console.log(' [2/7] Migrating old data...') const dataMigrations = [ `UPDATE users SET role = 'SERVER_ADMIN' WHERE role = 'ADMIN'`, `UPDATE users SET role = 'OPERATOR' WHERE role = 'EDITOR'`, `UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`, ] for (const sql of dataMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might not exist yet */ } } // ─── Step 3: Add missing columns (idempotent) ─── console.log(' [3/7] Adding missing columns...') const columnMigrations = [ // Tenants `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoUrl" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoFileKey" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "hiddenIconIds" TEXT[] DEFAULT '{}'`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "journalSuggestions" TEXT[] DEFAULT '{}'`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactEmail" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactPhone" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "address" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "notes" TEXT`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxUsers" INTEGER DEFAULT 5`, `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxProjects" INTEGER DEFAULT 10`, // Users `ALTER TABLE users ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN DEFAULT true`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerificationToken" TEXT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`, // Icon categories `ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "isGlobal" BOOLEAN DEFAULT false`, `ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "tenantId" TEXT`, // Projects `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planImageKey" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "featuresVersion" INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingStartedAt" TIMESTAMP`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingHeartbeat" TIMESTAMP`, // Journal `ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`, `ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`, // Modul-Baukasten (Cockpit-Konfiguration pro Mandant) `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`, // Windrichtung fΓΌrs Lagebild `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "windDirection" INTEGER`, // Γ–ffentlicher Nur-Ansicht-Teilen-Link (live, optional PIN) `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "shareToken" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "sharePin" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "shareEnabled" BOOLEAN NOT NULL DEFAULT false`, `CREATE UNIQUE INDEX IF NOT EXISTS "projects_shareToken_key" ON projects ("shareToken")`, ] let added = 0 for (const sql of columnMigrations) { try { await prisma.$executeRawUnsafe(sql) added++ } catch (e) { // Table might not exist yet β€” that's OK, prisma db push or first seed will create it } } console.log(` ${added}/${columnMigrations.length} column migrations executed`) // ─── Step 4: Create new tables ─── console.log(' [4/7] Creating new tables...') const tableMigrations = [ // Dictionary entries table `CREATE TABLE IF NOT EXISTS dictionary_entries ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), word TEXT NOT NULL, scope "DictionaryScope" NOT NULL DEFAULT 'GLOBAL', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "tenantId" TEXT REFERENCES tenants(id) ON DELETE CASCADE, UNIQUE(word, "tenantId") )`, // Rapports table `CREATE TABLE IF NOT EXISTS rapports ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "reportNumber" TEXT NOT NULL, token TEXT NOT NULL UNIQUE DEFAULT gen_random_uuid(), data JSONB NOT NULL DEFAULT '{}', "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, "createdById" TEXT REFERENCES users(id) ON DELETE SET NULL, UNIQUE("tenantId", "reportNumber") )`, // Exercise goals (Übungs-Cockpit) `CREATE TABLE IF NOT EXISTS exercise_goals ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), text TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'OPEN', note TEXT, "sortOrder" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE )`, // Modul-Baukasten: generische Zeilen fΓΌr Tabellen-Module `CREATE TABLE IF NOT EXISTS module_items ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "moduleId" TEXT NOT NULL, data JSONB NOT NULL DEFAULT '{}', "sortOrder" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE )`, `CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`, // Rechtsdokumente (versioniert, bewusst gesetzte Version) `CREATE TABLE IF NOT EXISTS legal_documents ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), type TEXT NOT NULL, version TEXT NOT NULL, title TEXT NOT NULL, "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "effectiveAt" TIMESTAMP(3), "contentHash" TEXT NOT NULL, url TEXT, "isActive" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(type, version) )`, `CREATE INDEX IF NOT EXISTS legal_documents_type_active_idx ON legal_documents(type, "isActive")`, // Revisionssichere Zustimmungen β€” nie ΓΌberschreiben, neue Version = neuer Datensatz `CREATE TABLE IF NOT EXISTS legal_acceptances ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, "organizationId" TEXT, "documentType" TEXT NOT NULL, "documentVersion" TEXT NOT NULL, "contentHash" TEXT NOT NULL, "acceptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, locale TEXT, context TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE("userId", "organizationId", "documentType", "documentVersion", context) )`, `CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`, `ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`, `ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`, // MFA / Zwei-Faktor `ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpSecret" TEXT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpConfirmedAt" TIMESTAMP(3)`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS "mfaBackupCodes" TEXT[] NOT NULL DEFAULT '{}'`, `CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, "credentialId" TEXT NOT NULL UNIQUE, "publicKey" TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, "deviceName" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "lastUsedAt" TIMESTAMP(3) )`, `CREATE INDEX IF NOT EXISTS webauthn_credentials_user_idx ON webauthn_credentials("userId")`, ] for (const sql of tableMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } } // ─── Step 4b: Rechtsdokumente seeden/aktivieren (idempotent) ─── // WICHTIG: Diese Metadaten mΓΌssen mit src/config/legal.ts ΓΌbereinstimmen. Version bewusst // erhΓΆhen, wenn sich der Inhalt WESENTLICH Γ€ndert (lΓΆst Re-Consent aus). console.log(' [4b] Seeding legal documents...') const crypto = require('crypto') const LEGAL_DOCS = [ { type: 'TERMS', version: '1.0.0', title: 'Nutzungsbedingungen', url: '/nutzungsbedingungen', publishedAt: '2026-07-23' }, { type: 'PRIVACY', version: '1.0.0', title: 'DatenschutzerklΓ€rung', url: '/datenschutz', publishedAt: '2026-07-23' }, { type: 'ORGANIZATION_DECLARATION', version: '1.0.0', title: 'OrganisationsbestΓ€tigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' }, { type: 'RESPONSIBLE_USE', version: '1.0.0', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' }, { type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0', title: 'Datenschutzvereinbarung fΓΌr Organisationen', url: '/organisationen/datenschutzvereinbarung', publishedAt: '2026-07-23' }, { type: 'FKS_ATTRIBUTION', version: '1.0.0', title: 'FKS-Quellenangabe & Nutzungshinweis', url: '/quellen-und-lizenzen', publishedAt: '2026-07-24' }, ] for (const doc of LEGAL_DOCS) { const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex') try { // Neue Version anlegen (falls noch nicht vorhanden), als aktiv markieren await prisma.$executeRawUnsafe( `INSERT INTO legal_documents (type, version, title, url, "contentHash", "publishedAt", "effectiveAt", "isActive") VALUES ($1,$2,$3,$4,$5,$6,$6,true) ON CONFLICT (type, version) DO UPDATE SET title = EXCLUDED.title, url = EXCLUDED.url, "isActive" = true`, doc.type, doc.version, doc.title, doc.url, contentHash, new Date(doc.publishedAt) ) // Andere Versionen desselben Typs deaktivieren (nur eine aktive je Typ) await prisma.$executeRawUnsafe( `UPDATE legal_documents SET "isActive" = false WHERE type = $1 AND version <> $2`, doc.type, doc.version ) } catch (e) { /* table might not exist yet on very first run */ } } // ─── Step 5: Set safe defaults ─── console.log(' [5/7] Setting defaults...') try { await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`) } catch (e) { /* ignore */ } // ─── Step 6: Detect orphan users (log only, no deletion) ─── console.log(' [6/7] Checking for orphan users...') try { const orphans = await prisma.user.findMany({ where: { role: { not: 'SERVER_ADMIN' }, memberships: { none: {} }, }, select: { id: true, email: true, name: true }, }) if (orphans.length > 0) { console.log(` ⚠️ Found ${orphans.length} orphan user(s) (NOT deleting β€” manual review required):`) for (const o of orphans) { console.log(` - ${o.email} (${o.name})`) } } else { console.log(' No orphan users found') } } catch (e) { console.log(' Orphan check skipped:', e.message) } // ─── Step 7: Backfill logoFileKey from logoUrl ─── console.log(' [7/7] Backfilling logoFileKey...') try { // Extract fileKey from MinIO URLs: the path after the bucket name // e.g. http://localhost:9002/lageplan-icons/logos/tenant-xxx.png β†’ logos/tenant-xxx.png const tenants = await prisma.tenant.findMany({ where: { logoUrl: { not: null }, logoFileKey: null }, select: { id: true, logoUrl: true }, }) for (const t of tenants) { if (!t.logoUrl) continue // Try to extract the fileKey from the URL const match = t.logoUrl.match(/logos\/[^?]+/) if (match) { await prisma.tenant.update({ where: { id: t.id }, data: { logoFileKey: match[0] }, }) } } if (tenants.length > 0) console.log(` Backfilled ${tenants.length} logo fileKey(s)`) } catch (e) { console.log(' Logo backfill skipped:', e.message) } // ─── Step 8: Drop unique constraint on rapports(tenantId, reportNumber) ─── console.log(' [8] Dropping rapports unique constraint on (tenantId, reportNumber)...') try { await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" DROP CONSTRAINT IF EXISTS "rapports_tenantId_reportNumber_key"`) console.log(' Constraint dropped (or did not exist)') } catch (e) { console.log(' Constraint drop skipped:', e.message) } // ─── Step 9: Add einsatzNr column to projects ─── console.log(' [9] Adding einsatzNr column to projects...') try { await prisma.$executeRawUnsafe(`ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "einsatzNr" TEXT`) console.log(' einsatzNr column added (or already exists)') } catch (e) { console.log(' einsatzNr column skipped:', e.message) } // ─── Step 10: Make rapports.tenantId nullable ─── console.log(' [10] Making rapports.tenantId nullable...') try { await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" ALTER COLUMN "tenantId" DROP NOT NULL`) console.log(' rapports.tenantId is now nullable') } catch (e) { console.log(' rapports.tenantId nullable skipped:', e.message) } // ─── Step 11: Add privacy consent columns to tenants ─── console.log(' [11] Adding privacy consent columns to tenants...') try { await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAccepted" BOOLEAN DEFAULT false`) await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAcceptedAt" TIMESTAMP`) await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "adminAccessAccepted" BOOLEAN DEFAULT false`) console.log(' Privacy consent columns added') } catch (e) { console.log(' Privacy consent columns skipped:', e.message) } // ─── Step 12: Create tenant_symbols table (CRITICAL β€” fail-fast) ─── console.log(' [12] Creating tenant_symbols table...') try { await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS tenant_symbols ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "isActive" BOOLEAN NOT NULL DEFAULT true, "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, "iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL, UNIQUE("tenantId", "iconId") ) `) console.log(' tenant_symbols table created (or already exists)') } catch (e) { console.error(' ❌ CRITICAL: tenant_symbols table creation failed:', e.message) throw e } // ─── Step 13: Create symbol_templates table ─── console.log(' [13] Creating symbol_templates table...') try { await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS symbol_templates ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "fileKey" TEXT NOT NULL, "originalFilename" TEXT NOT NULL, "displayName" TEXT, "categoryName" TEXT, "svgPath" TEXT, "metadata" JSONB NOT NULL DEFAULT '{}', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE("fileKey") ) `) console.log(' symbol_templates table created (or already exists)') } catch (e) { console.log(' symbol_templates table skipped:', e.message) } // ─── Step 14: Create tenant_categories table (CRITICAL β€” fail-fast) ─── console.log(' [14] Creating tenant_categories table...') try { await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS tenant_categories ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), "name" TEXT NOT NULL, "description" TEXT, "sortOrder" INTEGER NOT NULL DEFAULT 0, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE ) `) console.log(' tenant_categories table created (or already exists)') } catch (e) { console.error(' ❌ CRITICAL: tenant_categories table creation failed:', e.message) throw e } // ─── Step 14b: Ensure tenant_categories has NOT NULL defaults ─── console.log(' [14b] Ensuring tenant_categories columns...') const tcColumns = [ `ALTER TABLE tenant_categories ALTER COLUMN "isActive" SET DEFAULT true`, `ALTER TABLE tenant_categories ALTER COLUMN "sortOrder" SET DEFAULT 0`, `ALTER TABLE tenant_categories ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP`, `ALTER TABLE tenant_categories ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`, ] for (const sql of tcColumns) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ } } // ─── Step 15: Extend tenant_symbols with Phase 1 columns ─── console.log(' [15] Extending tenant_symbols with Phase 1 columns...') const tenantSymbolColumns = [ `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "svgPath" TEXT`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "customName" TEXT`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`, `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`, ] let tsAdded = 0 for (const sql of tenantSymbolColumns) { try { await prisma.$executeRawUnsafe(sql); tsAdded++ } catch (e) { /* ignore */ } } console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`) // ─── Step 15b: Add tenant_symbols.categoryId FK separately (idempotent) ─── console.log(' [15b] Adding tenant_symbols.categoryId FK...') try { await prisma.$executeRawUnsafe(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'tenant_symbols_categoryId_fkey' AND table_name = 'tenant_symbols' ) THEN ALTER TABLE tenant_symbols ADD CONSTRAINT "tenant_symbols_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES tenant_categories(id) ON DELETE SET NULL; END IF; EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'categoryId FK skipped: %', SQLERRM; END $$; `) console.log(' βœ… tenant_symbols.categoryId FK added') } catch (e) { console.log(' categoryId FK skipped:', e.message) } // ─── Step 16: Fix tenant_symbols FK (CASCADE β†’ SET NULL) ─── console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE β†’ SET NULL)...') try { // 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) } // ─── Step 18: Migrate legacy tenantSymbols (name, svgPath, categoryId, migratedFromIconId) ─── console.log(' [18] Migrating legacy tenantSymbols data...') try { const tenantsWithSymbols = await prisma.$queryRawUnsafe(` SELECT "tenantId", COUNT(id) as cnt FROM tenant_symbols GROUP BY "tenantId" `) let catsCreated = 0 let symsMigrated = 0 for (const row of tenantsWithSymbols) { const tenantId = row.tenantId // Create default category if none exists for this tenant let defaultCat = await prisma.$queryRawUnsafe(` SELECT id FROM tenant_categories WHERE "tenantId" = '${tenantId}' AND name = 'Meine Symbole' LIMIT 1 `) if (!defaultCat || !defaultCat.length) { const newCatId = crypto.randomUUID() await prisma.$executeRawUnsafe(` INSERT INTO tenant_categories (id, "tenantId", name, "sortOrder", "createdAt", "updatedAt") VALUES ('${newCatId}', '${tenantId}', 'Meine Symbole', 0, NOW(), NOW()) `) defaultCat = [{ id: newCatId }] catsCreated++ } const catId = defaultCat[0].id // Migrate symbols: set name, svgPath, categoryId, migratedFromIconId where null await prisma.$executeRawUnsafe(` UPDATE tenant_symbols ts SET name = COALESCE(ts.name, ts."customName", ia.name, 'Unbenannt'), "svgPath" = COALESCE(ts."svgPath", ia."fileKey"), "categoryId" = COALESCE(ts."categoryId", '${catId}'), "migratedFromIconId" = COALESCE(ts."migratedFromIconId", ts."iconId"), "updatedAt" = NOW() FROM icon_assets ia WHERE ts."tenantId" = '${tenantId}' AND ia.id = ts."iconId" AND (ts.name IS NULL OR ts."svgPath" IS NULL OR ts."categoryId" IS NULL OR ts."migratedFromIconId" IS NULL) `) // Also handle symbols where iconId is already null (orphaned) β€” at least set category & name await prisma.$executeRawUnsafe(` UPDATE tenant_symbols SET name = COALESCE(name, "customName", 'Unbenannt'), "categoryId" = COALESCE("categoryId", '${catId}'), "updatedAt" = NOW() WHERE "tenantId" = '${tenantId}' AND (name IS NULL OR "categoryId" IS NULL) `) symsMigrated++ } console.log(` βœ… ${catsCreated} default categories created, ${symsMigrated} tenants processed`) } catch (e) { console.log(' Legacy migration skipped:', e.message) } console.log('βœ… Database migrations complete') } migrate() .then(async () => { await prisma.$disconnect() }) .catch(async (e) => { console.error('Migration error:', e.message) await prisma.$disconnect() process.exit(1) })