/** * 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 "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 "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`, ] 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") )`, ] for (const sql of tableMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } } // ─── 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 "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) })