const fs = require('fs'); const path = require('path'); const knex = require('knex'); const knexConfig = require('../../knexfile'); const logger = require('../utils/logger'); const { extractShareToken } = require('../utils/shareLinkUtils'); // Ensure SQLite directory exists when using file-based DB (native installs) try { const isPostgres = knexConfig && knexConfig.client === 'pg'; if (!isPostgres && knexConfig && knexConfig.connection) { const filename = typeof knexConfig.connection === 'object' ? knexConfig.connection.filename : (typeof knexConfig.connection === 'string' ? knexConfig.connection : null); if (filename && typeof filename === 'string') { const dir = path.dirname(filename); if (dir && dir !== '.') { fs.mkdirSync(dir, { recursive: true }); } } } } catch (e) { // Non-fatal: log and continue; SQLite will fail later if still missing try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {} } // Create database connection with built-in retry logic const db = knex(knexConfig); // Connection retry configuration const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // Wrapper function to handle connection retries async function withRetry(queryFn, retries = MAX_RETRIES) { for (let i = 0; i < retries; i++) { try { return await queryFn(); } catch (error) { const isConnectionError = error.message && ( error.message.includes('Connection terminated unexpectedly') || error.message.includes('Connection ended unexpectedly') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT') ); if (isConnectionError && i < retries - 1) { logger.info(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1))); continue; } throw error; } } } async function initializeDatabase() { // Events table const hasEventsTable = await db.schema.hasTable('events'); if (!hasEventsTable) { await db.schema.createTable('events', (table) => { table.increments('id').primary(); table.string('slug').unique().notNullable(); table.string('event_type').notNullable(); table.string('event_name').notNullable(); table.date('event_date').notNullable(); table.string('customer_name'); table.string('customer_email'); table.string('host_email').notNullable(); table.string('host_name'); table.string('admin_email').notNullable(); table.string('password_hash').notNullable(); table.text('welcome_message'); table.text('color_theme'); table.string('share_link').unique().notNullable(); table.string('share_token').unique(); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('expires_at').notNullable(); table.boolean('is_active').defaultTo(true); table.boolean('is_archived').defaultTo(false); table.string('archive_path'); table.datetime('archived_at'); table.boolean('allow_user_uploads').defaultTo(false); table.integer('upload_category_id'); table.boolean('allow_downloads').defaultTo(true); table.boolean('disable_right_click').defaultTo(false); table.boolean('watermark_downloads').defaultTo(false); table.text('watermark_text'); table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); table.boolean('require_password').defaultTo(true); }); } else { // Check if color_theme needs to be updated to TEXT type // This is needed for larger theme configurations const isPostgres = knexConfig.client === 'pg'; if (!isPostgres) { // SQLite-specific migration try { await db.raw(` CREATE TABLE IF NOT EXISTS events_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, event_type TEXT NOT NULL, event_name TEXT NOT NULL, event_date DATE NOT NULL, customer_name TEXT, customer_email TEXT, host_name TEXT, host_email TEXT NOT NULL, admin_email TEXT NOT NULL, password_hash TEXT NOT NULL, welcome_message TEXT, color_theme TEXT, share_link TEXT UNIQUE NOT NULL, share_token TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, is_active BOOLEAN DEFAULT 1, is_archived BOOLEAN DEFAULT 0, archive_path TEXT, archived_at DATETIME, allow_user_uploads BOOLEAN DEFAULT 0, upload_category_id INTEGER, allow_downloads BOOLEAN DEFAULT 1, disable_right_click BOOLEAN DEFAULT 0, watermark_downloads BOOLEAN DEFAULT 0, watermark_text TEXT, hero_photo_id INTEGER, require_password BOOLEAN DEFAULT 1 ) `); const pragmaRows = await db.raw("PRAGMA table_info('events')"); const existingColumns = pragmaRows.map(row => row.name); const selectColumns = existingColumns.map((col) => { switch (col) { case 'allow_user_uploads': return "COALESCE(allow_user_uploads, 0) as allow_user_uploads"; case 'upload_category_id': return "upload_category_id"; case 'allow_downloads': return "COALESCE(allow_downloads, 1) as allow_downloads"; case 'disable_right_click': return "COALESCE(disable_right_click, 0) as disable_right_click"; case 'watermark_downloads': return "COALESCE(watermark_downloads, 0) as watermark_downloads"; case 'watermark_text': return 'watermark_text'; case 'hero_photo_id': return 'hero_photo_id'; case 'require_password': return 'COALESCE(require_password, 1) as require_password'; default: return col; } }); await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`); await db.raw('DROP TABLE events'); await db.raw('ALTER TABLE events_new RENAME TO events'); } catch (error) { // If the migration fails, it might already have been applied logger.debug('Color theme migration may have already been applied'); } } } const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token'); if (!hasShareTokenColumn) { await db.schema.table('events', (table) => { table.string('share_token').unique(); }); } const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name'); if (!hasHostNameColumn) { await db.schema.table('events', (table) => { table.string('host_name'); }); } try { const eventsWithoutToken = await db('events') .whereNull('share_token') .select('id', 'share_link'); for (const event of eventsWithoutToken) { const token = extractShareToken(event.share_link); if (token) { await db('events') .where({ id: event.id }) .update({ share_token: token }); } } } catch (error) { logger.warn('Share token backfill skipped', { error: error.message }); } // Photo metadata table const hasPhotosTable = await db.schema.hasTable('photos'); if (!hasPhotosTable) { await db.schema.createTable('photos', (table) => { table.increments('id').primary(); table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); table.string('filename').notNullable(); table.string('path').notNullable(); table.string('thumbnail_path'); table.string('type').notNullable(); // 'collage' or 'individual' table.integer('size_bytes'); table.string('uploaded_by').defaultTo('admin'); table.datetime('uploaded_at').defaultTo(db.fn.now()); table.integer('view_count').defaultTo(0); table.integer('download_count').defaultTo(0); }); } // Access logs table const hasAccessLogsTable = await db.schema.hasTable('access_logs'); if (!hasAccessLogsTable) { await db.schema.createTable('access_logs', (table) => { table.increments('id').primary(); table.integer('event_id').references('id').inTable('events'); table.string('ip_address'); table.string('user_agent'); table.string('action'); // 'view', 'download', 'login_success', 'login_fail' table.string('photo_id'); table.datetime('timestamp').defaultTo(db.fn.now()); }); } // Email queue table const hasEmailQueueTable = await db.schema.hasTable('email_queue'); if (!hasEmailQueueTable) { await db.schema.createTable('email_queue', (table) => { table.increments('id').primary(); table.integer('event_id').references('id').inTable('events'); table.string('recipient_email').notNullable(); table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete' table.json('email_data'); table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed' table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('scheduled_at').defaultTo(db.fn.now()); table.datetime('sent_at'); table.text('error_message'); table.integer('retry_count').defaultTo(0); }); } else { const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at'); if (!hasCreatedAt) { await db.schema.alterTable('email_queue', (table) => { table.datetime('created_at').defaultTo(db.fn.now()); }); try { await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() }); } catch (updateError) { logger.debug('Email queue created_at backfill skipped', { error: updateError.message }); } } } // Admin users table const hasAdminUsersTable = await db.schema.hasTable('admin_users'); if (!hasAdminUsersTable) { await db.schema.createTable('admin_users', (table) => { table.increments('id').primary(); table.string('username').unique().notNullable(); table.string('email').unique().notNullable(); table.string('password_hash').notNullable(); table.boolean('is_active').defaultTo(true); table.boolean('must_change_password').defaultTo(false); table.datetime('password_changed_at'); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('updated_at').defaultTo(db.fn.now()); table.datetime('last_login'); table.string('last_login_ip'); table.string('language', 2).defaultTo('en'); }); } else { // Check if updated_at column exists const hasUpdatedAt = await db.schema.hasColumn('admin_users', 'updated_at'); if (!hasUpdatedAt) { await db.schema.table('admin_users', (table) => { table.datetime('updated_at'); }); // Set default value for existing rows await db('admin_users').update({ updated_at: new Date() }); } // Check if must_change_password column exists const hasMustChangePassword = await db.schema.hasColumn('admin_users', 'must_change_password'); if (!hasMustChangePassword) { await db.schema.table('admin_users', (table) => { table.boolean('must_change_password').defaultTo(false); }); } // Check if password_changed_at column exists const hasPasswordChangedAt = await db.schema.hasColumn('admin_users', 'password_changed_at'); if (!hasPasswordChangedAt) { await db.schema.table('admin_users', (table) => { table.datetime('password_changed_at'); }); } // Check if last_login_ip column exists const hasLastLoginIp = await db.schema.hasColumn('admin_users', 'last_login_ip'); if (!hasLastLoginIp) { await db.schema.table('admin_users', (table) => { table.string('last_login_ip'); }); } const hasLanguage = await db.schema.hasColumn('admin_users', 'language'); if (!hasLanguage) { await db.schema.table('admin_users', (table) => { table.string('language', 2).defaultTo('en'); }); } } // Token revocation tables const hasRevokedTokensTable = await db.schema.hasTable('revoked_tokens'); if (!hasRevokedTokensTable) { await db.schema.createTable('revoked_tokens', (table) => { table.increments('id').primary(); table.string('token_id').notNullable().unique(); // JWT ID or generated ID table.integer('user_id').nullable(); // User who owned the token table.string('token_type', 20); // admin, gallery, etc. table.timestamp('revoked_at').defaultTo(db.fn.now()); table.timestamp('expires_at').notNullable(); // When token would have expired table.string('reason', 100); // password_change, logout, compromised, etc. table.text('metadata'); // Additional JSON data // Indexes for performance table.index('token_id'); table.index('user_id'); table.index('expires_at'); // For cleanup }); } const hasUserTokenRevocationsTable = await db.schema.hasTable('user_token_revocations'); if (!hasUserTokenRevocationsTable) { await db.schema.createTable('user_token_revocations', (table) => { table.integer('user_id').primary(); table.timestamp('revoked_at').notNullable(); table.string('reason', 100); // Index for quick lookups table.index('revoked_at'); }); } // Email configuration table const hasEmailConfigTable = await db.schema.hasTable('email_configs'); if (!hasEmailConfigTable) { await db.schema.createTable('email_configs', (table) => { table.increments('id').primary(); table.string('smtp_host').notNullable(); table.integer('smtp_port').notNullable(); table.boolean('smtp_secure').defaultTo(false); table.string('smtp_user'); table.string('smtp_pass'); table.string('from_email').notNullable(); table.string('from_name'); table.datetime('updated_at').defaultTo(db.fn.now()); }); } // Email templates table const hasEmailTemplatesTable = await db.schema.hasTable('email_templates'); if (!hasEmailTemplatesTable) { await db.schema.createTable('email_templates', (table) => { table.increments('id').primary(); table.string('template_key').unique().notNullable(); // 'gallery_created', 'expiration_warning', etc. table.string('subject').notNullable(); table.text('body_html').notNullable(); table.text('body_text'); table.json('variables'); // Available template variables table.datetime('updated_at').defaultTo(db.fn.now()); }); } // App settings table const hasAppSettingsTable = await db.schema.hasTable('app_settings'); if (!hasAppSettingsTable) { await db.schema.createTable('app_settings', (table) => { table.increments('id').primary(); table.string('setting_key').unique().notNullable(); table.json('setting_value'); table.string('setting_type'); // 'branding', 'theme', 'general' table.datetime('updated_at').defaultTo(db.fn.now()); }); } const defaultLanguageSetting = await db('app_settings') .where('setting_key', 'default_language') .first(); if (!defaultLanguageSetting) { await db('app_settings').insert({ setting_key: 'default_language', setting_value: JSON.stringify('en'), setting_type: 'general', updated_at: new Date(), }); } // Activity logs table const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); if (!hasActivityLogsTable) { await db.schema.createTable('activity_logs', (table) => { table.increments('id').primary(); table.string('activity_type').notNullable(); // 'event_created', 'photos_uploaded', etc. table.string('actor_type'); // 'admin', 'system', 'guest' table.integer('actor_id'); table.string('actor_name'); table.json('metadata'); // Additional data about the activity table.integer('event_id').references('id').inTable('events'); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('read_at').nullable(); }); } else { // Check if read_at column exists const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at'); if (!hasReadAt) { await db.schema.table('activity_logs', (table) => { table.datetime('read_at').nullable(); }); } } await ensureGlobalCategories(); } // Ensure photo categories exist for new deployments async function ensureGlobalCategories() { const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories'); if (!hasPhotoCategoriesTable) { await db.schema.createTable('photo_categories', (table) => { table.increments('id').primary(); table.string('name', 100).notNullable(); table.string('slug', 100).notNullable(); table.boolean('is_global').defaultTo(true); table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); table.timestamp('created_at').defaultTo(db.fn.now()); table.unique(['slug', 'event_id']); }); } const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id'); if (!hasCategoryIdColumn) { await db.schema.alterTable('photos', (table) => { table.integer('category_id').references('id').inTable('photo_categories'); }); } const hasCmsPagesTable = await db.schema.hasTable('cms_pages'); if (!hasCmsPagesTable) { await db.schema.createTable('cms_pages', (table) => { table.increments('id').primary(); table.string('slug', 100).unique().notNullable(); table.text('title_en'); table.text('title_de'); table.text('content_en'); table.text('content_de'); table.timestamp('updated_at').defaultTo(db.fn.now()); }); } const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first(); const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0; if (categoryCount === 0) { const defaultCategories = [ { name: 'Ceremony', slug: 'ceremony', is_global: true }, { name: 'Reception', slug: 'reception', is_global: true }, { name: 'Portraits', slug: 'portraits', is_global: true }, { name: 'Group Photos', slug: 'group-photos', is_global: true }, { name: 'Details', slug: 'details', is_global: true }, { name: 'Party', slug: 'party', is_global: true }, ]; await db('photo_categories').insert(defaultCategories); } const cmsPages = await db('cms_pages').select('slug'); const existingSlugs = cmsPages.map((page) => page.slug); const defaultPages = [ { slug: 'impressum', title_en: 'Legal Notice', title_de: 'Impressum', content_en: '

Legal Notice

Please edit this content in the admin panel.

', content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', updated_at: new Date(), }, { slug: 'datenschutz', title_en: 'Privacy Policy', title_de: 'Datenschutzerklärung', content_en: '

Privacy Policy

Please edit this content in the admin panel.

', content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', updated_at: new Date(), }, ]; for (const page of defaultPages) { if (!existingSlugs.includes(page.slug)) { await db('cms_pages').insert(page); } } } // Helper function to log activities async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { try { await db('activity_logs').insert({ activity_type: activityType, actor_type: actor?.type || 'system', actor_id: actor?.id || null, actor_name: actor?.name || null, metadata: JSON.stringify(metadata), event_id: eventId }); } catch (error) { logger.error('Failed to log activity:', { error: error.message }); } } module.exports = { db, initializeDatabase, logActivity, withRetry };