Project-Image-Uploader/backend/src/database/DatabaseManager.js
matthias.lotz 8dc5a03584 feat(database): Add consent management migrations and auto-migration system
- 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.
2025-11-09 20:57:48 +01:00

407 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;