Files
musadaq-saas/scripts/PROJECT_DOCUMENTATION.md
2026-05-06 21:24:56 +03:00

1033 lines
39 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Musadaq Project Documentation
This file contains the complete source code of the project (excluding dependencies and sensitive data).
## File: `fix_data.php`
```php
<?php
/**
* Data Reset & Fix Script
* Clears corrupted company/assignment data but keeps Users and Tenants.
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "--- Starting Data Reset ---\n";
try {
$db->beginTransaction();
// 1. Clear corrupted data tables
$db->exec("SET FOREIGN_KEY_CHECKS = 0");
$db->exec("TRUNCATE TABLE user_company_assignments");
$db->exec("TRUNCATE TABLE invoices");
$db->exec("TRUNCATE TABLE companies");
$db->exec("SET FOREIGN_KEY_CHECKS = 1");
echo "[OK] Cleared companies, invoices, and assignments.\n";
// 2. Ensure Super Admin does not have a tenant_id (if your schema allows NULL, else set to empty string)
// Actually, schema.sql says tenant_id CHAR(36) NOT NULL.
// This is a flaw in schema.sql for Super Admins. We will leave users alone for now.
// 3. Fix the admin's tenant_id to match the first available tenant
$stmt = $db->query("SELECT id FROM tenants LIMIT 1");
$tenantId = $stmt->fetchColumn();
if ($tenantId) {
$db->exec("UPDATE users SET tenant_id = '$tenantId' WHERE role != 'super_admin'");
echo "[OK] Linked all non-super-admin users to Tenant ID: $tenantId\n";
}
$db->commit();
echo "--- Reset Complete ---\n";
} catch (\Exception $e) {
$db->rollBack();
echo "[ERROR] Reset failed: " . $e->getMessage() . "\n";
}
```
## File: `update_phone.php`
```php
<?php
/**
* Update User Phone Script (Secure)
* Run: php scripts/update_phone.php --email=admin@musadaq.com --phone=963992952235
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
// Parse CLI arguments
$options = getopt("", ["email:", "phone:", "id:"]);
$email = $options['email'] ?? null;
$phone = $options['phone'] ?? null;
$id = $options['id'] ?? null;
if ((!$email && !$id) || !$phone) {
die("Usage: php scripts/update_phone.php --phone=yourphone [--email=your@email.com | --id=user-uuid]\n");
}
$db = Database::getInstance();
// 1. Sanitize phone
try {
$cleanPhone = preg_replace('/[^0-9+]/', '', $phone);
$phoneHash = hash('sha256', $cleanPhone);
$encryptedPhone = Encryption::encrypt($cleanPhone);
// 2. Update user
if ($id) {
$stmt = $db->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
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
$users = $db->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
-- ════════════════════════════════════════════════════════════
-- مُصادَق — 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,
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,
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 TEXT NULL,
supplier_name TEXT NULL,
supplier_address TEXT NULL,
buyer_tin TEXT NULL,
buyer_national_id TEXT NULL,
buyer_name TEXT 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('extracted', 'approved', 'rejected') DEFAULT 'extracted',
jofotara_uuid VARCHAR(255) NULL,
qr_code TEXT NULL,
original_file_path TEXT NULL,
invoice_category VARCHAR(20) DEFAULT 'simplified',
validation_errors JSON 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,
deleted_at DATETIME NULL,
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,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- JoFotara Submissions (Audit Trail)
CREATE TABLE jofotara_submissions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
invoice_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NOT NULL,
xml_payload LONGTEXT NULL,
xml_hash VARCHAR(64) NULL,
jofotara_uuid VARCHAR(255) NULL,
qr_code_raw TEXT NULL,
response_code VARCHAR(20) NULL,
response_body JSON NULL,
status ENUM('pending','submitted','accepted','rejected','error') DEFAULT 'pending',
error_message TEXT NULL,
retry_count TINYINT DEFAULT 0,
submitted_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 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: `migrate_phase2.php`
```php
<?php
/**
* Phase 2 Migration: AI Pre-Audit & Duplicate Prevention
* Run: php scripts/migrate_phase2.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 2 Migration\n";
echo " AI Pre-Audit & Duplicate Prevention\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// 1. Add invoice_hash for duplicate prevention
'add_invoice_hash' => "ALTER TABLE invoices ADD COLUMN invoice_hash VARCHAR(64) NULL",
// 2. Add validation_warnings for AI Pre-Audit
'add_validation_warnings' => "ALTER TABLE invoices ADD COLUMN validation_warnings JSON NULL",
// 2.5 Add deleted_at for soft delete (Missing in Phase 1 for this table)
'add_invoices_soft_delete' => "ALTER TABLE invoices ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL",
// 3. Create Unique Index to prevent duplicates within the same tenant & company
// Using a regular index for now, application logic will handle uniqueness to allow nulls
'add_hash_index' => "CREATE INDEX idx_invoice_hash ON invoices(tenant_id, company_id, invoice_hash)",
];
$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();
// Ignore "duplicate column" (1060), "duplicate key name" (1061), or "already exists" errors
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: `migrate_phase1.php`
```php
<?php
/**
* Phase 1 Migration: Subscriptions & Quota System
* Run: php scripts/migrate_phase1.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 1 Migration\n";
echo " Subscriptions & Quota System\n";
echo "═══════════════════════════════════════════\n\n";
$migrations = [
// 1. Add deleted_at to companies
'companies_soft_delete' => "ALTER TABLE companies ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL",
// 2. Add deleted_at to users
'users_soft_delete' => "ALTER TABLE users ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL",
// 3. Add email_hash to users (if missing)
'users_email_hash' => "ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) NULL",
// 4. Create subscription_plans table
'subscription_plans_table' => "
CREATE TABLE IF NOT EXISTS subscription_plans (
id VARCHAR(20) PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NOT NULL,
max_companies INT NOT NULL DEFAULT 1,
max_invoices_month INT NOT NULL DEFAULT 30,
max_users INT NOT NULL DEFAULT 2,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00,
ai_features BOOLEAN DEFAULT FALSE,
jofotara_enabled BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
features_json JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
// 4.5 Fix collation if table already exists
'subscription_plans_collation_fix' => "ALTER TABLE subscription_plans CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
// 5. Ensure subscriptions table exists with all needed columns
'subscriptions_table' => "
CREATE TABLE IF NOT EXISTS subscriptions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL UNIQUE,
plan_id VARCHAR(20) NOT NULL DEFAULT 'free',
max_companies INT NOT NULL DEFAULT 1,
max_invoices_per_month INT NOT NULL DEFAULT 15,
max_users INT NOT NULL DEFAULT 1,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00,
invoices_used_this_month INT NOT NULL DEFAULT 0,
status ENUM('active','past_due','cancelled','trial') DEFAULT 'trial',
current_period_start DATETIME NULL,
current_period_end DATETIME NULL,
trial_ends_at 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,
FOREIGN KEY (plan_id) REFERENCES subscription_plans(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
// 5.5 Fix collation if table already exists
'subscriptions_collation_fix' => "ALTER TABLE subscriptions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
// 6. Add plan_id column to subscriptions if upgrading from old schema
'subscriptions_plan_id' => "ALTER TABLE subscriptions ADD COLUMN plan_id VARCHAR(20) NOT NULL DEFAULT 'free'",
// 7. Add max_users column to subscriptions if missing
'subscriptions_max_users' => "ALTER TABLE subscriptions ADD COLUMN max_users INT NOT NULL DEFAULT 1",
// 8. Add trial_ends_at to subscriptions if missing
'subscriptions_trial' => "ALTER TABLE subscriptions ADD COLUMN trial_ends_at DATETIME NULL",
// 9. Index on subscriptions status
'subscriptions_status_idx' => "CREATE INDEX idx_sub_status ON subscriptions(status)",
];
$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();
// Ignore "duplicate column" (1060), "duplicate key name" (1061), or "already exists" errors
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";
// Seed subscription plans
echo "\n📦 Seeding subscription plans...\n";
$plans = [
['free', 'مجانية', 'Free', 1, 15, 1, 0.00, 1, 1, 10],
['basic', 'أساسية', 'Basic', 3, 100, 3, 15.00, 1, 1, 20],
['office', 'مكتبية', 'Office', 10, 500, 10, 45.00, 1, 1, 30],
['pro', 'احترافية', 'Pro', 25, 2000, 25, 99.00, 1, 1, 40],
['enterprise', 'مؤسسية', 'Enterprise', 999, 99999, 999, 249.00, 1, 1, 50],
];
$planStmt = $db->prepare("
INSERT INTO subscription_plans (id, name_ar, name_en, max_companies, max_invoices_month, max_users, price_jod, ai_features, jofotara_enabled, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
max_companies = VALUES(max_companies),
max_invoices_month = VALUES(max_invoices_month),
max_users = VALUES(max_users),
price_jod = VALUES(price_jod),
ai_features = VALUES(ai_features),
jofotara_enabled = VALUES(jofotara_enabled),
sort_order = VALUES(sort_order)
");
foreach ($plans as $plan) {
$planStmt->execute($plan);
echo " ✅ Plan: {$plan[0]} ({$plan[1]})\n";
}
// Auto-assign 'free' plan to any tenant without a subscription
echo "\n🔗 Auto-assigning free plan to tenants without subscriptions...\n";
$stmt = $db->query("
SELECT t.id FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
WHERE s.id IS NULL
");
$orphanTenants = $stmt->fetchAll();
if (!empty($orphanTenants)) {
$insertSub = $db->prepare("
INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, trial_ends_at)
VALUES (?, 'free', 1, 15, 1, 0.00, 'trial', NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), DATE_ADD(NOW(), INTERVAL 14 DAY))
");
foreach ($orphanTenants as $tenant) {
try {
$insertSub->execute([$tenant['id']]);
echo " ✅ Assigned free plan to tenant: {$tenant['id']}\n";
} catch (\Exception $e) {
echo " ⚠️ Tenant {$tenant['id']}: " . $e->getMessage() . "\n";
}
}
} else {
echo " All tenants already have subscriptions.\n";
}
echo "\n═══════════════════════════════════════════\n";
echo " Migration Complete!\n";
echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n";
echo "═══════════════════════════════════════════\n";
```
## File: `debug_collation.php`
```php
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
$tables = ['users', 'tenants', 'companies'];
foreach ($tables as $table) {
echo "Table: $table\n";
$stmt = $db->query("SHOW FULL COLUMNS FROM $table WHERE Field = 'id'");
$col = $stmt->fetch(PDO::FETCH_ASSOC);
print_r($col);
echo "--------------------------\n";
}
```
## File: `migrate.php`
```php
<?php
/**
* Advanced Migration Script: Schema Update + Data Encryption
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "--- Starting Security Migration ---\n";
// 1. Add email_hash column if it doesn't exist
try {
$db->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";
}
// 4. Create user_company_assignments table
try {
$db->exec("CREATE TABLE IF NOT EXISTS user_company_assignments (
id INT AUTO_INCREMENT,
user_id VARCHAR(100) NOT NULL,
company_id VARCHAR(100) NOT NULL,
assigned_by VARCHAR(100) NOT NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY uq_user_company (user_id, company_id),
CONSTRAINT fk_uca_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_admin FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "[OK] User_company_assignments table created.\n";
} catch (\Exception $e) {
echo "[SKIP] user_company_assignments table: " . $e->getMessage() . "\n";
}
// 5. Update invoices table to include uploaded_by
try {
$db->exec("ALTER TABLE invoices ADD COLUMN uploaded_by VARCHAR(100) NULL AFTER status");
$db->exec("ALTER TABLE invoices ADD CONSTRAINT fk_inv_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL");
echo "[OK] Updated invoices table with uploaded_by tracker.\n";
} catch (\Exception $e) {
echo "[SKIP] invoices table update: " . $e->getMessage() . "\n";
}
echo "--- Migration Complete ---\n";
```
## File: `seed_super_admin.php`
```php
<?php
/**
* Seed Super Admin Script
* Run this from CLI: php scripts/seed_super_admin.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "--- Starting Super Admin Seeding ---\n";
try {
$db->beginTransaction();
// 1. We must create a "System Tenant" for the Super Admin to satisfy the Foreign Key constraint
$systemTenantId = '00000000-0000-0000-0000-000000000000';
// Check if system tenant exists
$stmt = $db->prepare("SELECT id FROM tenants WHERE id = ?");
$stmt->execute([$systemTenantId]);
if (!$stmt->fetch()) {
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, 'System Administration', 'system@musadaq.com', 'active', NOW())");
$stmt->execute([$systemTenantId]);
echo "[OK] System Tenant created.\n";
}
// 2. Setup Super Admin details
$adminEmail = 'admin@musadaq.app';
$adminName = 'Hamza';
$adminPassword = env('SEED_ADMIN_PASSWORD', 'password123'); // Default for dev only
// Check if user already exists
$emailHash = hash('sha256', strtolower($adminEmail));
$stmt = $db->prepare("SELECT id FROM users WHERE email_hash = ?");
$stmt->execute([$emailHash]);
if ($stmt->fetch()) {
echo "[INFO] Super Admin already exists with this email.\n";
} else {
$adminId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$encryptedName = Encryption::encrypt($adminName);
$encryptedEmail = Encryption::encrypt($adminEmail);
$passwordHash = password_hash($adminPassword, PASSWORD_DEFAULT);
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, 'super_admin', 1, NOW())");
$stmt->execute([
$adminId,
$systemTenantId,
$encryptedName,
$encryptedEmail,
$emailHash,
$passwordHash
]);
echo "[OK] Super Admin created successfully!\n";
echo "----------------------------------------\n";
echo "Email: $adminEmail\n";
echo "Password: [FROM ENV]\n";
echo "Role: super_admin\n";
echo "----------------------------------------\n";
}
$db->commit();
echo "--- Seeding Complete ---\n";
} catch (\Exception $e) {
$db->rollBack();
echo "[ERROR] Seeding failed: " . $e->getMessage() . "\n";
}
```
## File: `backfill_hashes.php`
```php
<?php
/**
* Phase 2 Backfill: Generate hashes for existing invoices
* Run: php scripts/backfill_hashes.php
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Backfill Invoice Hashes\n";
echo "═══════════════════════════════════════════\n\n";
// 1. Fetch all invoices without a hash
$stmt = $db->query("SELECT id, company_id, supplier_tin, invoice_number, invoice_date FROM invoices WHERE invoice_hash IS NULL");
$invoices = $stmt->fetchAll();
if (empty($invoices)) {
echo "✅ No invoices found needing a hash update.\n";
exit;
}
echo "📦 Found " . count($invoices) . " invoices to process...\n";
$updated = 0;
$skipped = 0;
foreach ($invoices as $inv) {
// Decrypt supplier_tin if it's encrypted (assuming it is based on upload logic)
$rawTin = Encryption::decrypt($inv['supplier_tin'] ?? '') ?: ($inv['supplier_tin'] ?? '');
$invoiceNum = $inv['invoice_number'] ?? '';
$invoiceDate = $inv['invoice_date'] ?? '';
if (empty($rawTin) || empty($invoiceNum) || empty($invoiceDate)) {
echo " ⚠️ Skipping invoice [{$inv['id']}]: Missing critical data for hash calculation.\n";
$skipped++;
continue;
}
// Generate Hash
$rawHashString = $inv['company_id'] . '_' . $rawTin . '_' . $invoiceNum . '_' . $invoiceDate;
$invoiceHash = hash('sha256', strtolower($rawHashString));
try {
$updateStmt = $db->prepare("UPDATE invoices SET invoice_hash = ? WHERE id = ?");
$updateStmt->execute([$invoiceHash, $inv['id']]);
$updated++;
echo " ✅ Updated invoice [{$inv['id']}] | Hash: " . substr($invoiceHash, 0, 8) . "...\n";
} catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
echo " 🚫 Duplicate hash found for invoice [{$inv['id']}]. Skipping update.\n";
$skipped++;
} else {
echo " ❌ Failed to update [{$inv['id']}]: " . $e->getMessage() . "\n";
$skipped++;
}
}
}
echo "\n═══════════════════════════════════════════\n";
echo " Backfill Complete!\n";
echo " ✅ Updated: {$updated} | ⏭️ Skipped/Failed: {$skipped}\n";
echo "═══════════════════════════════════════════\n";
```
## File: `migrate_phase3_mobile.php`
```php
<?php
/**
* Phase 3 Migration: Mobile App Support
* Run: php scripts/migrate_phase3_mobile.php
*
* Adds tables and columns required for:
* - Mobile OTP Authentication
* - Device Management
* - Batch Invoice Upload from Scanner
* - Processing Queue for AI extraction
* - Notifications System
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "═══════════════════════════════════════════\n";
echo " مُصادَق — Phase 3 Migration\n";
echo " Mobile App + Batch Processing Support\n";
echo "═══════════════════════════════════════════\n\n";
// --- Fetch Parent Collation dynamically ---
$stmt = $db->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
<?php
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
$db = Database::getInstance();
echo "--- TENANTS ---\n";
$stmt = $db->query("SELECT * FROM tenants");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
echo "\n--- USERS ---\n";
$stmt = $db->query("SELECT u.id, u.name, u.role, u.tenant_id, t.name as tenant_name FROM users u LEFT JOIN tenants t ON u.tenant_id = t.id");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
echo "\n--- COMPANIES ---\n";
$stmt = $db->query("SELECT * FROM companies");
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
```