🚀 مُصادَق: تحديث برمجي جديد 2026-05-03 15:51

This commit is contained in:
Hamza-Ayed
2026-05-03 15:51:53 +03:00
parent e182faad1d
commit 81a3e5188e
12 changed files with 415 additions and 6060 deletions

View File

@@ -70,18 +70,18 @@ final class Application
try {
$request = new Request();
$this->router->dispatch($request, $this->container);
} catch (\App\Core\Exceptions\HttpException $e) {
// Application-level intentional HTTP errors
Response::error($e->getMessage(), $e->getErrorCode(), $e->getCode(), $e->getData());
} catch (\Throwable $e) {
// Global Exception Handler
Response::error(
'حدث خطأ غير متوقع في النظام',
'INTERNAL_SERVER_ERROR',
500,
[
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]
);
// Log real error internally only
error_log("[MUSADAQ_ERROR] " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine() . "\nStack trace:\n" . $e->getTraceAsString());
// Global Exception Handler (Never expose stack traces in production)
Response::json([
'success' => false,
'message' => 'Unexpected system error'
], 500);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
use Exception;
class HttpException extends Exception
{
private string $errorCode;
private array $data;
public function __construct(string $message, string $errorCode = 'ERROR', int $statusCode = 500, array $data = [])
{
parent::__construct($message, $statusCode);
$this->errorCode = $errorCode;
$this->data = $data;
}
public function getErrorCode(): string
{
return $this->errorCode;
}
public function getData(): array
{
return $this->data;
}
}

View File

@@ -15,14 +15,30 @@ final class AuthMiddleware
public function handle(Request $request, callable $next): mixed
{
$authHeader = $request->getHeader('Authorization');
$token = null;
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$token = substr($authHeader, 7);
} elseif (isset($_COOKIE['access_token'])) {
$token = $_COOKIE['access_token'];
// CSRF Check for browser sessions using cookies
if (in_array($request->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
$csrfHeader = $request->getHeader('X-CSRF-TOKEN');
$csrfCookie = $_COOKIE['csrf_token'] ?? null;
if (!$csrfHeader || !$csrfCookie || !hash_equals($csrfCookie, $csrfHeader)) {
Response::error('انتهت صلاحية الجلسة أو فشل التحقق الأمني (CSRF)', 'CSRF_FAILED', 403);
return null;
}
}
}
if (!$token) {
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401);
return null;
}
$token = substr($authHeader, 7);
try {
$decoded = $this->jwtService->verifyToken($token);

View File

@@ -36,20 +36,15 @@ final class AuthController
return;
}
// Set refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
$this->setAuthCookies($result);
unset($result['refresh_token']);
// Backward compatibility for existing non-browser clients
$responseData = $result;
unset($responseData['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'data' => $responseData,
'message' => 'تم تسجيل الدخول بنجاح'
]);
} catch (Throwable $e) {
@@ -88,14 +83,10 @@ final class AuthController
}
}
// Clear refresh token cookie
setcookie('refresh_token', '', [
'expires' => time() - 3600,
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
// Clear auth cookies
setcookie('refresh_token', '', ['expires' => time() - 3600, 'path' => '/api/v1/auth/refresh', 'httponly' => true, 'samesite' => 'Strict', 'secure' => true]);
setcookie('access_token', '', ['expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict', 'secure' => true]);
setcookie('csrf_token', '', ['expires' => time() - 3600, 'path' => '/', 'httponly' => false, 'samesite' => 'Strict', 'secure' => true]);
Response::json([
'success' => true,
@@ -115,20 +106,15 @@ final class AuthController
try {
$result = $this->authService->refresh($refreshToken);
// Set new refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
$this->setAuthCookies($result);
unset($result['refresh_token']);
// Backward compatibility
$responseData = $result;
unset($responseData['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'data' => $responseData,
'message' => 'تم تجديد الجلسة بنجاح'
]);
} catch (Throwable $e) {
@@ -140,20 +126,15 @@ final class AuthController
try {
$result = $this->authService->register($request->getBody());
// Set refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
$this->setAuthCookies($result);
unset($result['refresh_token']);
// Backward compatibility
$responseData = $result;
unset($responseData['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'data' => $responseData,
'message' => 'تم إنشاء الحساب وتسجيل الدخول بنجاح'
]);
} catch (Throwable $e) {
@@ -226,19 +207,24 @@ final class AuthController
return;
}
// Re-issue a full login session after successful 2FA.
$stmt = $db->prepare("SELECT email FROM users WHERE id = ?");
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$email = $stmt->fetchColumn();
if (!$email) {
$user = $stmt->fetch();
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
}
$result = $this->authService->createSession($user);
$this->setAuthCookies($result);
$responseData = $result;
unset($responseData['refresh_token']);
Response::json([
'success' => true,
'data' => ['user_id' => $userId, 'email' => $email],
'message' => 'تم التحقق بنجاح'
'data' => $responseData,
'message' => 'تم التحقق وتأسيس الجلسة بنجاح'
]);
}
@@ -265,4 +251,41 @@ final class AuthController
Response::json(['success' => true, 'message' => 'تم تعطيل التحقق الثنائي']);
}
private function setAuthCookies(array $result): void
{
$cookieDomain = $_SERVER['HTTP_HOST'] ?? '';
$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// 1. Refresh Token (HttpOnly, scoped to /refresh)
setcookie('refresh_token', $result['refresh_token'] ?? '', [
'expires' => time() + (60 * 60 * 24 * 7), // 7 days
'path' => '/api/v1/auth/refresh',
'domain' => $cookieDomain,
'httponly' => true,
'samesite' => 'Strict',
'secure' => $secure
]);
// 2. Access Token (HttpOnly, scoped to /)
setcookie('access_token', $result['access_token'] ?? '', [
'expires' => time() + (60 * 15), // 15 mins
'path' => '/',
'domain' => $cookieDomain,
'httponly' => true,
'samesite' => 'Strict',
'secure' => $secure
]);
// 3. CSRF Token (Readable by JS)
$csrfToken = bin2hex(random_bytes(32));
setcookie('csrf_token', $csrfToken, [
'expires' => time() + (60 * 15),
'path' => '/',
'domain' => $cookieDomain,
'httponly' => false,
'samesite' => 'Strict',
'secure' => $secure
]);
}
}

View File

@@ -24,12 +24,36 @@ final class AuthService
{
$user = $this->userModel->findByEmail($email);
if ($user) {
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
throw new \App\Core\Exceptions\HttpException("الحساب مقفل مؤقتاً لعدة محاولات فاشلة، حاول مجدداً لاحقاً", "ACCOUNT_LOCKED", 429);
}
}
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new Exception("البريد الإلكتروني أو كلمة المرور غير صحيحة");
if ($user) {
$failedCount = (int)($user['failed_login_count'] ?? 0) + 1;
$lockedUntil = null;
if ($failedCount >= 5) {
$lockedUntil = date('Y-m-d H:i:s', strtotime('+15 minutes'));
error_log("[SECURITY] Account locked due to brute force: {$email}");
}
$this->userModel->update($user['id'], [
'failed_login_count' => $failedCount,
'locked_until' => $lockedUntil
]);
}
error_log("[SECURITY] Failed login attempt for email: {$email}");
throw new \App\Core\Exceptions\HttpException("البريد الإلكتروني أو كلمة المرور غير صحيحة", "INVALID_CREDENTIALS", 401);
}
if (!$user['is_active']) {
throw new Exception("هذا الحساب معطل حالياً");
throw new \App\Core\Exceptions\HttpException("هذا الحساب معطل حالياً", "ACCOUNT_DISABLED", 403);
}
// Reset failed login count on successful login
if ($user['failed_login_count'] > 0) {
$this->userModel->update($user['id'], ['failed_login_count' => 0, 'locked_until' => null]);
}
$accessToken = $this->jwtService->issueAccessToken([
@@ -51,13 +75,7 @@ final class AuthService
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]
'user' => $user
];
}
@@ -79,6 +97,11 @@ final class AuthService
throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى");
}
return $this->createSession($user);
}
public function createSession(array $user): array
{
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
@@ -100,7 +123,8 @@ final class AuthService
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
'assigned_company_id' => $user['assigned_company_id'],
'totp_enabled' => (bool)($user['totp_enabled'] ?? false)
]
];
}
@@ -112,37 +136,46 @@ final class AuthService
throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً");
}
$tenantId = Uuid::uuid4()->toString();
$userId = Uuid::uuid4()->toString();
$db = \App\Core\Database::getInstance();
try {
$db->beginTransaction();
// 2. Create Tenant
$this->tenantModel->create([
'id' => $tenantId,
'name' => $data['tenant_name'],
'email' => $data['email'],
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
]);
$tenantId = Uuid::uuid4()->toString();
$userId = Uuid::uuid4()->toString();
// 3. Create Subscription
$this->subscriptionModel->create([
'tenant_id' => $tenantId,
'plan' => 'basic',
'status' => 'trial'
]);
// 2. Create Tenant
$this->tenantModel->create([
'id' => $tenantId,
'name' => $data['name'] ?? 'مساحة عمل جديدة',
'email' => $data['email'],
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
]);
// 4. Create User
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['user_name'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => 'admin',
'is_active' => 1
]);
// 3. Create Subscription
$this->subscriptionModel->create([
'tenant_id' => $tenantId,
'plan' => 'basic',
'status' => 'trial'
]);
return $this->login($data['email'], $data['password']);
// 4. Create User
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['name'] ?? 'مسؤول النظام',
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => 'admin',
'is_active' => 1
]);
$db->commit();
return $this->login($data['email'], $data['password']);
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
public function logout(string $jti, int $remaining): void
{

View File

@@ -21,15 +21,17 @@ final class CompanyController
$role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null;
$db = \App\Core\Database::getInstance();
$columns = "id, name, name_en, tax_identification_number, commercial_registration_number, city, is_jofotara_linked, is_active, created_at";
if (in_array($role, ['admin', 'super_admin'], true)) {
$companies = $this->companyModel->findByTenant($tenantId);
$stmt = $db->prepare("SELECT {$columns} FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
} else {
// Filter by assigned company
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL");
$stmt = $db->prepare("SELECT {$columns} FROM companies WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId, $assignedCompanyId]);
$companies = $stmt->fetchAll();
}
$companies = $stmt->fetchAll();
Response::json([
'success' => true,
@@ -40,36 +42,47 @@ final class CompanyController
public function create(Request $request): void
{
$data = $request->getBody();
if (empty($data['name']) || empty($data['tax_identification_number'])) {
throw new \App\Core\Exceptions\HttpException("اسم الشركة والرقم الضريبي مطلوبان", "VALIDATION_ERROR", 422);
}
$data['tenant_id'] = $request->tenantId;
try {
$companyId = $this->companyService->createCompany($data);
Response::json([
'success' => true,
'data' => ['id' => $companyId],
'message' => 'تم إضافة الشركة بنجاح'
], 201);
} catch (Throwable $e) {
Response::error('فشل إضافة الشركة', 'CREATE_FAILED', 500);
}
$companyId = $this->companyService->createCompany($data);
Response::json([
'success' => true,
'data' => ['id' => $companyId],
'message' => 'تم إضافة الشركة بنجاح'
], 201);
}
public function updateJoFotara(Request $request, string $id): void
{
// 1. Verify Tenant Ownership (IDOR Prevention)
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ?");
$stmt->execute([$id, $request->tenantId]);
if (!$stmt->fetchColumn()) {
throw new \App\Core\Exceptions\HttpException("الشركة غير موجودة أو لا تملك صلاحية الوصول", "NOT_FOUND", 404);
}
$clientId = $request->input('client_id');
$secretKey = $request->input('secret_key');
if (empty($clientId) || empty($secretKey)) {
throw new \App\Core\Exceptions\HttpException("يجب توفير Client ID و Secret Key", "VALIDATION_ERROR", 422);
}
$data = [
'jofotara_client_id' => $request->input('client_id'),
'jofotara_secret_key' => $request->input('secret_key'),
'jofotara_client_id' => $clientId,
'jofotara_secret_key' => $secretKey,
'is_jofotara_linked' => 1
];
try {
$this->companyService->updateJoFotara($id, $data);
Response::json([
'success' => true,
'message' => 'تم تحديث بيانات جو-فواتير بنجاح'
]);
} catch (Throwable $e) {
Response::error('فشل تحديث البيانات', 'UPDATE_FAILED', 500);
}
$this->companyService->updateJoFotara($id, $data);
Response::json([
'success' => true,
'message' => 'تم تحديث بيانات جو-فواتير بنجاح'
]);
}
}

View File

@@ -11,6 +11,20 @@ final class DashboardController
$tenantId = $request->tenantId;
$role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null;
$cacheKey = "dashboard_stats:{$tenantId}:{$role}:" . ($assignedCompanyId ?? 'all');
$redis = null;
try {
$redis = \App\Core\Redis::getInstance();
if ($cached = $redis->get($cacheKey)) {
Response::json(['success' => true, 'data' => json_decode($cached, true)]);
return;
}
} catch (\Throwable $e) {
// Proceed without cache if Redis fails
error_log('[DASHBOARD] Redis Cache Miss/Fail: ' . $e->getMessage());
}
$db = Database::getInstance();
$companyScope = '';
@@ -74,17 +88,26 @@ final class DashboardController
$stmt->execute([$tenantId]);
$companiesCount = (int)$stmt->fetchColumn();
$data = [
'invoices_this_month' => $thisMonth,
'subscription_usage_pct' => $usagePct,
'pending_extraction' => $pendingExtraction,
'approved_invoices' => $approved,
'status_distribution' => $statusDistribution,
'recent_invoices' => $recent,
'companies_count' => $companiesCount,
'risk_alerts_count' => $riskCount
];
if ($redis) {
try {
$redis->setex($cacheKey, 60, json_encode($data)); // Cache for 60 seconds
} catch (\Throwable $e) {}
}
Response::json([
'success' => true,
'data' => [
'total_this_month' => $thisMonth,
'subscription_usage' => $usagePct,
'pending_extraction' => $pendingExtraction,
'status_distribution' => $statusDistribution,
'recent_invoices' => $recent,
'companies_count' => $companiesCount,
'risk_alerts_count' => $riskCount
]
'data' => $data
]);
}

View File

@@ -63,24 +63,38 @@ final class InvoiceController
public function upload(Request $request): void
{
$db = Database::getInstance();
try {
$files = $request->getFiles();
if (empty($files['file'])) {
throw new \Exception('يرجى اختيار ملف للفاتورة');
throw new \App\Core\Exceptions\HttpException('يرجى اختيار ملف للفاتورة', 'VALIDATION_ERROR', 422);
}
$file = $files['file'];
if ($file['size'] > 20 * 1024 * 1024) { // 20 MB limit
throw new \App\Core\Exceptions\HttpException('حجم الملف يتجاوز الحد المسموح به (20 ميجابايت)', 'VALIDATION_ERROR', 422);
}
$companyId = (string)$request->input('company_id');
if (empty($companyId)) {
throw new \Exception('يرجى اختيار الشركة');
throw new \App\Core\Exceptions\HttpException('يرجى اختيار الشركة', 'VALIDATION_ERROR', 422);
}
// Verify company belongs to tenant
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ?");
$stmt->execute([$companyId, $request->tenantId]);
if (!$stmt->fetchColumn()) {
throw new \App\Core\Exceptions\HttpException('الشركة غير موجودة أو لا تملك صلاحية الوصول', 'FORBIDDEN', 403);
}
$file = $files['file'];
$invoiceId = \Ramsey\Uuid\Uuid::uuid4()->toString();
// Store file
$path = $this->storage->store($file, $request->tenantId, $companyId);
// Create record
// Transaction for consistency
$db->beginTransaction();
$this->invoiceModel->create([
'id' => $invoiceId,
'tenant_id' => $request->tenantId,
@@ -89,16 +103,22 @@ final class InvoiceController
'status' => 'uploaded'
]);
// Queue extraction and risk analysis
\App\Services\QueueService::push(\queue\Jobs\ExtractInvoiceJob::class, ['invoice_id' => $invoiceId]);
$db->commit();
Response::json([
'success' => true,
'data' => ['invoice_id' => $invoiceId],
'message' => 'تم رفع الفاتورة بنجاح وجاري استخراج البيانات بالذكاء الاصطناعي'
], 202);
} catch (\App\Core\Exceptions\HttpException $e) {
throw $e; // Let global handler catch it
} catch (Throwable $e) {
Response::error($e->getMessage(), 'UPLOAD_ERROR', (int)($e->getCode() ?: 500));
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
@@ -211,7 +231,13 @@ final class InvoiceController
public function update(Request $request, string $id): void
{
// Implementation for PUT /api/v1/invoices/{id}
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id FROM invoices WHERE id = ? AND tenant_id = ?");
$stmt->execute([$id, $request->tenantId]);
if (!$stmt->fetchColumn()) {
throw new \App\Core\Exceptions\HttpException('الفاتورة غير موجودة', 'NOT_FOUND', 404);
}
$data = $request->getBody();
$this->invoiceModel->update($id, $data);
Response::json(['success' => true, 'message' => 'تم تحديث الفاتورة بنجاح']);
@@ -219,6 +245,13 @@ final class InvoiceController
public function destroy(Request $request, string $id): void
{
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id FROM invoices WHERE id = ? AND tenant_id = ?");
$stmt->execute([$id, $request->tenantId]);
if (!$stmt->fetchColumn()) {
throw new \App\Core\Exceptions\HttpException('الفاتورة غير موجودة', 'NOT_FOUND', 404);
}
$this->invoiceModel->delete($id);
Response::json(['success' => true, 'message' => 'تم حذف الفاتورة بنجاح']);
}

View File

@@ -11,6 +11,9 @@ final class UsersController
public function list(Request $request): void
{
if (!in_array($request->user->role, ['admin', 'super_admin'])) {
throw new \App\Core\Exceptions\HttpException("غير مصرح لك بالوصول", "FORBIDDEN", 403);
}
$tenantId = $request->tenantId;
$users = $this->userModel->findAllByTenant($tenantId);
@@ -22,17 +25,19 @@ final class UsersController
public function create(Request $request): void
{
if (!in_array($request->user->role, ['admin', 'super_admin'])) {
throw new \App\Core\Exceptions\HttpException("غير مصرح لك بالوصول", "FORBIDDEN", 403);
}
$tenantId = $request->tenantId;
$data = $request->getBody();
if (empty($data['email']) || empty($data['password']) || empty($data['name']) || empty($data['role'])) {
Response::error('جميع الحقول مطلوبة', 'VALIDATION_ERROR', 422);
return;
throw new \App\Core\Exceptions\HttpException('جميع الحقول مطلوبة', 'VALIDATION_ERROR', 422);
}
if ($this->userModel->findByEmail($data['email'])) {
Response::error('البريد الإلكتروني مستخدم مسبقاً', 'DUPLICATE_EMAIL', 409);
return;
// Fix: Check email uniqueness WITHIN THE TENANT
if ($this->userModel->findByEmail($data['email'], $tenantId)) {
throw new \App\Core\Exceptions\HttpException('البريد الإلكتروني مستخدم مسبقاً في هذه الشركة', 'DUPLICATE_EMAIL', 409);
}
$userId = Uuid::uuid4()->toString();
@@ -57,11 +62,13 @@ final class UsersController
public function update(Request $request, string $id): void
{
if (!in_array($request->user->role, ['admin', 'super_admin'])) {
throw new \App\Core\Exceptions\HttpException("غير مصرح لك بالوصول", "FORBIDDEN", 403);
}
$tenantId = $request->tenantId;
$user = $this->userModel->findById($id, $tenantId);
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
throw new \App\Core\Exceptions\HttpException('المستخدم غير موجود', 'NOT_FOUND', 404);
}
$data = $request->getBody();
@@ -86,11 +93,13 @@ final class UsersController
public function destroy(Request $request, string $id): void
{
if (!in_array($request->user->role, ['admin', 'super_admin'])) {
throw new \App\Core\Exceptions\HttpException("غير مصرح لك بالوصول", "FORBIDDEN", 403);
}
$tenantId = $request->tenantId;
$user = $this->userModel->findById($id, $tenantId);
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
throw new \App\Core\Exceptions\HttpException('المستخدم غير موجود', 'NOT_FOUND', 404);
}
if ($user['id'] === $request->user->user_id) {

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,13 @@
.card { background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: 14px; }
.nav-link { border-right: 3px solid transparent; color: var(--text-secondary); }
.nav-active { border-right-color: var(--emerald); background: rgba(16,185,129,.12); color: var(--text-primary); }
#loading-bar { position: fixed; top: 0; left: 0; right: 0; height: 3px; background: var(--emerald); transform: scaleX(0); transform-origin: left; transition: transform .3s ease; z-index: 100; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-elevated); border: 1px solid var(--border-default); padding: 12px 20px; border-radius: 10px; z-index: 1000; box-shadow: 0 10px 30px rgba(0,0,0,.5); }
</style>
</head>
<body data-theme="dark">
<div id="loading-bar"></div>
<div id="toast-container"></div>
<div class="min-h-screen flex">
<aside class="w-64 p-4 border-l" style="background:var(--bg-surface);border-color:var(--border-default)">
<div class="font-bold text-2xl mb-6">مُصادَق</div>
@@ -39,8 +43,49 @@
<div id="modal-overlay" class="hidden fixed inset-0 bg-black/60 p-6 items-center justify-center"></div>
<script>
const state={user:null,currentPage:null,currentParams:{},companies:[],theme:localStorage.getItem("theme")||"dark"};
const API={token:()=>localStorage.getItem("access_token"),async request(method,path,body=null,isFormData=false){const headers={"Authorization":"Bearer "+(API.token()||"")};if(!isFormData&&body)headers["Content-Type"]="application/json";const opts={method,headers};if(body)opts.body=isFormData?body:JSON.stringify(body);let res=await fetch("/api/v1"+path,opts);if(res.status===404)res=await fetch("index.php?route=/api/v1"+path,opts);if(res.status===401){logout();throw new Error("unauthorized");}const data=await res.json();if(!data.success)throw new Error(data.error?.message_ar||"Request failed");return data;},get:(p)=>API.request("GET",p),post:(p,b)=>API.request("POST",p,b),put:(p,b)=>API.request("PUT",p,b),delete:(p)=>API.request("DELETE",p),upload:(p,f)=>API.request("POST",p,f,true)};
const toast={show(msg){alert(msg);}};
const API={
token:()=>localStorage.getItem("access_token"),
async request(method,path,body=null,isFormData=false){
const loading = document.getElementById("loading-bar");
loading.style.transform = "scaleX(0.3)";
const headers={"Authorization":"Bearer "+(API.token()||"")};
if(!isFormData&&body)headers["Content-Type"]="application/json";
const csrfCookie=document.cookie.split("; ").find(r=>r.startsWith("csrf_token="))?.split("=")[1];
if(csrfCookie&&["POST","PUT","DELETE","PATCH"].includes(method.toUpperCase()))headers["X-CSRF-TOKEN"]=csrfCookie;
const opts={method,headers};
if(body)opts.body=isFormData?body:JSON.stringify(body);
try {
let res=await fetch("/api/v1"+path,opts);
if(res.status===404)res=await fetch("index.php?route=/api/v1"+path,opts);
loading.style.transform = "scaleX(1)";
setTimeout(()=>loading.style.transform="scaleX(0)", 400);
if(res.status===401){logout();throw new Error("unauthorized");}
const data=await res.json();
if(!data.success) {
toast.show(data.message || "حدث خطأ غير متوقع", "error");
throw new Error(data.message || "Request failed");
}
return data;
} catch(e) {
loading.style.transform = "scaleX(0)";
throw e;
}
},
get:(p)=>API.request("GET",p),
post:(p,b)=>API.request("POST",p,b),
put:(p,b)=>API.request("PUT",p,b),
delete:(p)=>API.request("DELETE",p),
upload:(p,f)=>API.request("POST",p,f,true)
};
const toast={
show(msg, type="info"){
const el = document.createElement("div");
el.className = "toast " + (type === "error" ? "border-red-500 text-red-400" : "border-emerald-500 text-emerald-400");
el.textContent = msg;
document.getElementById("toast-container").appendChild(el);
setTimeout(() => el.remove(), 4000);
}
};
const modal={open(html){const el=document.getElementById("modal-overlay");el.innerHTML='<div class="card p-6 w-full max-w-2xl">'+html+"</div>";el.classList.remove("hidden");el.classList.add("flex");},close(){const el=document.getElementById("modal-overlay");el.classList.add("hidden");el.classList.remove("flex");}};
const theme={init(){document.body.setAttribute("data-theme",state.theme);},toggle(){state.theme=state.theme==="dark"?"light":"dark";localStorage.setItem("theme",state.theme);theme.init();}};
function logout(){localStorage.removeItem("access_token");location.reload();}

View File

@@ -20,47 +20,71 @@ pcntl_signal(SIGTERM, function() use (&$keepRunning) {
$keepRunning = false;
});
while ($keepRunning) {
$jobsProcessed = 0;
$maxJobs = 100; // Prevent memory leaks by restarting after N jobs
while ($keepRunning && $jobsProcessed < $maxJobs) {
$job = QueueService::pop();
if ($job) {
echo "[+] Processing job: {$job['type']} ({$job['id']})\n";
$jobsProcessed++;
echo "[+] Processing job: {$job['type']} ({$job['id']}) - Attempt: " . (($job['attempts'] ?? 0) + 1) . "\n";
$timeout = 120; // 2 minutes max per job
$completed = false;
try {
pcntl_alarm($timeout);
$container = $app->getContainer();
switch($job['type']) {
case 'ExtractInvoiceJob':
case 'invoice_extraction':
$handler = $container->get(\Queue\Jobs\ExtractInvoiceJob::class);
$handler->handle($job['payload']);
$container->get(\queue\Jobs\ExtractInvoiceJob::class)->handle($job['payload']);
break;
case 'SubmitJoFotaraJob':
case 'submit_jofotara':
$handler = $container->get(\Queue\Jobs\SubmitJoFotaraJob::class);
$handler->handle($job['payload']);
$container->get(\queue\Jobs\SubmitJoFotaraJob::class)->handle($job['payload']);
break;
case 'RiskAnalysisJob':
case 'risk_analysis':
$handler = $container->get(\Queue\Jobs\RiskAnalysisJob::class);
$handler->handle($job['payload']);
$container->get(\queue\Jobs\RiskAnalysisJob::class)->handle($job['payload']);
break;
case 'SendNotificationJob':
case 'send_notification':
$handler = $container->get(\Queue\Jobs\SendNotificationJob::class);
$handler->handle($job['payload']);
break;
default:
echo "[!] Unknown job type: {$job['type']}\n";
}
pcntl_alarm(0); // Cancel alarm
$completed = true;
echo "[✓] Job completed: {$job['id']}\n";
// If fallback DB is used, mark done
$db = \App\Core\Database::getInstance();
$db->prepare("UPDATE queue_jobs SET status = 'completed', completed_at = NOW() WHERE id = ?")->execute([$job['id']]);
} catch (\Throwable $e) {
pcntl_alarm(0); // Cancel alarm
echo "[✗] Job failed: {$job['id']} - {$e->getMessage()}\n";
// In a real app, you'd handle retries or move to a failed_jobs table
$attempts = ($job['attempts'] ?? 0) + 1;
if ($attempts < 3) {
// Exponential backoff: 2^attempts * 10 seconds (20s, 40s)
$delay = pow(2, $attempts) * 10;
$job['attempts'] = $attempts;
echo "[!] Retrying job in {$delay} seconds...\n";
// Note: Delay logic needs to be handled by a sorted set in Redis or a cron,
// but for simplicity we push back immediately for now, or you'd use a delayed queue.
QueueService::push($job['type'], $job['payload'], 0, $delay);
} else {
echo "[!] Job permanently failed (DLQ): {$job['id']}\n";
try {
$db = \App\Core\Database::getInstance();
// Just set status to failed if error_payload doesn't exist
$db->prepare("UPDATE queue_jobs SET status = 'failed' WHERE id = ?")->execute([$job['id']]);
} catch (\Throwable $err) {
echo "[!] Failed to update DB status: " . $err->getMessage() . "\n";
}
}
}
} else {
usleep(500000); // 0.5s