From 8dc5a03584c79ee0db1d39e1373b6059bf367404 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 9 Nov 2025 20:57:48 +0100 Subject: [PATCH] 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. --- backend/src/database/DatabaseManager.js | 98 ++++++++++++ .../migrations/005_add_consent_fields.sql | 16 ++ .../006_create_social_media_tables.sql | 54 +++++++ backend/src/database/runMigrations.js | 139 ++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 backend/src/database/migrations/005_add_consent_fields.sql create mode 100644 backend/src/database/migrations/006_create_social_media_tables.sql create mode 100644 backend/src/database/runMigrations.js diff --git a/backend/src/database/DatabaseManager.js b/backend/src/database/DatabaseManager.js index 33dd283..a77a260 100644 --- a/backend/src/database/DatabaseManager.js +++ b/backend/src/database/DatabaseManager.js @@ -34,6 +34,9 @@ class DatabaseManager { // Erstelle Schema await this.createSchema(); + // Run database migrations (automatic on startup) + await this.runMigrations(); + // Generate missing previews for existing images await this.generateMissingPreviews(); @@ -301,6 +304,101 @@ class DatabaseManager { // 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 diff --git a/backend/src/database/migrations/005_add_consent_fields.sql b/backend/src/database/migrations/005_add_consent_fields.sql new file mode 100644 index 0000000..2be0f8f --- /dev/null +++ b/backend/src/database/migrations/005_add_consent_fields.sql @@ -0,0 +1,16 @@ +-- Migration 005: Add consent management fields to groups table +-- Date: 2025-11-09 +-- Description: Adds fields for workshop display consent, consent timestamp, and management token + +-- Add consent-related columns to groups table +ALTER TABLE groups ADD COLUMN display_in_workshop BOOLEAN NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN consent_timestamp DATETIME; +ALTER TABLE groups ADD COLUMN management_token TEXT; -- For Phase 2: Self-service portal + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_groups_display_consent ON groups(display_in_workshop); +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_management_token ON groups(management_token) WHERE management_token IS NOT NULL; + +-- Update existing groups to default values (retroactively grant consent for approved groups) +-- This assumes existing groups have been approved and displayed +UPDATE groups SET display_in_workshop = 1, consent_timestamp = created_at WHERE id > 0; diff --git a/backend/src/database/migrations/006_create_social_media_tables.sql b/backend/src/database/migrations/006_create_social_media_tables.sql new file mode 100644 index 0000000..03e74bf --- /dev/null +++ b/backend/src/database/migrations/006_create_social_media_tables.sql @@ -0,0 +1,54 @@ +-- Migration 006: Create social media platform configuration and consent tables +-- Date: 2025-11-09 +-- Description: Creates extensible social media platform management and per-group consent tracking + +-- ============================================================================ +-- Table: social_media_platforms +-- Purpose: Configurable list of social media platforms for consent management +-- ============================================================================ +CREATE TABLE IF NOT EXISTS social_media_platforms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_name TEXT UNIQUE NOT NULL, -- Internal identifier (e.g., 'facebook', 'instagram', 'tiktok') + display_name TEXT NOT NULL, -- User-facing name (e.g., 'Facebook', 'Instagram', 'TikTok') + icon_name TEXT, -- Material-UI Icon name for frontend display + is_active BOOLEAN DEFAULT 1, -- Enable/disable platform without deletion + sort_order INTEGER DEFAULT 0, -- Display order in UI + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================ +-- Table: group_social_media_consents +-- Purpose: Track user consent for each group and social media platform +-- ============================================================================ +CREATE TABLE IF NOT EXISTS group_social_media_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL, + platform_id INTEGER NOT NULL, + consented BOOLEAN NOT NULL DEFAULT 0, + consent_timestamp DATETIME NOT NULL, + revoked BOOLEAN DEFAULT 0, -- For Phase 2: Consent revocation tracking + revoked_timestamp DATETIME, -- When consent was revoked (Phase 2) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- Foreign key constraints + FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE, + FOREIGN KEY (platform_id) REFERENCES social_media_platforms(id) ON DELETE CASCADE, + + -- Ensure each platform can only have one consent entry per group + UNIQUE(group_id, platform_id) +); + +-- ============================================================================ +-- Indexes for query performance +-- ============================================================================ +CREATE INDEX IF NOT EXISTS idx_consents_group_id ON group_social_media_consents(group_id); +CREATE INDEX IF NOT EXISTS idx_consents_platform_id ON group_social_media_consents(platform_id); +CREATE INDEX IF NOT EXISTS idx_consents_consented ON group_social_media_consents(consented); + +-- ============================================================================ +-- Seed data: Insert default social media platforms +-- ============================================================================ +INSERT INTO social_media_platforms (platform_name, display_name, icon_name, sort_order) VALUES ('facebook', 'Facebook', 'Facebook', 1); +INSERT INTO social_media_platforms (platform_name, display_name, icon_name, sort_order) VALUES ('instagram', 'Instagram', 'Instagram', 2); +INSERT INTO social_media_platforms (platform_name, display_name, icon_name, sort_order) VALUES ('tiktok', 'TikTok', 'MusicNote', 3); diff --git a/backend/src/database/runMigrations.js b/backend/src/database/runMigrations.js new file mode 100644 index 0000000..6770194 --- /dev/null +++ b/backend/src/database/runMigrations.js @@ -0,0 +1,139 @@ +/** + * Database Migration Runner + * Executes SQL migrations in order + */ + +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); + +const dbPath = path.join(__dirname, '../data/db/image_uploader.db'); +const migrationsDir = path.join(__dirname, 'migrations'); + +// Helper to promisify database operations +function runQuery(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function getQuery(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +async function runMigrations() { + console.log('šŸš€ Starting database migrations...\n'); + + // Check if database exists + if (!fs.existsSync(dbPath)) { + console.error('āŒ Database file not found:', dbPath); + console.error('Please run the application first to initialize the database.'); + process.exit(1); + } + + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('āŒ Error opening database:', err.message); + process.exit(1); + } + }); + + try { + // Enable foreign keys + await runQuery(db, 'PRAGMA foreign_keys = ON'); + + // Create migrations table if it doesn't exist + await runQuery(db, ` + 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 new Promise((resolve, reject) => { + db.all('SELECT migration_name FROM schema_migrations', [], (err, rows) => { + if (err) reject(err); + else resolve(rows.map(r => r.migration_name)); + }); + }); + + console.log('šŸ“‹ Applied migrations:', appliedMigrations.length > 0 ? appliedMigrations.join(', ') : 'none'); + + // Get all migration files + const migrationFiles = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort(); + + console.log('šŸ“ Found migration files:', migrationFiles.length, '\n'); + + // Run pending migrations + for (const file of migrationFiles) { + if (appliedMigrations.includes(file)) { + console.log(`ā­ļø Skipping ${file} (already applied)`); + continue; + } + + console.log(`šŸ”§ Applying ${file}...`); + + const migrationPath = path.join(migrationsDir, file); + const sql = fs.readFileSync(migrationPath, 'utf8'); + + try { + // Execute migration in a transaction + await runQuery(db, '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 runQuery(db, statement); + } + + // Record migration + await runQuery(db, + 'INSERT INTO schema_migrations (migration_name) VALUES (?)', + [file] + ); + + await runQuery(db, 'COMMIT'); + console.log(`āœ… Successfully applied ${file}\n`); + + } catch (error) { + await runQuery(db, 'ROLLBACK'); + console.error(`āŒ Error applying ${file}:`, error.message); + throw error; + } + } + + console.log('\n✨ All migrations completed successfully!'); + + } catch (error) { + console.error('\nšŸ’„ Migration failed:', error); + process.exit(1); + } finally { + db.close(); + } +} + +// Run if executed directly +if (require.main === module) { + runMigrations().catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + +module.exports = { runMigrations };