# Musadaq Project Documentation This file contains the complete source code of the project (excluding dependencies and sensitive data). ## File: `push.sh` ```sh #!/bin/bash # Get current timestamp TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S") echo "🚀 Starting Git Push Process..." echo "📅 Timestamp: $TIMESTAMP" # Add all changes git add . # Commit with timestamp git commit -m "Update: $TIMESTAMP" # Push to origin main explicitly git push origin main echo "✅ Done!" ``` ## File: `composer.json` ```json { "name": "musadaq/platform", "description": "Jordanian E-Invoicing Automation SaaS", "type": "project", "license": "proprietary", "require": { "php": ">=8.4", "ext-pdo": "*", "ext-pdo_mysql": "*", "ext-openssl": "*", "ext-sodium": "*", "ext-curl": "*", "ext-mbstring": "*", "ext-json": "*", "vlucas/phpdotenv": "^5.6", "monolog/monolog": "^3.5", "firebase/php-jwt": "^6.10", "ramsey/uuid": "^4.7", "nikic/fast-route": "^1.3", "predis/predis": "^2.2", "guzzlehttp/guzzle": "^7.9", "respect/validation": "^2.3", "league/flysystem": "^3.28", "symfony/mailer": "^7.1" }, "require-dev": { "phpunit/phpunit": "^11.0", "phpstan/phpstan": "^1.12", "squizlabs/php_codesniffer": "^3.10" }, "autoload": { "psr-4": { "App\\": "app/" } }, "config": { "optimize-autoloader": true, "sort-packages": true } } ``` ## File: `app/modules_app/auth/login.php` ```php 'required|email', 'password' => 'required' ]); if ($errors) { json_error('Validation Failed', 422, $errors); } $email = $data['email']; $password = $data['password']; // 2. DB Check (Using hash for lookup since email is encrypted) $db = Database::getInstance(); $emailHash = hash('sha256', strtolower($email)); $stmt = $db->prepare("SELECT * FROM users WHERE email_hash = ? LIMIT 1"); $stmt->execute([$emailHash]); $user = $stmt->fetch(); if (!$user || !password_verify($password, $user['password_hash'])) { json_error('بيانات الدخول غير صحيحة', 401); } // 3. Issue Token $secret = env('JWT_SECRET'); if (!$secret || strlen($secret) < 32) { error_log('FATAL: JWT_SECRET is missing or too short in .env'); json_error('Server configuration error', 500); } $payload = [ 'user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], 'exp' => time() + (15 * 60) // 15 minutes ]; $token = JWT::encode($payload, $secret); // 4. Update Refresh Token (Hashed before storage for security) $refreshToken = bin2hex(random_bytes(32)); $refreshTokenHash = hash('sha256', $refreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); $stmt->execute([$refreshTokenHash, $user['id']]); json_success([ 'access_token' => $token, 'refresh_token' => $refreshToken, 'user' => [ 'id' => $user['id'], 'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']), 'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']) ] ], 'تم تسجيل الدخول بنجاح'); ``` ## File: `app/modules_app/auth/logout.php` ```php prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?"); $stmt->execute([$userId]); json_success(null, 'تم تسجيل الخروج بنجاح'); ``` ## File: `app/modules_app/auth/refresh.php` ```php prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1"); $stmt->execute([$refreshTokenHash]); $user = $stmt->fetch(); if (!$user) { json_error('Invalid refresh token', 401); } $secret = env('JWT_SECRET'); if (!$secret || strlen($secret) < 32) { error_log('FATAL: JWT_SECRET is missing or too short in .env'); json_error('Server configuration error', 500); } $payload = [ 'user_id' => $user['id'], 'role' => $user['role'], 'exp' => time() + (15 * 60) ]; $newToken = JWT::encode($payload, $secret); $newRefreshToken = bin2hex(random_bytes(32)); $newRefreshTokenHash = hash('sha256', $newRefreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); $stmt->execute([$newRefreshTokenHash, $user['id']]); json_success([ 'access_token' => $newToken, 'refresh_token' => $newRefreshToken ], 'تم تجديد الجلسة بنجاح'); ``` ## File: `app/modules_app/dashboard/stats.php` ```php query("SELECT COUNT(*) FROM invoices"); $total = $stmt->fetchColumn(); // Pending Invoices $stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'pending'"); $pending = $stmt->fetchColumn(); // Approved Invoices $stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'approved'"); $approved = $stmt->fetchColumn(); } catch (\Exception $e) { // Fallback if table doesn't exist yet $total = 0; $pending = 0; $approved = 0; } json_success([ 'total' => $total, 'pending' => $pending, 'approved' => $approved ]); ``` ## File: `app/modules_app/users/index.php` ```php prepare("SELECT id, name, email, role, is_active, created_at FROM users"); $stmt->execute(); $users = $stmt->fetchAll(); // 4. Decrypt sensitive data for the UI foreach ($users as &$user) { // Try to decrypt. If it fails (e.g. data was plain text), keep original. $decryptedName = Encryption::decrypt($user['name']); $user['name'] = $decryptedName !== false ? $decryptedName : $user['name']; $decryptedEmail = Encryption::decrypt($user['email']); $user['email'] = $decryptedEmail !== false ? $decryptedEmail : $user['email']; } json_success($users); ``` ## File: `app/modules_app/users/create.php` ```php 'required', 'email' => 'required|email', 'password' => 'required', 'role' => 'required' ]); if ($errors) { json_error('Validation Failed', 422, $errors); } $db = Database::getInstance(); // 3. Encrypt sensitive data $encryptedName = Encryption::encrypt($data['name']); $encryptedEmail = Encryption::encrypt($data['email']); $emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login // 4. Save to Database try { $stmt = $db->prepare("INSERT INTO users (tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([ $decoded['tenant_id'], $encryptedName, $encryptedEmail, $emailHash, password_hash($data['password'], PASSWORD_DEFAULT), $data['role'], date('Y-m-d H:i:s') ]); json_success(null, 'تم إضافة المستخدم بنجاح'); } catch (\Exception $e) { if (str_contains($e->getMessage(), 'Duplicate entry')) { json_error('البريد الإلكتروني مسجل مسبقاً', 409); } json_error('حدث خطأ أثناء حفظ البيانات', 500); } ``` ## File: `app/modules_app/companies/index.php` ```php query("SELECT * FROM companies WHERE deleted_at IS NULL"); } // 2. Admin sees all companies in their tenant else if ($decoded['role'] === 'admin') { $stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND deleted_at IS NULL"); $stmt->execute([$decoded['tenant_id']]); } // 3. Others (accountant, etc) see only their assigned company else { // Need to get their assigned company_id from users table first $stmtUser = $db->prepare("SELECT company_id FROM users WHERE id = ?"); $stmtUser->execute([$decoded['user_id']]); $assignedCompanyId = $stmtUser->fetchColumn(); $stmt = $db->prepare("SELECT * FROM companies WHERE id = ? AND deleted_at IS NULL"); $stmt->execute([$assignedCompanyId]); } $companies = $stmt->fetchAll(); // 3. Decrypt fields foreach ($companies as &$company) { // Decrypt Name $decryptedName = Encryption::decrypt($company['name']); $company['name'] = $decryptedName !== false ? $decryptedName : $company['name']; // Decrypt Name EN if (!empty($company['name_en'])) { $decryptedNameEn = Encryption::decrypt($company['name_en']); $company['name_en'] = $decryptedNameEn !== false ? $decryptedNameEn : $company['name_en']; } // Redact JoFotara secrets if returned to UI (or just don't return them) unset($company['jofotara_client_id_encrypted']); unset($company['jofotara_secret_key_encrypted']); unset($company['certificate_password_encrypted']); } json_success($companies); ``` ## File: `app/modules_app/companies/create.php` ```php 'required', 'tax_identification_number' => 'required' ]); if ($errors) { json_error('Validation Failed', 422, $errors); } $db = Database::getInstance(); try { $db->beginTransaction(); // 2. Encrypt sensitive fields $encryptedName = Encryption::encrypt($data['name']); $encryptedNameEn = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null; // Encrypt JoFotara keys if provided $jofotaraClientId = !empty($data['jofotara_client_id']) ? Encryption::encrypt($data['jofotara_client_id']) : null; $jofotaraSecretKey = !empty($data['jofotara_secret_key']) ? Encryption::encrypt($data['jofotara_secret_key']) : null; // 3. Save to Database $stmt = $db->prepare(" INSERT INTO companies ( tenant_id, name, name_en, tax_identification_number, commercial_registration_number, city, address, contact_email, contact_phone, jofotara_client_id_encrypted, jofotara_secret_key_encrypted, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $decoded['user_id'], // Using current admin as tenant_id $encryptedName, $encryptedNameEn, $data['tax_identification_number'], $data['commercial_registration_number'] ?? null, $data['city'] ?? null, $data['address'] ?? null, $data['contact_email'] ?? null, $data['contact_phone'] ?? null, $jofotaraClientId, $jofotaraSecretKey, date('Y-m-d H:i:s') ]); $db->commit(); json_success(null, 'تم إنشاء الشركة بنجاح'); } catch (\Exception $e) { $db->rollBack(); json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500); } ``` ## File: `app/middleware/HmacMiddleware.php` ```php $maxAgeSeconds) { json_error('Request expired. Check your system clock.', 401); } // 4. Build the expected signature $body = file_get_contents('php://input'); $payload = $timestamp . '.' . $body; $secret = env('HMAC_SECRET_KEY'); if (!$secret || strlen($secret) < 32) { error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env'); json_error('Server configuration error', 500); } // 5. Verify using constant-time comparison (prevents timing attacks) if (!Security::verifySignature($payload, $signature, $secret)) { error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? '')); json_error('Invalid request signature', 401); } } } ``` ## File: `app/middleware/AuthMiddleware.php` ```php $ts > ($now - $timeWindow)) ); } } if (count($requests) >= $maxRequests) { flock($fp, LOCK_UN); fclose($fp); header('Retry-After: ' . $timeWindow); json_error('Too Many Requests. Please slow down.', 429); } // Record this request $requests[] = $now; // Write updated data back ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($requests)); } finally { flock($fp, LOCK_UN); fclose($fp); } } } ``` ## File: `app/core/Validator.php` ```php $rule) { if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) { $errors[$field] = "The {$field} field is required."; } if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) { $errors[$field] = "The {$field} must be a valid email address."; } } return $errors; } } ``` ## File: `app/core/Encryption.php` ```php PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } catch (PDOException $e) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['success' => false, 'message' => 'Database connection failed']); exit; } } return self::$instance; } } ``` ## File: `app/core/Security.php` ```php $value) { $data[$key] = self::sanitize($value); } } else if (is_string($data)) { $data = htmlspecialchars(strip_tags(trim($data)), ENT_QUOTES, 'UTF-8'); } return $data; } public static function generateRandomString(int $length = 64): string { return bin2hex(random_bytes($length / 2)); } public static function sign(string $data, string $secret): string { return hash_hmac('sha256', $data, $secret); } public static function verifySignature(string $data, string $signature, string $secret): bool { $expected = self::sign($data, $secret); return hash_equals($expected, $signature); } } ``` ## File: `app/core/JWT.php` ```php 'JWT', 'alg' => 'HS256']); $base64UrlHeader = self::base64UrlEncode($header); $base64UrlPayload = self::base64UrlEncode(json_encode($payload)); $signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true); $base64UrlSignature = self::base64UrlEncode($signature); return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature; } public static function decode(string $token, string $secret): ?array { $parts = explode('.', $token); if (count($parts) !== 3) return null; [$header, $payload, $signature] = $parts; $expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $header . "." . $payload, $secret, true)); if (!hash_equals($expectedSignature, $signature)) return null; $decodedPayload = json_decode(self::base64UrlDecode($payload), true); // Check expiry if (isset($decodedPayload['exp']) && $decodedPayload['exp'] < time()) return null; return $decodedPayload; } } ``` ## File: `app/bootstrap/init.php` ```php $success, 'data' => $data, // Return real data to client 'message' => $message, 'timestamp' => date('c') ], JSON_UNESCAPED_UNICODE); exit; } function json_error(string $message, int $code = 400, $errors = null) { json_response(false, $errors, $message, $code); } function json_success($data = null, ?string $message = 'Success', int $code = 200) { json_response(true, $data, $message, $code); } ``` ## File: `app/bootstrap/env.php` ```php $_ENV['DB_HOST'] ?? '127.0.0.1', 'port' => $_ENV['DB_PORT'] ?? '3306', 'database' => $_ENV['DB_DATABASE'] ?? 'musadaqDb', 'username' => $_ENV['DB_USERNAME'] ?? 'musadaqUser', 'password' => $_ENV['DB_PASSWORD'] ?? '', 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', ]; ``` ## File: `app/helpers/helpers.php` ```php "; var_dump($v); echo ""; } die(); } } ``` ## File: `public/login.php` ```php تسجيل الدخول | مُصادَق
م

مرحباً بك في مُصادَق

نظام أتمتة الفواتير الضريبية الذكي

ليس لديك حساب؟ ابدأ التجربة المجانية

``` ## File: `public/index.php` ```php [allowed_method, module_file] $routes = [ 'v1/auth/login' => ['POST', 'auth/login.php'], 'v1/auth/refresh' => ['POST', 'auth/refresh.php'], 'v1/auth/logout' => ['POST', 'auth/logout.php'], 'v1/users' => ['GET', 'users/index.php'], 'v1/users/create' => ['POST', 'users/create.php'], 'v1/companies' => ['GET', 'companies/index.php'], 'v1/companies/create' => ['POST', 'companies/create.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], ]; if (isset($routes[$route])) { [$allowedMethod, $moduleFile] = $routes[$route]; // H1 Fix: Enforce HTTP Method if ($_SERVER['REQUEST_METHOD'] !== $allowedMethod) { header("Allow: {$allowedMethod}"); json_error("Method Not Allowed. Use {$allowedMethod}.", 405); } $file = APP_PATH . '/modules_app/' . $moduleFile; if (file_exists($file)) { require_once $file; } else { json_error("Endpoint file missing: {$route}", 500); } } else { if (str_starts_with($route, 'v1/')) { json_error("Not Found: {$route}", 404); } else { include __DIR__ . '/shell.php'; exit; } } ``` ## File: `public/tool-encrypt.php` ```php أداة التشفير | مُصادَق

أداة تشفير البيانات

استخدم هذه القيم لتحديث مستخدمي النظام الحاليين يدوياً في قاعدة البيانات إذا أردت.
``` ## File: `public/shell.php` ```php مُصادَق | لوحة التحكم

إجمالي الفواتير

قيد المعالجة

تم الاعتماد

اسم الشركة الرقم الضريبي رقم التسجيل تاريخ الإضافة
الاسم البريد الإلكتروني الدور

إضافة مستخدم جديد 👤

إنشاء شركة جديدة 🏢

``` ## File: `public/register.php` ```php إنشاء حساب | مُصادَق

ابدأ مع مُصادَق

سجل شركتك الآن وابدأ أتمتة فواتيرك

لديك حساب بالفعل؟ تسجيل الدخول

``` ## File: `public/api.php` ```php ``` ## File: `scripts/schema.sql` ```sql -- ════════════════════════════════════════════════════════════ -- مُصادَق — Database Schema v1.0 -- ════════════════════════════════════════════════════════════ -- Tenants (Accounting Offices) CREATE TABLE tenants ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, phone VARCHAR(20), status ENUM('active', 'suspended', 'trial') DEFAULT 'trial', trial_ends_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Users CREATE TABLE users ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('super_admin','admin','accountant','viewer') NOT NULL, company_id CHAR(36) NULL, -- assigned company for accountant refresh_token_hash VARCHAR(255) NULL, is_active BOOLEAN DEFAULT TRUE, last_login_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_tenant_email (tenant_id, email), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ); -- API Keys (for external integrations / mobile scanner) CREATE TABLE api_keys ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, user_id CHAR(36) NOT NULL, name VARCHAR(100) NOT NULL, public_key VARCHAR(64) NOT NULL UNIQUE, secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash of secret last_used_at DATETIME NULL, is_active BOOLEAN DEFAULT TRUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- Companies CREATE TABLE companies ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, name_en VARCHAR(255) NULL, tax_identification_number VARCHAR(20) NOT NULL, address TEXT NULL, jofotara_client_id_encrypted TEXT NULL, jofotara_secret_key_encrypted TEXT NULL, jofotara_income_source_sequence VARCHAR(50) NULL, certificate_path VARCHAR(255) NULL, certificate_password_encrypted VARCHAR(500) NULL, is_active BOOLEAN DEFAULT TRUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_name (name), INDEX idx_tin (tax_identification_number), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ); -- Subscriptions CREATE TABLE subscriptions ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL UNIQUE, plan ENUM('basic','office','pro','enterprise') NOT NULL DEFAULT 'basic', max_companies INT NOT NULL DEFAULT 5, max_invoices_per_month INT NOT NULL DEFAULT 100, price_jod DECIMAL(10,2) NOT NULL DEFAULT 0, invoices_used_this_month INT NOT NULL DEFAULT 0, status ENUM('active','past_due','cancelled') DEFAULT 'active', current_period_start DATETIME NULL, current_period_end DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ); -- Invoices CREATE TABLE invoices ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, company_id CHAR(36) NOT NULL, invoice_number VARCHAR(100) NULL, invoice_date DATE NULL, invoice_type ENUM('cash','credit') DEFAULT 'cash', ubl_type_code CHAR(3) DEFAULT '388', payment_method_code CHAR(3) DEFAULT '013', supplier_tin VARCHAR(20) NULL, supplier_name VARCHAR(255) NULL, supplier_address TEXT NULL, buyer_tin VARCHAR(20) NULL, buyer_national_id VARCHAR(20) NULL, buyer_name VARCHAR(255) NULL, subtotal DECIMAL(15,3) DEFAULT 0, discount_total DECIMAL(15,3) DEFAULT 0, tax_amount DECIMAL(15,3) DEFAULT 0, grand_total DECIMAL(15,3) DEFAULT 0, currency_code CHAR(3) DEFAULT 'JOD', status ENUM('uploaded','extracting','extracted','validated','validation_failed','submitting','approved','rejected') DEFAULT 'uploaded', original_file_path TEXT NULL, invoice_category VARCHAR(20) DEFAULT 'simplified', validation_errors JSON NULL, qr_code TEXT NULL, ai_confidence_score DECIMAL(4,3) NULL, ai_prompt_tokens INT DEFAULT 0, ai_completion_tokens INT DEFAULT 0, ai_total_cost DECIMAL(10,6) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_tenant (tenant_id), INDEX idx_company (company_id), INDEX idx_status (status), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE ); -- Invoice Lines CREATE TABLE invoice_lines ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), invoice_id CHAR(36) NOT NULL, line_number INT NOT NULL, description TEXT NOT NULL, quantity DECIMAL(15,3) NOT NULL, unit_price DECIMAL(15,3) NOT NULL, discount DECIMAL(15,3) DEFAULT 0, tax_rate DECIMAL(5,4) NOT NULL, line_total DECIMAL(15,3) NOT NULL, FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ); -- Audit Logs CREATE TABLE audit_logs ( id CHAR(36) PRIMARY KEY DEFAULT (UUID()), tenant_id CHAR(36) NULL, user_id CHAR(36) NULL, action VARCHAR(100) NOT NULL, entity_type VARCHAR(50) NULL, entity_id CHAR(36) NULL, old_data JSON NULL, new_data JSON NULL, ip_address VARCHAR(45) NULL, user_agent VARCHAR(500) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant (tenant_id), INDEX idx_action (action), INDEX idx_created (created_at) ); ``` ## File: `scripts/migrate.php` ```php exec("ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) AFTER email, ADD INDEX (email_hash)"); echo "[OK] Added email_hash column and index.\n"; } catch (\Exception $e) { echo "[SKIP] email_hash column might already exist.\n"; } // 2. Fetch all users to encrypt their data $stmt = $db->query("SELECT id, name, email FROM users"); $users = $stmt->fetchAll(); echo "Found " . count($users) . " users. Starting encryption...\n"; $updateStmt = $db->prepare("UPDATE users SET name = ?, email = ?, email_hash = ? WHERE id = ?"); foreach ($users as $user) { // Check if data is already encrypted (to avoid double encryption) $isAlreadyEncrypted = Encryption::decrypt($user['email']) !== false; if ($isAlreadyEncrypted) { echo "User ID {$user['id']} is already encrypted. Skipping.\n"; continue; } // Encrypt Name $encryptedName = Encryption::encrypt($user['name']); // Encrypt Email $encryptedEmail = Encryption::encrypt($user['email']); // Generate Hash for lookup $emailHash = hash('sha256', strtolower($user['email'])); $updateStmt->execute([ $encryptedName, $encryptedEmail, $emailHash, $user['id'] ]); echo "User ID {$user['id']} migrated successfully.\n"; } // (Table creation logic removed because it is properly handled by schema.sql) echo "--- Migration Complete ---\n"; ```