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:
parent
4317d761d2
commit
8dc5a03584
|
|
@ -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
|
||||
|
|
|
|||
16
backend/src/database/migrations/005_add_consent_fields.sql
Normal file
16
backend/src/database/migrations/005_add_consent_fields.sql
Normal 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;
|
||||
|
|
@ -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);
|
||||
139
backend/src/database/runMigrations.js
Normal file
139
backend/src/database/runMigrations.js
Normal 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 };
|
||||
Loading…
Reference in New Issue
Block a user