diff --git a/app/Core/Cache.php b/app/Core/Cache.php index 7e7efb4..1ec9ffa 100644 --- a/app/Core/Cache.php +++ b/app/Core/Cache.php @@ -19,6 +19,10 @@ class Cache $pass = env('REDIS_PASSWORD', null); try { + if (!class_exists('\Predis\Client')) { + throw new \Exception('Predis client is not installed. Please run composer install.'); + } + self::$client = new \Predis\Client([ 'scheme' => 'tcp', 'host' => $host, @@ -26,7 +30,7 @@ class Cache 'password' => $pass, ]); self::$client->connect(); - } catch (\Exception $e) { + } catch (\Throwable $e) { // Catch \Throwable instead of \Exception to catch fatal class errors error_log("Redis Connection Error: " . $e->getMessage()); return null; } diff --git a/app/bootstrap/init.php b/app/bootstrap/init.php index c1c7e4c..19442b9 100644 --- a/app/bootstrap/init.php +++ b/app/bootstrap/init.php @@ -14,6 +14,12 @@ define('STORAGE_PATH', ROOT_PATH . '/storage'); require_once APP_PATH . '/bootstrap/env.php'; require_once APP_PATH . '/helpers/helpers.php'; +// Load Composer Autoloader +$vendorAutoload = ROOT_PATH . '/vendor/autoload.php'; +if (file_exists($vendorAutoload)) { + require_once $vendorAutoload; +} + // Self-healing Storage $dirs = ['/cache', '/logs', '/invoices', '/exports']; foreach ($dirs as $d) { diff --git a/app/modules_app/auth/mobile_request_otp.php b/app/modules_app/auth/mobile_request_otp.php index 51ce7ff..3b75c5b 100644 --- a/app/modules_app/auth/mobile_request_otp.php +++ b/app/modules_app/auth/mobile_request_otp.php @@ -41,10 +41,14 @@ try { $stmt->execute([$phoneHash]); $user = $stmt->fetch(); } catch (\PDOException $e) { - // Fallback to searching by plain phone if phone_hash column doesn't exist - $stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone = ? LIMIT 1"); - $stmt->execute([$phone]); - $user = $stmt->fetch(); + try { + // Fallback to searching by plain phone if phone_hash column doesn't exist + $stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone = ? LIMIT 1"); + $stmt->execute([$phone]); + $user = $stmt->fetch(); + } catch (\PDOException $fallbackException) { + json_error('حدث خطأ في قاعدة البيانات: ' . $fallbackException->getMessage(), 500); + } } if (!$user) { diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index a2d1685..8f49dd5 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -30,6 +30,7 @@ if (!in_array($data['role'] ?? '', $allowedRoles, true)) { $errors = Validator::validate($data, [ 'name' => 'required', 'email' => 'required|email', + 'phone' => 'required', 'password' => 'required', 'role' => 'required' ]); @@ -45,6 +46,9 @@ $encryptedName = Encryption::encrypt($data['name']); $encryptedEmail = Encryption::encrypt($data['email']); $emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login +$encryptedPhone = Encryption::encrypt($data['phone']); +$phoneHash = hash('sha256', preg_replace('/[^0-9+]/', '', $data['phone'])); + // 3. Determine Tenant ID $tenantId = null; if ($decoded['role'] === 'super_admin') { @@ -62,13 +66,15 @@ if ($decoded['role'] === 'super_admin') { // 4. Save to Database try { - $stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, phone, phone_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([ \App\Core\Database::generateUuid(), $tenantId, $encryptedName, $encryptedEmail, $emailHash, + $encryptedPhone, + $phoneHash, password_hash($data['password'], PASSWORD_DEFAULT), $data['role'], date('Y-m-d H:i:s') diff --git a/public/migrate_db.php b/public/migrate_db.php new file mode 100644 index 0000000..c1ae0bb --- /dev/null +++ b/public/migrate_db.php @@ -0,0 +1,34 @@ +query("DESCRIBE users"); + $columns = $stmt->fetchAll(PDO::FETCH_COLUMN); + + if (!in_array('phone_hash', $columns)) { + echo "Adding 'phone_hash' column...\n"; + $db->exec("ALTER TABLE users ADD COLUMN phone_hash VARCHAR(64) NULL AFTER phone"); + $db->exec("CREATE INDEX idx_phone_hash ON users(phone_hash)"); + echo "Column 'phone_hash' added successfully.\n"; + } else { + echo "Column 'phone_hash' already exists.\n"; + } + + if (!in_array('email_hash', $columns)) { + echo "Adding 'email_hash' column...\n"; + $db->exec("ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) NULL AFTER email"); + $db->exec("CREATE INDEX idx_email_hash ON users(email_hash)"); + echo "Column 'email_hash' added successfully.\n"; + } else { + echo "Column 'email_hash' already exists.\n"; + } + + echo "Migration completed.\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/scripts/PROJECT_DOCUMENTATION.md b/scripts/PROJECT_DOCUMENTATION.md index dd3a465..f8c5493 100644 --- a/scripts/PROJECT_DOCUMENTATION.md +++ b/scripts/PROJECT_DOCUMENTATION.md @@ -53,6 +53,84 @@ try { ``` +## File: `update_phone.php` + +```php +prepare("UPDATE users SET phone = ?, phone_hash = ? WHERE id = ?"); + $stmt->execute([$encryptedPhone, $phoneHash, $id]); + $identifier = "ID $id"; + } else { + // Note: Searching by encrypted email will likely fail due to IV randomness. Use ID. + $stmt = $db->prepare("UPDATE users SET phone = ?, phone_hash = ? WHERE email = ?"); + $stmt->execute([$encryptedPhone, $phoneHash, $email]); + $identifier = "email $email"; + } + + if ($stmt->rowCount() > 0) { + echo "✅ Success! Phone updated for $identifier\n"; + echo " Encrypted: $encryptedPhone\n"; + echo " Hash: $phoneHash\n"; + } else { + echo "❌ Failed. User with $identifier not found or no changes made.\n"; + } + +} catch (Exception $e) { + echo "❌ Error: " . $e->getMessage() . "\n"; +} + +``` + +## File: `list_users.php` + +```php +query("SELECT id, email, name FROM users")->fetchAll(); + +echo "ID | Email | Name\n"; +echo "----------------------\n"; +foreach ($users as $user) { + $email = App\Core\Encryption::decrypt($user['email']) ?: $user['email']; + $name = App\Core\Encryption::decrypt($user['name']) ?: $user['name']; + echo "{$user['id']} | $email | $name\n"; +} + +``` + ## File: `schema.sql` ```sql @@ -79,6 +157,7 @@ CREATE TABLE users ( name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, + phone VARCHAR(255), role ENUM('super_admin','admin','accountant','viewer') NOT NULL, company_id CHAR(36) NULL, -- assigned company for accountant refresh_token_hash VARCHAR(255) NULL, @@ -486,6 +565,27 @@ echo "════════════════════════ ``` +## File: `debug_collation.php` + +```php +query("SHOW FULL COLUMNS FROM $table WHERE Field = 'id'"); + $col = $stmt->fetch(PDO::FETCH_ASSOC); + print_r($col); + echo "--------------------------\n"; +} + +``` + ## File: `migrate.php` ```php @@ -738,6 +838,175 @@ echo "════════════════════════ ``` +## File: `migrate_phase3_mobile.php` + +```php +query("SHOW FULL COLUMNS FROM users WHERE Field = 'id'"); +$userCol = $stmt->fetch(PDO::FETCH_ASSOC); +$charsetCollation = ""; +if ($userCol && !empty($userCol['Collation'])) { + $collation = $userCol['Collation']; + list($charset) = explode('_', $collation); + $charsetCollation = "CHARACTER SET {$charset} COLLATE {$collation}"; +} + +$migrations = [ + + // ─── 1. User Device Management ───────────────────────── + 'create_user_devices' => " + CREATE TABLE IF NOT EXISTS user_devices ( + id CHAR(36) {$charsetCollation} PRIMARY KEY DEFAULT (UUID()), + user_id CHAR(36) {$charsetCollation} NOT NULL, + device_fingerprint VARCHAR(64) NOT NULL, + device_name VARCHAR(100) NULL, + platform ENUM('android','ios','web') NOT NULL DEFAULT 'android', + app_version VARCHAR(20) NULL, + push_token TEXT NULL, + device_secret VARCHAR(128) NULL, + is_trusted BOOLEAN DEFAULT FALSE, + last_seen_at DATETIME NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_user_device (user_id, device_fingerprint), + INDEX idx_device_fingerprint (device_fingerprint) + ) + ", + + // ─── 2. Users table: Add phone + mobile fields ───────── + 'add_users_phone' => "ALTER TABLE users ADD COLUMN phone VARCHAR(255) NULL AFTER email", + 'add_users_phone_hash' => "ALTER TABLE users ADD COLUMN phone_hash VARCHAR(64) NULL AFTER phone", + 'add_users_pin_hash' => "ALTER TABLE users ADD COLUMN pin_hash VARCHAR(255) NULL AFTER password_hash", + 'add_users_biometric' => "ALTER TABLE users ADD COLUMN biometric_enabled BOOLEAN DEFAULT FALSE AFTER pin_hash", + 'add_users_phone_index' => "CREATE INDEX idx_phone_hash ON users(phone_hash)", + + // ─── 3. Invoice Batches (Mobile Scanner) ─────────────── + 'create_invoice_batches' => " + CREATE TABLE IF NOT EXISTS invoice_batches ( + id CHAR(36) {$charsetCollation} PRIMARY KEY, + tenant_id CHAR(36) {$charsetCollation} NOT NULL, + company_id CHAR(36) {$charsetCollation} NOT NULL, + uploaded_by CHAR(36) {$charsetCollation} NULL, + total_images INT NOT NULL DEFAULT 0, + processed_images INT NOT NULL DEFAULT 0, + failed_images INT NOT NULL DEFAULT 0, + status ENUM('uploading','processing','done','partial_fail','failed') DEFAULT 'uploading', + source ENUM('mobile_scan','web_upload','whatsapp') DEFAULT 'mobile_scan', + pdf_path VARCHAR(500) NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + INDEX idx_tenant_status (tenant_id, status), + INDEX idx_company (company_id), + INDEX idx_uploaded_by (uploaded_by), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL + ) + ", + + // ─── 4. Invoice Processing Queue ─────────────────────── + 'create_processing_queue' => " + CREATE TABLE IF NOT EXISTS invoice_processing_queue ( + id INT AUTO_INCREMENT PRIMARY KEY, + batch_id CHAR(36) {$charsetCollation} NOT NULL, + invoice_id CHAR(36) {$charsetCollation} NULL, + tenant_id CHAR(36) {$charsetCollation} NOT NULL, + company_id CHAR(36) {$charsetCollation} NOT NULL, + image_path VARCHAR(500) NOT NULL, + image_order INT NOT NULL DEFAULT 0, + status ENUM('pending','processing','done','failed') DEFAULT 'pending', + attempts INT DEFAULT 0, + max_attempts INT DEFAULT 3, + error_message TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + INDEX idx_status_tenant (status, tenant_id), + INDEX idx_batch (batch_id), + INDEX idx_pending (status, attempts) + ) + ", + + // ─── 5. Add batch_id to invoices table ───────────────── + 'add_invoices_batch_id' => "ALTER TABLE invoices ADD COLUMN batch_id CHAR(36) NULL AFTER company_id", + 'add_invoices_batch_index' => "CREATE INDEX idx_batch_id ON invoices(batch_id)", + + // ─── 6. Notifications Table ──────────────────────────── + 'create_notifications' => " + CREATE TABLE IF NOT EXISTS notifications ( + id CHAR(36) {$charsetCollation} PRIMARY KEY DEFAULT (UUID()), + tenant_id CHAR(36) {$charsetCollation} NOT NULL, + user_id CHAR(36) {$charsetCollation} NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NULL, + data JSON NULL, + is_read BOOLEAN DEFAULT FALSE, + read_at DATETIME NULL, + push_sent BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_unread (user_id, is_read), + INDEX idx_tenant (tenant_id), + INDEX idx_type (type), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + ", +]; + +$success = 0; +$skipped = 0; +$failed = 0; + +foreach ($migrations as $name => $sql) { + try { + $db->exec($sql); + echo " ✅ {$name}\n"; + $success++; + } catch (\PDOException $e) { + $msg = $e->getMessage(); + if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'Duplicate key name') || str_contains($msg, 'already exists')) { + echo " ⏭️ {$name} (already exists)\n"; + $skipped++; + } else { + echo " ❌ {$name}: {$msg}\n"; + $failed++; + } + } +} + +echo "\n═══════════════════════════════════════════\n"; +echo " Migration Complete!\n"; +echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n"; +echo "═══════════════════════════════════════════\n"; + +``` + ## File: `debug_data.php` ```php