/** * Migration script: moves integration credentials and configurable settings * from environment variables into the database. * * Run with: * npx ts-node -P tsconfig.json -e "require('./prisma/migrate-env-to-db.ts')" * * Or (with dotenv): * node -r dotenv/config -r ts-node/register prisma/migrate-env-to-db.ts * * The script is idempotent - safe to run multiple times. */ import { PrismaClient, IntegrationType } from '@prisma/client'; import * as crypto from 'crypto'; const prisma = new PrismaClient(); // --------------------------------------------------------------------------- // Encryption helpers - MUST match EncryptionService exactly // --------------------------------------------------------------------------- const IV_LENGTH = 16; // 128 bits const TAG_LENGTH = 16; // 128 bits const KEY_LENGTH = 32; // 256 bits /** * Derives a 256-bit key using PBKDF2 - matches EncryptionService.deriveKey() */ function deriveKey(password: string): Buffer { const salt = 'tos-encryption-salt-v1'; return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256'); } /** * Builds the encryption key buffer - matches EncryptionService.onModuleInit() */ function buildEncryptionKey(encryptionKey: string): Buffer { if (encryptionKey.length < KEY_LENGTH) { return deriveKey(encryptionKey); } // Use first 32 UTF-8 bytes when key is long enough return Buffer.from(encryptionKey.slice(0, KEY_LENGTH), 'utf-8'); } /** * Encrypts a JSON object - matches EncryptionService.encryptObject() * * Format: base64( IV[16] || ciphertext || authTag[16] ) */ function encryptObject(data: Record, encryptionKeyRaw: string): string { const json = JSON.stringify(data); const key = buildEncryptionKey(encryptionKeyRaw); const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_LENGTH, }); const encrypted = Buffer.concat([ cipher.update(json, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); // Combine IV + ciphertext + authTag, encode as base64 const combined = Buffer.concat([iv, encrypted, authTag]); return combined.toString('base64'); } // --------------------------------------------------------------------------- // Migration // --------------------------------------------------------------------------- async function migrate() { console.log('Starting .env to DB migration...\n'); // 1. Migrate system settings const settingsMap: Record< string, { key: string; category: string; valueType: string; description: string } > = { ENABLE_SYNC_JOBS: { key: 'feature.syncJobs.enabled', category: 'feature', valueType: 'boolean', description: 'Hintergrund-Sync-Jobs aktivieren', }, SWAGGER_ENABLED: { key: 'feature.swagger.enabled', category: 'feature', valueType: 'boolean', description: 'Swagger API-Dokumentation aktivieren', }, CORS_ORIGINS: { key: 'cors.origins', category: 'cors', valueType: 'string', description: 'Erlaubte CORS Origins (kommagetrennt)', }, SYNC_INTERVAL_PLENTYONE: { key: 'sync.interval.plentyone', category: 'sync', valueType: 'number', description: 'PlentyONE Sync-Intervall (Minuten)', }, SYNC_INTERVAL_ZULIP: { key: 'sync.interval.zulip', category: 'sync', valueType: 'number', description: 'Zulip Sync-Intervall (Minuten)', }, SYNC_INTERVAL_TODOIST: { key: 'sync.interval.todoist', category: 'sync', valueType: 'number', description: 'Todoist Sync-Intervall (Minuten)', }, SYNC_INTERVAL_FREESCOUT: { key: 'sync.interval.freescout', category: 'sync', valueType: 'number', description: 'FreeScout Sync-Intervall (Minuten)', }, SYNC_INTERVAL_NEXTCLOUD: { key: 'sync.interval.nextcloud', category: 'sync', valueType: 'number', description: 'Nextcloud Sync-Intervall (Minuten)', }, SYNC_INTERVAL_ECODMS: { key: 'sync.interval.ecodms', category: 'sync', valueType: 'number', description: 'ecoDMS Sync-Intervall (Minuten)', }, SYNC_INTERVAL_GEMBADOCS: { key: 'sync.interval.gembadocs', category: 'sync', valueType: 'number', description: 'GembaDocs Sync-Intervall (Minuten)', }, }; console.log(' Migrating system settings...'); for (const [envKey, setting] of Object.entries(settingsMap)) { const envValue = process.env[envKey]; if (envValue !== undefined) { await prisma.systemSetting.upsert({ where: { key: setting.key }, update: { value: envValue }, create: { key: setting.key, value: envValue, category: setting.category, valueType: setting.valueType, description: setting.description, }, }); console.log(` Migrated ${envKey} -> ${setting.key} = ${envValue}`); } else { console.log(` Skipped ${envKey} (not set in environment)`); } } // 2. Migrate integration credentials const encryptionKey = process.env.ENCRYPTION_KEY; if (!encryptionKey) { console.warn( '\n WARNING: ENCRYPTION_KEY not set. Skipping credential migration.', ); console.log('\nDone (settings only).'); await prisma.$disconnect(); return; } // Find admin user for createdById const adminUser = await prisma.user.findFirst({ where: { roles: { some: { role: { name: 'admin' } } } }, }); if (!adminUser) { console.warn( '\n WARNING: No admin user found. Skipping credential migration.', ); console.log('\nDone (settings only).'); await prisma.$disconnect(); return; } const integrations: Array<{ type: IntegrationType; name: string; envMap: Record; }> = [ { type: IntegrationType.PLENTYONE, name: 'PlentyONE Default', envMap: { baseUrl: 'PLENTYONE_BASE_URL', clientId: 'PLENTYONE_CLIENT_ID', clientSecret: 'PLENTYONE_CLIENT_SECRET', }, }, { type: IntegrationType.ZULIP, name: 'Zulip Default', envMap: { baseUrl: 'ZULIP_BASE_URL', email: 'ZULIP_EMAIL', apiKey: 'ZULIP_API_KEY', }, }, { type: IntegrationType.TODOIST, name: 'Todoist Default', envMap: { apiToken: 'TODOIST_API_TOKEN', }, }, { type: IntegrationType.FREESCOUT, name: 'FreeScout Default', envMap: { apiUrl: 'FREESCOUT_API_URL', apiKey: 'FREESCOUT_API_KEY', }, }, { type: IntegrationType.NEXTCLOUD, name: 'Nextcloud Default', envMap: { serverUrl: 'NEXTCLOUD_URL', username: 'NEXTCLOUD_USERNAME', password: 'NEXTCLOUD_PASSWORD', }, }, { type: IntegrationType.ECODMS, name: 'ecoDMS Default', envMap: { apiUrl: 'ECODMS_API_URL', username: 'ECODMS_USERNAME', password: 'ECODMS_PASSWORD', }, }, { type: IntegrationType.GEMBADOCS, name: 'GembaDocs Default', envMap: { apiUrl: 'GEMBADOCS_API_URL', apiKey: 'GEMBADOCS_API_KEY', }, }, ]; console.log('\n Migrating integration credentials...'); for (const integration of integrations) { const creds: Record = {}; let hasValues = false; for (const [credKey, envKey] of Object.entries(integration.envMap)) { const val = process.env[envKey]; if (val) { creds[credKey] = val; hasValues = true; } } if (hasValues) { const encrypted = encryptObject(creds, encryptionKey); const existing = await prisma.integrationCredential.findFirst({ where: { type: integration.type, name: integration.name }, }); if (existing) { await prisma.integrationCredential.update({ where: { id: existing.id }, data: { credentials: encrypted }, }); console.log(` Updated ${integration.type} credentials`); } else { await prisma.integrationCredential.create({ data: { type: integration.type, name: integration.name, credentials: encrypted, createdById: adminUser.id, }, }); console.log(` Created ${integration.type} credentials`); } } else { console.log(` Skipped ${integration.type} (no env vars set)`); } } console.log('\nMigration complete!'); await prisma.$disconnect(); } migrate().catch((error) => { console.error('Migration failed:', error); prisma.$disconnect(); process.exit(1); });