const mysql = require('mysql2/promise'); require('dotenv').config(); const fs = require('fs'); const path = require('path'); const NODE_ENV = process.env.NODE_ENV || 'development'; const getSSLConfig = () => { const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true'; const caPath = process.env.DB_SSL_CA_PATH; if (!useSSL) return undefined; try { if (caPath) { const resolved = path.resolve(caPath); if (fs.existsSync(resolved)) { console.log('๐Ÿ” (createDb) Loading DB CA certificate from:', resolved); return { ca: fs.readFileSync(resolved), rejectUnauthorized: false }; } else { console.warn('โš ๏ธ (createDb) CA file not found at:', resolved, '- proceeding with rejectUnauthorized:false'); } } else { console.warn('โš ๏ธ (createDb) DB_SSL_CA_PATH not set - proceeding with rejectUnauthorized:false'); } } catch (e) { console.warn('โš ๏ธ (createDb) Failed to load CA file:', e.message, '- proceeding with rejectUnauthorized:false'); } return { rejectUnauthorized: false }; }; let dbConfig; if (NODE_ENV === 'development') { dbConfig = { host: process.env.DEV_DB_HOST || 'localhost', port: Number(process.env.DEV_DB_PORT) || 3306, user: process.env.DEV_DB_USER || 'root', password: process.env.DEV_DB_PASSWORD || '', // XAMPP default: no password database: process.env.DEV_DB_NAME || 'profitplanet_centralserver', ssl: undefined }; } else { dbConfig = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, ssl: getSSLConfig() }; } const allowCreateDb = String(process.env.DB_ALLOW_CREATE_DB || 'false').toLowerCase() === 'true'; const SYSTEM_POOLS = [ { pool_name: 'ABO 60', description: 'System pool for ABO 60 capsule distribution', price: 0.01, pool_type: 'coffee', price_per_capsule_gross: 0.01 }, { pool_name: 'ABO 120', description: 'System pool for ABO 120 capsule distribution', price: 0.01, pool_type: 'coffee', price_per_capsule_gross: 0.01 }, { pool_name: 'Business', description: 'System pool for Business capsule distribution', price: 0.02, pool_type: 'other', price_per_capsule_gross: 0.02 }, { pool_name: 'Gigantea', description: 'System pool for Gigantea capsule distribution', price: 0.02, pool_type: 'other', price_per_capsule_gross: 0.02 }, { pool_name: 'Core', description: 'Every member receives 1 cent per capsule sold โ€” the amount multiplies with each member, not divided.', price: 0.01, pool_type: 'other', price_per_capsule_gross: 0.01 }, ]; // --- Performance Helpers --- async function ensureIndex(conn, table, indexName, indexDDL) { const [rows] = await conn.query(`SHOW INDEX FROM \`${table}\` WHERE Key_name = ?`, [indexName]); if (!rows.length) { await conn.query(`CREATE INDEX \`${indexName}\` ON \`${table}\` (${indexDDL})`); console.log(`๐Ÿ†• Created index ${indexName} ON ${table}`); } else { console.log(`โ„น๏ธ Index ${indexName} already exists on ${table}`); } } // --- Schema pre-check helpers (Approach A) --- async function columnExists(conn, table, column) { const [rows] = await conn.query( `SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, [table, column] ); return rows.length > 0; } async function addColumnIfMissing(conn, table, column, ddlFragment /* includes type + nullability + AFTER/etc */) { if (await columnExists(conn, table, column)) return false; await conn.query(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${ddlFragment}`); console.log(`๐Ÿ†• Added column ${table}.${column}`); return true; } async function constraintExists(conn, table, constraintName) { const [rows] = await conn.query( `SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? LIMIT 1`, [table, constraintName] ); return rows.length > 0; } async function addForeignKeyIfMissing(conn, table, constraintName, ddl /* full ALTER TABLE ... ADD CONSTRAINT ... */) { if (await constraintExists(conn, table, constraintName)) return false; await conn.query(ddl); console.log(`๐Ÿ†• Added constraint ${constraintName} on ${table}`); return true; } async function getPrimaryKeyColumns(conn, table) { const [rows] = await conn.query( `SELECT k.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS t JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k ON t.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND t.TABLE_SCHEMA = k.TABLE_SCHEMA AND t.TABLE_NAME = k.TABLE_NAME WHERE t.TABLE_SCHEMA = DATABASE() AND t.TABLE_NAME = ? AND t.CONSTRAINT_TYPE = 'PRIMARY KEY' ORDER BY k.ORDINAL_POSITION`, [table] ); return rows.map(r => r.COLUMN_NAME); } const createDatabase = async () => { console.log('๐Ÿš€ Starting MySQL database initialization...'); console.log('๐Ÿ“ Database host:', process.env.DB_HOST); console.log('๐Ÿ“ Database name:', process.env.DB_NAME); let connection; try { if (allowCreateDb) { // Connect without specifying a database to create it if it doesn't exist connection = await mysql.createConnection({ host: dbConfig.host, port: dbConfig.port, user: dbConfig.user, password: dbConfig.password, ssl: dbConfig.ssl }); await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbConfig.database}\`;`); console.log(`โœ… Database "${dbConfig.database}" created/verified`); await connection.end(); } else { console.log('โ„น๏ธ Skipping database creation (DB_ALLOW_CREATE_DB=false)'); } // Reconnect with the database specified connection = await mysql.createConnection(dbConfig); console.log('โœ… Connected to MySQL database'); // --- Core Tables --- // 1. users table: Central user authentication and common attributes await connection.query(` CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, user_type ENUM('personal', 'company', 'guest') NOT NULL, role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user', iban VARCHAR(34), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_login_at TIMESTAMP NULL, INDEX idx_email (email), INDEX idx_user_type (user_type), INDEX idx_role (role) ); `); console.log('โœ… Users table created/verified'); // Migrate existing role ENUM to include 'guest' try { await connection.query(` ALTER TABLE users MODIFY COLUMN role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user' `); console.log('โœ… Updated users.role column to include guest'); } catch (err) { console.warn('โš ๏ธ Could not modify users.role column:', err.message); } // 2. personal_profiles table: Details specific to personal users await connection.query(` CREATE TABLE IF NOT EXISTS personal_profiles ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, phone VARCHAR(255) NULL, date_of_birth DATE, nationality VARCHAR(255), address TEXT, zip_code VARCHAR(20), city VARCHAR(100), country VARCHAR(100), phone_secondary VARCHAR(255), emergency_contact_name VARCHAR(255), emergency_contact_phone VARCHAR(255), account_holder_name VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, UNIQUE KEY unique_user_profile (user_id) ); `); console.log('โœ… Personal profiles table created/verified'); // 3. company_profiles table: Details specific to company users await connection.query(` CREATE TABLE IF NOT EXISTS company_profiles ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, company_name VARCHAR(255) NOT NULL, registration_number VARCHAR(255) UNIQUE, phone VARCHAR(255) NULL, address TEXT, zip_code VARCHAR(20), city VARCHAR(100), country VARCHAR(100), branch VARCHAR(255), number_of_employees INT, business_type VARCHAR(255), contact_person_name VARCHAR(255), contact_person_phone VARCHAR(255), account_holder_name VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, UNIQUE KEY unique_user_profile (user_id), UNIQUE KEY unique_registration_number (registration_number) ); `); console.log('โœ… Company profiles table created/verified'); // Ensure registration_number allows NULL if table already existed try { await connection.query(`ALTER TABLE company_profiles MODIFY COLUMN registration_number VARCHAR(255) UNIQUE NULL`); console.log('๐Ÿ”ง Ensured registration_number allows NULL'); } catch (e) { console.log('โ„น๏ธ registration_number column already allows NULL or ALTER not required'); } // Ensure phone columns are nullable if tables already existed try { await connection.query(`ALTER TABLE personal_profiles MODIFY COLUMN phone VARCHAR(255) NULL`); await connection.query(`ALTER TABLE company_profiles MODIFY COLUMN phone VARCHAR(255) NULL`); console.log('๐Ÿ”ง Ensured phone columns are nullable'); } catch (e) { console.log('โ„น๏ธ Phone columns already nullable or ALTER not required'); } // ATU number for company profiles await addColumnIfMissing(connection, 'company_profiles', 'atu_number', `VARCHAR(50) NULL AFTER registration_number`); // 4. user_status table: Comprehensive tracking of user verification and completion steps await connection.query(` CREATE TABLE IF NOT EXISTS user_status ( user_id INT PRIMARY KEY, status ENUM('inactive', 'pending', 'active', 'suspended', 'archived') DEFAULT 'pending', previous_status ENUM('inactive', 'pending', 'active', 'suspended', 'archived') NULL, email_verified BOOLEAN DEFAULT FALSE, email_verified_at TIMESTAMP NULL, profile_completed BOOLEAN DEFAULT FALSE, profile_completed_at TIMESTAMP NULL, documents_uploaded BOOLEAN DEFAULT FALSE, documents_uploaded_at TIMESTAMP NULL, contract_signed BOOLEAN DEFAULT FALSE, contract_signed_at TIMESTAMP NULL, is_admin_verified BOOLEAN DEFAULT FALSE, admin_verified_at TIMESTAMP NULL, registration_completed BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); console.log('โœ… User status table created/verified'); // Modify existing ENUM columns to add 'archived' status (for existing databases) try { await connection.query(` ALTER TABLE user_status MODIFY COLUMN status ENUM('inactive', 'pending', 'active', 'suspended', 'archived') DEFAULT 'pending' `); console.log('โœ… Updated status column to include archived'); } catch (err) { console.warn('โš ๏ธ Could not modify status column:', err.message); } try { await connection.query(` ALTER TABLE user_status MODIFY COLUMN previous_status ENUM('inactive', 'pending', 'active', 'suspended', 'archived') NULL `); console.log('โœ… Updated previous_status column to include archived'); } catch (err) { console.warn('โš ๏ธ Could not modify previous_status column:', err.message); } // --- Authentication & Verification Tables --- // 5. refresh_tokens table: For refresh token authentication await connection.query(` CREATE TABLE IF NOT EXISTS refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token VARCHAR(255) UNIQUE NOT NULL, expires_at DATETIME NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMP NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_user_id (user_id), INDEX idx_expires_at (expires_at) ); `); console.log('โœ… Refresh tokens table created/verified'); // Ensure refresh token column length supports longer tokens (JWT/base64/etc.) try { await connection.query(` ALTER TABLE refresh_tokens MODIFY COLUMN token VARCHAR(512) NOT NULL; `); console.log('๐Ÿ”ง Ensured refresh_tokens.token is VARCHAR(512)'); } catch (e) { console.log('โ„น๏ธ refresh_tokens.token length ALTER skipped:', e.message); } // 6. email_verifications table: For email verification codes await connection.query(` CREATE TABLE IF NOT EXISTS email_verifications ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, verification_code VARCHAR(6) NOT NULL, expires_at DATETIME NOT NULL, verified_at TIMESTAMP NULL, attempts INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_user_code (user_id, verification_code), INDEX idx_expires_at (expires_at) ); `); console.log('โœ… Email verifications table created/verified'); // 7. password_resets table: For password reset tokens await connection.query(` CREATE TABLE IF NOT EXISTS password_resets ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token VARCHAR(255) UNIQUE NOT NULL, expires_at DATETIME NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used_at TIMESTAMP NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_user_token (user_id, token), INDEX idx_expires_at (expires_at) ); `); console.log('โœ… Password resets table created/verified'); // --- Document & Logging Tables --- // 8. user_documents table: Stores object storage IDs await connection.query(` CREATE TABLE IF NOT EXISTS user_documents ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, document_type ENUM('personal_id', 'company_id', 'signature', 'contract', 'other') NOT NULL, contract_type ENUM('contract','gdpr','abo') NOT NULL DEFAULT 'contract', object_storage_id VARCHAR(255) UNIQUE NULL, signatureBase64 LONGTEXT NULL, original_filename VARCHAR(255), file_size INT, mime_type VARCHAR(100), upload_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, verified_by_admin BOOLEAN DEFAULT FALSE, admin_verified_at TIMESTAMP NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_user_document_type (user_id, document_type), INDEX idx_user_contract_type (user_id, contract_type), INDEX idx_object_storage_id (object_storage_id) ); `); // Backfill/migrate for older schemas (NO IF NOT EXISTS; pre-check instead) await addColumnIfMissing( connection, 'user_documents', 'contract_type', `ENUM('contract','gdpr','abo') NOT NULL DEFAULT 'contract' AFTER document_type` ); await ensureIndex(connection, 'user_documents', 'idx_user_contract_type', '`user_id`, `contract_type`'); // Ensure enum includes 'abo' on existing schemas try { await connection.query(` ALTER TABLE user_documents MODIFY COLUMN contract_type ENUM('contract','gdpr','abo') NOT NULL DEFAULT 'contract'; `); console.log("๐Ÿ”ง Ensured user_documents.contract_type includes 'abo'"); } catch (e) { console.log("โ„น๏ธ user_documents.contract_type ENUM ALTER skipped:", e.message); } await connection.query(` ALTER TABLE user_documents MODIFY COLUMN object_storage_id VARCHAR(255) NULL; `); await addColumnIfMissing( connection, 'user_documents', 'signatureBase64', `LONGTEXT NULL AFTER object_storage_id` ); console.log('โœ… User documents table created/verified'); // 8c. document_templates table: Stores template metadata and object storage keys await connection.query(` CREATE TABLE IF NOT EXISTS document_templates ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, type VARCHAR(100) NOT NULL, contract_type ENUM('contract','gdpr','abo') NULL DEFAULT NULL, storageKey VARCHAR(255) NOT NULL, description TEXT, lang VARCHAR(10) NOT NULL, user_type ENUM('personal','company','both') DEFAULT 'both', version INT DEFAULT 1, state ENUM('active','inactive') DEFAULT 'inactive', createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CHECK ( (type <> 'contract' AND contract_type IS NULL) OR (type = 'contract' AND contract_type IN ('contract','gdpr','abo')) ) ); `); console.log('โœ… Document templates table created/verified'); // Ensure enum includes 'abo' on existing schemas try { await connection.query(` ALTER TABLE document_templates MODIFY COLUMN contract_type ENUM('contract','gdpr','abo') NULL DEFAULT NULL; `); console.log("๐Ÿ”ง Ensured document_templates.contract_type includes 'abo'"); } catch (e) { console.log("โ„น๏ธ document_templates.contract_type ENUM ALTER skipped:", e.message); } // Ensure CHECK constraint includes 'abo' (best-effort; some MySQL/MariaDB versions ignore/limit CHECK) try { const [checks] = await connection.query(` SELECT tc.CONSTRAINT_NAME AS name, cc.CHECK_CLAUSE AS clause FROM information_schema.TABLE_CONSTRAINTS tc JOIN information_schema.CHECK_CONSTRAINTS cc ON tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA AND tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME WHERE tc.CONSTRAINT_SCHEMA = DATABASE() AND tc.TABLE_NAME = 'document_templates' AND tc.CONSTRAINT_TYPE = 'CHECK' `); const checkRows = Array.isArray(checks) ? checks : []; const toDrop = checkRows.filter(r => { const clause = (r && r.clause) ? String(r.clause) : ''; const looksLikeOldContractTypeCheck = clause.includes('contract_type') && clause.includes("'contract'") && clause.includes("'gdpr'") && !clause.includes("'abo'"); return looksLikeOldContractTypeCheck; }); if (toDrop.length) { for (const r of toDrop) { const name = r && r.name ? String(r.name) : ''; if (!name) continue; try { // MariaDB: DROP CONSTRAINT; MySQL: DROP CHECK try { await connection.query(`ALTER TABLE document_templates DROP CONSTRAINT \`${name}\`;`); } catch (e1) { await connection.query(`ALTER TABLE document_templates DROP CHECK \`${name}\`;`); } } catch (e2) { console.log('โ„น๏ธ document_templates CHECK drop skipped:', e2.message); } } } // Add (or re-add) the desired check constraint const hasDesired = checkRows.some(r => { const clause = (r && r.clause) ? String(r.clause) : ''; return clause.includes("contract_type IN ('contract','gdpr','abo')"); }); if (!hasDesired) { try { await connection.query(` ALTER TABLE document_templates ADD CONSTRAINT chk_document_templates_contract_type CHECK ( (type <> 'contract' AND contract_type IS NULL) OR (type = 'contract' AND contract_type IN ('contract','gdpr','abo')) ); `); console.log("๐Ÿ”ง Ensured document_templates CHECK allows 'abo'"); } catch (e) { console.log('โ„น๏ธ document_templates CHECK add skipped:', e.message); } } } catch (e) { console.log('โ„น๏ธ document_templates CHECK migration skipped:', e.message); } await connection.query(` CREATE TABLE IF NOT EXISTS no_user_abo_mails ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, abonement_id INT NOT NULL, status ENUM('pending','linked') DEFAULT 'pending', source VARCHAR(50) DEFAULT 'subscribe', created_by_user_id INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_no_user_abo_email_abonement (email, abonement_id), INDEX idx_no_user_abo_email_status (email, status) ); `); console.log('โœ… no_user_abo_mails table created/verified'); // 8b. user_id_documents table: Stores ID-specific metadata (front/back object storage IDs) await connection.query(` CREATE TABLE IF NOT EXISTS user_id_documents ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, document_type ENUM('personal_id', 'company_id') NOT NULL, front_object_storage_id VARCHAR(255) NOT NULL, back_object_storage_id VARCHAR(255) NULL, original_filename_front VARCHAR(255), original_filename_back VARCHAR(255), id_type VARCHAR(50), id_number VARCHAR(100), expiry_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); `); console.log('โœ… User ID documents table created/verified'); // 9. user_action_logs table: For detailed user activity logging await connection.query(` CREATE TABLE IF NOT EXISTS user_action_logs ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, affected_user_id INT NULL, action VARCHAR(100) NOT NULL, performed_by_user_id INT NULL, details JSON, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, -- NEW FK FOREIGN KEY (affected_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (performed_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_user_id (user_id), -- NEW index INDEX idx_affected_user (affected_user_id), INDEX idx_performed_by (performed_by_user_id), INDEX idx_action (action), INDEX idx_created_at (created_at) ); `); console.log('โœ… User action logs table created/verified'); // --- Add missing user_id column for existing databases + backfill --- // (convert from "try/catch duplicate" to pre-check) if (await addColumnIfMissing(connection, 'user_action_logs', 'user_id', `INT NULL`)) { await addForeignKeyIfMissing( connection, 'user_action_logs', 'fk_user_action_logs_user', ` ALTER TABLE user_action_logs ADD CONSTRAINT fk_user_action_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ` ); await ensureIndex(connection, 'user_action_logs', 'idx_user_id', '`user_id`'); } // Backfill: prefer performed_by_user_id, else affected_user_id try { await connection.query(` UPDATE user_action_logs SET user_id = performed_by_user_id WHERE user_id IS NULL AND performed_by_user_id IS NOT NULL `); await connection.query(` UPDATE user_action_logs SET user_id = affected_user_id WHERE user_id IS NULL AND affected_user_id IS NOT NULL `); console.log('๐Ÿงน Backfilled user_action_logs.user_id from performed_by_user_id/affected_user_id'); } catch (e) { console.warn('โš ๏ธ Could not backfill user_action_logs.user_id:', e.message); } // --- Email & Registration Flow Tables --- // 10. email_attempts table: For tracking email sending attempts await connection.query(` CREATE TABLE IF NOT EXISTS email_attempts ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, attempt_type ENUM('verification', 'password_reset', 'registration_completion', 'notification', 'other') NOT NULL, email_address VARCHAR(255) NOT NULL, success BOOLEAN DEFAULT FALSE, error_message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_user_attempts (user_id, attempt_type), INDEX idx_created_at (created_at) ); `); console.log('โœ… Email attempts table created/verified'); // --- Referral Tables --- // 12. referral_tokens table: Manages referral codes await connection.query(` CREATE TABLE IF NOT EXISTS referral_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(64) UNIQUE NOT NULL, created_by_user_id INT NOT NULL, expires_at DATETIME NOT NULL, max_uses INT DEFAULT -1, uses_remaining INT DEFAULT -1, status ENUM('active', 'inactive', 'expired', 'exhausted') DEFAULT 'active', deactivation_reason VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_token (token), INDEX idx_status_expires (status, expires_at), INDEX idx_created_by (created_by_user_id) ); `); // News table for News Manager await connection.query(` CREATE TABLE IF NOT EXISTS news ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, summary TEXT NULL, content MEDIUMTEXT NULL, slug VARCHAR(255) UNIQUE NOT NULL, category VARCHAR(128) NULL, object_storage_id VARCHAR(255) NULL, original_filename VARCHAR(255) NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, published_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); `); console.log('โœ… News table created/verified'); console.log('โœ… Referral tokens table created/verified'); // Generated label columns (virtual) to display 'unlimited' instead of -1 in UI tools try { await connection.query(` ALTER TABLE referral_tokens ADD COLUMN max_uses_label VARCHAR(20) GENERATED ALWAYS AS (CASE WHEN max_uses = -1 THEN 'unlimited' ELSE CAST(max_uses AS CHAR) END) VIRTUAL `); console.log('๐Ÿ†• Added virtual column referral_tokens.max_uses_label'); } catch (e) { console.log('โ„น๏ธ max_uses_label already exists or cannot add:', e.message); } try { await connection.query(` ALTER TABLE referral_tokens ADD COLUMN uses_remaining_label VARCHAR(20) GENERATED ALWAYS AS (CASE WHEN uses_remaining = -1 THEN 'unlimited' ELSE CAST(uses_remaining AS CHAR) END) VIRTUAL `); console.log('๐Ÿ†• Added virtual column referral_tokens.uses_remaining_label'); } catch (e) { console.log('โ„น๏ธ uses_remaining_label already exists or cannot add:', e.message); } // Normalized view now sources the generated label columns (still handy for joins) try { await connection.query(` CREATE OR REPLACE VIEW referral_tokens_normalized AS SELECT rt.*, rt.max_uses_label AS max_uses_display, rt.uses_remaining_label AS uses_remaining_display FROM referral_tokens rt; `); console.log('๐Ÿ†• referral_tokens_normalized view created/updated'); } catch (e) { console.warn('โš ๏ธ Could not create referral_tokens_normalized view:', e.message); } // 13. referral_token_usage table: Tracks each use of a referral token await connection.query(` CREATE TABLE IF NOT EXISTS referral_token_usage ( id INT AUTO_INCREMENT PRIMARY KEY, referral_token_id INT NOT NULL, used_by_user_id INT NOT NULL, used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (referral_token_id) REFERENCES referral_tokens(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, UNIQUE KEY unique_token_user_usage (referral_token_id, used_by_user_id), INDEX idx_token_usage (referral_token_id), INDEX idx_user_usage (used_by_user_id) ); `); console.log('โœ… Referral token usage table created/verified'); // --- Affiliates table (for Affiliate Manager) --- await connection.query(` CREATE TABLE IF NOT EXISTS affiliates ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT NULL, url VARCHAR(1024) NOT NULL, object_storage_id VARCHAR(255) NULL, original_filename VARCHAR(255) NULL, category VARCHAR(128) NOT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, commission_rate DECIMAL(5,2) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_affiliates_created (created_at), INDEX idx_affiliates_active (is_active), INDEX idx_affiliates_category (category) ); `); console.log('โœ… Affiliates table created/verified'); // --- Authorization Tables --- // 14. permissions table: Defines granular permissions await connection.query(` CREATE TABLE IF NOT EXISTS permissions ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description VARCHAR(255), is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Added created_by INT NULL, -- Added FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); `); console.log('โœ… Permissions table created/verified'); // 15. user_permissions join table: Assigns specific permissions to users await connection.query(` CREATE TABLE IF NOT EXISTS user_permissions ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, permission_id INT NOT NULL, granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, granted_by INT NULL, -- Added column FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, -- FK constraint UNIQUE KEY unique_user_permission (user_id, permission_id) ); `); console.log('โœ… User permissions table created/verified'); // --- User Settings Table --- await connection.query(` CREATE TABLE IF NOT EXISTS user_settings ( user_id INT PRIMARY KEY, theme ENUM('light', 'dark') DEFAULT 'light', font_size ENUM('normal', 'large') DEFAULT 'normal', high_contrast_mode BOOLEAN DEFAULT FALSE, two_factor_auth_enabled BOOLEAN DEFAULT FALSE, account_visibility ENUM('public', 'private') DEFAULT 'public', show_email BOOLEAN DEFAULT TRUE, show_phone BOOLEAN DEFAULT TRUE, data_export_requested BOOLEAN DEFAULT FALSE, last_data_export_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); `); console.log('โœ… User settings table created/verified'); // --- Company Settings (single-row, global invoice / company info) --- await connection.query(` CREATE TABLE IF NOT EXISTS company_settings ( id INT PRIMARY KEY DEFAULT 1, company_name VARCHAR(200) NOT NULL DEFAULT 'ProfitPlanet GmbH', company_street VARCHAR(255) NOT NULL DEFAULT '', company_postal_city VARCHAR(255) NOT NULL DEFAULT '', company_country VARCHAR(100) NOT NULL DEFAULT 'Germany', qr_code_60_base64 LONGTEXT NULL, qr_code_120_base64 LONGTEXT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CHECK (id = 1) ); `); // Seed default row if missing await connection.query(` INSERT IGNORE INTO company_settings (id) VALUES (1); `); console.log('โœ… Company settings table created/verified'); // Backward-compatible: add QR code columns if missing await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL'); await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL'); // --- I18n Preferences (single-row, admin language-management settings) --- await connection.query(` CREATE TABLE IF NOT EXISTS i18n_preferences ( id TINYINT PRIMARY KEY DEFAULT 1, categories_json JSON NULL, global_keys_json JSON NULL, updated_by_user_id INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT chk_i18n_preferences_singleton CHECK (id = 1), FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); `); await connection.query(` INSERT IGNORE INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id) VALUES (1, JSON_ARRAY(), JSON_ARRAY(), NULL); `); console.log('โœ… i18n preferences table created/verified'); // Backward-compatible for older schemas await addColumnIfMissing(connection, 'i18n_preferences', 'categories_json', 'JSON NULL'); await addColumnIfMissing(connection, 'i18n_preferences', 'global_keys_json', 'JSON NULL'); await addColumnIfMissing(connection, 'i18n_preferences', 'updated_by_user_id', 'INT NULL'); await addForeignKeyIfMissing( connection, 'i18n_preferences', 'i18n_preferences_ibfk_1', `ALTER TABLE \`i18n_preferences\` ADD CONSTRAINT \`i18n_preferences_ibfk_1\` FOREIGN KEY (\`updated_by_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` ); // Language metadata used by language-management UI await connection.query(` CREATE TABLE IF NOT EXISTS i18n_languages ( id BIGINT AUTO_INCREMENT PRIMARY KEY, language_code VARCHAR(16) NOT NULL, label VARCHAR(100) NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, is_custom BOOLEAN NOT NULL DEFAULT FALSE, created_by_user_id INT NULL, updated_by_user_id INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT uq_i18n_languages_code UNIQUE (language_code), FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); `); console.log('โœ… i18n languages table created/verified'); // Translation overrides/custom values per language await connection.query(` CREATE TABLE IF NOT EXISTS i18n_translation_overrides ( id BIGINT AUTO_INCREMENT PRIMARY KEY, language_code VARCHAR(16) NOT NULL, namespace VARCHAR(128) NOT NULL, t_key VARCHAR(255) NOT NULL, t_value LONGTEXT NOT NULL, is_custom BOOLEAN NOT NULL DEFAULT TRUE, created_by_user_id INT NULL, updated_by_user_id INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT uq_i18n_translation_overrides UNIQUE (language_code, namespace, t_key), FOREIGN KEY (language_code) REFERENCES i18n_languages(language_code) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); `); console.log('โœ… i18n translation overrides table created/verified'); await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_lang', '`language_code`'); await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_namespace', '`namespace`'); await ensureIndex(connection, 'i18n_translation_overrides', 'idx_i18n_overrides_lang_namespace', '`language_code`, `namespace`'); // --- Dashboard Platforms (admin managed dashboard cards) --- await connection.query(` CREATE TABLE IF NOT EXISTS dashboard_plattforms ( id VARCHAR(36) PRIMARY KEY, title VARCHAR(255) NOT NULL, description TEXT NULL, href VARCHAR(1024) NOT NULL, icon VARCHAR(255) NULL, color VARCHAR(255) NULL, state BOOLEAN NOT NULL DEFAULT TRUE, disabled BOOLEAN NOT NULL DEFAULT FALSE, disabled_text VARCHAR(255) NULL, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); `); console.log('โœ… Dashboard plattforms table created/verified'); await ensureIndex(connection, 'dashboard_plattforms', 'idx_dashboard_plattforms_state', '`state`'); await ensureIndex(connection, 'dashboard_plattforms', 'idx_dashboard_plattforms_sort_order', '`sort_order`'); await ensureIndex(connection, 'dashboard_plattforms', 'idx_dashboard_plattforms_title', '`title`'); // Optional one-time migration from legacy company_settings.dashboard_plattforms (JSON column) try { const hasLegacyColumn = await columnExists(connection, 'company_settings', 'dashboard_plattforms'); if (hasLegacyColumn) { const [countRows] = await connection.query(`SELECT COUNT(*) AS c FROM dashboard_plattforms`); const count = Number(countRows?.[0]?.c ?? 0); if (count === 0) { const [rows] = await connection.query(`SELECT dashboard_plattforms FROM company_settings WHERE id = 1`); const raw = rows?.[0]?.dashboard_plattforms; let parsed = []; try { if (raw == null) parsed = []; else if (typeof raw === 'string') parsed = JSON.parse(raw); else parsed = raw; } catch (_) { parsed = []; } const list = Array.isArray(parsed) ? parsed : []; if (list.length) { for (const p of list) { const id = typeof p?.id === 'string' ? p.id.trim() : ''; if (!id) continue; const title = typeof p?.title === 'string' ? p.title : ''; const href = typeof p?.href === 'string' ? p.href : ''; await connection.query( `INSERT INTO dashboard_plattforms (id, title, description, href, icon, color, state, disabled, disabled_text, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description), href = VALUES(href), icon = VALUES(icon), color = VALUES(color), state = VALUES(state), disabled = VALUES(disabled), disabled_text = VALUES(disabled_text), sort_order = VALUES(sort_order)`, [ id, title, typeof p?.description === 'string' ? p.description : (p?.description == null ? '' : String(p.description)), href, typeof p?.icon === 'string' ? p.icon : (p?.icon == null ? '' : String(p.icon)), typeof p?.color === 'string' ? p.color : (p?.color == null ? '' : String(p.color)), p?.state === false ? 0 : 1, p?.disabled === true ? 1 : 0, p?.disabledText == null ? null : String(p.disabledText), Number.isFinite(Number(p?.sortOrder)) ? Number(p.sortOrder) : 0, ] ); } console.log('๐Ÿ” Migrated legacy dashboard_plattforms JSON into dashboard_plattforms table'); } } } } catch (e) { console.warn('โš ๏ธ Dashboard platforms migration skipped/failed:', e?.message || e); } // --- Rate Limiting Table --- await connection.query(` CREATE TABLE IF NOT EXISTS rate_limit ( id INT AUTO_INCREMENT PRIMARY KEY, rate_key VARCHAR(255) NOT NULL, window_start DATETIME NOT NULL, count INT DEFAULT 0, window_seconds INT NOT NULL, max INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY unique_key_window (rate_key, window_start) ); `); console.log('โœ… Rate limit table created/verified'); // --- Tax: countries and VAT rates --- await connection.query(` CREATE TABLE IF NOT EXISTS countries ( id INT AUTO_INCREMENT PRIMARY KEY, country_code VARCHAR(3) NOT NULL, country_name VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, created_by INT NULL, updated_by INT NULL, CONSTRAINT uq_country_code UNIQUE (country_code), INDEX idx_country_name (country_name), FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); `); console.log('โœ… Countries table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS vat_rates ( id BIGINT AUTO_INCREMENT PRIMARY KEY, country_id INT NOT NULL, standard_rate DECIMAL(6,3) NULL, reduced_rate DECIMAL(6,3) NULL, super_reduced_rate DECIMAL(6,3) NULL, parking_rate DECIMAL(6,3) NULL, effective_from DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, effective_to DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, created_by INT NULL, updated_by INT NULL, FOREIGN KEY (country_id) REFERENCES countries(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_vat_country_eff_to (country_id, effective_to), INDEX idx_vat_standard (standard_rate) ); `); console.log('โœ… VAT rates table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS vat_rate_history ( id BIGINT AUTO_INCREMENT PRIMARY KEY, country_id INT NOT NULL, standard_rate DECIMAL(6,3) NULL, reduced_rate DECIMAL(6,3) NULL, super_reduced_rate DECIMAL(6,3) NULL, parking_rate DECIMAL(6,3) NULL, effective_from DATETIME NOT NULL, effective_to DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, updated_by INT NULL, FOREIGN KEY (country_id) REFERENCES countries(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_vat_hist_country_from (country_id, effective_from), INDEX idx_vat_hist_country_to (country_id, effective_to) ); `); console.log('โœ… VAT rate history table created/verified'); // --- NEW: company_stamps table (for company/admin managed stamps) --- await connection.query(` CREATE TABLE IF NOT EXISTS company_stamps ( id INT AUTO_INCREMENT PRIMARY KEY, company_id INT NOT NULL, label VARCHAR(100) NULL, mime_type VARCHAR(50) NOT NULL, image_base64 LONGTEXT NOT NULL, is_active BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (company_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, UNIQUE KEY unique_company_label (company_id, label), INDEX idx_company_active (company_id, is_active) ); `); console.log('โœ… Company stamps table created/verified'); // --- Coffee / Subscriptions Table (simplified) --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_table ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency CHAR(3) NOT NULL DEFAULT 'EUR', is_featured BOOLEAN NOT NULL DEFAULT FALSE, billing_interval ENUM('day','week','month','year') NULL, interval_count INT UNSIGNED NULL, object_storage_id VARCHAR(255) NULL, original_filename VARCHAR(255) NULL, state BOOLEAN NOT NULL DEFAULT TRUE, pack_group VARCHAR(100) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_pack_group (pack_group) ); `); console.log('โœ… Coffee table (simplified) created/verified'); // --- Coffee shipping fees (fixed package sizes) --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_shipping_fees ( id INT AUTO_INCREMENT PRIMARY KEY, piece_count INT NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT uq_coffee_shipping_fees_piece_count UNIQUE (piece_count), CONSTRAINT chk_coffee_shipping_fees_piece_count CHECK (piece_count IN (60, 120)) ); `); await connection.query(` INSERT INTO coffee_shipping_fees (piece_count, price) VALUES (60, 0.00), (120, 0.00) ON DUPLICATE KEY UPDATE piece_count = VALUES(piece_count) `); console.log('โœ… Coffee shipping fees table created/verified and seeded (60/120)'); // --- Coffee Abonements (subscriptions) --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_abonements ( id BIGINT AUTO_INCREMENT PRIMARY KEY, pack_group VARCHAR(100) NOT NULL DEFAULT '', status ENUM('active','paused','canceled','expired') NOT NULL DEFAULT 'active', started_at DATETIME NOT NULL, ended_at DATETIME NULL, next_billing_at DATETIME NULL, billing_interval ENUM('day','week','month','year') NOT NULL, interval_count INT UNSIGNED NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL, currency CHAR(3) NOT NULL, is_auto_renew TINYINT(1) NOT NULL DEFAULT 1, notes VARCHAR(255) NULL, pack_breakdown JSON NULL, first_name VARCHAR(100) NULL, last_name VARCHAR(100) NULL, email VARCHAR(255) NULL, street VARCHAR(255) NULL, postal_code VARCHAR(20) NULL, city VARCHAR(100) NULL, country VARCHAR(100) NULL, frequency VARCHAR(50) NULL, referred_by INT NULL, -- NEW: user_id of the logged-in user who referred the subscription created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_abon_referred_by FOREIGN KEY (referred_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_pack_group (pack_group), INDEX idx_abon_status (status), INDEX idx_abon_billing (next_billing_at), INDEX idx_abon_created (created_at) ); `); console.log('โœ… Coffee abonements table updated'); // Ownership columns for "self" and "gift" subscriptions await addColumnIfMissing( connection, 'coffee_abonements', 'user_id', `INT NULL AFTER referred_by` ); await addColumnIfMissing( connection, 'coffee_abonements', 'purchaser_user_id', `INT NULL AFTER user_id` ); await ensureIndex(connection, 'coffee_abonements', 'idx_abon_user_id', '`user_id`'); await ensureIndex(connection, 'coffee_abonements', 'idx_abon_purchaser_user_id', '`purchaser_user_id`'); await addForeignKeyIfMissing( connection, 'coffee_abonements', 'fk_abon_user', `ALTER TABLE \`coffee_abonements\` ADD CONSTRAINT \`fk_abon_user\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` ); await addForeignKeyIfMissing( connection, 'coffee_abonements', 'fk_abon_purchaser_user', `ALTER TABLE \`coffee_abonements\` ADD CONSTRAINT \`fk_abon_purchaser_user\` FOREIGN KEY (\`purchaser_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` ); // Contract fields await addColumnIfMissing(connection, 'coffee_abonements', 'contract_number', `VARCHAR(50) NULL AFTER purchaser_user_id`); await addColumnIfMissing(connection, 'coffee_abonements', 'contract_storage_key', `VARCHAR(255) NULL AFTER contract_number`); // Additional shipping / contact fields await addColumnIfMissing(connection, 'coffee_abonements', 'phone', `VARCHAR(50) NULL AFTER country`); // Contract recipient await addColumnIfMissing(connection, 'coffee_abonements', 'recipient_name', `VARCHAR(255) NULL AFTER phone`); await addColumnIfMissing(connection, 'coffee_abonements', 'recipient_address', `TEXT NULL AFTER recipient_name`); // Payment await addColumnIfMissing(connection, 'coffee_abonements', 'payment_method', `VARCHAR(30) NULL AFTER frequency`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_by_email', `TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_method`); // Invoice address await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_same_as_shipping', `TINYINT(1) NOT NULL DEFAULT 1 AFTER invoice_by_email`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_full_name', `VARCHAR(200) NULL AFTER invoice_same_as_shipping`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_street', `VARCHAR(255) NULL AFTER invoice_full_name`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_postal_code', `VARCHAR(20) NULL AFTER invoice_street`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_city', `VARCHAR(100) NULL AFTER invoice_postal_code`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_phone', `VARCHAR(50) NULL AFTER invoice_city`); await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_email', `VARCHAR(255) NULL AFTER invoice_phone`); // --- Coffee Abonement History --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_abonement_history ( id BIGINT AUTO_INCREMENT PRIMARY KEY, abonement_id BIGINT NOT NULL, event_type VARCHAR(50) NOT NULL, event_at DATETIME NOT NULL, actor_user_id INT NULL, details JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_hist_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_hist_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_hist_abon (abonement_id), INDEX idx_hist_event_at (event_at), INDEX idx_hist_event_type (event_type) ); `); console.log('โœ… Coffee abonement history table created/verified'); // --- Invoices: unified for subscriptions and shop orders --- await connection.query(` CREATE TABLE IF NOT EXISTS invoices ( id BIGINT AUTO_INCREMENT PRIMARY KEY, invoice_number VARCHAR(64) NOT NULL UNIQUE, user_id INT NULL, source_type ENUM('subscription','shop') NOT NULL, source_id BIGINT NOT NULL, buyer_name VARCHAR(255) NULL, buyer_email VARCHAR(255) NULL, buyer_street VARCHAR(255) NULL, buyer_postal_code VARCHAR(20) NULL, buyer_city VARCHAR(100) NULL, buyer_country VARCHAR(100) NULL, currency CHAR(3) NOT NULL, total_net DECIMAL(12,2) NOT NULL DEFAULT 0.00, total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00, total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00, vat_rate DECIMAL(6,3) NULL, status ENUM('draft','issued','paid','canceled') NOT NULL DEFAULT 'draft', issued_at DATETIME NULL, due_at DATETIME NULL, pdf_storage_key VARCHAR(255) NULL, context JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_invoice_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_invoice_source (source_type, source_id), INDEX idx_invoice_issued (issued_at), INDEX idx_invoice_status (status) ); `); console.log('โœ… Invoices table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS invoice_items ( id BIGINT AUTO_INCREMENT PRIMARY KEY, invoice_id BIGINT NOT NULL, product_id BIGINT NULL, sku VARCHAR(128) NULL, description VARCHAR(512) NOT NULL, quantity DECIMAL(12,3) NOT NULL DEFAULT 1.000, unit_price DECIMAL(12,2) NOT NULL DEFAULT 0.00, tax_rate DECIMAL(6,3) NULL, line_net DECIMAL(12,2) NOT NULL DEFAULT 0.00, line_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00, line_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_item_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_item_invoice (invoice_id) ); `); console.log('โœ… Invoice items table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS invoice_payments ( id BIGINT AUTO_INCREMENT PRIMARY KEY, invoice_id BIGINT NOT NULL, payment_method VARCHAR(64) NOT NULL, transaction_id VARCHAR(128) NULL, amount DECIMAL(12,2) NOT NULL, paid_at DATETIME NULL, status ENUM('succeeded','pending','failed','refunded') NOT NULL DEFAULT 'pending', details JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_payment_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE, INDEX idx_payment_invoice (invoice_id), INDEX idx_payment_status (status), INDEX idx_payment_paid_at (paid_at) ); `); console.log('โœ… Invoice payments table created/verified'); // Extend coffee_abonement_history with optional related_invoice_id await addColumnIfMissing(connection, 'coffee_abonement_history', 'related_invoice_id', `BIGINT NULL`); await addForeignKeyIfMissing( connection, 'coffee_abonement_history', 'fk_hist_invoice', ` ALTER TABLE coffee_abonement_history ADD CONSTRAINT fk_hist_invoice FOREIGN KEY (related_invoice_id) REFERENCES invoices(id) ON DELETE SET NULL ON UPDATE CASCADE ` ); // --- Matrix: Global 5-ary tree config and relations --- await connection.query(` CREATE TABLE IF NOT EXISTS matrix_config ( id TINYINT PRIMARY KEY DEFAULT 1, master_top_user_id INT NOT NULL, name VARCHAR(255) NULL, -- ADDED (was missing, caused Unknown column 'name') created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_matrix_config_master FOREIGN KEY (master_top_user_id) REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT chk_matrix_singleton CHECK (id = 1) ); `); // Safeguard: if table pre-existed without name column, add it (pre-check) await addColumnIfMissing(connection, 'matrix_config', 'name', `VARCHAR(255) NULL`); console.log('โœ… Matrix config table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS user_tree_edges ( id BIGINT AUTO_INCREMENT PRIMARY KEY, parent_user_id INT NOT NULL, child_user_id INT NOT NULL, position INT NOT NULL, -- CHANGED: allow unlimited positions created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_edges_parent FOREIGN KEY (parent_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_edges_child FOREIGN KEY (child_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT uq_child UNIQUE (child_user_id), CONSTRAINT uq_parent_position UNIQUE (parent_user_id, position) -- REMOVED: chk_position CHECK (position BETWEEN 1 AND 5) ); `); console.log('โœ… User tree edges table created/verified'); // --- Migration: relax position constraints on existing schemas --- try { await connection.query(`ALTER TABLE user_tree_edges MODIFY COLUMN position INT NOT NULL`); console.log('๐Ÿ› ๏ธ user_tree_edges.position changed to INT'); } catch (e) { console.log('โ„น๏ธ position type change skipped:', e.message); } try { try { await connection.query('ALTER TABLE user_tree_edges DROP CONSTRAINT chk_position'); } catch (e1) { await connection.query('ALTER TABLE user_tree_edges DROP CHECK chk_position'); } console.log('๐Ÿงน Dropped CHECK constraint chk_position on user_tree_edges'); } catch (e) { // MySQL versions or engines may report different messages if CHECK is not enforced or named differently console.log('โ„น๏ธ DROP CHECK chk_position skipped:', e.message); } await connection.query(` CREATE TABLE IF NOT EXISTS matrix_instances ( id INT AUTO_INCREMENT PRIMARY KEY, root_user_id INT NOT NULL, name VARCHAR(255) NULL, is_active BOOLEAN DEFAULT TRUE, max_depth INT NULL, ego_activated_at TIMESTAMP NULL, immediate_children_count INT DEFAULT 0, first_free_position TINYINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_matrix_instances_root FOREIGN KEY (root_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT chk_first_free_pos CHECK (first_free_position IS NULL OR (first_free_position BETWEEN 1 AND 5)) ); `); console.log('โœ… matrix_instances table created/verified'); await connection.query(` CREATE TABLE IF NOT EXISTS pools ( id INT AUTO_INCREMENT PRIMARY KEY, pool_name VARCHAR(255) NOT NULL, description TEXT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, subscription_coffee_id BIGINT NULL, pool_type ENUM('coffee','other') NOT NULL DEFAULT 'other', is_active TINYINT(1) NOT NULL DEFAULT 1, created_by INT NULL, updated_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT uq_pools_name UNIQUE (pool_name), CONSTRAINT fk_pools_subscription_coffee FOREIGN KEY (subscription_coffee_id) REFERENCES coffee_table(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT fk_pools_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT fk_pools_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_pools_active (is_active), INDEX idx_pools_type (pool_type), INDEX idx_pools_subscription_coffee (subscription_coffee_id) ); `); console.log('โœ… Pools table created/verified'); // Backward-compatible migration for existing pools table await addColumnIfMissing(connection, 'pools', 'subscription_coffee_id', `BIGINT NULL`); await ensureIndex(connection, 'pools', 'idx_pools_subscription_coffee', '`subscription_coffee_id`'); await addForeignKeyIfMissing( connection, 'pools', 'fk_pools_subscription_coffee', ` ALTER TABLE pools ADD CONSTRAINT fk_pools_subscription_coffee FOREIGN KEY (subscription_coffee_id) REFERENCES coffee_table(id) ON DELETE SET NULL ON UPDATE CASCADE ` ); await connection.query(` CREATE TABLE IF NOT EXISTS pool_capsule_rules ( id INT AUTO_INCREMENT PRIMARY KEY, pool_id INT NOT NULL, price_per_capsule_gross DECIMAL(10,4) NOT NULL, applies_to_all_capsules TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1, created_by INT NULL, updated_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT uq_pool_capsule_rules_pool UNIQUE (pool_id), CONSTRAINT fk_pool_capsule_rules_pool FOREIGN KEY (pool_id) REFERENCES pools(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_capsule_rules_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT fk_pool_capsule_rules_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, INDEX idx_pool_capsule_rules_active (is_active) ); `); console.log('โœ… pool_capsule_rules table created/verified'); for (const cfg of SYSTEM_POOLS) { await connection.query( `INSERT INTO pools (pool_name, description, price, subscription_coffee_id, pool_type, is_active, created_by, updated_by) VALUES (?, ?, ?, NULL, ?, 1, NULL, NULL) ON DUPLICATE KEY UPDATE description = VALUES(description), price = VALUES(price), subscription_coffee_id = NULL, pool_type = VALUES(pool_type), is_active = 1, updated_by = NULL, updated_at = NOW()`, [cfg.pool_name, cfg.description, cfg.price, cfg.pool_type] ); const [poolRows] = await connection.query( `SELECT id FROM pools WHERE pool_name = ? LIMIT 1`, [cfg.pool_name] ); const poolId = poolRows?.[0]?.id; if (!poolId) continue; await connection.query( `INSERT INTO pool_capsule_rules (pool_id, price_per_capsule_gross, applies_to_all_capsules, is_active, created_by, updated_by) VALUES (?, ?, 1, 1, NULL, NULL) ON DUPLICATE KEY UPDATE price_per_capsule_gross = VALUES(price_per_capsule_gross), applies_to_all_capsules = 1, is_active = 1, updated_by = NULL, updated_at = NOW()`, [poolId, cfg.price_per_capsule_gross] ); } const systemPoolNames = SYSTEM_POOLS.map((x) => x.pool_name); const systemPoolPlaceholders = systemPoolNames.map(() => '?').join(','); await connection.query( `UPDATE pools SET is_active = 0, updated_at = NOW() WHERE pool_name NOT IN (${systemPoolPlaceholders})`, systemPoolNames ); console.log('โœ… System pools synchronized (ABO 60, ABO 120, Business, Gigantea, Core)'); await connection.query(` CREATE TABLE IF NOT EXISTS pool_members ( id INT AUTO_INCREMENT PRIMARY KEY, pool_id INT NOT NULL, user_id INT NOT NULL, created_by INT NULL, joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_pool_members_pool FOREIGN KEY (pool_id) REFERENCES pools(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_members_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT uq_pool_members UNIQUE (pool_id, user_id), INDEX idx_pool_members_pool (pool_id), INDEX idx_pool_members_user (user_id) ); `); console.log('โœ… pool_members table created/verified'); // Track sold capsules per paid invoice as calculation basis for pools await connection.query(` CREATE TABLE IF NOT EXISTS capsule_sales ( id BIGINT AUTO_INCREMENT PRIMARY KEY, invoice_id BIGINT NOT NULL, abonement_id BIGINT NOT NULL, coffee_table_id BIGINT NOT NULL, capsules_count INT NOT NULL, sold_at DATETIME NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'EUR', details JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_capsule_sales_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_capsule_sales_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_capsule_sales_coffee FOREIGN KEY (coffee_table_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT uq_capsule_sales_invoice_coffee UNIQUE (invoice_id, coffee_table_id), INDEX idx_capsule_sales_invoice (invoice_id), INDEX idx_capsule_sales_abon (abonement_id), INDEX idx_capsule_sales_sold_at (sold_at) ); `); console.log('โœ… capsule_sales table created/verified'); // Track money inflow into pools from subscriptions/invoices await connection.query(` CREATE TABLE IF NOT EXISTS pool_inflows ( id BIGINT AUTO_INCREMENT PRIMARY KEY, pool_id INT NOT NULL, invoice_id BIGINT NULL, abonement_id BIGINT NOT NULL, coffee_table_id BIGINT NOT NULL, event_type ENUM('invoice_paid','subscription_created','renewal_paid','manual_adjustment') NOT NULL DEFAULT 'invoice_paid', capsules_count INT NOT NULL, price_per_capsule_net DECIMAL(10,4) NOT NULL, amount_net DECIMAL(12,2) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'EUR', details JSON NULL, created_by_user_id INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_pool_inflows_pool FOREIGN KEY (pool_id) REFERENCES pools(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_inflows_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_inflows_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_inflows_coffee FOREIGN KEY (coffee_table_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_pool_inflows_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT uq_pool_inflow_invoice_event UNIQUE (pool_id, invoice_id, coffee_table_id, event_type), INDEX idx_pool_inflows_pool_created (pool_id, created_at), INDEX idx_pool_inflows_abon (abonement_id), INDEX idx_pool_inflows_invoice (invoice_id) ); `); console.log('โœ… pool_inflows table created/verified'); // Backward-compatible migration for existing pool_inflows table await addColumnIfMissing(connection, 'pool_inflows', 'invoice_id', `BIGINT NULL`); await ensureIndex(connection, 'pool_inflows', 'idx_pool_inflows_invoice', '`invoice_id`'); await addForeignKeyIfMissing( connection, 'pool_inflows', 'fk_pool_inflows_invoice', ` ALTER TABLE pool_inflows ADD CONSTRAINT fk_pool_inflows_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE ` ); try { await connection.query(` ALTER TABLE pool_inflows MODIFY COLUMN event_type ENUM('invoice_paid','subscription_created','renewal_paid','manual_adjustment') NOT NULL DEFAULT 'invoice_paid' `); } catch (e) { console.log('โ„น๏ธ pool_inflows.event_type enum alignment skipped:', e.message); } try { await connection.query(`ALTER TABLE pool_inflows DROP INDEX uq_pool_inflow_event`); } catch (e) { console.log('โ„น๏ธ old pool inflow unique index drop skipped:', e.message); } try { await connection.query(` ALTER TABLE pool_inflows ADD CONSTRAINT uq_pool_inflow_invoice_event UNIQUE (pool_id, invoice_id, coffee_table_id, event_type) `); } catch (e) { console.log('โ„น๏ธ new pool inflow unique index creation skipped:', e.message); } // --- user_matrix_metadata: add matrix_instance_id + alter PK --- await connection.query(` CREATE TABLE IF NOT EXISTS user_matrix_metadata ( matrix_instance_id INT PRIMARY KEY, root_user_id INT NOT NULL, ego_activated_at TIMESTAMP NULL, last_bfs_fill_at TIMESTAMP NULL, immediate_children_count INT DEFAULT 0, first_free_position TINYINT NULL, name VARCHAR(255) NULL, is_active BOOLEAN DEFAULT TRUE, max_depth INT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_meta_instance FOREIGN KEY (matrix_instance_id) REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_meta_root FOREIGN KEY (root_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT chk_meta_first_free CHECK (first_free_position IS NULL OR (first_free_position BETWEEN 1 AND 5)), INDEX idx_meta_root (root_user_id) ); `); console.log('โœ… user_matrix_metadata (multi) created/verified'); // Migration: if legacy data without matrix_instances rows const [instCheck] = await connection.query(`SELECT COUNT(*) AS cnt FROM matrix_instances`); if (Number(instCheck[0].cnt) === 0) { const [legacyCfg] = await connection.query(`SELECT master_top_user_id, name FROM matrix_config WHERE id=1`); if (legacyCfg.length) { const legacyRoot = legacyCfg[0].master_top_user_id; const legacyName = legacyCfg[0].name || null; // Determine existing root children stats const [legacyEdges] = await connection.query(`SELECT position FROM user_tree_edges WHERE parent_user_id = ?`, [legacyRoot]); const usedRootPos = new Set(legacyEdges.map(r => Number(r.position))); let firstFree = null; for (let i = 1; i <= 5; i++) { if (!usedRootPos.has(i)) { firstFree = i; break; } } // Create initial instance const [instRes] = await connection.query(` INSERT INTO matrix_instances (root_user_id, name, is_active, max_depth, ego_activated_at, immediate_children_count, first_free_position) VALUES (?, ?, TRUE, NULL, NOW(), ?, NULL) -- CHANGED: first_free_position NULL for root `, [legacyRoot, legacyName, legacyEdges.length, /* firstFree removed */ null]); const firstInstanceId = instRes.insertId; // Backfill metadata await connection.query(` INSERT INTO user_matrix_metadata (matrix_instance_id, root_user_id, ego_activated_at, immediate_children_count, first_free_position, name, is_active, max_depth) VALUES (?, ?, NOW(), ?, ?, ?, TRUE, NULL) `, [firstInstanceId, legacyRoot, legacyEdges.length, firstFree, legacyName]); console.log('๐Ÿงฉ Migration: created first matrix_instance id=', firstInstanceId); } } // --- Legacy cleanup: remove old matrix_id / fk_edges_matrix referencing obsolete `matrices` table --- try { const [legacyCols] = await connection.query(`SHOW COLUMNS FROM user_tree_edges LIKE 'matrix_id'`); if (legacyCols.length) { console.log('๐Ÿงน Found legacy user_tree_edges.matrix_id; dropping old FK & column'); try { await connection.query(`ALTER TABLE user_tree_edges DROP FOREIGN KEY fk_edges_matrix`); } catch (e) { console.log('โ„น๏ธ fk_edges_matrix drop skipped:', e.message); } try { await connection.query(`ALTER TABLE user_tree_edges DROP COLUMN matrix_id`); } catch (e) { console.log('โ„น๏ธ matrix_id column drop skipped:', e.message); } } } catch (e) { console.log('โ„น๏ธ Legacy matrix_id cleanup check failed:', e.message); } // --- Ensure multi-instance columns (idempotent) --- await addColumnIfMissing(connection, 'user_tree_edges', 'matrix_instance_id', `INT NULL`); await addColumnIfMissing(connection, 'user_tree_edges', 'rogue_user', `BOOLEAN DEFAULT FALSE`); await addForeignKeyIfMissing( connection, 'user_tree_edges', 'fk_edges_instance', ` ALTER TABLE user_tree_edges ADD CONSTRAINT fk_edges_instance FOREIGN KEY (matrix_instance_id) REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE ` ); // Backfill matrix_instance_id for existing edges const [firstInstRow] = await connection.query(`SELECT id FROM matrix_instances ORDER BY id ASC LIMIT 1`); const firstInstanceId = firstInstRow[0]?.id; if (firstInstanceId) { await connection.query( `UPDATE user_tree_edges SET matrix_instance_id = ? WHERE matrix_instance_id IS NULL`, [firstInstanceId] ); } // Indexes (pre-check) await ensureIndex(connection, 'user_tree_edges', 'idx_edges_instance_parent', '`matrix_instance_id`, `parent_user_id`'); await ensureIndex(connection, 'user_tree_edges', 'idx_edges_instance_child', '`matrix_instance_id`, `child_user_id`'); await ensureIndex(connection, 'user_tree_edges', 'idx_edges_rogue', '`matrix_instance_id`, `rogue_user`'); // --- Alter user_tree_closure: add matrix_instance_id --- await connection.query(` CREATE TABLE IF NOT EXISTS user_tree_closure ( matrix_instance_id INT NOT NULL, ancestor_user_id INT NOT NULL, descendant_user_id INT NOT NULL, depth INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT pk_closure PRIMARY KEY (matrix_instance_id, ancestor_user_id, descendant_user_id), CONSTRAINT fk_closure_instance FOREIGN KEY (matrix_instance_id) REFERENCES matrix_instances(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_closure_ancestor FOREIGN KEY (ancestor_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_closure_descendant FOREIGN KEY (descendant_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT chk_depth_nonneg CHECK (depth >= 0) ); `); console.log('โœ… user_tree_closure (multi) created/verified'); // If legacy closure table existed without matrix_instance_id, migrate it safely (pre-check) if (!(await columnExists(connection, 'user_tree_closure', 'matrix_instance_id'))) { await connection.query(`ALTER TABLE user_tree_closure ADD COLUMN matrix_instance_id INT NULL`); if (firstInstanceId) { await connection.query( `UPDATE user_tree_closure SET matrix_instance_id = ? WHERE matrix_instance_id IS NULL`, [firstInstanceId] ); } // Best-effort: enforce NOT NULL after backfill try { await connection.query(`ALTER TABLE user_tree_closure MODIFY COLUMN matrix_instance_id INT NOT NULL`); } catch (e) { console.log('โ„น๏ธ user_tree_closure.matrix_instance_id NOT NULL enforcement skipped:', e.message); } // Best-effort: align PK to (matrix_instance_id, ancestor_user_id, descendant_user_id) try { const pkCols = await getPrimaryKeyColumns(connection, 'user_tree_closure'); const wants = ['matrix_instance_id', 'ancestor_user_id', 'descendant_user_id']; const matches = pkCols.length === wants.length && pkCols.every((c, i) => c === wants[i]); if (!matches) { await connection.query(`ALTER TABLE user_tree_closure DROP PRIMARY KEY`); await connection.query( `ALTER TABLE user_tree_closure ADD PRIMARY KEY (matrix_instance_id, ancestor_user_id, descendant_user_id)` ); } } catch (e) { console.log('โ„น๏ธ user_tree_closure PK alignment skipped:', e.message); } } await ensureIndex(connection, 'user_tree_closure', 'idx_closure_instance_depth', '`matrix_instance_id`, `depth`'); await ensureIndex(connection, 'user_tree_closure', 'idx_closure_instance_ancestor', '`matrix_instance_id`, `ancestor_user_id`'); // Remove singleton constraint if present (best effort) try { try { await connection.query('ALTER TABLE matrix_config DROP CONSTRAINT chk_matrix_singleton'); } catch (e1) { await connection.query('ALTER TABLE matrix_config DROP CHECK chk_matrix_singleton'); } console.log('๐Ÿงน Dropped chk_matrix_singleton'); } catch (e) { console.log('โ„น๏ธ chk_matrix_singleton drop skipped:', e.message); } // --- Added Index Optimization Section --- try { // Core / status await ensureIndex(connection, 'users', 'idx_users_created_at', 'created_at'); await ensureIndex(connection, 'user_status', 'idx_user_status_status', 'status'); await ensureIndex(connection, 'user_status', 'idx_user_status_registration_completed', 'registration_completed'); // Tokens & auth await ensureIndex(connection, 'refresh_tokens', 'idx_refresh_user_expires', 'user_id, expires_at'); await ensureIndex(connection, 'email_verifications', 'idx_email_verifications_user_expires', 'user_id, expires_at'); await ensureIndex(connection, 'password_resets', 'idx_password_resets_user_expires', 'user_id, expires_at'); // Documents await ensureIndex(connection, 'user_documents', 'idx_user_documents_upload_at', 'upload_at'); await ensureIndex(connection, 'user_documents', 'idx_user_documents_verified', 'verified_by_admin'); await ensureIndex(connection, 'user_id_documents', 'idx_user_id_docs_user_type', 'user_id, document_type'); // Activity logs (composite for common filtered ordering) await ensureIndex(connection, 'user_action_logs', 'idx_user_action_logs_action_created', 'action, created_at'); // Referrals await ensureIndex(connection, 'referral_token_usage', 'idx_referral_token_usage_used_at', 'used_at'); // Permissions await ensureIndex(connection, 'permissions', 'idx_permissions_is_active', 'is_active'); await ensureIndex(connection, 'user_permissions', 'idx_user_permissions_granted_by', 'granted_by'); // Settings await ensureIndex(connection, 'user_settings', 'idx_user_settings_theme', 'theme'); // Rate limit (for queries only on rate_key) await ensureIndex(connection, 'rate_limit', 'idx_rate_limit_rate_key', 'rate_key'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_user_type', 'user_type'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_state_user_type', 'state, user_type'); await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_company', 'company_id'); await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active'); console.log('๐Ÿš€ Performance indexes created/verified'); } catch (e) { console.warn('โš ๏ธ Index optimization phase encountered an issue:', e.message); } console.log('๐ŸŽ‰ Normalized database schema created/updated successfully!'); await connection.end(); return true; } catch (error) { console.error('๐Ÿ’ฅ Error during database initialization:', error.message); if (connection) { await connection.end(); } throw error; } } module.exports = { createDatabase };