- Add Migration 005: consent fields to groups table (display_in_workshop, consent_timestamp, management_token) - Add Migration 006: social_media_platforms and group_social_media_consents tables - Implement automatic migration execution in DatabaseManager.initialize() - Add standalone migration runner script (runMigrations.js) - Seed data: Facebook, Instagram, TikTok platforms Note: DatabaseManager statement splitting needs improvement for complex SQL. Manual migration execution works correctly via sqlite3.
407 lines
16 KiB
JavaScript
407 lines
16 KiB
JavaScript
const sqlite3 = require('sqlite3').verbose();
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
class DatabaseManager {
|
||
constructor() {
|
||
this.db = null;
|
||
// Place database file under data/db
|
||
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
|
||
this.schemaPath = path.join(__dirname, 'schema.sql');
|
||
}
|
||
|
||
async initialize() {
|
||
try {
|
||
// Stelle sicher, dass das data-Verzeichnis existiert
|
||
const dataDir = path.dirname(this.dbPath);
|
||
if (!fs.existsSync(dataDir)) {
|
||
fs.mkdirSync(dataDir, { recursive: true });
|
||
}
|
||
|
||
// Öffne Datenbankverbindung
|
||
this.db = new sqlite3.Database(this.dbPath, (err) => {
|
||
if (err) {
|
||
console.error('Fehler beim Öffnen der Datenbank:', err.message);
|
||
throw err;
|
||
} else {
|
||
console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
|
||
}
|
||
});
|
||
|
||
// Aktiviere Foreign Keys
|
||
await this.run('PRAGMA foreign_keys = ON');
|
||
|
||
// Erstelle Schema
|
||
await this.createSchema();
|
||
|
||
// Run database migrations (automatic on startup)
|
||
await this.runMigrations();
|
||
|
||
// Generate missing previews for existing images
|
||
await this.generateMissingPreviews();
|
||
|
||
console.log('✓ Datenbank erfolgreich initialisiert');
|
||
} catch (error) {
|
||
console.error('Fehler bei Datenbank-Initialisierung:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async createSchema() {
|
||
try {
|
||
console.log('🔨 Erstelle Datenbank-Schema...');
|
||
|
||
// Erstelle Groups Tabelle
|
||
await this.run(`
|
||
CREATE TABLE IF NOT EXISTS groups (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
group_id TEXT UNIQUE NOT NULL,
|
||
year INTEGER NOT NULL,
|
||
title TEXT NOT NULL,
|
||
description TEXT,
|
||
name TEXT,
|
||
upload_date DATETIME NOT NULL,
|
||
approved BOOLEAN DEFAULT FALSE,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
// Migration: Füge approved Feld zu bestehenden Tabellen hinzu (falls nicht vorhanden)
|
||
try {
|
||
await this.run('ALTER TABLE groups ADD COLUMN approved BOOLEAN DEFAULT FALSE');
|
||
console.log('✓ Approved Feld zur bestehenden Tabelle hinzugefügt');
|
||
} catch (error) {
|
||
// Feld existiert bereits - das ist okay
|
||
if (!error.message.includes('duplicate column')) {
|
||
console.warn('Migration Warnung:', error.message);
|
||
}
|
||
}
|
||
console.log('✓ Groups Tabelle erstellt');
|
||
|
||
// Erstelle Images Tabelle
|
||
await this.run(`
|
||
CREATE TABLE IF NOT EXISTS images (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
group_id TEXT NOT NULL,
|
||
file_name TEXT NOT NULL,
|
||
original_name TEXT NOT NULL,
|
||
file_path TEXT NOT NULL,
|
||
upload_order INTEGER NOT NULL,
|
||
file_size INTEGER,
|
||
mime_type TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
console.log('✓ Images Tabelle erstellt');
|
||
|
||
// Migration: Füge preview_path Feld zur images Tabelle hinzu (falls nicht vorhanden)
|
||
try {
|
||
await this.run('ALTER TABLE images ADD COLUMN preview_path TEXT');
|
||
console.log('✓ preview_path Feld zur images Tabelle hinzugefügt');
|
||
} catch (error) {
|
||
// Feld existiert bereits - das ist okay
|
||
if (!error.message.includes('duplicate column')) {
|
||
console.warn('Migration Warnung:', error.message);
|
||
}
|
||
}
|
||
|
||
// Migration: Füge image_description Feld zur images Tabelle hinzu (falls nicht vorhanden)
|
||
try {
|
||
await this.run('ALTER TABLE images ADD COLUMN image_description TEXT');
|
||
console.log('✓ image_description Feld zur images Tabelle hinzugefügt');
|
||
} catch (error) {
|
||
// Feld existiert bereits - das ist okay
|
||
if (!error.message.includes('duplicate column')) {
|
||
console.warn('Migration Warnung:', error.message);
|
||
}
|
||
}
|
||
|
||
// Erstelle Deletion Log Tabelle
|
||
await this.run(`
|
||
CREATE TABLE IF NOT EXISTS deletion_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
group_id TEXT NOT NULL,
|
||
year INTEGER NOT NULL,
|
||
image_count INTEGER NOT NULL,
|
||
upload_date DATETIME NOT NULL,
|
||
deleted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
deletion_reason TEXT DEFAULT 'auto_cleanup_7days',
|
||
total_file_size INTEGER
|
||
)
|
||
`);
|
||
console.log('✓ Deletion Log Tabelle erstellt');
|
||
|
||
// Erstelle Indizes
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_year ON groups(year)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_upload_date ON groups(upload_date)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_approved ON groups(approved)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_cleanup ON groups(approved, upload_date)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_images_group_id ON images(group_id)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_images_upload_order ON images(upload_order)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_deletion_log_deleted_at ON deletion_log(deleted_at DESC)');
|
||
await this.run('CREATE INDEX IF NOT EXISTS idx_deletion_log_year ON deletion_log(year)');
|
||
console.log('✓ Indizes erstellt');
|
||
|
||
// Erstelle Trigger
|
||
await this.run(`
|
||
CREATE TRIGGER IF NOT EXISTS update_groups_timestamp
|
||
AFTER UPDATE ON groups
|
||
FOR EACH ROW
|
||
BEGIN
|
||
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||
END
|
||
`);
|
||
console.log('✓ Trigger erstellt');
|
||
|
||
console.log('✅ Datenbank-Schema vollständig erstellt');
|
||
} catch (error) {
|
||
console.error('💥 Fehler beim Erstellen des Schemas:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Promise-wrapper für sqlite3.run
|
||
run(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
this.db.run(sql, params, function(err) {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve({ id: this.lastID, changes: this.changes });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Promise-wrapper für sqlite3.get
|
||
get(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
this.db.get(sql, params, (err, row) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve(row);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Promise-wrapper für sqlite3.all
|
||
all(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
this.db.all(sql, params, (err, rows) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve(rows);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Transaction support
|
||
async transaction(callback) {
|
||
await this.run('BEGIN TRANSACTION');
|
||
try {
|
||
const result = await callback(this);
|
||
await this.run('COMMIT');
|
||
return result;
|
||
} catch (error) {
|
||
await this.run('ROLLBACK');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
close() {
|
||
return new Promise((resolve, reject) => {
|
||
if (this.db) {
|
||
this.db.close((err) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
console.log('✓ Datenbankverbindung geschlossen');
|
||
resolve();
|
||
}
|
||
});
|
||
} else {
|
||
resolve();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Gesundheitscheck
|
||
async healthCheck() {
|
||
try {
|
||
const result = await this.get('SELECT 1 as test');
|
||
return result && result.test === 1;
|
||
} catch (error) {
|
||
console.error('Database health check failed:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Generate missing previews for existing images
|
||
async generateMissingPreviews() {
|
||
try {
|
||
console.log('🔍 Checking for images without previews...');
|
||
|
||
// Get all images that don't have a preview_path yet
|
||
const imagesWithoutPreview = await this.all(`
|
||
SELECT id, group_id, file_name, file_path
|
||
FROM images
|
||
WHERE preview_path IS NULL OR preview_path = ''
|
||
`);
|
||
|
||
if (imagesWithoutPreview.length === 0) {
|
||
console.log('✓ All images have previews');
|
||
return;
|
||
}
|
||
|
||
console.log(`📸 Found ${imagesWithoutPreview.length} image(s) without preview, generating...`);
|
||
|
||
const ImagePreviewService = require('../services/ImagePreviewService');
|
||
const fsp = require('fs').promises;
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const image of imagesWithoutPreview) {
|
||
try {
|
||
// Check if original file exists
|
||
const originalPath = ImagePreviewService.getOriginalPath(image.file_name);
|
||
await fsp.access(originalPath);
|
||
|
||
// Generate preview
|
||
const previewFileName = ImagePreviewService._getPreviewFileName(image.file_name);
|
||
const previewPath = ImagePreviewService.getPreviewPath(previewFileName);
|
||
|
||
const result = await ImagePreviewService.generatePreview(originalPath, previewPath);
|
||
|
||
if (result.success) {
|
||
// Update database with preview_path
|
||
await this.run(`
|
||
UPDATE images
|
||
SET preview_path = ?
|
||
WHERE id = ?
|
||
`, [previewFileName, image.id]);
|
||
|
||
successCount++;
|
||
} else {
|
||
console.warn(` ⚠️ Preview generation failed for ${image.file_name}: ${result.error}`);
|
||
failCount++;
|
||
}
|
||
} catch (error) {
|
||
console.warn(` ⚠️ Could not process ${image.file_name}: ${error.message}`);
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
console.log(`✓ Preview generation complete: ${successCount} success, ${failCount} failed`);
|
||
} catch (error) {
|
||
console.warn('⚠️ Preview generation check failed:', error.message);
|
||
// Don't throw - this shouldn't prevent DB initialization
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run pending database migrations automatically
|
||
* Migrations are SQL files in the migrations/ directory
|
||
*/
|
||
async runMigrations() {
|
||
try {
|
||
console.log('🔄 Checking for database migrations...');
|
||
|
||
const migrationsDir = path.join(__dirname, 'migrations');
|
||
|
||
// Check if migrations directory exists
|
||
if (!fs.existsSync(migrationsDir)) {
|
||
console.log(' ℹ️ No migrations directory found, skipping migrations');
|
||
return;
|
||
}
|
||
|
||
// Create migrations tracking table if it doesn't exist
|
||
await this.run(`
|
||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
migration_name TEXT UNIQUE NOT NULL,
|
||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
|
||
// Get list of applied migrations
|
||
const appliedMigrations = await this.all('SELECT migration_name FROM schema_migrations');
|
||
const appliedSet = new Set(appliedMigrations.map(m => m.migration_name));
|
||
|
||
// Get all migration files
|
||
const migrationFiles = fs.readdirSync(migrationsDir)
|
||
.filter(f => f.endsWith('.sql'))
|
||
.sort();
|
||
|
||
if (migrationFiles.length === 0) {
|
||
console.log(' ℹ️ No migration files found');
|
||
return;
|
||
}
|
||
|
||
let appliedCount = 0;
|
||
|
||
// Run pending migrations
|
||
for (const file of migrationFiles) {
|
||
if (appliedSet.has(file)) {
|
||
continue; // Already applied
|
||
}
|
||
|
||
console.log(` 🔧 Applying migration: ${file}`);
|
||
|
||
const migrationPath = path.join(migrationsDir, file);
|
||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||
|
||
try {
|
||
// Execute migration in a transaction
|
||
await this.run('BEGIN TRANSACTION');
|
||
|
||
// Split by semicolon and execute each statement
|
||
const statements = sql
|
||
.split(';')
|
||
.map(s => s.trim())
|
||
.filter(s => s.length > 0 && !s.startsWith('--'));
|
||
|
||
for (const statement of statements) {
|
||
await this.run(statement);
|
||
}
|
||
|
||
// Record migration
|
||
await this.run(
|
||
'INSERT INTO schema_migrations (migration_name) VALUES (?)',
|
||
[file]
|
||
);
|
||
|
||
await this.run('COMMIT');
|
||
appliedCount++;
|
||
console.log(` ✅ Successfully applied: ${file}`);
|
||
|
||
} catch (error) {
|
||
await this.run('ROLLBACK');
|
||
console.error(` ❌ Error applying ${file}:`, error.message);
|
||
throw new Error(`Migration failed: ${file} - ${error.message}`);
|
||
}
|
||
}
|
||
|
||
if (appliedCount > 0) {
|
||
console.log(`✓ Applied ${appliedCount} database migration(s)`);
|
||
} else {
|
||
console.log('✓ Database is up to date');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Migration error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Singleton Instance
|
||
const dbManager = new DatabaseManager();
|
||
|
||
module.exports = dbManager; |