Update: 2026-05-03 23:57:27

This commit is contained in:
Hamza-Ayed
2026-05-03 23:57:27 +03:00
parent bef134ea77
commit b6db8da450
4 changed files with 425 additions and 7 deletions

View File

@@ -17,7 +17,10 @@ $data = input();
$errors = Validator::validate($data, [
'name' => 'required',
'email' => 'required|email'
'email' => 'required|email',
'manager_name' => 'required',
'manager_email' => 'required|email',
'manager_password' => 'required'
]);
if ($errors) {
@@ -27,14 +30,51 @@ if ($errors) {
$db = Database::getInstance();
try {
$stmt = $db->prepare("INSERT INTO tenants (name, email, phone, status, created_at) VALUES (?, ?, ?, 'active', NOW())");
$db->beginTransaction();
// Generate Tenant UUID in PHP so we can use it immediately
$tenantId = 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)
);
// 1. Create Tenant
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, phone, status, created_at) VALUES (?, ?, ?, ?, 'active', NOW())");
$stmt->execute([
$tenantId,
$data['name'],
$data['email'],
$data['phone'] ?? null
]);
json_success(null, 'تم إنشاء المكتب بنجاح');
// Generate User UUID
$userId = 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)
);
// Encrypt sensitive user data
$encryptedName = \App\Core\Encryption::encrypt($data['manager_name']);
$encryptedEmail = \App\Core\Encryption::encrypt($data['manager_email']);
$emailHash = hash('sha256', strtolower($data['manager_email']));
// 2. Create Initial Manager (Admin) for this Tenant
$stmtUser = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, 'admin', NOW())");
$stmtUser->execute([
$userId,
$tenantId,
$encryptedName,
$encryptedEmail,
$emailHash,
password_hash($data['manager_password'], PASSWORD_DEFAULT)
]);
$db->commit();
json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح');
} catch (\Exception $e) {
json_error('حدث خطأ أثناء حفظ البيانات', 500);
$db->rollBack();
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
}

View File

@@ -46,11 +46,22 @@ $encryptedName = Encryption::encrypt($data['name']);
$encryptedEmail = Encryption::encrypt($data['email']);
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
// 3. Determine Tenant ID
$tenantId = null;
if ($decoded['role'] === 'super_admin') {
if (empty($data['tenant_id'])) {
json_error('يجب اختيار المكتب المحاسبي', 422);
}
$tenantId = $data['tenant_id'];
} else {
$tenantId = $decoded['tenant_id'];
}
// 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'],
$tenantId,
$encryptedName,
$encryptedEmail,
$emailHash,

View File

@@ -153,6 +153,15 @@
<option value="admin">مدير نظام</option>
</select>
</div>
<div x-show="user?.role === 'super_admin'">
<label class="block text-xs text-gray-500 uppercase mb-1">تعيين لمكتب (للسوبر أدمن فقط)</label>
<select x-model="newUser.tenant_id" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
<option value="">-- اختر المكتب --</option>
<template x-for="t in tenants" :key="t.id">
<option :value="t.id" x-text="t.name"></option>
</template>
</select>
</div>
<div class="pt-4 flex gap-3">
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded font-bold transition">حفظ المستخدم</button>
<button type="button" @click="showAddModal = false" class="px-6 py-3 border border-gray-800 rounded hover:bg-gray-800 transition">إلغاء</button>
@@ -178,6 +187,20 @@
<label class="block text-xs text-gray-500 uppercase mb-1">الهاتف</label>
<input type="text" x-model="newTenant.phone" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<hr class="border-gray-800 my-4">
<h4 class="text-sm font-bold text-gray-400">معلومات مدير المكتب (Admin)</h4>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">اسم المدير</label>
<input type="text" x-model="newTenant.manager_name" required class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">بريد المدير (لتسجيل الدخول)</label>
<input type="email" x-model="newTenant.manager_email" required class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">كلمة المرور</label>
<input type="password" x-model="newTenant.manager_password" required class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 p-3 rounded font-bold transition">حفظ</button>
<button type="button" @click="showAddTenantModal = false" class="flex-1 bg-gray-800 hover:bg-gray-700 p-3 rounded transition">إلغاء</button>
@@ -223,17 +246,22 @@
page: 'dashboard',
users: [],
companies: [],
tenants: [],
stats: { total: 0, pending: 0, approved: 0 },
showAddModal: false,
showAddCompanyModal: false,
newUser: { name: '', email: '', password: '', role: 'employee' },
newUser: { name: '', email: '', password: '', role: 'employee', tenant_id: '' },
newCompany: { name: '', tax_identification_number: '', commercial_registration_number: '', address: '' },
newTenant: { name: '', email: '', phone: '', manager_name: '', manager_email: '', manager_password: '' },
init() {
if (!this.user) window.location.href = '/login.php';
this.loadUsers();
this.loadStats();
this.loadCompanies();
if (this.user.role === 'super_admin') {
this.loadTenants();
}
},
title() {
@@ -277,7 +305,7 @@
const json = await res.json();
if (json.success) {
this.showAddModal = false;
this.newUser = { name: '', email: '', password: '', role: 'employee' };
this.newUser = { name: '', email: '', password: '', role: 'employee', tenant_id: '' };
this.loadUsers();
alert('تم إضافة المستخدم بنجاح');
} else {
@@ -305,6 +333,34 @@
}
},
async loadTenants() {
const res = await fetch('/api/v1/tenants', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
});
const json = await res.json();
if (json.success) this.tenants = json.data;
},
async createTenant() {
const res = await fetch('/api/v1/tenants/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
},
body: JSON.stringify(this.newTenant)
});
const json = await res.json();
if (json.success) {
this.showAddTenantModal = false;
this.newTenant = { name: '', email: '', phone: '', manager_name: '', manager_email: '', manager_password: '' };
this.loadTenants();
alert('تم إنشاء المكتب وتعيين المدير بنجاح!');
} else {
alert(json.message);
}
},
logout() {
localStorage.clear();
window.location.href = '/login.php';

View File

@@ -0,0 +1,311 @@
# 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: `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: `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";
```