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.
This commit is contained in:
Matthias Lotz 2025-11-09 20:57:48 +01:00
parent 4317d761d2
commit 8dc5a03584
4 changed files with 307 additions and 0 deletions

View File

@ -34,6 +34,9 @@ class DatabaseManager {
// Erstelle Schema // Erstelle Schema
await this.createSchema(); await this.createSchema();
// Run database migrations (automatic on startup)
await this.runMigrations();
// Generate missing previews for existing images // Generate missing previews for existing images
await this.generateMissingPreviews(); await this.generateMissingPreviews();
@ -301,6 +304,101 @@ class DatabaseManager {
// Don't throw - this shouldn't prevent DB initialization // 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 // Singleton Instance

View File

@ -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;

View File

@ -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);

View File

@ -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 };