From ca4a7c2e701c3bb432eb0ee1f3712f5c8c43cd11 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 30 Jul 2026 02:27:45 +0300 Subject: [PATCH] Update: 2026-07-30 02:27:45 --- .DS_Store | Bin 6148 -> 8196 bytes app/Core/AI.php | 85 +++- app/Middleware/AuthMiddleware.php | 7 + app/Middleware/HmacMiddleware.php | 184 +++++-- app/Middleware/QuotaMiddleware.php | 54 +- app/Services/InvoiceProcessor.php | 471 +++++++++++++----- app/Services/NotificationService.php | 184 ++++++- app/cron/process_batches.php | 48 +- app/helpers/helpers.php | 3 +- app/middleware/AuthMiddleware.php | 7 + app/middleware/HmacMiddleware.php | 184 +++++-- app/middleware/QuotaMiddleware.php | 54 +- app/modules_app/auth/login.php | 34 +- app/modules_app/auth/logout.php | 23 +- app/modules_app/auth/mobile_request_otp.php | 15 +- app/modules_app/auth/mobile_verify_otp.php | 19 +- app/modules_app/auth/refresh.php | 106 +++- app/modules_app/auth/register_device.php | 8 + app/modules_app/batches/create.php | 23 +- app/modules_app/batches/finalize.php | 30 +- app/modules_app/batches/process_worker.php | 38 -- app/modules_app/batches/status.php | 14 +- app/modules_app/batches/upload_image.php | 35 +- app/modules_app/invoices/upload.php | 43 +- app/modules_app/users/create.php | 15 +- musadaq-app/android/app/build.gradle.kts | 10 + .../android/app/src/main/AndroidManifest.xml | 33 +- .../kotlin/com/musadaq/app/MainActivity.kt | 230 ++++++++- .../com/musadaq/app/MusadaqWidgetProvider.kt | 107 ++++ musadaq-app/android/settings.gradle.kts | 2 +- .../MusadaqLiveActivity.swift | 88 +--- .../MusadaqLiveActivityBundle.swift | 51 +- .../MusadaqLiveActivityControl.swift | 58 +-- musadaq-app/ios/Podfile.lock | 73 ++- musadaq-app/ios/Runner/AppDelegate.swift | 221 ++++++++ musadaq-app/ios/Runner/Info.plist | 8 + musadaq-app/ios/Runner/Runner.entitlements | 5 + musadaq-app/lib/core/network/api_config.dart | 9 + musadaq-app/lib/core/network/dio_client.dart | 3 +- .../lib/core/network/hmac_interceptor.dart | 145 +++++- .../core/services/invoice_upload_service.dart | 199 ++++++-- .../core/services/live_activity_service.dart | 127 +++++ .../services/push_notification_service.dart | 180 ++++++- .../services/upload_progress_service.dart | 124 ++++- .../lib/core/storage/secure_storage.dart | 21 + .../auth/controllers/auth_controller.dart | 18 +- .../main_shell/views/main_shell_view.dart | 82 ++- .../controllers/scanner_controller.dart | 314 +++++++++--- musadaq-app/lib/main.dart | 18 +- musadaq-app/pubspec.lock | 40 ++ musadaq-app/pubspec.yaml | 3 + .../create_test_account.php | 24 +- .../legacy_migrations}/migrate_008.php | 13 +- .../legacy_migrations}/migrate_db.php | 11 +- .../legacy_migrations}/migrate_phase2.php | 23 +- scripts/migrate_queue_hardening.php | 176 +++++++ 56 files changed, 3391 insertions(+), 709 deletions(-) delete mode 100644 app/modules_app/batches/process_worker.php create mode 100644 musadaq-app/android/app/src/main/kotlin/com/musadaq/app/MusadaqWidgetProvider.kt create mode 100644 musadaq-app/lib/core/network/api_config.dart create mode 100644 musadaq-app/lib/core/services/live_activity_service.dart rename {public => scripts/legacy_migrations}/create_test_account.php (77%) rename {public => scripts/legacy_migrations}/migrate_008.php (73%) rename {public => scripts/legacy_migrations}/migrate_db.php (77%) rename {public => scripts/legacy_migrations}/migrate_phase2.php (77%) create mode 100644 scripts/migrate_queue_hardening.php diff --git a/.DS_Store b/.DS_Store index b7fdd5b306cfc8fa982cac819b93ba0c7b9ec477..df62d0c92d518f7806cc23b54c081547251f029e 100644 GIT binary patch literal 8196 zcmeHM&1(}u6n~qh`LJ5z7xcKIH>;+t6pJ7+M%05S-BJ%KHQR(VxXH%srl~EJh+fo- zhzAi5{sAfo9u#`Dh2l-D6w!khJxC8;MDXC7k0vv_*?K8b=nTxf&Ai{t`{lQ9XEGZA z(9*SBA3zTP#IO>Z=)$H=A%0SQCB%F7qt$RcScN`g+%8poNY$YfPzopolmbctrNBl} z0QYQCVgdU;^Qu-UpcL4Y3Xt_d$4YF9+Xl0A>A;2_0nmoAn-;uIkPEarw<&HL%%TU2 z#)hJ0Lp6TH(AaS7*Euf5ZG%}h95jCT(0F8xpHMU!9p~369JCa(s#OXo1^fz-vwIj0 zLIH~4O6T`GFQ1FPlAXzzmTh8R%(X_Qtgj2lWq)o>e`TWeZa_P81&2Z$<5r;tCR~RD zV1WY_i2;Nc+oMD-i_wSoA1_fZZRRW4EmmtIMSg6#gDCQwL{{TqLk{vXH@YHv5F)RA z#=`dLFO-)L)#4P4lKIe8tpl=9@n{s8`hyyAj~IXDo$=uJ2O7WWZN>F_rFdItycmA# zEVx)rtd3WKo!>FC4F)fs z5{wi%-a0+}?b%PNQ^34p4*eS~^6frF`9|=rku10H85@_fC~|WSSxSD9+wB*7248)m z+&q|;c0m}C2SjxYBgdIEs%6VA7qFjfsZpBeRaDZ&e4mh5!j4ftLgZOretPc7GS7AL zyfo(#wF^c`T(-of zwtA6#8nJE5T)obkqs4rEEV1i^U3Rm@a=uCj6mxlmT)I#!=j?jgu9b5R9cwU09Lrdu zXJ)2B*6kKE0I_vvqr7Z4)b zACHd3-@b3Ml~Aqh4Ms_9X)x8SvjQv!pog6QPX@pLU+1W5I;DV8U?VA@g)>Gbjf_^e zE-$$dcIa5IU?qj%8qCrm*boTmr^j)$%|8tBTIaqLw+&{|gXO=!2vGY!kzMWoM1@UU F{sPwi4N?FA delta 139 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGjUE#6q~50D9Q|y2a9DgB$ovj<>ln(rEg4J z&dA6-`HQgn|null + */ + public static function extractInvoices(string $base64Data, string $mimeType): ?array { $apiKey = env('GEMINI_API_KEY'); if (!$apiKey) { @@ -64,7 +106,7 @@ class AI error_log("AI Error: cURL failed (attempt $attempt): $curlError"); if ($attempt < self::$maxRetries) { $wait = pow(2, $attempt) + rand(1, 3); - echo " Retrying in {$wait}s (cURL error)...\n"; + self::progress(" Retrying in {$wait}s (cURL error)..."); sleep($wait); continue; } @@ -78,7 +120,7 @@ class AI // Retry on 503 (overloaded) or 429 (rate limit) if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) { $wait = pow(2, $attempt) + rand(1, 3); - echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n"; + self::progress(" Gemini $httpCode - retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")..."); sleep($wait); continue; } @@ -106,18 +148,22 @@ class AI return null; } - // If the AI returns an array of invoices, extract the first one - if (isset($data['invoices']) && is_array($data['invoices']) && count($data['invoices']) > 0) { - $data = $data['invoices'][0]; - } - // Track token usage from Gemini response $usage = $result['usageMetadata'] ?? []; if (!empty($usage)) { self::logTokenUsage($usage); } - return $data; + // The prompt asks for {"invoices": [...]}. Return the whole list; a single + // photo can legitimately hold two receipts, and the old code kept only + // $data['invoices'][0] and discarded the rest without a trace. + if (isset($data['invoices']) && is_array($data['invoices'])) { + $invoices = array_values(array_filter($data['invoices'], 'is_array')); + return empty($invoices) ? null : $invoices; + } + + // Model answered with a bare invoice object rather than the wrapper. + return empty($data) ? null : [$data]; } /** @@ -138,16 +184,27 @@ class AI $totalCostUsd = $inputCost + $outputCost; $totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD - $db->prepare(" - INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at) - VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW()) - ")->execute([ + $params = [ $inputTokens, $outputTokens, $totalTokens, round($totalCostUsd, 8), round($totalCostJod, 8), - ]); + ]; + + try { + // Preferred: attribute the cost to a tenant. + $db->prepare(" + INSERT INTO ai_usage_log (id, tenant_id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW()) + ")->execute(array_merge([self::$tenantContext], $params)); + } catch (\PDOException $e) { + // Older deployments have no tenant_id column yet - fall back. + $db->prepare(" + INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at) + VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW()) + ")->execute($params); + } } catch (\Exception $e) { // Never crash the main flow for logging error_log("[AI] Token usage log failed: " . $e->getMessage()); diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php index 385e551..48454d7 100644 --- a/app/Middleware/AuthMiddleware.php +++ b/app/Middleware/AuthMiddleware.php @@ -44,6 +44,13 @@ final class AuthMiddleware exit; } + // Defence in depth for mobile requests: prove the caller also holds the + // per-device secret, so a stolen bearer token on its own is not enough. + // Controlled by HMAC_ENFORCE in .env - see HmacMiddleware. + if (($decoded['source'] ?? null) === 'mobile') { + HmacMiddleware::verify($decoded); + } + return $decoded; } } diff --git a/app/Middleware/HmacMiddleware.php b/app/Middleware/HmacMiddleware.php index f265c92..457e81c 100644 --- a/app/Middleware/HmacMiddleware.php +++ b/app/Middleware/HmacMiddleware.php @@ -1,62 +1,182 @@ a present signature is verified and a bad + * one is rejected, but a missing signature is + * allowed through. Lets older app builds keep + * working while the new build rolls out. + * HMAC_ENFORCE=true -> a signature is mandatory. */ declare(strict_types=1); namespace App\Middleware; -use App\Core\Security; +use App\Core\Database; +use App\Core\Encryption; final class HmacMiddleware { /** - * @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes) + * @param array $decoded The decoded JWT payload from AuthMiddleware::check(). + * @param int $maxAgeSeconds Replay window (default: 5 minutes). */ - public static function verify(int $maxAgeSeconds = 300): void + public static function verify(array $decoded, int $maxAgeSeconds = 300): void { - $headers = getallheaders(); - $signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; - $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; + $enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true'; - // 1. Ensure both headers are present - if (empty($signature) || empty($timestamp)) { - json_error('Missing HMAC signature or timestamp', 401); + $headers = getallheaders(); + $signature = $headers['X-Signature'] ?? $headers['x-signature'] + ?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; + $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; + + // 1. Missing headers: hard fail only when enforcing. + if ($signature === '' || $timestamp === '') { + if ($enforce) { + json_error('Missing request signature', 401); + } + return; } - // 2. Validate timestamp is numeric + // 2. Validate timestamp format. if (!ctype_digit((string)$timestamp)) { json_error('Invalid timestamp format', 401); } - // 3. Replay attack prevention — reject stale requests - $age = abs(time() - (int)$timestamp); - if ($age > $maxAgeSeconds) { - json_error('Request expired. Check your system clock.', 401); + // 3. Replay prevention. The client sends milliseconds; older callers may + // send seconds, so normalise by magnitude rather than guessing. + $ts = (int)$timestamp; + $tsSeconds = $ts > 100000000000 ? intdiv($ts, 1000) : $ts; + + if (abs(time() - $tsSeconds) > $maxAgeSeconds) { + json_error('Request expired. Check your device clock.', 401); } - // 4. Build the expected signature - $body = file_get_contents('php://input'); - $payload = $timestamp . '.' . $body; - $secret = env('HMAC_SECRET_KEY'); + // 4. Look up this device's secret. + $deviceId = $decoded['device_id'] ?? null; + $userId = $decoded['user_id'] ?? null; - if (!$secret || strlen($secret) < 32) { - error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env'); - json_error('Server configuration error', 500); + if (!$deviceId || !$userId) { + if ($enforce) { + json_error('Signed requests require a registered device', 401); + } + return; } - // 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); + $secret = self::deviceSecret((string)$userId, (string)$deviceId); + if ($secret === null) { + if ($enforce) { + json_error('Unknown device. Please sign in again.', 401); + } + return; + } + + // 5. Rebuild the signing payload exactly as the client does. + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $body = file_get_contents('php://input'); + + // index.php accepts both clean URLs (/api/v1/batches/create) and the + // ?route=v1/batches/create form, which produce different REQUEST_URIs for + // the same endpoint. Accept either so the signature does not depend on + // how the deployment happens to rewrite URLs. + $paths = array_unique(array_filter([ + self::requestPath(), + self::normalisePath((string)($_GET['route'] ?? '')), + ])); + + $candidates = []; + foreach ($paths as $path) { + if ($body !== '' && $body !== false) { + $candidates[] = "{$method}:{$path}:{$timestamp}:{$body}"; + } + // GET/multipart requests sign without a body. + $candidates[] = "{$method}:{$path}:{$timestamp}"; + } + + foreach ($candidates as $payload) { + $expected = hash_hmac('sha256', $payload, $secret); + if (hash_equals($expected, strtolower($signature))) { + return; // Verified. + } + } + + error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? '')); + json_error('Invalid request signature', 401); + } + + /** + * The path the client signed. Dio signs options.path, i.e. the endpoint + * relative to the API base URL ("batches/finalize"), without a query string. + */ + private static function requestPath(): string + { + $uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: ''; + return self::normalisePath($uri); + } + + /** + * Strip the leading slash and any deployment prefix so the signed value + * matches what the client hashed. + */ + private static function normalisePath(string $raw): string + { + $path = ltrim(trim($raw), '/'); + + foreach (['api/v1/', 'api/', 'v1/'] as $prefix) { + if (str_starts_with($path, $prefix)) { + $path = substr($path, strlen($prefix)); + break; + } + } + + return $path; + } + + /** + * Decrypt the stored per-device secret, or null if the device is unknown. + */ + private static function deviceSecret(string $userId, string $deviceId): ?string + { + try { + $db = Database::getInstance(); + $stmt = $db->prepare(" + SELECT device_secret FROM user_devices + WHERE user_id = ? AND device_fingerprint = ? AND is_trusted = 1 + LIMIT 1 + "); + $stmt->execute([$userId, $deviceId]); + $stored = $stmt->fetchColumn(); + + if (!$stored) { + return null; + } + + // Legacy rows hold a bcrypt hash, which is one-way and therefore + // unusable for HMAC. Treat those devices as un-signable until the + // user logs in again on the new build. + if (str_starts_with((string)$stored, '$2y$') || str_starts_with((string)$stored, '$2a$')) { + return null; + } + + $plain = Encryption::decrypt((string)$stored); + return $plain === false ? null : $plain; + } catch (\Throwable $e) { + error_log('[HmacMiddleware] device secret lookup failed: ' . $e->getMessage()); + return null; } } } diff --git a/app/Middleware/QuotaMiddleware.php b/app/Middleware/QuotaMiddleware.php index 5875548..5fec022 100644 --- a/app/Middleware/QuotaMiddleware.php +++ b/app/Middleware/QuotaMiddleware.php @@ -21,14 +21,16 @@ final class QuotaMiddleware * * @return array The current subscription data (for UI display) */ - public static function checkInvoiceQuota(string $tenantId): array + public static function checkInvoiceQuota(string $tenantId, int $needed = 1): array { $cacheKey = "quota_sub_{$tenantId}"; $sub = Cache::get($cacheKey); - if ($sub === false || $sub === null) { - $db = Database::getInstance(); + // $db is needed by the auto-reset branch below regardless of whether the + // subscription came from the cache, so resolve it unconditionally. + $db = Database::getInstance(); + if ($sub === false || $sub === null) { // Fetch subscription with plan info $stmt = $db->prepare(" SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod @@ -78,18 +80,30 @@ final class QuotaMiddleware $sub['current_period_start'] = $newStart; $sub['current_period_end'] = $newEnd; + // The cached copy still holds the pre-reset counters. + Cache::delete($cacheKey); + error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}"); } - // Check invoice quota + // Check invoice quota. $needed lets callers reserve a whole batch at once + // instead of only asking "is there room for one more?". $used = (int)$sub['invoices_used_this_month']; $limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility + $needed = max(1, $needed); - if ($used >= $limit) { - json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [ + if (($used + $needed) > $limit) { + $remaining = max(0, $limit - $used); + $message = $remaining === 0 + ? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.' + : 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.'; + + json_error($message, 429, [ 'quota_type' => 'invoices', 'used' => $used, 'limit' => $limit, + 'requested' => $needed, + 'remaining' => $remaining, 'plan' => $sub['plan_id'] ?? 'free', 'plan_name' => $sub['plan_name'] ?? 'مجانية', 'period_end' => $sub['current_period_end'], @@ -99,6 +113,34 @@ final class QuotaMiddleware return $sub; } + /** + * Non-fatal variant of checkInvoiceQuota() for background workers. + * + * Workers must never emit an HTTP response, so this returns a boolean + * instead of calling json_error(). + */ + public static function hasInvoiceQuota(string $tenantId, int $needed = 1): bool + { + try { + $db = Database::getInstance(); + $stmt = $db->prepare(" + SELECT invoices_used_this_month, max_invoices_per_month, status + FROM subscriptions WHERE tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) return false; + if (in_array($sub['status'], ['cancelled', 'past_due'], true)) return false; + + return ((int)$sub['invoices_used_this_month'] + max(1, $needed)) + <= (int)$sub['max_invoices_per_month']; + } catch (\Throwable $e) { + error_log('[QuotaMiddleware] hasInvoiceQuota failed: ' . $e->getMessage()); + return false; + } + } + /** * Increment the monthly invoice counter after a successful upload. */ diff --git a/app/Services/InvoiceProcessor.php b/app/Services/InvoiceProcessor.php index 86e12aa..4e96938 100644 --- a/app/Services/InvoiceProcessor.php +++ b/app/Services/InvoiceProcessor.php @@ -20,6 +20,90 @@ class InvoiceProcessor } } + /** + * Atomically claim a pending queue item. + * + * Two consumers race for the queue (the background task spawned by + * batches/finalize.php and the every-minute cron). A SELECT-then-UPDATE + * would let both see the same row as 'pending' and process it twice, + * producing duplicate invoices and double-charging the tenant's quota. + * A single conditional UPDATE makes the claim exclusive: only the process + * whose UPDATE affected a row owns the item. + * + * @return array|null The claimed item joined with its batch, or null if + * another process got there first / it is exhausted. + */ + private static function claimQueueItem(\PDO $db, int $queueId): ?array + { + $claim = $db->prepare(" + UPDATE invoice_processing_queue + SET status = 'processing', + attempts = attempts + 1, + claimed_at = NOW(), + error_message = NULL + WHERE id = ? + AND status = 'pending' + AND attempts < COALESCE(max_attempts, 3) + "); + $claim->execute([$queueId]); + + if ($claim->rowCount() !== 1) { + return null; + } + + $stmt = $db->prepare(" + SELECT q.*, b.tenant_id AS batch_tenant_id, b.company_id AS batch_company_id, + b.uploaded_by, b.total_images, b.status AS batch_status + FROM invoice_processing_queue q + JOIN invoice_batches b ON q.batch_id = b.id + WHERE q.id = ? + "); + $stmt->execute([$queueId]); + $item = $stmt->fetch(); + + return $item ?: null; + } + + /** + * Release a claimed item back to the queue for a later retry, or bury it as + * permanently failed once max_attempts is exhausted. + * + * Only a permanent failure counts towards the batch's failed_images, so a + * transient AI hiccup does not prematurely "complete" a batch. + */ + private static function failQueueItem(\PDO $db, array $item, string $reason): void + { + $queueId = (int)$item['id']; + $attempts = (int)($item['attempts'] ?? 1); + $maxAttempts = (int)($item['max_attempts'] ?? 3); + $batchId = (string)$item['batch_id']; + + if ($attempts < $maxAttempts) { + $db->prepare(" + UPDATE invoice_processing_queue + SET status = 'pending', error_message = ? + WHERE id = ? + ")->execute([$reason, $queueId]); + + self::log("Queue ID $queueId: will retry ($attempts/$maxAttempts) - $reason"); + return; + } + + $db->prepare(" + UPDATE invoice_processing_queue + SET status = 'failed', error_message = ?, processed_at = NOW() + WHERE id = ? + ")->execute([$reason, $queueId]); + + $db->prepare(" + UPDATE invoice_batches SET failed_images = failed_images + 1 WHERE id = ? + ")->execute([$batchId]); + + self::log("Queue ID $queueId: PERMANENTLY FAILED after $attempts attempt(s) - $reason"); + + self::checkBatchCompletion($batchId); + } + /** * Processes a single invoice queue item by its ID. */ @@ -34,37 +118,41 @@ class InvoiceProcessor return false; } + $item = null; + // Once the invoice is committed the item must never be released back to + // 'pending', or a later worker would re-insert the same invoice. + $committed = false; + try { - // Fetch the queue item and its batch info - $stmt = $db->prepare(" - SELECT q.*, b.tenant_id, b.company_id, b.uploaded_by, b.total_images - FROM invoice_processing_queue q - JOIN invoice_batches b ON q.batch_id = b.id - WHERE q.id = ? AND q.status = 'pending' - "); - $stmt->execute([$queueId]); - $item = $stmt->fetch(); + $item = self::claimQueueItem($db, $queueId); if (!$item) { - self::log("Queue ID $queueId: Not found or not pending. Skipping."); + self::log("Queue ID $queueId: not claimable (already taken, not pending, or attempts exhausted). Skipping."); return false; } - $batchId = $item['batch_id']; - $tenantId = $item['tenant_id']; - $companyId = $item['company_id']; + $batchId = (string)$item['batch_id']; + $tenantId = (string)$item['batch_tenant_id']; + $companyId = (string)$item['batch_company_id']; $userId = $item['uploaded_by']; - $imagePath = $item['image_path']; + $imagePath = (string)$item['image_path']; self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId"); - // Mark as processing - $db->prepare("UPDATE invoice_processing_queue SET status = 'processing' WHERE id = ?")->execute([$queueId]); - - // Check file exists + // Check file exists. A missing file will never appear, so bury it + // immediately rather than burning the remaining retries. if (!file_exists($imagePath)) { self::log("Queue ID $queueId: FILE NOT FOUND: $imagePath"); - $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'File not found' WHERE id = ?")->execute([$queueId]); + $item['attempts'] = $item['max_attempts'] ?? 3; + self::failQueueItem($db, $item, 'File not found'); + return false; + } + + // Refuse to spend AI credit the tenant no longer has. + if (!QuotaMiddleware::hasInvoiceQuota($tenantId)) { + self::log("Queue ID $queueId: tenant $tenantId is out of invoice quota."); + $item['attempts'] = $item['max_attempts'] ?? 3; + self::failQueueItem($db, $item, 'تم استنفاد رصيد الفواتير لهذا الشهر'); return false; } @@ -75,124 +163,92 @@ class InvoiceProcessor $base64Data = base64_encode($fileContent); // AI Extraction (this takes ~5-15 seconds) - $extracted = AI::extractInvoiceData($base64Data, $mimeType); + AI::setTenantContext($tenantId); + $extractedInvoices = AI::extractInvoices($base64Data, $mimeType); + AI::setTenantContext(null); - if (!$extracted) { - self::log("Queue ID $queueId: AI extraction returned NULL (failed)."); - $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'AI failed to extract data from image' WHERE id = ?")->execute([$queueId]); + if (empty($extractedInvoices)) { + self::log("Queue ID $queueId: AI extraction returned nothing."); + self::failQueueItem($db, $item, 'AI failed to extract data from image'); return false; } - self::log("Queue ID $queueId: AI extraction successful. Saving to DB..."); + self::log("Queue ID $queueId: AI extracted " . count($extractedInvoices) . " invoice(s). Saving to DB..."); + + $createdInvoiceIds = []; // Save to database in a transaction $db->beginTransaction(); try { - $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); - - $supplierTin = $extracted['supplier']['tin'] ?? ''; - $invoiceNum = $extracted['invoice_number'] ?? ''; - $invoiceDate = $extracted['invoice_date'] ?? ''; - $validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null; - - $stmt = $db->prepare(" - INSERT INTO invoices ( - id, tenant_id, company_id, uploaded_by, original_file_path, status, - invoice_number, invoice_date, invoice_type, invoice_category, - supplier_tin, supplier_name, supplier_address, - buyer_tin, buyer_name, buyer_national_id, - subtotal, tax_amount, discount_total, grand_total, currency_code, - created_at - ) VALUES ( - ?, ?, ?, ?, ?, 'extracted', - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, - NOW() - ) - "); - - $stmt->execute([ - $invoiceId, $tenantId, $companyId, $userId, $imagePath, - $invoiceNum, $validDate, $extracted['invoice_type'] ?? 'cash', $extracted['invoice_category'] ?? 'simplified', - Encryption::encrypt($supplierTin), Encryption::encrypt($extracted['supplier']['name'] ?? ''), Encryption::encrypt($extracted['supplier']['address'] ?? ''), - Encryption::encrypt($extracted['buyer']['tin'] ?? ''), Encryption::encrypt($extracted['buyer']['name'] ?? ''), Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), - $extracted['subtotal'] ?? 0, $extracted['tax_amount'] ?? 0, $extracted['discount_total'] ?? 0, $extracted['grand_total'] ?? 0, $extracted['currency_code'] ?? 'JOD' - ]); - - // Save invoice line items - if (!empty($extracted['lines'])) { - $lineStmt = $db->prepare(" - INSERT INTO invoice_lines ( - id, invoice_id, line_number, description, - quantity, unit_price, tax_rate, tax_amount, - discount_amount, net_total, line_total, tax_category - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - "); - foreach ($extracted['lines'] as $idx => $line) { - $quantity = (float)($line['quantity'] ?? 1); - $unitPrice = (float)($line['unit_price'] ?? 0); - $taxRate = (float)($line['tax_rate'] ?? 0); - $discount = (float)($line['discount'] ?? $line['discount_amount'] ?? 0); - $subtotal = $quantity * $unitPrice; - $taxAmount = (float)($line['tax_amount'] ?? ($subtotal * $taxRate)); - $netTotal = (float)($line['net_total'] ?? ($line['line_total'] ?? ($subtotal + $taxAmount - $discount))); - - $lineStmt->execute([ - vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)), - $invoiceId, - $line['line_number'] ?? ($idx + 1), - $line['description'] ?? '', - $quantity, - $unitPrice, - $taxRate, - $taxAmount, - $discount, - $netTotal, - $netTotal, // line_total - $line['tax_category'] ?? 'standard' - ]); - } - self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items."); + foreach ($extractedInvoices as $extracted) { + $createdInvoiceIds[] = self::insertInvoice( + $db, + $extracted, + $batchId, + $tenantId, + $companyId, + $userId, + $imagePath + ); } + $primaryInvoiceId = $createdInvoiceIds[0]; + // Mark queue item done - $db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, processed_at = NOW() WHERE id = ?")->execute([$invoiceId, $queueId]); + $db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, error_message = NULL, processed_at = NOW() WHERE id = ?") + ->execute([$primaryInvoiceId, $queueId]); // Update batch progress - $db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?")->execute([$batchId]); - // Increment quota - QuotaMiddleware::incrementInvoiceUsage($tenantId); + $db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?") + ->execute([$batchId]); $db->commit(); - self::log("Queue ID $queueId: ✓ Invoice $invoiceId created and committed."); + $committed = true; + self::log("Queue ID $queueId: OK - invoice(s) " . implode(', ', $createdInvoiceIds) . " committed."); } catch (\Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } self::log("Queue ID $queueId: DB ERROR: " . $e->getMessage()); - try { - $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = ? WHERE id = ?")->execute([$e->getMessage(), $queueId]); - } catch (\Throwable $e2) {} + self::failQueueItem($db, $item, $e->getMessage()); return false; } + // Charge quota only for invoices that are actually committed. This runs + // outside the transaction so a quota-write failure cannot roll back + // (or be rolled back by) the invoice itself. + // + // Wrapped separately: letting this bubble to the outer catch would + // release an ALREADY-COMMITTED item back to 'pending' and the next + // worker would insert the same invoice a second time. + try { + foreach ($createdInvoiceIds as $_) { + QuotaMiddleware::incrementInvoiceUsage($tenantId); + } + } catch (\Throwable $quotaErr) { + self::log("Queue ID $queueId: quota increment failed (invoice already saved): " . $quotaErr->getMessage()); + } + // Check if entire batch is complete self::checkBatchCompletion($batchId); - // Progress/Completion Push + // Progress push (silent data message + Live Activity update) try { - $stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?"); + $stmt = $db->prepare("SELECT total_images, processed_images, failed_images, status, uploaded_by FROM invoice_batches WHERE id = ?"); $stmt->execute([$batchId]); $currentBatch = $stmt->fetch(); if ($currentBatch) { $notifier = new NotificationService(); - // Send data notification with invoice_id for auto-navigation + // 'batch_progress' is the type NotificationService recognises for + // iOS Live Activity / Android Live Update payloads. $notifier->sendDataNotification($currentBatch['uploaded_by'], [ - 'type' => 'invoice_processed', - 'batch_id' => $batchId, - 'invoice_id' => $invoiceId, - 'processed' => $currentBatch['processed_images'], - 'total' => $currentBatch['total_images'] + 'type' => 'batch_progress', + 'batch_id' => $batchId, + 'invoice_id' => $createdInvoiceIds[0], + 'processed' => $currentBatch['processed_images'], + 'failed' => $currentBatch['failed_images'], + 'total' => $currentBatch['total_images'], + 'is_done' => in_array($currentBatch['status'], ['done', 'partial_fail', 'failed'], true) ? 1 : 0, ]); } } catch (\Throwable $pushErr) { @@ -203,42 +259,199 @@ class InvoiceProcessor } catch (\Throwable $e) { self::log("Queue ID $queueId: UNHANDLED EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + // Never leave a claimed row stuck in 'processing' - unless the invoice + // was already committed, in which case retrying would duplicate it. + if ($item !== null && !$committed) { + try { + self::failQueueItem($db, $item, $e->getMessage()); + } catch (\Throwable $e2) { + self::log("Queue ID $queueId: could not release item: " . $e2->getMessage()); + } + } return false; } } + /** + * Insert one extracted invoice (plus its line items) and return its id. + */ + private static function insertInvoice( + \PDO $db, + array $extracted, + string $batchId, + string $tenantId, + string $companyId, + ?string $userId, + string $imagePath + ): string { + $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); + + $supplierTin = $extracted['supplier']['tin'] ?? ''; + $invoiceNum = $extracted['invoice_number'] ?? ''; + $invoiceDate = $extracted['invoice_date'] ?? ''; + $validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null; + + $stmt = $db->prepare(" + INSERT INTO invoices ( + id, tenant_id, company_id, batch_id, uploaded_by, original_file_path, status, + invoice_number, invoice_date, invoice_type, invoice_category, + supplier_tin, supplier_name, supplier_address, + buyer_tin, buyer_name, buyer_national_id, + subtotal, tax_amount, discount_total, grand_total, currency_code, + created_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, 'extracted', + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + NOW() + ) + "); + + $stmt->execute([ + $invoiceId, $tenantId, $companyId, $batchId, $userId, $imagePath, + $invoiceNum, $validDate, $extracted['invoice_type'] ?? 'cash', $extracted['invoice_category'] ?? 'simplified', + Encryption::encrypt($supplierTin), Encryption::encrypt($extracted['supplier']['name'] ?? ''), Encryption::encrypt($extracted['supplier']['address'] ?? ''), + Encryption::encrypt($extracted['buyer']['tin'] ?? ''), Encryption::encrypt($extracted['buyer']['name'] ?? ''), Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), + $extracted['subtotal'] ?? 0, $extracted['tax_amount'] ?? 0, $extracted['discount_total'] ?? 0, $extracted['grand_total'] ?? 0, $extracted['currency_code'] ?? 'JOD' + ]); + + // Save invoice line items + if (!empty($extracted['lines'])) { + $lineStmt = $db->prepare(" + INSERT INTO invoice_lines ( + id, invoice_id, line_number, description, + quantity, unit_price, tax_rate, tax_amount, + discount_amount, net_total, line_total, tax_category + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + "); + foreach ($extracted['lines'] as $idx => $line) { + $quantity = (float)($line['quantity'] ?? 1); + $unitPrice = (float)($line['unit_price'] ?? 0); + $taxRate = (float)($line['tax_rate'] ?? 0); + $discount = (float)($line['discount'] ?? $line['discount_amount'] ?? 0); + $subtotal = $quantity * $unitPrice; + $taxAmount = (float)($line['tax_amount'] ?? ($subtotal * $taxRate)); + $netTotal = (float)($line['net_total'] ?? ($line['line_total'] ?? ($subtotal + $taxAmount - $discount))); + + $lineStmt->execute([ + vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)), + $invoiceId, + $line['line_number'] ?? ($idx + 1), + $line['description'] ?? '', + $quantity, + $unitPrice, + $taxRate, + $taxAmount, + $discount, + $netTotal, + $netTotal, // line_total + $line['tax_category'] ?? 'standard' + ]); + } + self::log("Invoice $invoiceId: saved " . count($extracted['lines']) . " line items."); + } + + return $invoiceId; + } + + /** + * Close a batch once every image has reached a terminal state. + * + * Two rules matter here: + * 1. A batch still in 'uploading' is NEVER completed. total_images grows as + * each image arrives, so a worker that processed the first image would + * otherwise see 1 >= 1 and lock the batch while the user is still + * uploading - after which upload-image and finalize both reject with a + * misleading "already processed" error. + * 2. Permanent failures count towards completion, otherwise one bad photo + * leaves the batch (and the app's progress bar) hanging forever. + */ public static function checkBatchCompletion(string $batchId): void { try { $db = Database::getInstance(); - $stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?"); + $stmt = $db->prepare(" + SELECT total_images, processed_images, failed_images, status, uploaded_by + FROM invoice_batches WHERE id = ? + "); $stmt->execute([$batchId]); $batch = $stmt->fetch(); - - if ($batch && $batch['processed_images'] >= $batch['total_images']) { - $db->prepare("UPDATE invoice_batches SET status = 'done', completed_at = NOW() WHERE id = ?")->execute([$batchId]); - self::log("Batch $batchId: COMPLETE ({$batch['processed_images']}/{$batch['total_images']})"); - try { - // Try to get the last invoice_id for this batch for completion navigation - $invStmt = $db->prepare("SELECT id FROM invoices WHERE original_file_path IN (SELECT image_path FROM invoice_processing_queue WHERE batch_id = ?) ORDER BY created_at DESC LIMIT 1"); - $invStmt->execute([$batchId]); - $lastInvoiceId = $invStmt->fetchColumn(); + if (!$batch) { + return; + } - $notifier = new NotificationService(); - $notifier->sendNotification( - $batch['uploaded_by'], - "اكتملت معالجة الدفعة", - "تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.", - [ - 'type' => 'batch_complete', - 'batch_id' => $batchId, - 'invoice_id' => $lastInvoiceId ?: '' - ] - ); - } catch (\Throwable $e) { - self::log("Batch $batchId: Completion notification failed: " . $e->getMessage()); - } + // Rule 1: the user is still uploading - nothing to close yet. + if ($batch['status'] === 'uploading') { + self::log("Batch $batchId: still uploading, completion check deferred."); + return; + } + + // Already terminal. + if (in_array($batch['status'], ['done', 'partial_fail', 'failed'], true)) { + return; + } + + $total = (int)$batch['total_images']; + $processed = (int)$batch['processed_images']; + $failed = (int)$batch['failed_images']; + + // Rule 2: terminal means done OR permanently failed. + if ($total <= 0 || ($processed + $failed) < $total) { + return; + } + + if ($failed === 0) { + $finalStatus = 'done'; + } elseif ($processed === 0) { + $finalStatus = 'failed'; + } else { + $finalStatus = 'partial_fail'; + } + + $db->prepare("UPDATE invoice_batches SET status = ?, completed_at = NOW() WHERE id = ?") + ->execute([$finalStatus, $batchId]); + self::log("Batch $batchId: $finalStatus ($processed ok / $failed failed / $total total)"); + + try { + // invoices.batch_id is populated now, so this is a direct lookup. + $invStmt = $db->prepare("SELECT id FROM invoices WHERE batch_id = ? ORDER BY created_at DESC LIMIT 1"); + $invStmt->execute([$batchId]); + $lastInvoiceId = $invStmt->fetchColumn(); + + [$title, $body] = match ($finalStatus) { + 'done' => [ + 'اكتملت معالجة الدفعة', + 'تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.', + ], + 'partial_fail' => [ + 'اكتملت المعالجة مع أخطاء', + "تمت معالجة {$processed} فاتورة بنجاح، وفشلت {$failed}. يمكنك إعادة تصوير الفواتير الفاشلة.", + ], + default => [ + 'فشلت معالجة الدفعة', + "لم نتمكن من استخراج البيانات من {$failed} فاتورة. يرجى إعادة التصوير بإضاءة أفضل.", + ], + }; + + $notifier = new NotificationService(); + $notifier->sendNotification( + $batch['uploaded_by'], + $title, + $body, + [ + 'type' => 'batch_complete', + 'batch_id' => $batchId, + 'status' => $finalStatus, + 'processed' => $processed, + 'failed' => $failed, + 'total' => $total, + 'is_done' => 1, + 'invoice_id' => $lastInvoiceId ?: '', + ] + ); + } catch (\Throwable $e) { + self::log("Batch $batchId: Completion notification failed: " . $e->getMessage()); } } catch (\Throwable $e) { self::log("Batch $batchId: checkBatchCompletion error: " . $e->getMessage()); diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index a80d628..d817155 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -77,27 +77,109 @@ class NotificationService public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool { $db = Database::getInstance(); + + // Fetch the Live Activity token alongside the FCM one. They are different + // tokens with different delivery semantics: the FCM registration token + // wakes the app, while an ActivityKit token is the only thing that can + // update a Live Activity on the lock screen. + $sql = "SELECT push_token, live_activity_token, platform + FROM user_devices + WHERE user_id = ? AND push_token IS NOT NULL"; + $params = [$userId]; + if ($deviceId) { - $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL"); - $stmt->execute([$userId, $deviceId]); - } else { - $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL"); - $stmt->execute([$userId]); + $sql .= " AND device_fingerprint = ?"; + $params[] = $deviceId; } - $tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN); - if (empty($tokens)) return false; + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + $devices = $stmt->fetchAll(); + } catch (\PDOException $e) { + // Deployment has not run the live_activity_token migration yet. + $fallbackSql = str_replace(', live_activity_token', '', $sql); + $stmt = $db->prepare($fallbackSql); + $stmt->execute($params); + $devices = $stmt->fetchAll(); + } + if (empty($devices)) return false; + + $isLiveActivityUpdate = ($data['type'] ?? '') === 'batch_progress'; $successCount = 0; - foreach ($tokens as $token) { - if ($this->dispatchToFcm($token, null, null, $data)) { + + foreach ($devices as $device) { + // 1. Silent data push to the app itself (drives in-app progress and + // the Android ongoing notification). + if ($this->dispatchToFcm($device['push_token'], null, null, $data)) { $successCount++; } + + // 2. Separate ActivityKit push for the iOS Live Activity. + $activityToken = $device['live_activity_token'] ?? null; + if ($isLiveActivityUpdate && !empty($activityToken)) { + if ($this->dispatchLiveActivityUpdate($activityToken, $data)) { + $successCount++; + } + } } return $successCount > 0; } + /** + * Push an ActivityKit content-state update to a running Live Activity. + * + * The content-state keys must match InvoiceBatchAttributes.ContentState in + * the iOS widget extension exactly, or iOS discards the update. + */ + private function dispatchLiveActivityUpdate(string $activityToken, array $data): bool + { + $accessToken = $this->getAccessToken(); + if (!$accessToken) return false; + + $processed = (int)($data['processed'] ?? 0); + $total = max(1, (int)($data['total'] ?? 1)); + $failed = (int)($data['failed'] ?? 0); + $isDone = !empty($data['is_done']); + + $message = [ + 'apns' => [ + // The token that identifies the ACTIVITY, not the app install. + 'live_activity_token' => $activityToken, + 'headers' => [ + 'apns-push-type' => 'liveactivity', + 'apns-priority' => '10', + 'apns-topic' => env('IOS_BUNDLE_ID', 'com.musadaq.app') . '.push-type.liveactivity', + ], + 'payload' => [ + 'aps' => [ + 'timestamp' => time(), + 'event' => $isDone ? 'end' : 'update', + 'content-state' => [ + 'current' => $processed, + 'total' => $total, + 'failed' => $failed, + 'isDone' => $isDone, + ], + // Give iOS a moment to show the final state before it + // clears a finished activity. + 'dismissal-date' => $isDone ? (time() + 30) : null, + ], + ], + ], + ]; + + // Strip nulls: APNs rejects a null dismissal-date. + $message['apns']['payload']['aps'] = array_filter( + $message['apns']['payload']['aps'], + static fn($v) => $v !== null + ); + + return $this->postToFcm(['message' => $message], 'live-activity'); + } + /** * Dispatch notification to Firebase via HTTP v1 API */ @@ -108,11 +190,6 @@ class NotificationService return false; } - $accessToken = $this->getAccessToken(); - if (!$accessToken) return false; - - $url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send"; - $message = [ 'token' => $token, 'data' => array_map('strval', $data), @@ -138,7 +215,11 @@ class NotificationService ], ]; } else { - // Silent push / Live Activity Update + // Silent data push: wakes the app so it can update its own progress + // UI. Live Activity updates are a SEPARATE push that must target the + // ActivityKit token - see dispatchLiveActivityUpdate(). Sending + // apns-push-type: liveactivity to an app registration token, as this + // used to do, never reaches the activity. $message['android'] = [ 'priority' => 'high' ]; @@ -153,19 +234,31 @@ class NotificationService ] ] ]; - - // If the data contains live activity update markers, adjust headers for iOS ActivityKit - if (isset($data['type']) && $data['type'] === 'batch_progress') { - $message['apns']['headers']['apns-push-type'] = 'liveactivity'; - $message['apns']['headers']['apns-priority'] = '10'; - $message['apns']['payload']['aps']['content-state'] = $data; - $message['apns']['payload']['aps']['timestamp'] = time(); - $message['apns']['payload']['aps']['event'] = 'update'; - } } - $payload = ['message' => $message]; + return $this->postToFcm(['message' => $message], 'message'); + } + /** + * POST a prepared FCM v1 payload. Shared by the notification, silent-data and + * Live Activity paths so auth/error handling lives in one place. + */ + private function postToFcm(array $payload, string $kind): bool + { + if (!file_exists($this->serviceAccountPath)) { + error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}"); + return false; + } + + $accessToken = $this->getAccessToken(); + if (!$accessToken) return false; + + if (empty($this->projectId)) { + error_log('[NotificationService] Firebase project id is empty'); + return false; + } + + $url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send"; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); @@ -175,20 +268,57 @@ class NotificationService 'Content-Type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); curl_close($ch); + if ($curlError) { + error_log("[NotificationService] FCM cURL error ($kind): $curlError"); + return false; + } + if ($httpCode !== 200) { - error_log("[NotificationService] FCM Send Error ($httpCode): " . $response); + error_log("[NotificationService] FCM Send Error [$kind] ($httpCode): " . $response); + + // A token the server rejected as unregistered will never work again; + // clear it so we stop paying for the round trip on every batch. + if (in_array($httpCode, [400, 404], true) && str_contains((string)$response, 'UNREGISTERED')) { + $this->pruneToken($payload); + } return false; } return true; } + /** + * Drop a token FCM reported as permanently invalid. + */ + private function pruneToken(array $payload): void + { + $token = $payload['message']['token'] ?? null; + $activityToken = $payload['message']['apns']['live_activity_token'] ?? null; + + try { + $db = Database::getInstance(); + if ($token) { + $db->prepare("UPDATE user_devices SET push_token = NULL WHERE push_token = ?") + ->execute([$token]); + error_log('[NotificationService] Pruned unregistered push token'); + } + if ($activityToken) { + $db->prepare("UPDATE user_devices SET live_activity_token = NULL WHERE live_activity_token = ?") + ->execute([$activityToken]); + } + } catch (\Throwable $e) { + error_log('[NotificationService] pruneToken failed: ' . $e->getMessage()); + } + } + /** * Get OAuth2 Access Token for Firebase using Service Account JWT * Self-contained: no external libraries needed. diff --git a/app/cron/process_batches.php b/app/cron/process_batches.php index 8b4d7de..5bd24d9 100644 --- a/app/cron/process_batches.php +++ b/app/cron/process_batches.php @@ -47,16 +47,56 @@ try { $processed = 0; $failed = 0; - // Get ALL pending items (no infinite loop!) + // Get pending items whose batch is ready for processing. + // + // The b.status <> 'uploading' filter is essential: queue rows are inserted as + // 'pending' the moment each image lands, while total_images is still growing. + // Picking them up mid-upload made the batch look complete after the first + // image, which locked the batch and made every later upload fail with + // "لا يمكن إضافة صور لدفعة تمت معالجتها". $stmt = $db->prepare(" - SELECT id FROM invoice_processing_queue - WHERE status = 'pending' - ORDER BY created_at ASC + SELECT q.id + FROM invoice_processing_queue q + JOIN invoice_batches b ON q.batch_id = b.id + WHERE q.status = 'pending' + AND q.attempts < COALESCE(q.max_attempts, 3) + AND b.status <> 'uploading' + ORDER BY q.created_at ASC LIMIT 20 "); $stmt->execute(); $items = $stmt->fetchAll(\PDO::FETCH_COLUMN); + // Rescue items abandoned in 'processing' by a crashed worker. claimed_at is + // set at claim time, so "claimed over 10 minutes ago and still processing" + // reliably means the owning process died mid-flight. + $rescued = $db->prepare(" + UPDATE invoice_processing_queue + SET status = 'pending', + error_message = 'Reclaimed from a stalled worker' + WHERE status = 'processing' + AND claimed_at IS NOT NULL + AND claimed_at < (NOW() - INTERVAL 10 MINUTE) + "); + $rescued->execute(); + if ($rescued->rowCount() > 0) { + workerLog("Reclaimed {$rescued->rowCount()} stalled item(s) back to pending."); + } + + // Also close out batches whose images all reached a terminal state but whose + // status was never flipped (e.g. the worker died right after the last item). + $stuck = $db->query(" + SELECT id FROM invoice_batches + WHERE status = 'processing' + AND total_images > 0 + AND (processed_images + failed_images) >= total_images + LIMIT 50 + ")->fetchAll(\PDO::FETCH_COLUMN); + foreach ($stuck as $stuckBatchId) { + workerLog("Closing stuck batch: $stuckBatchId"); + InvoiceProcessor::checkBatchCompletion((string)$stuckBatchId); + } + if (empty($items)) { workerLog("No pending items. Exiting."); } else { diff --git a/app/helpers/helpers.php b/app/helpers/helpers.php index f2f9120..0b3f68a 100644 --- a/app/helpers/helpers.php +++ b/app/helpers/helpers.php @@ -10,7 +10,8 @@ if (!function_exists('env')) { } if (!function_exists('input')) { - function input(string $key = null, $default = null) { + // ?string, not string: implicit nullable params are deprecated in PHP 8.4. + function input(?string $key = null, $default = null) { static $inputData = null; if ($inputData === null) { $json = file_get_contents('php://input'); diff --git a/app/middleware/AuthMiddleware.php b/app/middleware/AuthMiddleware.php index 385e551..48454d7 100644 --- a/app/middleware/AuthMiddleware.php +++ b/app/middleware/AuthMiddleware.php @@ -44,6 +44,13 @@ final class AuthMiddleware exit; } + // Defence in depth for mobile requests: prove the caller also holds the + // per-device secret, so a stolen bearer token on its own is not enough. + // Controlled by HMAC_ENFORCE in .env - see HmacMiddleware. + if (($decoded['source'] ?? null) === 'mobile') { + HmacMiddleware::verify($decoded); + } + return $decoded; } } diff --git a/app/middleware/HmacMiddleware.php b/app/middleware/HmacMiddleware.php index f265c92..457e81c 100644 --- a/app/middleware/HmacMiddleware.php +++ b/app/middleware/HmacMiddleware.php @@ -1,62 +1,182 @@ a present signature is verified and a bad + * one is rejected, but a missing signature is + * allowed through. Lets older app builds keep + * working while the new build rolls out. + * HMAC_ENFORCE=true -> a signature is mandatory. */ declare(strict_types=1); namespace App\Middleware; -use App\Core\Security; +use App\Core\Database; +use App\Core\Encryption; final class HmacMiddleware { /** - * @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes) + * @param array $decoded The decoded JWT payload from AuthMiddleware::check(). + * @param int $maxAgeSeconds Replay window (default: 5 minutes). */ - public static function verify(int $maxAgeSeconds = 300): void + public static function verify(array $decoded, int $maxAgeSeconds = 300): void { - $headers = getallheaders(); - $signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; - $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; + $enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true'; - // 1. Ensure both headers are present - if (empty($signature) || empty($timestamp)) { - json_error('Missing HMAC signature or timestamp', 401); + $headers = getallheaders(); + $signature = $headers['X-Signature'] ?? $headers['x-signature'] + ?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; + $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; + + // 1. Missing headers: hard fail only when enforcing. + if ($signature === '' || $timestamp === '') { + if ($enforce) { + json_error('Missing request signature', 401); + } + return; } - // 2. Validate timestamp is numeric + // 2. Validate timestamp format. if (!ctype_digit((string)$timestamp)) { json_error('Invalid timestamp format', 401); } - // 3. Replay attack prevention — reject stale requests - $age = abs(time() - (int)$timestamp); - if ($age > $maxAgeSeconds) { - json_error('Request expired. Check your system clock.', 401); + // 3. Replay prevention. The client sends milliseconds; older callers may + // send seconds, so normalise by magnitude rather than guessing. + $ts = (int)$timestamp; + $tsSeconds = $ts > 100000000000 ? intdiv($ts, 1000) : $ts; + + if (abs(time() - $tsSeconds) > $maxAgeSeconds) { + json_error('Request expired. Check your device clock.', 401); } - // 4. Build the expected signature - $body = file_get_contents('php://input'); - $payload = $timestamp . '.' . $body; - $secret = env('HMAC_SECRET_KEY'); + // 4. Look up this device's secret. + $deviceId = $decoded['device_id'] ?? null; + $userId = $decoded['user_id'] ?? null; - if (!$secret || strlen($secret) < 32) { - error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env'); - json_error('Server configuration error', 500); + if (!$deviceId || !$userId) { + if ($enforce) { + json_error('Signed requests require a registered device', 401); + } + return; } - // 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); + $secret = self::deviceSecret((string)$userId, (string)$deviceId); + if ($secret === null) { + if ($enforce) { + json_error('Unknown device. Please sign in again.', 401); + } + return; + } + + // 5. Rebuild the signing payload exactly as the client does. + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $body = file_get_contents('php://input'); + + // index.php accepts both clean URLs (/api/v1/batches/create) and the + // ?route=v1/batches/create form, which produce different REQUEST_URIs for + // the same endpoint. Accept either so the signature does not depend on + // how the deployment happens to rewrite URLs. + $paths = array_unique(array_filter([ + self::requestPath(), + self::normalisePath((string)($_GET['route'] ?? '')), + ])); + + $candidates = []; + foreach ($paths as $path) { + if ($body !== '' && $body !== false) { + $candidates[] = "{$method}:{$path}:{$timestamp}:{$body}"; + } + // GET/multipart requests sign without a body. + $candidates[] = "{$method}:{$path}:{$timestamp}"; + } + + foreach ($candidates as $payload) { + $expected = hash_hmac('sha256', $payload, $secret); + if (hash_equals($expected, strtolower($signature))) { + return; // Verified. + } + } + + error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? '')); + json_error('Invalid request signature', 401); + } + + /** + * The path the client signed. Dio signs options.path, i.e. the endpoint + * relative to the API base URL ("batches/finalize"), without a query string. + */ + private static function requestPath(): string + { + $uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: ''; + return self::normalisePath($uri); + } + + /** + * Strip the leading slash and any deployment prefix so the signed value + * matches what the client hashed. + */ + private static function normalisePath(string $raw): string + { + $path = ltrim(trim($raw), '/'); + + foreach (['api/v1/', 'api/', 'v1/'] as $prefix) { + if (str_starts_with($path, $prefix)) { + $path = substr($path, strlen($prefix)); + break; + } + } + + return $path; + } + + /** + * Decrypt the stored per-device secret, or null if the device is unknown. + */ + private static function deviceSecret(string $userId, string $deviceId): ?string + { + try { + $db = Database::getInstance(); + $stmt = $db->prepare(" + SELECT device_secret FROM user_devices + WHERE user_id = ? AND device_fingerprint = ? AND is_trusted = 1 + LIMIT 1 + "); + $stmt->execute([$userId, $deviceId]); + $stored = $stmt->fetchColumn(); + + if (!$stored) { + return null; + } + + // Legacy rows hold a bcrypt hash, which is one-way and therefore + // unusable for HMAC. Treat those devices as un-signable until the + // user logs in again on the new build. + if (str_starts_with((string)$stored, '$2y$') || str_starts_with((string)$stored, '$2a$')) { + return null; + } + + $plain = Encryption::decrypt((string)$stored); + return $plain === false ? null : $plain; + } catch (\Throwable $e) { + error_log('[HmacMiddleware] device secret lookup failed: ' . $e->getMessage()); + return null; } } } diff --git a/app/middleware/QuotaMiddleware.php b/app/middleware/QuotaMiddleware.php index 5875548..5fec022 100644 --- a/app/middleware/QuotaMiddleware.php +++ b/app/middleware/QuotaMiddleware.php @@ -21,14 +21,16 @@ final class QuotaMiddleware * * @return array The current subscription data (for UI display) */ - public static function checkInvoiceQuota(string $tenantId): array + public static function checkInvoiceQuota(string $tenantId, int $needed = 1): array { $cacheKey = "quota_sub_{$tenantId}"; $sub = Cache::get($cacheKey); - if ($sub === false || $sub === null) { - $db = Database::getInstance(); + // $db is needed by the auto-reset branch below regardless of whether the + // subscription came from the cache, so resolve it unconditionally. + $db = Database::getInstance(); + if ($sub === false || $sub === null) { // Fetch subscription with plan info $stmt = $db->prepare(" SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod @@ -78,18 +80,30 @@ final class QuotaMiddleware $sub['current_period_start'] = $newStart; $sub['current_period_end'] = $newEnd; + // The cached copy still holds the pre-reset counters. + Cache::delete($cacheKey); + error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}"); } - // Check invoice quota + // Check invoice quota. $needed lets callers reserve a whole batch at once + // instead of only asking "is there room for one more?". $used = (int)$sub['invoices_used_this_month']; $limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility + $needed = max(1, $needed); - if ($used >= $limit) { - json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [ + if (($used + $needed) > $limit) { + $remaining = max(0, $limit - $used); + $message = $remaining === 0 + ? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.' + : 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.'; + + json_error($message, 429, [ 'quota_type' => 'invoices', 'used' => $used, 'limit' => $limit, + 'requested' => $needed, + 'remaining' => $remaining, 'plan' => $sub['plan_id'] ?? 'free', 'plan_name' => $sub['plan_name'] ?? 'مجانية', 'period_end' => $sub['current_period_end'], @@ -99,6 +113,34 @@ final class QuotaMiddleware return $sub; } + /** + * Non-fatal variant of checkInvoiceQuota() for background workers. + * + * Workers must never emit an HTTP response, so this returns a boolean + * instead of calling json_error(). + */ + public static function hasInvoiceQuota(string $tenantId, int $needed = 1): bool + { + try { + $db = Database::getInstance(); + $stmt = $db->prepare(" + SELECT invoices_used_this_month, max_invoices_per_month, status + FROM subscriptions WHERE tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) return false; + if (in_array($sub['status'], ['cancelled', 'past_due'], true)) return false; + + return ((int)$sub['invoices_used_this_month'] + max(1, $needed)) + <= (int)$sub['max_invoices_per_month']; + } catch (\Throwable $e) { + error_log('[QuotaMiddleware] hasInvoiceQuota failed: ' . $e->getMessage()); + return false; + } + } + /** * Increment the monthly invoice counter after a successful upload. */ diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php index 11affd5..045a1fc 100644 --- a/app/modules_app/auth/login.php +++ b/app/modules_app/auth/login.php @@ -40,7 +40,12 @@ if (!$user || !password_verify($password, $user['password_hash'])) { } $deviceId = $data['device_id'] ?? null; -$isReviewer = (strtolower($email) === 'reviewer@musadaq.jo'); + +// App-store reviewer account skips the WhatsApp OTP step (reviewers have no +// access to the registered phone). Configured via .env so the exception is not +// baked into the source, and disabled entirely when the var is unset. +$reviewerEmail = strtolower(trim((string)env('REVIEWER_EMAIL', ''))); +$isReviewer = $reviewerEmail !== '' && strtolower($email) === $reviewerEmail; if ($deviceId && !$isReviewer) { // Generate and send WhatsApp OTP @@ -127,7 +132,9 @@ if ($deviceId) { $deviceName, $data['platform'] ?? 'web', $data['app_version'] ?? '1.0.0', - password_hash($deviceSecret, PASSWORD_DEFAULT), + // Stored encrypted, NOT bcrypt-hashed: the server must be able to + // recompute the client's HMAC signature from this same secret. + \App\Core\Encryption::encrypt($deviceSecret), ]); } @@ -152,11 +159,28 @@ $payload = [ $token = JWT::encode($payload, $secret); -// 5. Update Refresh Token (Hashed before storage for security) +// 5. Issue Refresh Token (hashed before storage). +// +// Mobile tokens are stored PER DEVICE. users.refresh_token_hash is a single +// column, so writing there logged the user out of every other device silently. $refreshToken = bin2hex(random_bytes(32)); $refreshTokenHash = hash('sha256', $refreshToken); -$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?"); -$stmt->execute([$refreshTokenHash, $user['id']]); +$refreshTtlDays = $deviceId ? 60 : 7; +$refreshExpiresAt = date('Y-m-d H:i:s', time() + ($refreshTtlDays * 24 * 3600)); + +if ($deviceId) { + $stmt = $db->prepare(" + UPDATE user_devices + SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW() + WHERE user_id = ? AND device_fingerprint = ? + "); + $stmt->execute([$refreshTokenHash, $refreshExpiresAt, $user['id'], $deviceId]); + + $db->prepare("UPDATE users SET last_login_at = NOW() WHERE id = ?")->execute([$user['id']]); +} else { + $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?"); + $stmt->execute([$refreshTokenHash, $user['id']]); +} // 6. Secure Refresh Token delivery via HttpOnly Cookie (for web) if (!$deviceId) { diff --git a/app/modules_app/auth/logout.php b/app/modules_app/auth/logout.php index 3377e63..cf90bdc 100644 --- a/app/modules_app/auth/logout.php +++ b/app/modules_app/auth/logout.php @@ -10,9 +10,26 @@ use App\Middleware\AuthMiddleware; $decoded = AuthMiddleware::check(); $userId = $decoded['user_id']; -// 2. Invalidate Refresh Token +// 2. Invalidate the refresh token. +// A mobile logout must only sign THIS device out - clearing the shared user +// column would drop every other device the user owns. $db = Database::getInstance(); -$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?"); -$stmt->execute([$userId]); +$deviceId = $decoded['device_id'] ?? null; + +if ($deviceId) { + $stmt = $db->prepare(" + UPDATE user_devices + SET refresh_token_hash = NULL, + refresh_expires_at = NULL, + push_token = NULL, + live_activity_token = NULL, + is_trusted = 0 + WHERE user_id = ? AND device_fingerprint = ? + "); + $stmt->execute([$userId, $deviceId]); +} else { + $stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?"); + $stmt->execute([$userId]); +} json_success(null, 'تم تسجيل الخروج بنجاح'); diff --git a/app/modules_app/auth/mobile_request_otp.php b/app/modules_app/auth/mobile_request_otp.php index aceaf5b..06ed623 100644 --- a/app/modules_app/auth/mobile_request_otp.php +++ b/app/modules_app/auth/mobile_request_otp.php @@ -64,8 +64,12 @@ try { exit; } + // A disabled account must produce the SAME answer as an unknown number, + // otherwise this endpoint tells an attacker which phones are registered. if (!$user['is_active']) { - json_error('الحساب معطّل. تواصل مع المسؤول.', 403); + error_log("OTP request for disabled account: user {$user['id']}"); + json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق'); + exit; } // 3. Generate OTP (6 digits) @@ -102,16 +106,17 @@ try { $result = $whatsappService->sendMessage($phone, $message); if (!$result['success']) { - error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}"); - json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500, ['whatsapp_debug' => $result]); + // Internal provider details stay in the log, not in the HTTP response. + error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone} - " . json_encode($result)); + json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500); } - // Log for development (REMOVE IN PRODUCTION!) + // Development only - never reached when APP_DEBUG is false. if (env('APP_DEBUG', 'false') === 'true') { error_log("DEV OTP for {$phone}: {$otp}"); } - json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب'); + json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب'); } catch (\Exception $e) { safe_error($e, 'auth/mobile_request_otp'); diff --git a/app/modules_app/auth/mobile_verify_otp.php b/app/modules_app/auth/mobile_verify_otp.php index 0437d44..909ae9a 100644 --- a/app/modules_app/auth/mobile_verify_otp.php +++ b/app/modules_app/auth/mobile_verify_otp.php @@ -132,7 +132,9 @@ $stmt->execute([ $platform, $appVersion, $pushToken, - password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed + // Stored encrypted (reversible), not bcrypt-hashed: HmacMiddleware has to + // recompute the same signature the client produced from this secret. + \App\Core\Encryption::encrypt($deviceSecret), ]); // 6. Generate JWT (30 days for mobile) @@ -153,11 +155,20 @@ $payload = [ $token = JWT::encode($payload, $secret); -// 7. Generate refresh token +// 7. Generate refresh token, stored against THIS device so signing in on a +// second phone does not silently invalidate the first one. $refreshToken = bin2hex(random_bytes(32)); $refreshTokenHash = hash('sha256', $refreshToken); -$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?"); -$stmt->execute([$refreshTokenHash, $userId]); +$refreshExpiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600)); // 60 days + +$stmt = $db->prepare(" + UPDATE user_devices + SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW() + WHERE user_id = ? AND device_fingerprint = ? +"); +$stmt->execute([$refreshTokenHash, $refreshExpiresAt, $userId, $deviceId]); + +$db->prepare("UPDATE users SET last_login_at = NOW() WHERE id = ?")->execute([$userId]); // 8. Decrypt name for response $userName = $user['name']; diff --git a/app/modules_app/auth/refresh.php b/app/modules_app/auth/refresh.php index bcb4f69..4ff445e 100644 --- a/app/modules_app/auth/refresh.php +++ b/app/modules_app/auth/refresh.php @@ -1,13 +1,30 @@ prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1"); -$stmt->execute([$refreshTokenHash]); -$user = $stmt->fetch(); +$user = null; +$isMobile = false; + +// 1. Mobile: per-device lookup. +if ($deviceId) { + $stmt = $db->prepare(" + SELECT u.*, d.device_fingerprint, d.refresh_expires_at + FROM user_devices d + JOIN users u ON u.id = d.user_id + WHERE d.refresh_token_hash = ? + AND d.device_fingerprint = ? + AND d.is_trusted = 1 + AND u.is_active = 1 + LIMIT 1 + "); + $stmt->execute([$refreshTokenHash, $deviceId]); + $user = $stmt->fetch(); + + if ($user) { + $isMobile = true; + if (!empty($user['refresh_expires_at']) && strtotime($user['refresh_expires_at']) < time()) { + json_error('انتهت صلاحية الجلسة. يرجى تسجيل الدخول من جديد.', 401); + } + } +} + +// 2. Web: single-column lookup. +if (!$user) { + $stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$refreshTokenHash]); + $user = $stmt->fetch(); +} if (!$user) { json_error('Invalid refresh token', 401); } -// 3. Generate New Access Token -$secret = $_ENV['JWT_SECRET'] ?? null; -if (!$secret) { +// 3. Generate a new access 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'], // Now including tenant_id + 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], - 'exp' => time() + (15 * 60) // 15 minutes + 'device_id' => $isMobile ? $deviceId : null, + 'source' => $isMobile ? 'mobile' : 'web', + 'exp' => time() + ($isMobile ? (30 * 24 * 3600) : (15 * 60)), ]; -$token = JWT::encode($payload, $secret, 'HS256'); +$token = JWT::encode($payload, $secret); + +// 4. Rotate the refresh token so a leaked one cannot be replayed indefinitely. +$newRefreshToken = bin2hex(random_bytes(32)); +$newHash = hash('sha256', $newRefreshToken); + +if ($isMobile) { + $expiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600)); + $db->prepare(" + UPDATE user_devices + SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW() + WHERE user_id = ? AND device_fingerprint = ? + ")->execute([$newHash, $expiresAt, $user['id'], $deviceId]); +} else { + $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?") + ->execute([$newHash, $user['id']]); + + setcookie('refresh_token', $newRefreshToken, [ + 'expires' => time() + (7 * 24 * 60 * 60), + 'path' => '/api/v1/auth/refresh', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); +} json_success([ - 'access_token' => $token + 'access_token' => $token, + // Web keeps receiving it via the HttpOnly cookie only. + 'refresh_token' => $isMobile ? $newRefreshToken : null, ]); diff --git a/app/modules_app/auth/register_device.php b/app/modules_app/auth/register_device.php index f972225..bdee9d7 100644 --- a/app/modules_app/auth/register_device.php +++ b/app/modules_app/auth/register_device.php @@ -32,6 +32,14 @@ if (isset($data['push_token'])) { $params[] = $data['push_token']; } +// ActivityKit push token for the currently running Live Activity (iOS). +// This is a DIFFERENT token from the FCM registration token above: APNs +// liveactivity pushes must target this one specifically. +if (isset($data['live_activity_token'])) { + $updateFields[] = 'live_activity_token = ?'; + $params[] = $data['live_activity_token']; +} + if (isset($data['app_version'])) { $updateFields[] = 'app_version = ?'; $params[] = $data['app_version']; diff --git a/app/modules_app/batches/create.php b/app/modules_app/batches/create.php index 4283b35..d719117 100644 --- a/app/modules_app/batches/create.php +++ b/app/modules_app/batches/create.php @@ -32,7 +32,9 @@ if ($errors) { $companyId = $data['company_id']; $source = $data['source'] ?? 'mobile_scan'; -$expectedImages = (int)($data['expected_images'] ?? 0); +// The mobile client sends 'total_images'; older/web callers send 'expected_images'. +// Accept both so the expected count is never silently zero. +$expectedImages = (int)($data['expected_images'] ?? $data['total_images'] ?? 0); // 2. Permission check $db = Database::getInstance(); @@ -52,24 +54,25 @@ if ($decoded['role'] !== 'super_admin' && $company['tenant_id'] !== $tenantId) { // Use the actual tenant of the company $targetTenantId = $company['tenant_id']; -// 3. Check quota (preview — don't increment yet) +// 3. Reserve quota for the WHOLE batch up front, not just one invoice. +// checkInvoiceQuota() responds and exits by itself when there is no room, so the +// old try/catch wrapper never actually caught anything. if ($decoded['role'] !== 'super_admin') { - try { - QuotaMiddleware::checkInvoiceQuota($targetTenantId); - } catch (\Exception $e) { - json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429); - } + QuotaMiddleware::checkInvoiceQuota($targetTenantId, max(1, $expectedImages)); } // 4. Generate batch ID $batchId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); -// 5. Create batch record +// 5. Create batch record. +// total_images starts at 0 and is incremented by upload-image for each file that +// actually lands. Seeding it with the client's expected count here would double +// count and break the (processed + failed) >= total completion check. $stmt = $db->prepare(" INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status) - VALUES (?, ?, ?, ?, ?, ?, 'uploading') + VALUES (?, ?, ?, ?, 0, ?, 'uploading') "); -$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $expectedImages, $source]); +$stmt->execute([$batchId, $targetTenantId, $companyId, $userId, $source]); // 6. Create upload directory $uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId; diff --git a/app/modules_app/batches/finalize.php b/app/modules_app/batches/finalize.php index 029eccd..75206a3 100644 --- a/app/modules_app/batches/finalize.php +++ b/app/modules_app/batches/finalize.php @@ -48,14 +48,19 @@ if ($batch['total_images'] == 0) { json_error('لا يمكن إنهاء دفعة فارغة', 400); } -// 2. Mark as processing +// 2. Mark as processing - atomically, so two concurrent finalize calls cannot +// both start a background worker for the same batch. $stmt = $db->prepare(" - UPDATE invoice_batches - SET status = 'processing', updated_at = NOW() - WHERE id = ? + UPDATE invoice_batches + SET status = 'processing', updated_at = NOW() + WHERE id = ? AND status = 'uploading' "); $stmt->execute([$batchId]); +if ($stmt->rowCount() !== 1) { + json_error('تم إنهاء هذه الدفعة مسبقاً', 400); +} + // 3. Send response IMMEDIATELY to mobile app // We manually build the response instead of using json_success() because it calls exit() $responsePayload = json_encode([ @@ -117,7 +122,16 @@ $bgLog = function(string $msg) { $bgLog("Background processing started for batch: $batchId"); try { - $queueStmt = $db->prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending' ORDER BY created_at ASC"); + // processQueueItem() claims each row atomically, so it is safe for the cron + // worker to be walking the same batch at the same time - whoever claims a row + // first owns it and the other simply skips it. + $queueStmt = $db->prepare(" + SELECT id FROM invoice_processing_queue + WHERE batch_id = ? + AND status = 'pending' + AND attempts < COALESCE(max_attempts, 3) + ORDER BY image_order ASC, created_at ASC + "); $queueStmt->execute([$batchId]); $items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN); @@ -127,12 +141,16 @@ try { $bgLog("Processing queue item: $queueId"); try { $success = InvoiceProcessor::processQueueItem((int)$queueId); - $bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED")); + $bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED/SKIPPED")); } catch (\Throwable $e) { $bgLog("Queue item $queueId EXCEPTION: " . $e->getMessage()); } } + // Final sweep: if every item ended up terminal, close the batch here rather + // than waiting up to a minute for the cron to notice. + InvoiceProcessor::checkBatchCompletion($batchId); + $bgLog("Background processing finished for batch: $batchId"); } catch (\Throwable $e) { diff --git a/app/modules_app/batches/process_worker.php b/app/modules_app/batches/process_worker.php deleted file mode 100644 index 91cb6cb..0000000 --- a/app/modules_app/batches/process_worker.php +++ /dev/null @@ -1,38 +0,0 @@ -prepare("SELECT id FROM invoice_processing_queue WHERE batch_id = ? AND status = 'pending'"); -$stmt->execute([$batchId]); -$items = $stmt->fetchAll(); - -foreach ($items as $item) { - InvoiceProcessor::processQueueItem((int)$item['id']); -} - -echo "Done"; diff --git a/app/modules_app/batches/status.php b/app/modules_app/batches/status.php index b777c52..c271f44 100644 --- a/app/modules_app/batches/status.php +++ b/app/modules_app/batches/status.php @@ -40,15 +40,21 @@ if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $t // 2. Get items $stmt = $db->prepare(" - SELECT id, invoice_id, image_order, status, error_message, created_at, processed_at + SELECT id, invoice_id, image_order, status, attempts, max_attempts, error_message, created_at, processed_at FROM invoice_processing_queue WHERE batch_id = ? - ORDER BY image_order ASC + ORDER BY image_order ASC, id ASC "); $stmt->execute([$batchId]); $items = $stmt->fetchAll(); +// 3. Tell the client explicitly whether it should keep polling. Without this the +// app polled forever whenever a batch ended in anything other than 'done'. +$isTerminal = in_array($batch['status'], ['done', 'partial_fail', 'failed'], true); + json_success([ - 'batch' => $batch, - 'items' => $items + 'batch' => $batch, + 'items' => $items, + 'is_terminal' => $isTerminal, + 'should_poll' => !$isTerminal, ], 'تم جلب حالة الدفعة'); diff --git a/app/modules_app/batches/upload_image.php b/app/modules_app/batches/upload_image.php index 76576fd..7ef18a3 100644 --- a/app/modules_app/batches/upload_image.php +++ b/app/modules_app/batches/upload_image.php @@ -18,7 +18,9 @@ $userId = $decoded['user_id']; // 1. Validate request $batchId = $_POST['batch_id'] ?? null; -$imageOrder = (int)($_POST['image_order'] ?? 0); +// The mobile client sends 'order_index'; accept both spellings so every image +// does not end up with order 0 (which also made every saved file img_000_*). +$imageOrder = (int)($_POST['image_order'] ?? $_POST['order_index'] ?? 0); if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { $uploadError = $_FILES['image']['error'] ?? 'No file'; @@ -46,11 +48,20 @@ if ($batch['status'] !== 'uploading') { json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400); } -// 3. Validate file type -$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf']; -$mimeType = $_FILES['image']['type']; -if (!in_array($mimeType, $allowedTypes)) { - json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422); +// 3. Validate file type. +// Sniff the real type off the temp file - $_FILES[...]['type'] is supplied by the +// client and can claim anything. +$allowedTypes = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + 'application/pdf' => 'pdf', +]; +$mimeType = @mime_content_type($_FILES['image']['tmp_name']) ?: ''; +if (!isset($allowedTypes[$mimeType])) { + json_error('نوع الملف غير مدعوم. المسموح: صور (JPG, PNG, WEBP, HEIC) و PDF', 422); } // 4. Validate file size (max 10MB) @@ -59,6 +70,16 @@ if ($_FILES['image']['size'] > $maxSize) { json_error('حجم الصورة أكبر من 10 ميغابايت', 422); } +// 4b. Enforce quota per image, not just once per batch. Checking only at +// batches/create let a 50-image batch through on a single remaining credit. +$queuedStmt = $db->prepare("SELECT COUNT(*) FROM invoice_processing_queue WHERE batch_id = ?"); +$queuedStmt->execute([$batchId]); +$alreadyQueued = (int)$queuedStmt->fetchColumn(); + +if ($decoded['role'] !== 'super_admin') { + \App\Middleware\QuotaMiddleware::checkInvoiceQuota($tenantId, $alreadyQueued + 1); +} + // 5. Save file $companyId = $batch['company_id']; $uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId; @@ -66,7 +87,7 @@ if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } -$extension = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION) ?: 'jpg'; +$extension = $allowedTypes[$mimeType]; $fileName = sprintf('img_%03d_%s.%s', $imageOrder, bin2hex(random_bytes(4)), $extension); $targetPath = $uploadDir . '/' . $fileName; diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php index ba09031..051f2e4 100644 --- a/app/modules_app/invoices/upload.php +++ b/app/modules_app/invoices/upload.php @@ -84,7 +84,31 @@ try { } } - $extension = pathinfo($_FILES['invoice']['name'], PATHINFO_EXTENSION); + // 4a. Validate the file BEFORE storing it. The client-supplied + // $_FILES['invoice']['type'] is attacker-controlled, so sniff the real type + // off the temp file and derive the extension from that, never from the name. + $allowedMimeTypes = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + 'application/pdf' => 'pdf', + ]; + + $maxSize = 10 * 1024 * 1024; // 10MB + if ($_FILES['invoice']['size'] > $maxSize) { + json_error('حجم الملف أكبر من 10 ميغابايت', 422); + exit; + } + + $detectedMime = @mime_content_type($_FILES['invoice']['tmp_name']) ?: ''; + if (!isset($allowedMimeTypes[$detectedMime])) { + json_error('نوع الملف غير مدعوم. المسموح: صور (JPG, PNG, WEBP, HEIC) و PDF', 422); + exit; + } + + $extension = $allowedMimeTypes[$detectedMime]; $fileName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $extension; $targetFile = $uploadDir . $fileName; @@ -94,13 +118,17 @@ try { } // 5. Run AI Extraction - $mimeType = $_FILES['invoice']['type']; + $mimeType = $detectedMime; $fileContent = file_get_contents($targetFile); $base64Data = base64_encode($fileContent); - $extracted = AI::extractInvoiceData($base64Data, $mimeType); + // extractInvoices() returns EVERY invoice in the image. extractInvoiceData() + // silently kept only the first, so a photo holding two receipts lost one. + AI::setTenantContext($tenantId); + $extractedInvoices = AI::extractInvoices($base64Data, $mimeType); + AI::setTenantContext(null); - if (!$extracted) { + if (empty($extractedInvoices)) { $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $stmt = $db->prepare(" INSERT INTO invoices ( @@ -115,9 +143,14 @@ try { } // 6. Save Extracted Data + // Multiple invoices in one image each consume a credit, so make sure the + // tenant can actually cover the whole set before writing any of them. + if ($decoded['role'] !== 'super_admin' && count($extractedInvoices) > 1) { + QuotaMiddleware::checkInvoiceQuota($tenantId, count($extractedInvoices)); + } + $db->beginTransaction(); - $extractedInvoices = $extracted['invoices'] ?? [$extracted]; $savedIds = []; foreach ($extractedInvoices as $inv) { diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index c759158..dc3a1f2 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -88,16 +88,25 @@ try { date('Y-m-d H:i:s') ]); - json_success(null, 'تم إضافة المستخدم بنجاح'); - + // Audit BEFORE responding: json_success() calls exit(), so anything after it + // never ran and user creation was never recorded in the audit log. AuditLogger::log('user.created', 'user', null, null, [ 'name' => $data['name'], 'email' => $data['email'], 'role' => $data['role'], ], $decoded); + + json_success(null, 'تم إضافة المستخدم بنجاح'); } catch (\Exception $e) { - if (str_contains($e->getMessage(), 'Duplicate entry')) { + $msg = $e->getMessage(); + if (str_contains($msg, 'Duplicate entry')) { + // Say which field actually collided - reporting "email" for a duplicate + // phone sent admins hunting for the wrong problem. + if (str_contains($msg, 'phone')) { + json_error('رقم الهاتف مسجل مسبقاً لمستخدم آخر', 409); + } json_error('البريد الإلكتروني مسجل مسبقاً', 409); } + error_log('[users/create] ' . $msg); json_error('حدث خطأ أثناء حفظ البيانات', 500); } diff --git a/musadaq-app/android/app/build.gradle.kts b/musadaq-app/android/app/build.gradle.kts index 83421e6..b0978b3 100644 --- a/musadaq-app/android/app/build.gradle.kts +++ b/musadaq-app/android/app/build.gradle.kts @@ -26,6 +26,9 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + // flutter_local_notifications uses java.time APIs that need desugaring + // to run on our minSdk of 24. + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -77,3 +80,10 @@ android { flutter { source = "../.." } + +dependencies { + // Required by isCoreLibraryDesugaringEnabled above. + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + // NotificationCompat / NotificationManagerCompat used by MainActivity. + implementation("androidx.core:core-ktx:1.15.0") +} diff --git a/musadaq-app/android/app/src/main/AndroidManifest.xml b/musadaq-app/android/app/src/main/AndroidManifest.xml index 2c9ae3a..64eb9e5 100644 --- a/musadaq-app/android/app/src/main/AndroidManifest.xml +++ b/musadaq-app/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,13 @@ - + + + + + + + + + + + + + + + + + + when (call.method) { + "start" -> { + val company = call.argument("companyName") ?: "" + val total = call.argument("total") ?: 1 + showProgress( + company = company, + current = 0, + total = total, + failed = 0, + isDone = false, + statusText = "جارٍ الرفع" + ) + // Android has no per-activity push token; the FCM device token + // already reaches this notification via the data message. + result.success(null) + } + + "update" -> { + showProgress( + company = call.argument("companyName") ?: currentCompany, + current = call.argument("current") ?: 0, + total = call.argument("total") ?: 1, + failed = call.argument("failed") ?: 0, + isDone = call.argument("isDone") ?: false, + statusText = call.argument("statusText") ?: "" + ) + result.success(null) + } + + "end" -> { + clearProgress() + result.success(null) + } + + "isSupported" -> result.success( + NotificationManagerCompat.from(this).areNotificationsEnabled() + ) + + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + WIDGET_CHANNEL + ).setMethodCallHandler { call, result -> + when (call.method) { + "updateWidget" -> { + @Suppress("UNCHECKED_CAST") + val data = call.arguments as? Map ?: emptyMap() + MusadaqWidgetProvider.saveData(this, data) + MusadaqWidgetProvider.refreshAll(this) + result.success(true) + } + else -> result.notImplemented() + } + } + } + + private var currentCompany: String = "" + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Without this channel existing, Android 8+ DISCARDS any notification the + // server addresses to "high_importance_channel" — which is what the + // backend sends. That silently killed every push notification. + manager.createNotificationChannel( + NotificationChannel( + PUSH_CHANNEL_ID, + "إشعارات مُصادَق", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "إشعارات معالجة الفواتير والتنبيهات المهمة" + enableVibration(true) + } + ) + + manager.createNotificationChannel( + NotificationChannel( + PROGRESS_CHANNEL_ID, + "تقدّم رفع الفواتير", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "شريط تقدّم رفع ومعالجة الفواتير" + setShowBadge(false) + } + ) + } + + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + private fun showProgress( + company: String, + current: Int, + total: Int, + failed: Int, + isDone: Boolean, + statusText: String + ) { + if (!hasNotificationPermission()) return + + currentCompany = company + val safeTotal = if (total < 1) 1 else total + val done = current + failed + + val title = when { + !isDone -> if (statusText.isNotEmpty()) "مُصادَق — $statusText" else "مُصادَق — جارٍ الرفع" + failed > 0 && current > 0 -> "اكتمل مع أخطاء" + failed > 0 -> "فشلت المعالجة" + else -> "تم بنجاح" + } + + val text = buildString { + append("$current / $safeTotal فاتورة") + if (failed > 0) append(" — $failed فاشلة") + if (company.isNotEmpty()) append(" — $company") + } + + val builder = NotificationCompat.Builder(this, PROGRESS_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle(title) + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(!isDone) + .setAutoCancel(isDone) + + if (isDone) { + builder.setProgress(0, 0, false) + builder.setSmallIcon(android.R.drawable.stat_sys_upload_done) + } else { + builder.setProgress(safeTotal, done, false) + // Android 16+ can promote an ongoing notification to a Live Update + // (status-bar chip). Called reflectively so the project still builds + // against an older compileSdk. + promoteToLiveUpdate(builder) + } + + try { + NotificationManagerCompat.from(this) + .notify(PROGRESS_NOTIFICATION_ID, builder.build()) + progressActive = true + } catch (e: SecurityException) { + // Permission revoked between the check and the notify. + } + } + + /** + * Best-effort call to NotificationCompat.Builder#setRequestPromotedOngoing, + * which only exists on newer androidx/API levels. + */ + private fun promoteToLiveUpdate(builder: NotificationCompat.Builder) { + if (Build.VERSION.SDK_INT < 36) return + try { + val method = builder.javaClass.getMethod( + "setRequestPromotedOngoing", + Boolean::class.javaPrimitiveType + ) + method.invoke(builder, true) + } catch (e: NoSuchMethodException) { + // Older androidx.core — plain ongoing notification is the fallback. + } catch (e: Exception) { + // Never let a cosmetic upgrade break the upload. + } + } + + private fun clearProgress() { + if (!progressActive) return + progressActive = false + NotificationManagerCompat.from(this).cancel(PROGRESS_NOTIFICATION_ID) + } +} diff --git a/musadaq-app/android/app/src/main/kotlin/com/musadaq/app/MusadaqWidgetProvider.kt b/musadaq-app/android/app/src/main/kotlin/com/musadaq/app/MusadaqWidgetProvider.kt new file mode 100644 index 0000000..2bad274 --- /dev/null +++ b/musadaq-app/android/app/src/main/kotlin/com/musadaq/app/MusadaqWidgetProvider.kt @@ -0,0 +1,107 @@ +package com.musadaq.app + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews + +/** + * Home screen widget showing invoice counts and remaining quota. + * + * res/layout/widget_musadaq.xml and res/xml/widget_musadaq_info.xml already + * existed, but no provider class or was ever declared — so Android + * never offered the widget and HomeWidgetService's `updateWidget` channel call + * had nothing on the other end. + * + * Values are cached in SharedPreferences because a widget can be redrawn by the + * OS at any time, including when the Flutter engine is not running. + */ +class MusadaqWidgetProvider : AppWidgetProvider() { + + companion object { + private const val PREFS = "musadaq_widget_prefs" + + private const val KEY_TOTAL = "total_invoices" + private const val KEY_PENDING = "pending_invoices" + private const val KEY_QUOTA_USED = "quota_used" + private const val KEY_QUOTA_LIMIT = "quota_limit" + private const val KEY_LAST_UPDATE = "last_update" + + /** Persist the latest stats pushed down from Dart. */ + fun saveData(context: Context, data: Map) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply { + putInt(KEY_TOTAL, (data[KEY_TOTAL] as? Number)?.toInt() ?: 0) + putInt(KEY_PENDING, (data[KEY_PENDING] as? Number)?.toInt() ?: 0) + putInt(KEY_QUOTA_USED, (data[KEY_QUOTA_USED] as? Number)?.toInt() ?: 0) + putInt(KEY_QUOTA_LIMIT, (data[KEY_QUOTA_LIMIT] as? Number)?.toInt() ?: 0) + putString(KEY_LAST_UPDATE, data[KEY_LAST_UPDATE] as? String ?: "") + apply() + } + } + + /** Ask the OS to redraw every placed instance of this widget. */ + fun refreshAll(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds( + ComponentName(context, MusadaqWidgetProvider::class.java) + ) + if (ids.isEmpty()) return + + for (id in ids) { + manager.updateAppWidget(id, buildViews(context)) + } + } + + private fun buildViews(context: Context): RemoteViews { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val views = RemoteViews(context.packageName, R.layout.widget_musadaq) + + views.setTextViewText( + R.id.widget_total_invoices, + prefs.getInt(KEY_TOTAL, 0).toString() + ) + views.setTextViewText( + R.id.widget_pending, + prefs.getInt(KEY_PENDING, 0).toString() + ) + views.setTextViewText( + R.id.widget_quota, + "${prefs.getInt(KEY_QUOTA_USED, 0)}/${prefs.getInt(KEY_QUOTA_LIMIT, 0)}" + ) + + val lastUpdate = prefs.getString(KEY_LAST_UPDATE, "") ?: "" + views.setTextViewText( + R.id.widget_last_update, + if (lastUpdate.length >= 16) lastUpdate.substring(11, 16) else "--:--" + ) + + // Tapping the scan button opens the app. + val launchIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("open_scanner", true) + } + val pending = PendingIntent.getActivity( + context, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_scan_button, pending) + + return views + } + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (id in appWidgetIds) { + appWidgetManager.updateAppWidget(id, buildViews(context)) + } + } +} diff --git a/musadaq-app/android/settings.gradle.kts b/musadaq-app/android/settings.gradle.kts index bd7522f..c4b6403 100644 --- a/musadaq-app/android/settings.gradle.kts +++ b/musadaq-app/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false // START: FlutterFire Configuration id("com.google.gms.google-services") version("4.3.15") apply false // END: FlutterFire Configuration diff --git a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivity.swift b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivity.swift index 8283e19..1809d61 100644 --- a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivity.swift +++ b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivity.swift @@ -2,83 +2,15 @@ // MusadaqLiveActivity.swift // MusadaqLiveActivity // -// Created by Hamza Aleghwairyeen on 07/05/2026. +// Intentionally empty. +// +// This file used to hold the stock Xcode widget template (a "😀" static widget +// with its own TimelineProvider). It was never added to +// MusadaqLiveActivityBundle, so it only ever shipped as dead code — and its +// file-scope `Provider` type collided conceptually with the control widget's. +// +// The real implementation is InvoiceBatchLiveActivity in +// MusadaqLiveActivityBundle.swift. // -import WidgetKit -import SwiftUI - -struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), emoji: "😀") - } - - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let entry = SimpleEntry(date: Date(), emoji: "😀") - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, emoji: "😀") - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let emoji: String -} - -struct MusadaqLiveActivityEntryView : View { - var entry: Provider.Entry - - var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Emoji:") - Text(entry.emoji) - } - } -} - -struct MusadaqLiveActivity: Widget { - let kind: String = "MusadaqLiveActivity" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(iOS 17.0, *) { - MusadaqLiveActivityEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - MusadaqLiveActivityEntryView(entry: entry) - .padding() - .background() - } - } - .configurationDisplayName("My Widget") - .description("This is an example widget.") - } -} - -#Preview(as: .systemSmall) { - MusadaqLiveActivity() -} timeline: { - SimpleEntry(date: .now, emoji: "😀") - SimpleEntry(date: .now, emoji: "🤩") -} +import Foundation diff --git a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift index 5ac729a..36bdb2b 100644 --- a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift +++ b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift @@ -10,11 +10,18 @@ import SwiftUI import ActivityKit // ─── 1. Data Model ─────────────────────────────────── +// +// Must stay identical to InvoiceBatchAttributes in Runner/AppDelegate.swift and +// to the "content-state" keys the server sends in +// NotificationService::dispatchLiveActivityUpdate(). A mismatch makes iOS drop +// the update silently. struct InvoiceBatchAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { var current: Int var total: Int var isDone: Bool + var failed: Int = 0 + var statusText: String = "" } var companyName: String } @@ -27,7 +34,31 @@ struct MusadaqLiveActivityBundle: WidgetBundle { } } -// ─── 3. Widget ─────────────────────────────────────── +// ─── 3. Copy helpers ───────────────────────────────── +// A partially failed batch must not read as a clean success. +private func headline(for state: InvoiceBatchAttributes.ContentState) -> String { + if state.isDone { + if state.failed > 0 && state.current > 0 { return "⚠️ اكتمل مع أخطاء" } + if state.failed > 0 { return "❌ فشلت المعالجة" } + return "✅ تم بنجاح" + } + return state.statusText.isEmpty + ? "مُصادَق — جارٍ الرفع..." + : "مُصادَق — \(state.statusText)" +} + +private func subtitle(for state: InvoiceBatchAttributes.ContentState, company: String) -> String { + var text = "\(state.current) / \(state.total) فاتورة" + if state.failed > 0 { + text += " — \(state.failed) فاشلة" + } + if !company.isEmpty { + text += " — \(company)" + } + return text +} + +// ─── 4. Widget ─────────────────────────────────────── struct InvoiceBatchLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: InvoiceBatchAttributes.self) { context in @@ -40,15 +71,17 @@ struct InvoiceBatchLiveActivity: Widget { .foregroundColor(Color(red: 0.831, green: 0.659, blue: 0.263)) // #D4A843 .font(.title2) VStack(alignment: .leading, spacing: 4) { - Text(context.state.isDone ? "✅ تم الرفع بنجاح" : "مُصادَق — جارٍ الرفع...") + Text(headline(for: context.state)) .font(.caption.bold()) .foregroundColor(.white) ProgressView( - value: Double(context.state.current), - total: Double(context.state.total) + value: Double(context.state.current + context.state.failed), + total: Double(max(context.state.total, 1)) ) - .tint(Color(red: 0.831, green: 0.659, blue: 0.263)) - Text("\(context.state.current) / \(context.state.total) فاتورة — \(context.attributes.companyName)") + .tint(context.state.failed > 0 + ? Color(red: 0.96, green: 0.62, blue: 0.04) + : Color(red: 0.831, green: 0.659, blue: 0.263)) + Text(subtitle(for: context.state, company: context.attributes.companyName)) .font(.caption2) .foregroundColor(.gray) } @@ -66,8 +99,10 @@ struct InvoiceBatchLiveActivity: Widget { .font(.caption.bold()).foregroundColor(.white) } DynamicIslandExpandedRegion(.bottom) { - ProgressView(value: Double(context.state.current), - total: Double(context.state.total)) + // Divide-by-zero guard: total arrives as 0 if a push lands + // before the first image is registered. + ProgressView(value: Double(context.state.current + context.state.failed), + total: Double(max(context.state.total, 1))) .tint(Color(red: 0.831, green: 0.659, blue: 0.263)) } } compactLeading: { diff --git a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityControl.swift b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityControl.swift index 72653e1..575dd7b 100644 --- a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityControl.swift +++ b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityControl.swift @@ -2,53 +2,15 @@ // MusadaqLiveActivityControl.swift // MusadaqLiveActivity // -// Created by Hamza Aleghwairyeen on 07/05/2026. +// Intentionally empty. +// +// This file used to hold the stock Xcode ControlWidget template — a "Start +// Timer" toggle whose `perform()` body was an empty comment. It was not listed +// in MusadaqLiveActivityBundle, so iOS never surfaced it; had it been surfaced, +// it would have shown users a toggle that does nothing. +// +// Musadaq has no Control Center widget. If one is added later, declare it here +// AND add it to MusadaqLiveActivityBundle. // -import AppIntents -import SwiftUI -import WidgetKit - -struct MusadaqLiveActivityControl: ControlWidget { - var body: some ControlWidgetConfiguration { - StaticControlConfiguration( - kind: "com.example.musadaqApp.MusadaqLiveActivity", - provider: Provider() - ) { value in - ControlWidgetToggle( - "Start Timer", - isOn: value, - action: StartTimerIntent() - ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") - } - } - .displayName("Timer") - .description("A an example control that runs a timer.") - } -} - -extension MusadaqLiveActivityControl { - struct Provider: ControlValueProvider { - var previewValue: Bool { - false - } - - func currentValue() async throws -> Bool { - let isRunning = true // Check if the timer is running - return isRunning - } - } -} - -struct StartTimerIntent: SetValueIntent { - static let title: LocalizedStringResource = "Start a timer" - - @Parameter(title: "Timer is running") - var value: Bool - - func perform() async throws -> some IntentResult { - // Start / stop the timer based on `value`. - return .result() - } -} +import Foundation diff --git a/musadaq-app/ios/Podfile.lock b/musadaq-app/ios/Podfile.lock index 5a6ce3a..77d372f 100644 --- a/musadaq-app/ios/Podfile.lock +++ b/musadaq-app/ios/Podfile.lock @@ -46,32 +46,32 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase/CoreOnly (12.12.0): - - FirebaseCore (~> 12.12.0) - - Firebase/Messaging (12.12.0): + - Firebase/CoreOnly (12.13.0): + - FirebaseCore (~> 12.13.0) + - Firebase/Messaging (12.13.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.12.0) - - firebase_core (4.7.0): - - Firebase/CoreOnly (= 12.12.0) + - FirebaseMessaging (~> 12.13.0) + - firebase_core (4.9.0): + - Firebase/CoreOnly (= 12.13.0) - Flutter - - firebase_messaging (16.2.0): - - Firebase/Messaging (= 12.12.0) + - firebase_messaging (16.2.2): + - Firebase/Messaging (= 12.13.0) - firebase_core - Flutter - - FirebaseCore (12.12.1): - - FirebaseCoreInternal (~> 12.12.0) + - FirebaseCore (12.13.0): + - FirebaseCoreInternal (~> 12.13.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.12.0): + - FirebaseCoreInternal (12.13.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.12.0): - - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (12.13.0): + - FirebaseCore (~> 12.13.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.12.0): - - FirebaseCore (~> 12.12.0) - - FirebaseInstallations (~> 12.12.0) + - FirebaseMessaging (12.13.0): + - FirebaseCore (~> 12.13.0) + - FirebaseInstallations (~> 12.13.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) @@ -84,6 +84,8 @@ PODS: - Mantle - SDWebImage - SDWebImageWebPCoder + - flutter_local_notifications (0.0.1): + - Flutter - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS @@ -141,15 +143,12 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - ObjectBox (4.4.1) + - ObjectBox (5.3.0-beta.4) - objectbox_flutter_libs (0.0.1): - Flutter - - ObjectBox (= 4.4.1) + - ObjectBox (= 5.3.0-beta.4) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - printing (1.0.0): @@ -187,13 +186,13 @@ DEPENDENCIES: - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - freerasp (from `.symlinks/plugins/freerasp/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - printing (from `.symlinks/plugins/printing/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) @@ -245,6 +244,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_image_compress_common: :path: ".symlinks/plugins/flutter_image_compress_common/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" freerasp: @@ -257,8 +258,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" printing: @@ -285,28 +284,28 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33 - firebase_core: 9156a152117c843440b0b990c785aa0259bc5447 - firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269 - FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 - FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 - FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e - FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 + Firebase: 7d62445aeabdaea36f7d372f33052fed9a72514f + firebase_core: 0013f886fbd0b4950865551eaab47784424bfdb5 + firebase_messaging: b875e4088ddd9ecd1834f6c89f6e0a1ffc7d98f0 + FirebaseCore: 58905958aa00a061397a0fd759ae4b55bddb3576 + FirebaseCoreInternal: 37bee58388fc6d183f0ab1b32d69ae44f2cf8aad + FirebaseInstallations: 134bde50e477628ded76070efdb12d515d53f948 + FirebaseMessaging: 30564b85d2f81a96f9d312bd23acf8186ff092ae Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 freerasp: d77275f774facb901f52e9608e5bd34768728363 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 - objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 + ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a + objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 @@ -317,7 +316,7 @@ SPEC CHECKSUMS: speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079 diff --git a/musadaq-app/ios/Runner/AppDelegate.swift b/musadaq-app/ios/Runner/AppDelegate.swift index 6266644..0be75a2 100644 --- a/musadaq-app/ios/Runner/AppDelegate.swift +++ b/musadaq-app/ios/Runner/AppDelegate.swift @@ -1,13 +1,234 @@ +import ActivityKit import Flutter import UIKit +// ───────────────────────────────────────────────────────────────────────────── +// Live Activity attributes. +// +// This MUST stay byte-for-byte compatible with InvoiceBatchAttributes in +// MusadaqLiveActivity/MusadaqLiveActivityBundle.swift. ActivityKit matches the +// app's activity to the widget's UI by attributes type name and shape, and the +// two targets compile separately, so the declaration is intentionally duplicated +// rather than shared. Change one, change the other. +// ───────────────────────────────────────────────────────────────────────────── +struct InvoiceBatchAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var current: Int + var total: Int + var isDone: Bool + var failed: Int = 0 + var statusText: String = "" + } + + var companyName: String +} + @main @objc class AppDelegate: FlutterAppDelegate { + + /// The Live Activity currently on screen, if any. + private var currentActivity: Any? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + if let controller = window?.rootViewController as? FlutterViewController { + registerLiveActivityChannel(with: controller.binaryMessenger) + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // MARK: - Flutter channel + + private func registerLiveActivityChannel(with messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel( + name: "com.musadaq.liveactivity", + binaryMessenger: messenger + ) + + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(nil) + return + } + + let args = call.arguments as? [String: Any] ?? [:] + + switch call.method { + case "start": + self.startActivity(args: args, result: result) + case "update": + self.updateActivity(args: args, result: result) + case "end": + self.endActivity(args: args, result: result) + case "isSupported": + result(self.areActivitiesEnabled()) + default: + result(FlutterMethodNotImplemented) + } + } + } + + // MARK: - ActivityKit + + private func areActivitiesEnabled() -> Bool { + if #available(iOS 16.2, *) { + return ActivityAuthorizationInfo().areActivitiesEnabled + } + return false + } + + /// Starts a Live Activity and returns its ActivityKit push token (hex) so the + /// server can update it remotely while the app is closed. + private func startActivity(args: [String: Any], result: @escaping FlutterResult) { + guard #available(iOS 16.2, *) else { + result(nil) + return + } + + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + // The user disabled Live Activities for this app. Not an error. + result(nil) + return + } + + // Never stack activities: replace any previous one. + endCurrentActivityImmediately() + + let companyName = args["companyName"] as? String ?? "" + let total = args["total"] as? Int ?? 1 + let current = args["current"] as? Int ?? 0 + + let attributes = InvoiceBatchAttributes(companyName: companyName) + let state = InvoiceBatchAttributes.ContentState( + current: current, + total: max(total, 1), + isDone: false, + failed: 0, + statusText: "جارٍ الرفع" + ) + + do { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: state, staleDate: Date().addingTimeInterval(30 * 60)), + pushType: .token + ) + self.currentActivity = activity + + // The push token arrives asynchronously, so hand the result back from the + // first token update rather than blocking here. + var didReturn = false + let lock = NSLock() + + Task { + for await tokenData in activity.pushTokenUpdates { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + lock.lock() + let shouldReturn = !didReturn + didReturn = true + lock.unlock() + + if shouldReturn { + DispatchQueue.main.async { result(token) } + } else { + // Token rotated mid-activity: tell Flutter so it can re-register. + DispatchQueue.main.async { + if let controller = self.window?.rootViewController as? FlutterViewController { + FlutterMethodChannel( + name: "com.musadaq.liveactivity", + binaryMessenger: controller.binaryMessenger + ).invokeMethod("onPushTokenChanged", arguments: token) + } + } + } + } + } + + // Don't leave Flutter awaiting forever if no token ever arrives. + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + lock.lock() + let shouldReturn = !didReturn + didReturn = true + lock.unlock() + if shouldReturn { result(nil) } + } + } catch { + NSLog("[LiveActivity] request failed: \(error.localizedDescription)") + result(FlutterError( + code: "START_FAILED", + message: error.localizedDescription, + details: nil + )) + } + } + + private func updateActivity(args: [String: Any], result: @escaping FlutterResult) { + guard #available(iOS 16.2, *), + let activity = currentActivity as? Activity else { + result(nil) + return + } + + let current = args["current"] as? Int ?? 0 + let total = max(args["total"] as? Int ?? 1, 1) + let failed = args["failed"] as? Int ?? 0 + let isDone = args["isDone"] as? Bool ?? false + let statusText = args["statusText"] as? String ?? "" + + let state = InvoiceBatchAttributes.ContentState( + current: current, + total: total, + isDone: isDone, + failed: failed, + statusText: statusText + ) + + Task { + await activity.update(.init(state: state, staleDate: Date().addingTimeInterval(30 * 60))) + DispatchQueue.main.async { result(nil) } + } + } + + private func endActivity(args: [String: Any], result: @escaping FlutterResult) { + guard #available(iOS 16.2, *), + let activity = currentActivity as? Activity else { + currentActivity = nil + result(nil) + return + } + + let finalText = args["finalText"] as? String ?? "اكتمل" + let finalState = InvoiceBatchAttributes.ContentState( + current: activity.content.state.total, + total: activity.content.state.total, + isDone: true, + failed: activity.content.state.failed, + statusText: finalText + ) + + currentActivity = nil + + Task { + // Leave the finished state visible briefly instead of vanishing instantly. + await activity.end( + .init(state: finalState, staleDate: nil), + dismissalPolicy: .after(Date().addingTimeInterval(10)) + ) + DispatchQueue.main.async { result(nil) } + } + } + + private func endCurrentActivityImmediately() { + guard #available(iOS 16.2, *), + let activity = currentActivity as? Activity else { + return + } + currentActivity = nil + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } } diff --git a/musadaq-app/ios/Runner/Info.plist b/musadaq-app/ios/Runner/Info.plist index 7575ff2..a45a662 100644 --- a/musadaq-app/ios/Runner/Info.plist +++ b/musadaq-app/ios/Runner/Info.plist @@ -45,6 +45,14 @@ تطبيق مُصادَق قد يحتاج للوصول إلى الموقع الجغرافي لتحسين تجربة المستخدم وتوفير خدمات مخصصة حسب المنطقة. NSSupportsLiveActivities + + UIBackgroundModes + + remote-notification + fetch + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/musadaq-app/ios/Runner/Runner.entitlements b/musadaq-app/ios/Runner/Runner.entitlements index 5aaef89..b6c2fcd 100644 --- a/musadaq-app/ios/Runner/Runner.entitlements +++ b/musadaq-app/ios/Runner/Runner.entitlements @@ -6,5 +6,10 @@ group.com.musadaq.app + + aps-environment + development diff --git a/musadaq-app/lib/core/network/api_config.dart b/musadaq-app/lib/core/network/api_config.dart new file mode 100644 index 0000000..649b764 --- /dev/null +++ b/musadaq-app/lib/core/network/api_config.dart @@ -0,0 +1,9 @@ +/// Single source of truth for the API base URL. +/// +/// It used to be duplicated as a literal in every place that needed a bare Dio, +/// which meant a URL change had to be found in several files. +class ApiConfig { + const ApiConfig._(); + + static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/'; +} diff --git a/musadaq-app/lib/core/network/dio_client.dart b/musadaq-app/lib/core/network/dio_client.dart index 179fffb..a502e17 100644 --- a/musadaq-app/lib/core/network/dio_client.dart +++ b/musadaq-app/lib/core/network/dio_client.dart @@ -1,9 +1,10 @@ import 'package:dio/dio.dart'; +import 'api_config.dart'; import 'hmac_interceptor.dart'; import '../storage/secure_storage.dart'; class DioClient { - static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/'; // Update with actual URL + static const String baseUrl = ApiConfig.baseUrl; late final Dio dio; DioClient() { diff --git a/musadaq-app/lib/core/network/hmac_interceptor.dart b/musadaq-app/lib/core/network/hmac_interceptor.dart index fc1c1e0..bb0bfff 100644 --- a/musadaq-app/lib/core/network/hmac_interceptor.dart +++ b/musadaq-app/lib/core/network/hmac_interceptor.dart @@ -1,15 +1,32 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; -import '../storage/secure_storage.dart'; +import 'package:get/get.dart'; +import '../storage/secure_storage.dart'; +import '../utils/logger.dart'; +import 'api_config.dart'; + +/// Signs outgoing requests with the per-device secret and attaches the JWT. +/// +/// The signature format must match HmacMiddleware on the server exactly: +/// payload = "METHOD:path:timestampMs[:jsonBody]" +/// signature = HMAC-SHA256(payload, device_secret) (lowercase hex) +/// headers = X-Signature, X-Timestamp +/// +/// Bodies that are not JSON maps (FormData uploads) are signed without a body +/// segment, because there is no canonical string form to hash. class HmacInterceptor extends Interceptor { final SecureStorage secureStorage; HmacInterceptor(this.secureStorage); + /// Guards against several parallel 401s all firing a refresh at once. + static Future? _inFlightRefresh; + @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + void onRequest( + RequestOptions options, RequestInterceptorHandler handler) async { final token = await secureStorage.getToken(); final deviceSecret = await secureStorage.getDeviceSecret(); @@ -20,35 +37,123 @@ class HmacInterceptor extends Interceptor { // Only sign if we have a device secret (after login) if (deviceSecret != null) { final timestamp = DateTime.now().millisecondsSinceEpoch.toString(); - - // Create signature payload - String payload = '${options.method}:${options.path}:$timestamp'; - - // Include body in signature if present - if (options.data != null && options.data is Map) { + + final method = options.method.toUpperCase(); + final path = canonicalPath(options.path); + + var payload = '$method:$path:$timestamp'; + if (options.data is Map) { payload += ':${jsonEncode(options.data)}'; } - // Generate HMAC-SHA256 - final key = utf8.encode(deviceSecret); - final bytes = utf8.encode(payload); - final hmac = Hmac(sha256, key); - final digest = hmac.convert(bytes); + final hmac = Hmac(sha256, utf8.encode(deviceSecret)); + final digest = hmac.convert(utf8.encode(payload)); - // Attach headers options.headers['X-Timestamp'] = timestamp; options.headers['X-Signature'] = digest.toString(); } - super.onRequest(options, handler); + handler.next(options); + } + + /// Strips a leading slash and any api/v1 prefix so client and server hash the + /// same string regardless of how the base URL is configured. + static String canonicalPath(String path) { + var p = path; + if (p.startsWith('http')) { + p = Uri.parse(p).path; + } + p = p.split('?').first; + p = p.replaceFirst(RegExp(r'^/+'), ''); + for (final prefix in ['api/v1/', 'api/', 'v1/']) { + if (p.startsWith(prefix)) { + p = p.substring(prefix.length); + break; + } + } + return p; } @override - void onError(DioException err, ErrorInterceptorHandler handler) { - if (err.response?.statusCode == 401) { - // Handle Token Expiry / Unauthorized - // TODO: Trigger logout or token refresh + void onError(DioException err, ErrorInterceptorHandler handler) async { + final status = err.response?.statusCode; + final path = err.requestOptions.path; + + // Only try to recover from an expired access token, and never for the auth + // endpoints themselves (that would recurse). + final isAuthCall = path.contains('auth/'); + if (status != 401 || isAuthCall) { + return handler.next(err); + } + + final refreshed = await _refreshSession(); + + if (!refreshed) { + // The session is genuinely gone. Clear it and send the user to login once. + // Previously this branch was an empty TODO, so the app just surfaced + // unexplained errors forever once the 30-day JWT expired. + await secureStorage.clearAll(); + if (Get.currentRoute != '/login') { + Get.offAllNamed('/login'); + } + return handler.next(err); + } + + // Retry the original request once with the fresh token. + try { + final retryDio = Dio(BaseOptions(baseUrl: ApiConfig.baseUrl)); + retryDio.interceptors.add(HmacInterceptor(secureStorage)); + + final response = await retryDio.fetch(err.requestOptions); + return handler.resolve(response); + } catch (e) { + AppLogger.error('Retry after token refresh failed', e); + return handler.next(err); + } + } + + /// Exchanges the stored refresh token for a new access token. + /// Concurrent callers share a single in-flight request. + Future _refreshSession() { + return _inFlightRefresh ??= _doRefresh().whenComplete(() { + _inFlightRefresh = null; + }); + } + + Future _doRefresh() async { + try { + final refreshToken = await secureStorage.getRefreshToken(); + final deviceId = await secureStorage.getDeviceId(); + + if (refreshToken == null || deviceId == null) return false; + + // A bare Dio: this request must not pass back through this interceptor. + final dio = Dio(BaseOptions( + baseUrl: ApiConfig.baseUrl, + headers: {'Content-Type': 'application/json'}, + )); + + final res = await dio.post('auth/refresh', data: { + 'refresh_token': refreshToken, + 'device_id': deviceId, + }); + + final data = res.data is Map ? res.data['data'] : null; + final newAccess = data is Map ? data['access_token'] as String? : null; + if (newAccess == null) return false; + + await secureStorage.saveToken(newAccess); + + final newRefresh = data is Map ? data['refresh_token'] as String? : null; + if (newRefresh != null && newRefresh.isNotEmpty) { + await secureStorage.saveRefreshToken(newRefresh); + } + + AppLogger.print('Access token refreshed'); + return true; + } catch (e) { + AppLogger.error('Token refresh failed', e); + return false; } - super.onError(err, handler); } } diff --git a/musadaq-app/lib/core/services/invoice_upload_service.dart b/musadaq-app/lib/core/services/invoice_upload_service.dart index 7bd5042..78b9e29 100644 --- a/musadaq-app/lib/core/services/invoice_upload_service.dart +++ b/musadaq-app/lib/core/services/invoice_upload_service.dart @@ -3,74 +3,215 @@ import 'package:dio/dio.dart'; import '../network/dio_client.dart'; import '../utils/logger.dart'; +/// Outcome of a batch upload. +/// +/// The old API returned `String?` — a null told the caller "it failed" but not +/// why, so the UI could only ever show one generic message. Quota rejections, +/// a dead network and a partially uploaded batch all looked identical. +class BatchUploadResult { + final bool success; + final String? batchId; + + /// Arabic message from the server, safe to show to the user. + final String? errorMessage; + + /// Images that never made it to the server. + final int failedUploads; + + /// Images that were accepted by the server. + final int uploadedCount; + + const BatchUploadResult({ + required this.success, + this.batchId, + this.errorMessage, + this.failedUploads = 0, + this.uploadedCount = 0, + }); + + bool get isPartial => success && failedUploads > 0; +} + class InvoiceUploadService { final Dio _dio = DioClient().client; - /// Uploads a batch of images to the server - /// Returns the batchId if successful, null otherwise. - Future uploadBatch({ + /// How many times a single image upload is retried before giving up on it. + static const int _maxUploadAttempts = 3; + + /// Uploads a batch of images to the server. + /// + /// Individual images are retried on transient failures; an image that still + /// fails is reported in [BatchUploadResult.failedUploads] rather than silently + /// dropped. The batch is only finalized if at least one image landed. + Future uploadBatch({ required String companyId, required List images, - required Function(int current, int total) onProgress, + required void Function(int current, int total) onProgress, }) async { + String? batchId; + try { // 1. Create Batch AppLogger.print('Creating new batch for company: $companyId'); final createResponse = await _dio.post('batches/create', data: { 'company_id': companyId, + // Server reads 'expected_images'; 'total_images' is sent too for older + // builds of the API that only understood that key. + 'expected_images': images.length, 'total_images': images.length, }); if (createResponse.statusCode != 200 && createResponse.statusCode != 201) { - throw Exception('Failed to create batch'); + return BatchUploadResult( + success: false, + errorMessage: _messageOf(createResponse.data) ?? 'فشل إنشاء الدفعة', + ); } - final String batchId = createResponse.data['data']['batch_id']; + batchId = createResponse.data['data']['batch_id'] as String; AppLogger.print('Batch created successfully: $batchId'); - // 2. Upload Images sequentially + // 2. Upload images sequentially, retrying each one. + int uploaded = 0; + int failed = 0; + String? lastError; + for (int i = 0; i < images.length; i++) { final file = images[i]; - final fileName = file.path.split('/').last; - - FormData formData = FormData.fromMap({ - 'batch_id': batchId, - 'image': await MultipartFile.fromFile(file.path, filename: fileName), - 'order_index': i + 1, - }); - - AppLogger.print('Uploading image ${i + 1}/${images.length}: $fileName'); - - await _dio.post( - 'batches/upload-image', - data: formData, - onSendProgress: (int sent, int total) { - // Can be used for detailed progress bar per image if needed - }, + final ok = await _uploadSingleImage( + batchId: batchId, + file: file, + order: i + 1, + onError: (msg) => lastError = msg, ); + if (ok) { + uploaded++; + } else { + failed++; + AppLogger.error('Image ${i + 1} failed after $_maxUploadAttempts attempts', lastError); + } + onProgress(i + 1, images.length); } + // Nothing landed — do not finalize an empty batch, the server rejects it + // anyway and the user needs a real error. + if (uploaded == 0) { + return BatchUploadResult( + success: false, + batchId: batchId, + errorMessage: lastError ?? 'فشل رفع جميع الصور. تحقق من اتصالك بالإنترنت.', + failedUploads: failed, + ); + } + // 3. Finalize Batch - AppLogger.print('Finalizing batch: $batchId'); + AppLogger.print('Finalizing batch: $batchId ($uploaded uploaded, $failed failed)'); final finalizeResponse = await _dio.post('batches/finalize', data: { 'batch_id': batchId, }); if (finalizeResponse.statusCode != 200) { - throw Exception('Failed to finalize batch'); + return BatchUploadResult( + success: false, + batchId: batchId, + errorMessage: _messageOf(finalizeResponse.data) ?? 'فشل بدء معالجة الدفعة', + failedUploads: failed, + uploadedCount: uploaded, + ); } AppLogger.print('Batch finalized successfully!'); - return batchId; - + return BatchUploadResult( + success: true, + batchId: batchId, + failedUploads: failed, + uploadedCount: uploaded, + ); } on DioException catch (e) { AppLogger.error('Upload batch failed (DioException)', e.response?.data); - return null; + return BatchUploadResult( + success: false, + batchId: batchId, + errorMessage: _messageOf(e.response?.data) ?? _networkMessage(e), + ); } catch (e) { AppLogger.error('Upload batch failed', e); - return null; + return BatchUploadResult( + success: false, + batchId: batchId, + errorMessage: 'حدث خطأ غير متوقع أثناء الرفع', + ); + } + } + + Future _uploadSingleImage({ + required String batchId, + required File file, + required int order, + required void Function(String message) onError, + }) async { + final fileName = file.path.split('/').last; + + for (int attempt = 1; attempt <= _maxUploadAttempts; attempt++) { + try { + final formData = FormData.fromMap({ + 'batch_id': batchId, + 'image': await MultipartFile.fromFile(file.path, filename: fileName), + // Server reads 'image_order'; 'order_index' kept for compatibility. + 'image_order': order, + 'order_index': order, + }); + + AppLogger.print('Uploading image $order: $fileName (attempt $attempt)'); + final res = await _dio.post('batches/upload-image', data: formData); + + if (res.statusCode == 200 && res.data['success'] == true) { + return true; + } + + onError(_messageOf(res.data) ?? 'فشل رفع الصورة'); + } on DioException catch (e) { + final status = e.response?.statusCode ?? 0; + onError(_messageOf(e.response?.data) ?? _networkMessage(e)); + + // 4xx means the server rejected this file on its merits (bad type, over + // quota, batch closed). Retrying cannot change the answer. + if (status >= 400 && status < 500) { + return false; + } + } catch (e) { + onError('فشل قراءة الصورة من الجهاز'); + return false; + } + + if (attempt < _maxUploadAttempts) { + await Future.delayed(Duration(milliseconds: 500 * attempt)); + } + } + + return false; + } + + static String? _messageOf(dynamic data) { + if (data is Map && data['message'] is String) { + final msg = data['message'] as String; + if (msg.trim().isNotEmpty) return msg; + } + return null; + } + + static String _networkMessage(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'انتهت مهلة الاتصال بالخادم. تحقق من الإنترنت وحاول مرة أخرى.'; + case DioExceptionType.connectionError: + return 'لا يوجد اتصال بالإنترنت.'; + default: + return 'فشل الاتصال بالخادم.'; } } } diff --git a/musadaq-app/lib/core/services/live_activity_service.dart b/musadaq-app/lib/core/services/live_activity_service.dart new file mode 100644 index 0000000..f989097 --- /dev/null +++ b/musadaq-app/lib/core/services/live_activity_service.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Bridge to the platform's "ongoing task" UI. +/// +/// * iOS → ActivityKit Live Activity (lock screen + Dynamic Island). +/// * Android → an ongoing progress notification, promoted to a Live Update +/// (the Android 16 equivalent of a Live Activity) where the OS supports it. +/// +/// Every method is best-effort: a missing platform implementation or a user who +/// denied notifications must never break an upload. Failures are logged, not +/// thrown. +class LiveActivityService { + LiveActivityService._(); + + static final LiveActivityService instance = LiveActivityService._(); + + static const MethodChannel _channel = + MethodChannel('com.musadaq.liveactivity'); + + bool _active = false; + + /// True while an activity/notification is currently on screen. + bool get isActive => _active; + + /// Called when iOS rotates the ActivityKit push token mid-activity. The old + /// token stops working, so whoever registered it must send the new one. + void Function(String token)? onPushTokenChanged; + + bool _handlerInstalled = false; + + void _installHandler() { + if (_handlerInstalled) return; + _handlerInstalled = true; + _channel.setMethodCallHandler((call) async { + if (call.method == 'onPushTokenChanged') { + final token = call.arguments as String?; + if (token != null && token.isNotEmpty) { + _pushToken = token; + AppLogger.print('LiveActivity push token rotated'); + onPushTokenChanged?.call(token); + } + } + return null; + }); + } + + /// The ActivityKit push token for the running Live Activity, if any. + /// + /// iOS only, and only available after [start]. This is NOT the FCM token: the + /// server must target this token specifically (APNs `liveactivity` push type) + /// to update the activity while the app is closed. + String? _pushToken; + String? get pushToken => _pushToken; + + Future start({ + required String companyName, + required int total, + }) async { + _installHandler(); + try { + final token = await _channel.invokeMethod('start', { + 'companyName': companyName, + 'total': total, + 'current': 0, + }); + _active = true; + if (token != null && token.isNotEmpty) { + _pushToken = token; + AppLogger.print('LiveActivity started, push token acquired'); + } else { + AppLogger.print('LiveActivity started (no push token)'); + } + } on MissingPluginException { + // Platform has no implementation — nothing to show, carry on. + } on PlatformException catch (e) { + AppLogger.error('LiveActivity start failed', e.message); + } catch (e) { + AppLogger.error('LiveActivity start failed', e); + } + } + + Future update({ + required int current, + required int total, + int failed = 0, + bool isDone = false, + String? statusText, + }) async { + if (!_active) return; + try { + await _channel.invokeMethod('update', { + 'current': current, + 'total': total, + 'failed': failed, + 'isDone': isDone, + if (statusText != null) 'statusText': statusText, + }); + } on MissingPluginException { + // ignored + } catch (e) { + AppLogger.error('LiveActivity update failed', e); + } + } + + Future end({String? finalText}) async { + if (!_active) return; + _active = false; + _pushToken = null; + try { + await _channel.invokeMethod('end', { + if (finalText != null) 'finalText': finalText, + }); + } on MissingPluginException { + // ignored + } catch (e) { + AppLogger.error('LiveActivity end failed', e); + } + } + + /// Whether this platform can show a Live Activity at all. + /// Used to decide if the ActivityKit push token is worth uploading. + bool get supportsPushToken => Platform.isIOS; +} diff --git a/musadaq-app/lib/core/services/push_notification_service.dart b/musadaq-app/lib/core/services/push_notification_service.dart index 19eb707..d82d1ea 100644 --- a/musadaq-app/lib/core/services/push_notification_service.dart +++ b/musadaq-app/lib/core/services/push_notification_service.dart @@ -1,29 +1,175 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:get/get.dart'; + +import '../network/dio_client.dart'; +import '../storage/secure_storage.dart'; import '../utils/logger.dart'; +/// Push notifications, end to end. +/// +/// Previously this class only asked for permission and logged foreground +/// messages, so: +/// * nothing was ever shown while the app was open, +/// * tapping a notification did nothing, +/// * a token rotated by FCM was never sent to the server, silently killing +/// notifications for that device forever, +/// * on Android the channel the server targets did not exist, so the OS +/// dropped the notification outright. class PushNotificationService { static final FirebaseMessaging _fcm = FirebaseMessaging.instance; + static final FlutterLocalNotificationsPlugin _local = + FlutterLocalNotificationsPlugin(); + + /// Must match the channel_id the server sends and the + /// default_notification_channel_id in AndroidManifest.xml. + static const String channelId = 'high_importance_channel'; + static const String channelName = 'إشعارات مُصادَق'; + static const String channelDescription = + 'إشعارات معالجة الفواتير والتنبيهات المهمة'; + + static const AndroidNotificationChannel _channel = AndroidNotificationChannel( + channelId, + channelName, + description: channelDescription, + importance: Importance.high, + playSound: true, + ); + + static bool _initialized = false; static Future initialize() async { - // 1. Request permissions (iOS) - NotificationSettings settings = await _fcm.requestPermission( + if (_initialized) return; + _initialized = true; + + // 1. Request permission (iOS + Android 13+). + final settings = await _fcm.requestPermission( alert: true, badge: true, sound: true, ); - AppLogger.print('User granted permission: ${settings.authorizationStatus}'); - // 2. Handle foreground messages - FirebaseMessaging.onMessage.listen((RemoteMessage message) { - AppLogger.print('Received foreground message: ${message.notification?.title}'); - // You can show a local notification here if needed + // 2. Create the Android channel BEFORE any notification arrives. On Android + // 8+ a notification addressed to a non-existent channel is discarded. + await _local + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(_channel); + + // 3. Initialise local notifications so foreground messages are visible. + await _local.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/launcher_icon'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + ), + onDidReceiveNotificationResponse: (response) { + if (response.payload != null && response.payload!.isNotEmpty) { + _routeFromPayload(jsonDecode(response.payload!)); + } + }, + ); + + // 4. Foreground messages: FCM does not draw anything itself here, so present + // the notification ourselves. + FirebaseMessaging.onMessage.listen(_showForeground); + + // 5. Notification tapped while the app was backgrounded. + FirebaseMessaging.onMessageOpenedApp.listen((message) { + AppLogger.print('Notification opened app: ${message.messageId}'); + _routeFromPayload(message.data); + }); + + // 6. App launched from a terminated state by tapping a notification. + final initialMessage = await _fcm.getInitialMessage(); + if (initialMessage != null) { + // Defer until the first route is on screen. + Future.delayed(const Duration(milliseconds: 800), + () => _routeFromPayload(initialMessage.data)); + } + + // 7. FCM rotates tokens. Without this the server keeps a dead token. + _fcm.onTokenRefresh.listen((token) async { + AppLogger.print('FCM token refreshed'); + await registerTokenWithServer(token); }); } + /// Draw a notification for a foreground message. + /// Data-only messages (batch progress) are intentionally not shown. + static Future _showForeground(RemoteMessage message) async { + final notification = message.notification; + if (notification == null) { + AppLogger.print('Silent data message: ${message.data['type']}'); + return; + } + + await _local.show( + message.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/launcher_icon', + ), + iOS: const DarwinNotificationDetails(presentSound: true), + ), + payload: jsonEncode(message.data), + ); + } + + /// Navigate based on the notification payload. + static void _routeFromPayload(Map data) { + final type = data['type']?.toString(); + final invoiceId = data['invoice_id']?.toString(); + + switch (type) { + case 'batch_complete': + case 'invoice_processed': + case 'batch_progress': + if (invoiceId != null && invoiceId.isNotEmpty) { + Get.toNamed('/invoice-detail', arguments: invoiceId); + } else { + Get.toNamed('/main'); + } + break; + default: + Get.toNamed('/notifications'); + } + } + + /// The FCM registration token for this device. + /// + /// On iOS the APNs token must exist first; asking for the FCM token too early + /// throws, which is why this used to return null on iPhones. static Future getToken() async { try { - String? token = await _fcm.getToken(); + if (Platform.isIOS) { + final apnsToken = await _fcm.getAPNSToken(); + if (apnsToken == null) { + // Give APNs registration a moment, then try once more. + await Future.delayed(const Duration(seconds: 3)); + final retry = await _fcm.getAPNSToken(); + if (retry == null) { + AppLogger.error('APNs token unavailable - push disabled on this device', null); + return null; + } + } + } + + final token = await _fcm.getToken(); AppLogger.print('FCM Token: $token'); return token; } catch (e) { @@ -31,4 +177,22 @@ class PushNotificationService { return null; } } + + /// Send the current token to the server. Safe to call when not signed in. + static Future registerTokenWithServer([String? token]) async { + try { + final jwt = await SecureStorage().getToken(); + if (jwt == null) return; // Not signed in yet; login will send it. + + final pushToken = token ?? await getToken(); + if (pushToken == null) return; + + await DioClient().client.post('auth/mobile/register-device', data: { + 'push_token': pushToken, + }); + AppLogger.print('Push token registered with server'); + } catch (e) { + AppLogger.error('Failed to register push token', e); + } + } } diff --git a/musadaq-app/lib/core/services/upload_progress_service.dart b/musadaq-app/lib/core/services/upload_progress_service.dart index 56147be..3bcfad7 100644 --- a/musadaq-app/lib/core/services/upload_progress_service.dart +++ b/musadaq-app/lib/core/services/upload_progress_service.dart @@ -1,48 +1,158 @@ +import 'dart:async'; + import 'package:get/get.dart'; +import 'live_activity_service.dart'; + +/// Batch upload / processing state shown in the in-app overlay, and mirrored to +/// the OS (iOS Live Activity, Android Live Update) so progress stays visible +/// when the app is backgrounded. class UploadProgressService extends GetxService { var isUploading = false.obs; var progress = 0.0.obs; var companyName = ''.obs; var totalImages = 0.obs; var currentImageIndex = 0.obs; - var status = 'uploading'.obs; // uploading, processing, done - void startUpload(String company, int total) { + /// uploading | processing | done | partial | failed + var status = 'uploading'.obs; + + /// Images the server could not extract. + var failedImages = 0.obs; + + /// User-facing reason, set when [status] is `failed` or `partial`. + var errorMessage = ''.obs; + + final LiveActivityService _liveActivity = LiveActivityService.instance; + + Timer? _dismissTimer; + + /// The ActivityKit push token of the running Live Activity, if the platform + /// provided one. The caller uploads it so the server can push updates. + String? get liveActivityPushToken => _liveActivity.pushToken; + + Future startUpload(String company, int total) async { + _dismissTimer?.cancel(); isUploading.value = true; companyName.value = company; totalImages.value = total; currentImageIndex.value = 0; + failedImages.value = 0; + errorMessage.value = ''; progress.value = 0.0; status.value = 'uploading'; + + await _liveActivity.start(companyName: company, total: total); } void updateProgress(double p, int current) { progress.value = p; currentImageIndex.value = current; + // Upload occupies the first half of the bar, extraction the second half. + _liveActivity.update( + current: current, + total: totalImages.value, + statusText: 'جارٍ رفع الصور', + ); } void startProcessing() { status.value = 'processing'; progress.value = 0.5; + currentImageIndex.value = 0; + _liveActivity.update( + current: 0, + total: totalImages.value, + statusText: 'جارٍ استخراج البيانات', + ); } - void updateProcessingProgress(int processed, int total) { + void updateProcessingProgress(int processed, int total, {int failed = 0}) { status.value = 'processing'; - progress.value = processed / total; + // Guard against a divide-by-zero that would put NaN into the progress bar. + progress.value = total > 0 ? (processed + failed) / total : 0.0; currentImageIndex.value = processed; totalImages.value = total; + failedImages.value = failed; + _liveActivity.update( + current: processed, + total: total, + failed: failed, + statusText: 'جارٍ استخراج البيانات', + ); } + /// All images processed successfully. void complete() { status.value = 'done'; progress.value = 1.0; - Future.delayed(const Duration(seconds: 3), () { + currentImageIndex.value = totalImages.value; + _liveActivity.update( + current: totalImages.value, + total: totalImages.value, + isDone: true, + statusText: 'تم بنجاح', + ); + _scheduleDismiss(const Duration(seconds: 3), 'تم بنجاح'); + } + + /// Some images succeeded, some permanently failed. + void completePartial({ + required int processed, + required int failed, + required int total, + String? message, + }) { + status.value = 'partial'; + totalImages.value = total; + currentImageIndex.value = processed; + failedImages.value = failed; + progress.value = 1.0; + errorMessage.value = + message ?? 'نجحت $processed فاتورة وفشلت $failed. يمكنك إعادة تصوير الفواتير الفاشلة.'; + _liveActivity.update( + current: processed, + total: total, + failed: failed, + isDone: true, + statusText: 'اكتمل مع أخطاء', + ); + // Kept on screen longer than a clean success: the user has to act on it. + _scheduleDismiss(const Duration(seconds: 10), 'اكتمل مع أخطاء'); + } + + /// Nothing could be processed, or the upload itself failed. + void fail([String? message]) { + status.value = 'failed'; + errorMessage.value = message ?? 'فشلت العملية. يرجى المحاولة مرة أخرى.'; + _liveActivity.update( + current: currentImageIndex.value, + total: totalImages.value, + failed: failedImages.value, + isDone: true, + statusText: 'فشلت العملية', + ); + _scheduleDismiss(const Duration(seconds: 8), 'فشلت العملية'); + } + + /// Hide the overlay immediately (user dismissed it). + void dismiss() { + _dismissTimer?.cancel(); + isUploading.value = false; + _liveActivity.end(); + } + + void _scheduleDismiss(Duration after, String finalText) { + _dismissTimer?.cancel(); + _dismissTimer = Timer(after, () { isUploading.value = false; + _liveActivity.end(finalText: finalText); }); } - void fail() { - isUploading.value = false; + @override + void onClose() { + _dismissTimer?.cancel(); + super.onClose(); } } diff --git a/musadaq-app/lib/core/storage/secure_storage.dart b/musadaq-app/lib/core/storage/secure_storage.dart index c9d22ce..6a44ff7 100644 --- a/musadaq-app/lib/core/storage/secure_storage.dart +++ b/musadaq-app/lib/core/storage/secure_storage.dart @@ -7,6 +7,8 @@ class SecureStorage { static const String _keyDeviceSecret = 'device_secret'; static const String _keyUserId = 'user_id'; static const String _keyEmail = 'user_email'; + static const String _keyRefreshToken = 'refresh_token'; + static const String _keyDeviceId = 'device_id'; Future saveToken(String token) async { await _storage.write(key: _keyToken, value: token); @@ -24,6 +26,25 @@ class SecureStorage { return await _storage.read(key: _keyDeviceSecret); } + /// Long-lived token used to mint a new access token without re-login. + Future saveRefreshToken(String token) async { + await _storage.write(key: _keyRefreshToken, value: token); + } + + Future getRefreshToken() async { + return await _storage.read(key: _keyRefreshToken); + } + + /// The device fingerprint sent at login. Needed to refresh, because refresh + /// tokens are stored per device on the server. + Future saveDeviceId(String deviceId) async { + await _storage.write(key: _keyDeviceId, value: deviceId); + } + + Future getDeviceId() async { + return await _storage.read(key: _keyDeviceId); + } + Future saveEmail(String email) async { await _storage.write(key: _keyEmail, value: email); } diff --git a/musadaq-app/lib/features/auth/controllers/auth_controller.dart b/musadaq-app/lib/features/auth/controllers/auth_controller.dart index 520493b..3b01dae 100644 --- a/musadaq-app/lib/features/auth/controllers/auth_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/auth_controller.dart @@ -10,6 +10,10 @@ import '../../../core/utils/app_snackbar.dart'; import '../../../core/services/push_notification_service.dart'; class AuthController extends GetxController { + /// App-store review account that skips biometric setup. Must match + /// REVIEWER_EMAIL on the server. + static const String _reviewerEmail = 'reviewer@musadaq.jo'; + final Dio _dio = DioClient().client; final SecureStorage _storage = SecureStorage(); @@ -96,6 +100,12 @@ class AuthController extends GetxController { // Save secure data await _storage.saveToken(data['access_token']); await _storage.saveDeviceSecret(data['device_secret']); + // Both are required to refresh the session later: the server keeps + // refresh tokens per device. + await _storage.saveDeviceId(deviceId); + if (data['refresh_token'] != null) { + await _storage.saveRefreshToken(data['refresh_token']); + } if (data['user']['email'] != null) { await _storage.saveEmail(data['user']['email']); } @@ -103,7 +113,7 @@ class AuthController extends GetxController { AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); // Navigate to Biometric Setup (unless it's the reviewer) - if (data['user']['email'] == 'reviewer@musadaq.jo') { + if (data['user']['email'] == _reviewerEmail) { Get.offAllNamed(AppRoutes.MAIN); } else { Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); @@ -170,6 +180,10 @@ class AuthController extends GetxController { if (data['device_secret'] != null) { await _storage.saveDeviceSecret(data['device_secret']); } + await _storage.saveDeviceId(deviceId); + if (data['refresh_token'] != null) { + await _storage.saveRefreshToken(data['refresh_token']); + } if (data['user']['email'] != null) { await _storage.saveEmail(data['user']['email']); @@ -178,7 +192,7 @@ class AuthController extends GetxController { AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); // Navigate to Dashboard for reviewer, else Biometric Setup - if (email == 'reviewer@musadaq.jo') { + if (email == _reviewerEmail) { Get.offAllNamed(AppRoutes.MAIN); } else { Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); diff --git a/musadaq-app/lib/features/main_shell/views/main_shell_view.dart b/musadaq-app/lib/features/main_shell/views/main_shell_view.dart index 3d22d5f..c5daf9d 100644 --- a/musadaq-app/lib/features/main_shell/views/main_shell_view.dart +++ b/musadaq-app/lib/features/main_shell/views/main_shell_view.dart @@ -17,8 +17,11 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { final MainShellController _shellController = Get.find(); + // Must be find(), not put(): main.dart already registers the permanent + // instance. Re-putting it here swapped in a second, empty instance that other + // controllers were no longer holding, so progress updates went nowhere. final UploadProgressService _progressService = - Get.put(UploadProgressService()); + Get.find(); // 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4) final List _pages = const [ @@ -179,13 +182,37 @@ class _MainShellViewState extends State { final status = _progressService.status.value; final progress = _progressService.progress.value; - Color accentColor = - status == 'done' ? const Color(0xFF10B981) : const Color(0xFF0F4C81); - String statusText = status == 'uploading' - ? 'جاري رفع الصور...' - : (status == 'processing' - ? 'جاري استخراج البيانات...' - : 'اكتملت المعالجة ✓'); + // Terminal states must look different from in-progress ones, otherwise a + // failed or partially failed batch is indistinguishable from a success. + final bool isTerminal = + status == 'done' || status == 'partial' || status == 'failed'; + + final Color accentColor = switch (status) { + 'done' => const Color(0xFF10B981), + 'partial' => const Color(0xFFF59E0B), + 'failed' => const Color(0xFFEF4444), + _ => const Color(0xFF0F4C81), + }; + + final String statusText = switch (status) { + 'uploading' => 'جاري رفع الصور...', + 'processing' => 'جاري استخراج البيانات...', + 'done' => 'اكتملت المعالجة ✓', + 'partial' => 'اكتملت المعالجة مع أخطاء', + 'failed' => 'فشلت العملية', + _ => 'جاري المعالجة...', + }; + + final IconData? terminalIcon = switch (status) { + 'done' => Icons.check_circle, + 'partial' => Icons.warning_amber_rounded, + 'failed' => Icons.error_outline, + _ => null, + }; + + final String detailText = _progressService.errorMessage.value.isNotEmpty + ? _progressService.errorMessage.value + : '${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}'; return Card( elevation: 8, @@ -199,9 +226,8 @@ class _MainShellViewState extends State { children: [ Row( children: [ - status == 'done' - ? const Icon(Icons.check_circle, - color: Color(0xFF10B981), size: 24) + terminalIcon != null + ? Icon(terminalIcon, color: accentColor, size: 24) : const SizedBox( width: 24, height: 24, @@ -213,10 +239,14 @@ class _MainShellViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(statusText, - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13)), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: status == 'failed' ? accentColor : null)), Text( - '${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}', + detailText, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, color: isDark ? Colors.white38 : Colors.grey), @@ -224,13 +254,23 @@ class _MainShellViewState extends State { ], ), ), - Text( - '${(progress * 100).toInt()}%', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: accentColor), - ), + if (isTerminal) + // A terminal card no longer moves on its own, so give the user + // a way to clear it instead of waiting out a timer. + IconButton( + icon: const Icon(Icons.close, size: 18), + color: isDark ? Colors.white38 : Colors.grey, + onPressed: _progressService.dismiss, + tooltip: 'إخفاء', + ) + else + Text( + '${(progress * 100).toInt()}%', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: accentColor), + ), ], ), const SizedBox(height: 10), diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart index abe276c..679e696 100644 --- a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; import 'package:get/get.dart' hide FormData, MultipartFile; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:path_provider/path_provider.dart'; @@ -12,6 +12,7 @@ import '../../../core/utils/logger.dart'; import '../../../core/utils/app_snackbar.dart'; import '../../../core/services/image_processing_service.dart'; import '../../../core/services/invoice_upload_service.dart'; +import '../../../core/services/live_activity_service.dart'; import '../../../core/network/dio_client.dart'; class ScannerController extends GetxController { @@ -31,50 +32,87 @@ class ScannerController extends GetxController { final UploadProgressService _progressService = Get.find(); + /// Kept so the listener is torn down with the controller — otherwise it + /// outlives the screen and calls Get.toNamed() from a disposed controller. + StreamSubscription? _fcmSubscription; + @override void onInit() { super.onInit(); fetchCompanies(); _initFcmListener(); + + // iOS can hand out a new ActivityKit token while an activity is running; the + // previous one silently stops working, so re-register whenever it rotates. + LiveActivityService.instance.onPushTokenChanged = + (_) => _registerLiveActivityToken(); + } + + @override + void onClose() { + _fcmSubscription?.cancel(); + LiveActivityService.instance.onPushTokenChanged = null; + super.onClose(); } void _initFcmListener() { - FirebaseMessaging.onMessage.listen((RemoteMessage message) { + _fcmSubscription = + FirebaseMessaging.onMessage.listen((RemoteMessage message) { final data = message.data; final type = data['type']; final batchId = data['batch_id']; - if (batchId != currentBatchId.value) return; + // Ignore progress for a batch we are no longer tracking. + if (batchId == null || batchId != currentBatchId.value) return; + if (isBatchDone.value) return; + + final processed = _toInt(data['processed']); + final failed = _toInt(data['failed']); + final total = _toInt(data['total'], fallback: totalImagesCount.value); if (type == 'invoice_processed' || type == 'batch_progress') { - processedImagesCount.value = - int.tryParse(data['processed'].toString()) ?? 0; - totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0; + processedImagesCount.value = processed; + totalImagesCount.value = total; - // Update global progress service - _progressService.updateProcessingProgress( - processedImagesCount.value, totalImagesCount.value); + _progressService.updateProcessingProgress(processed, total, + failed: failed); - // If it's a single invoice, we can navigate directly - if (totalImagesCount.value == 1 && data['invoice_id'] != null) { + // The server marks the last progress push of a batch as done, which lets + // us finish immediately instead of waiting for the next poll. + final isDone = data['is_done'] == '1' || data['is_done'] == 1; + if (isDone) { isBatchDone.value = true; - _progressService.complete(); - - // Open invoice details - Get.toNamed('/invoice-detail', arguments: data['invoice_id']); + _finishBatch( + status: failed > 0 ? 'partial_fail' : 'done', + processed: processed, + failed: failed, + total: total, + items: _itemsFromPush(data), + ); } } else if (type == 'batch_complete') { isBatchDone.value = true; - _progressService.complete(); - - // Optionally navigate to invoices list or specific invoice - if (data['invoice_id'] != null && data['invoice_id'].toString().isNotEmpty) { - Get.toNamed('/invoice-detail', arguments: data['invoice_id']); - } + _finishBatch( + status: (data['status'] ?? 'done').toString(), + processed: processed, + failed: failed, + total: total, + items: _itemsFromPush(data), + ); } }); } + /// Shapes a push payload like a /batches/status item list so _finishBatch can + /// treat both sources the same way. + static List _itemsFromPush(Map data) { + final invoiceId = data['invoice_id']; + if (invoiceId == null || invoiceId.toString().isEmpty) return const []; + return [ + {'invoice_id': invoiceId} + ]; + } + Future fetchCompanies() async { isLoadingCompanies.value = true; try { @@ -92,24 +130,36 @@ class ScannerController extends GetxController { Future addImage(String imagePath) async { File originalFile = File(imagePath); capturedImages.add(originalFile); - int index = capturedImages.length - 1; if (imagePath.toLowerCase().endsWith('.pdf')) { AppLogger.print('Added PDF file, skipping image processing: $imagePath'); return; } - ImageProcessingService.processInvoiceImage(originalFile) + // Enhancement runs in the background so the camera stays responsive, but the + // result must be written back by identity, not by index: the list can be + // reordered, have items removed, or be cleared by an upload while this is in + // flight, and a stale index would overwrite the wrong photo. + final future = ImageProcessingService.processInvoiceImage(originalFile) .then((processedFile) { - if (processedFile != null && index < capturedImages.length) { + final index = capturedImages.indexOf(originalFile); + if (processedFile != null && index != -1) { capturedImages[index] = processedFile; AppLogger.print('Finished processing image in background.'); } }).catchError((e) { AppLogger.error('Failed to process image in background', e); }); + + // Tracked so uploadBatch() can wait for enhancement to finish instead of + // shipping the unprocessed originals. + _pendingImageProcessing.add(future); + future.whenComplete(() => _pendingImageProcessing.remove(future)); } + /// Background image-enhancement futures still in flight. + final List> _pendingImageProcessing = []; + Future pickPdfFile() async { try { FilePickerResult? result = await FilePicker.platform.pickFiles( @@ -176,9 +226,9 @@ class ScannerController extends GetxController { try { isProcessing.value = true; uploadProgress.value = 0.0; - - _progressService.startUpload(selectedCompanyName.value, 1); - + + await _progressService.startUpload(selectedCompanyName.value, 1); + final file = File(filePath); final fileName = file.path.split('/').last; @@ -201,11 +251,12 @@ class ScannerController extends GetxController { AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح'); Get.back(); } else { - _progressService.fail(); - AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل استيراد ملف الإكسل'); + final msg = response.data['message'] ?? 'فشل استيراد ملف الإكسل'; + _progressService.fail(msg); + AppSnackbar.showError('خطأ', msg); } } catch (e) { - _progressService.fail(); + _progressService.fail('حدث خطأ أثناء رفع ملف الإكسل'); AppLogger.error('Excel upload failed', e); AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل'); } finally { @@ -233,26 +284,42 @@ class ScannerController extends GetxController { isProcessing.value = true; uploadProgress.value = 0.0; + // Wait for any in-flight image enhancement so we upload the processed + // versions rather than whichever originals happened to still be in place. + if (_pendingImageProcessing.isNotEmpty) { + AppLogger.print( + 'Waiting for ${_pendingImageProcessing.length} image(s) to finish processing...'); + await Future.wait(List.of(_pendingImageProcessing)); + } + + final imagesToUpload = List.of(capturedImages); + AppLogger.print( - 'Uploading batch of ${capturedImages.length} images to company ${selectedCompanyId.value}...'); + 'Uploading batch of ${imagesToUpload.length} images to company ${selectedCompanyId.value}...'); // Start global progress - _progressService.startUpload(selectedCompanyName.value, capturedImages.length); + await _progressService.startUpload( + selectedCompanyName.value, imagesToUpload.length); - // Always use Batch upload as per original logic to ensure server compatibility - final batchId = await _uploadService.uploadBatch( + // Register the Live Activity push token so the server can update the + // lock-screen activity while the app is closed. + await _registerLiveActivityToken(); + + final result = await _uploadService.uploadBatch( companyId: selectedCompanyId.value, - images: capturedImages, + images: imagesToUpload, onProgress: (current, total) { - uploadProgress.value = current / total; + uploadProgress.value = total > 0 ? current / total : 0.0; _progressService.updateProgress(uploadProgress.value, current); }, ); - if (batchId != null) { + if (result.success && result.batchId != null) { + final batchId = result.batchId!; currentBatchId.value = batchId; - totalImagesCount.value = capturedImages.length; + totalImagesCount.value = result.uploadedCount; processedImagesCount.value = 0; + isBatchDone.value = false; capturedImages.clear(); uploadProgress.value = 0.0; @@ -262,16 +329,29 @@ class ScannerController extends GetxController { _progressService.startProcessing(); Get.back(); // Go back to dashboard, progress will show in overlay - AppSnackbar.showSuccess( - 'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية'); - + + if (result.isPartial) { + // Be honest: some photos never reached the server. + AppSnackbar.showWarning( + 'تم البدء مع تنبيه', + 'تم رفع ${result.uploadedCount} صورة، وفشل رفع ${result.failedUploads}. جاري استخراج البيانات للمرفوعة.', + ); + } else { + AppSnackbar.showSuccess( + 'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية'); + } + _startPolling(batchId); } else { - _progressService.fail(); - AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'); + // Surface the server's actual reason (quota, bad file, no network) + // instead of one generic failure message. + final message = + result.errorMessage ?? 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'; + _progressService.fail(message); + AppSnackbar.showError('خطأ', message); } } catch (e) { - _progressService.fail(); + _progressService.fail('حدث خطأ غير متوقع أثناء الرفع'); AppLogger.error('Failed to upload batch/single', e); AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع'); } finally { @@ -279,52 +359,148 @@ class ScannerController extends GetxController { } } + /// Push the ActivityKit token (iOS) up to the server so it can drive the Live + /// Activity remotely. Silent no-op elsewhere. + Future _registerLiveActivityToken() async { + final token = _progressService.liveActivityPushToken; + if (token == null || token.isEmpty) return; + try { + await DioClient().client.post('auth/mobile/register-device', data: { + 'live_activity_token': token, + }); + AppLogger.print('Live Activity token registered with server'); + } catch (e) { + // Non-critical: local updates still work while the app is foregrounded. + AppLogger.error('Failed to register Live Activity token', e); + } + } + + /// Hard ceiling on polling. Without one, a batch that never reached a terminal + /// state (the old code only ever stopped on 'done') kept the app hitting the + /// API every 5s forever while the user stared at a frozen progress bar. + static const Duration _pollTimeout = Duration(minutes: 10); + + /// Consecutive network errors tolerated before giving up. + static const int _maxPollErrors = 5; + void _startPolling(String batchId) { bool firstPoll = true; - - // Check status periodically + int consecutiveErrors = 0; + final deadline = DateTime.now().add(_pollTimeout); + Future.doWhile(() async { - // Wait before checking // First poll is after 8 seconds (AI takes time), subsequent are 5 seconds await Future.delayed(Duration(seconds: firstPoll ? 8 : 5)); firstPoll = false; - // If we are no longer interested in this batch or it's done, stop polling + // A newer batch was started, or a push notification already finished us. if (currentBatchId.value != batchId || isBatchDone.value) return false; - try { - final res = await DioClient().client.get('batches/status', queryParameters: {'batch_id': batchId}); - if (res.data['success'] == true) { - final batch = res.data['data']['batch']; - final items = res.data['data']['items'] as List; - - processedImagesCount.value = int.tryParse(batch['processed_images'].toString()) ?? 0; - totalImagesCount.value = int.tryParse(batch['total_images'].toString()) ?? 1; - - _progressService.updateProcessingProgress(processedImagesCount.value, totalImagesCount.value); + if (DateTime.now().isAfter(deadline)) { + AppLogger.error('Polling timed out for batch $batchId', null); + isBatchDone.value = true; + _progressService.fail( + 'استغرقت المعالجة وقتاً أطول من المتوقع. تحقق من قائمة الفواتير بعد قليل.'); + AppSnackbar.showWarning('تأخر في المعالجة', + 'ما زالت الدفعة قيد المعالجة على الخادم. راجع قائمة الفواتير بعد قليل.'); + return false; + } - if (batch['status'] == 'done') { + try { + final res = await DioClient() + .client + .get('batches/status', queryParameters: {'batch_id': batchId}); + + if (res.data['success'] == true) { + consecutiveErrors = 0; + + final data = res.data['data']; + final batch = data['batch']; + final items = (data['items'] as List?) ?? const []; + + final processed = _toInt(batch['processed_images']); + final failed = _toInt(batch['failed_images']); + final total = _toInt(batch['total_images'], fallback: 1); + final batchStatus = (batch['status'] ?? '').toString(); + + processedImagesCount.value = processed; + totalImagesCount.value = total; + + _progressService.updateProcessingProgress(processed, total, + failed: failed); + + // The server tells us when to stop; fall back to the status string for + // older API builds that do not send is_terminal. + final isTerminal = data['is_terminal'] == true || + const ['done', 'partial_fail', 'failed'].contains(batchStatus); + + if (isTerminal) { isBatchDone.value = true; - _progressService.complete(); - - // If it's a single invoice, find the invoice_id and navigate - if (totalImagesCount.value == 1 && items.isNotEmpty) { - final invoiceId = items.first['invoice_id']; - if (invoiceId != null) { - Get.toNamed('/invoice-detail', arguments: invoiceId); - } - } + _finishBatch( + status: batchStatus, + processed: processed, + failed: failed, + total: total, + items: items, + ); return false; // Stop polling } + } else { + consecutiveErrors++; } } catch (e) { - AppLogger.error('Polling error', e); + consecutiveErrors++; + AppLogger.error('Polling error ($consecutiveErrors/$_maxPollErrors)', e); + + if (consecutiveErrors >= _maxPollErrors) { + isBatchDone.value = true; + _progressService.fail( + 'تعذّر الاتصال بالخادم لمتابعة حالة المعالجة. راجع قائمة الفواتير لاحقاً.'); + return false; + } } return true; // Continue polling }); } + /// Single place that decides what the user sees when a batch reaches a + /// terminal state, so polling and push notifications behave identically. + void _finishBatch({ + required String status, + required int processed, + required int failed, + required int total, + List items = const [], + }) { + if (failed > 0 && processed > 0) { + _progressService.completePartial( + processed: processed, failed: failed, total: total); + AppSnackbar.showWarning('اكتمل مع أخطاء', + 'نجحت $processed فاتورة وفشلت $failed. يمكنك إعادة تصوير الفواتير الفاشلة.'); + } else if (processed == 0 && failed > 0) { + _progressService.fail( + 'فشل استخراج البيانات من جميع الصور. يرجى إعادة التصوير بإضاءة أفضل.'); + AppSnackbar.showError('فشلت المعالجة', + 'لم نتمكن من استخراج البيانات. يرجى إعادة التصوير بإضاءة أفضل.'); + } else { + _progressService.complete(); + } + + // Auto-open the invoice only for a clean single-invoice batch. + if (total == 1 && failed == 0 && items.isNotEmpty) { + final invoiceId = items.first['invoice_id']; + if (invoiceId != null && invoiceId.toString().isNotEmpty) { + Get.toNamed('/invoice-detail', arguments: invoiceId); + } + } + } + + static int _toInt(dynamic value, {int fallback = 0}) { + if (value == null) return fallback; + return int.tryParse(value.toString()) ?? fallback; + } + void selectCompany(String id, String name) { selectedCompanyId.value = id; selectedCompanyName.value = name; diff --git a/musadaq-app/lib/main.dart b/musadaq-app/lib/main.dart index 69eb257..0597a85 100644 --- a/musadaq-app/lib/main.dart +++ b/musadaq-app/lib/main.dart @@ -19,21 +19,29 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { void main() async { WidgetsFlutterBinding.ensureInitialized(); - // 1. Initialize Firebase & Notifications + // 1. Initialize Firebase await Firebase.initializeApp(); - await PushNotificationService.initialize(); - // 2. Register background handler + // 2. Register the background handler BEFORE any listener is attached, so a + // message arriving during startup is not lost. FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); - // 3. Security check (MUST be first) + // 3. Notifications (permissions, Android channel, tap routing, token refresh) + await PushNotificationService.initialize(); + + // 4. Security check (MUST be first) Get.put(DeviceSecurityService(), permanent: true); - // 4. Register global services + // 5. Register global services Get.put(UploadProgressService(), permanent: true); Get.put(HomeWidgetService(), permanent: true); Get.put(ShorebirdUpdateService(), permanent: true); + // 6. Re-sync the push token on every launch. It can rotate while the app is + // closed (reinstall, restore from backup, FCM rotation), and a stale token on + // the server means the device silently stops receiving notifications. + PushNotificationService.registerTokenWithServer(); + runApp(const MusadaqApp()); } diff --git a/musadaq-app/pubspec.lock b/musadaq-app/pubspec.lock index 874239c..66970c6 100644 --- a/musadaq-app/pubspec.lock +++ b/musadaq-app/pubspec.lock @@ -550,6 +550,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1485,6 +1517,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.9" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: diff --git a/musadaq-app/pubspec.yaml b/musadaq-app/pubspec.yaml index b5b47d2..36a28d4 100644 --- a/musadaq-app/pubspec.yaml +++ b/musadaq-app/pubspec.yaml @@ -65,6 +65,9 @@ dependencies: package_info_plus: ^8.0.0 firebase_core: ^4.7.0 firebase_messaging: ^16.2.0 + # Needed to actually DISPLAY a notification while the app is in the foreground + # and to create the Android channel the server targets. + flutter_local_notifications: ^19.4.2 # ─── Code Push (OTA Updates) ──────────────────────── shorebird_code_push: ^2.0.0 diff --git a/public/create_test_account.php b/scripts/legacy_migrations/create_test_account.php similarity index 77% rename from public/create_test_account.php rename to scripts/legacy_migrations/create_test_account.php index bbde69c..0666a75 100644 --- a/public/create_test_account.php +++ b/scripts/legacy_migrations/create_test_account.php @@ -1,10 +1,14 @@ */ - -require_once __DIR__ . '/../app/bootstrap/init.php'; - +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit('Not Found'); +} +require_once __DIR__ . '/../../app/bootstrap/init.php'; use App\Core\Database; use App\Core\Encryption; @@ -31,7 +35,12 @@ try { $userName = "App Reviewer"; $userEmail = "reviewer@musadaq.jo"; - $userPassword = "Reviewer2026!"; + // Password must be supplied on the command line - never hardcoded. + // php scripts/legacy_migrations/create_test_account.php '' + $userPassword = $argv[1] ?? ''; + if (strlen($userPassword) < 12) { + exit("Usage: php create_test_account.php '' (min 12 chars)\n"); + } // 3. Encrypt data $encryptedTenantName = Encryption::encrypt($tenantName); @@ -68,10 +77,7 @@ try { $db->commit(); echo "

✅ تم إنشاء الحساب التجريبي بنجاح!

"; echo "

البريد الإلكتروني: $userEmail

"; - echo "

كلمة المرور: $userPassword

"; - - // Delete this file for security - @unlink(__FILE__); + echo "

كلمة المرور: (the one you passed on the command line)

"; } catch (\Exception $e) { $db->rollBack(); diff --git a/public/migrate_008.php b/scripts/legacy_migrations/migrate_008.php similarity index 73% rename from public/migrate_008.php rename to scripts/legacy_migrations/migrate_008.php index a90864a..160e8e4 100644 --- a/public/migrate_008.php +++ b/scripts/legacy_migrations/migrate_008.php @@ -1,14 +1,19 @@ */ -require_once __DIR__ . '/../app/bootstrap/init.php'; - +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit('Not Found'); +} +require_once __DIR__ . '/../../app/bootstrap/init.php'; use App\Core\Database; try { $db = Database::getInstance(); - $sql = file_get_contents(__DIR__ . '/../database/migrations/008_invoice_lines_enhance.sql'); + $sql = file_get_contents(__DIR__ . '/../../database/migrations/008_invoice_lines_enhance.sql'); // Split by semicolon and execute $queries = array_filter(array_map('trim', explode(';', $sql))); diff --git a/public/migrate_db.php b/scripts/legacy_migrations/migrate_db.php similarity index 77% rename from public/migrate_db.php rename to scripts/legacy_migrations/migrate_db.php index c1ae0bb..e4292e7 100644 --- a/public/migrate_db.php +++ b/scripts/legacy_migrations/migrate_db.php @@ -1,5 +1,14 @@ + */ +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit('Not Found'); +} +require_once __DIR__ . '/../../app/bootstrap/init.php'; use App\Core\Database; try { diff --git a/public/migrate_phase2.php b/scripts/legacy_migrations/migrate_phase2.php similarity index 77% rename from public/migrate_phase2.php rename to scripts/legacy_migrations/migrate_phase2.php index 4a2757a..26a543a 100644 --- a/public/migrate_phase2.php +++ b/scripts/legacy_migrations/migrate_phase2.php @@ -1,5 +1,14 @@ + */ +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit('Not Found'); +} +require_once __DIR__ . '/../../app/bootstrap/init.php'; use App\Core\Database; try { @@ -64,7 +73,17 @@ try { FOREIGN KEY (batch_id) REFERENCES invoice_batches(id) ON DELETE CASCADE ) "); - $db->exec("CREATE INDEX IF NOT EXISTS idx_status_tenant ON invoice_processing_queue (status, tenant_id)"); + // MySQL has no "CREATE INDEX IF NOT EXISTS" - check information_schema instead. + $idxStmt = $db->prepare(" + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'invoice_processing_queue' + AND index_name = 'idx_status_tenant' + "); + $idxStmt->execute(); + if ((int)$idxStmt->fetchColumn() === 0) { + $db->exec("CREATE INDEX idx_status_tenant ON invoice_processing_queue (status, tenant_id)"); + } echo "Created invoice_processing_queue table.\n"; echo "Phase 2 Migrations completed successfully.\n"; diff --git a/scripts/migrate_queue_hardening.php b/scripts/migrate_queue_hardening.php new file mode 100644 index 0000000..e32b3fc --- /dev/null +++ b/scripts/migrate_queue_hardening.php @@ -0,0 +1,176 @@ + detects workers that died mid-item + * - invoice_processing_queue.max_attempts -> retry ceiling (was referenced, never existed on old installs) + * - invoice_processing_queue.image_order -> per-image ordering + * - invoice_processing_queue.company_id -> written by batches/upload_image.php + * - invoice_batches.failed_images -> failure accounting for completion + * - invoice_batches.updated_at -> touched on every upload + * - invoice_batches.status -> adds 'failed' to the enum + * - invoices.batch_id -> links an invoice back to its batch + * - ai_usage_log.tenant_id -> per-office AI cost attribution + * + * Idempotent: safe to run repeatedly. + * + * Usage: php scripts/migrate_queue_hardening.php + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit('Not Found'); +} + +require_once __DIR__ . '/../app/bootstrap/init.php'; + +use App\Core\Database; + +$db = Database::getInstance(); + +function columnExists(\PDO $db, string $table, string $column): bool +{ + $stmt = $db->prepare(" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? + "); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; +} + +function tableExists(\PDO $db, string $table): bool +{ + $stmt = $db->prepare(" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + "); + $stmt->execute([$table]); + return (int)$stmt->fetchColumn() > 0; +} + +function indexExists(\PDO $db, string $table, string $index): bool +{ + $stmt = $db->prepare(" + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? + "); + $stmt->execute([$table, $index]); + return (int)$stmt->fetchColumn() > 0; +} + +function addColumn(\PDO $db, string $table, string $column, string $definition): void +{ + if (!tableExists($db, $table)) { + echo " - skip {$table}.{$column} (table missing)\n"; + return; + } + if (columnExists($db, $table, $column)) { + echo " = {$table}.{$column} already present\n"; + return; + } + $db->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); + echo " + {$table}.{$column} added\n"; +} + +function addIndex(\PDO $db, string $table, string $index, string $columns): void +{ + if (!tableExists($db, $table)) return; + if (indexExists($db, $table, $index)) { + echo " = index {$table}.{$index} already present\n"; + return; + } + $db->exec("CREATE INDEX `{$index}` ON `{$table}` ({$columns})"); + echo " + index {$table}.{$index} added\n"; +} + +echo "=== Queue hardening migration ===\n"; + +try { + echo "\n[1] invoice_processing_queue\n"; + addColumn($db, 'invoice_processing_queue', 'company_id', "CHAR(36) NULL AFTER tenant_id"); + addColumn($db, 'invoice_processing_queue', 'image_order', "INT NOT NULL DEFAULT 0 AFTER image_path"); + addColumn($db, 'invoice_processing_queue', 'attempts', "INT NOT NULL DEFAULT 0"); + addColumn($db, 'invoice_processing_queue', 'max_attempts', "INT NOT NULL DEFAULT 3 AFTER attempts"); + addColumn($db, 'invoice_processing_queue', 'claimed_at', "DATETIME NULL AFTER created_at"); + addIndex($db, 'invoice_processing_queue', 'idx_batch', 'batch_id'); + addIndex($db, 'invoice_processing_queue', 'idx_claim', 'status, attempts'); + + echo "\n[2] invoice_batches\n"; + addColumn($db, 'invoice_batches', 'failed_images', "INT NOT NULL DEFAULT 0 AFTER processed_images"); + addColumn($db, 'invoice_batches', 'updated_at', "DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"); + if (tableExists($db, 'invoice_batches')) { + // Widen the status enum so a fully-failed batch has a terminal state. + $db->exec(" + ALTER TABLE invoice_batches + MODIFY COLUMN status ENUM('uploading','processing','done','partial_fail','failed') + NOT NULL DEFAULT 'uploading' + "); + echo " ~ invoice_batches.status enum widened (adds 'failed')\n"; + } + + echo "\n[3] invoices\n"; + addColumn($db, 'invoices', 'batch_id', "CHAR(36) NULL AFTER company_id"); + addIndex($db, 'invoices', 'idx_batch_id', 'batch_id'); + + echo "\n[4] ai_usage_log\n"; + addColumn($db, 'ai_usage_log', 'tenant_id', "CHAR(36) NULL AFTER id"); + addIndex($db, 'ai_usage_log', 'idx_tenant', 'tenant_id'); + + echo "\n[5] user_devices (Live Activity + per-device refresh tokens)\n"; + addColumn($db, 'user_devices', 'live_activity_token', "TEXT NULL AFTER push_token"); + addColumn($db, 'user_devices', 'refresh_token_hash', "CHAR(64) NULL AFTER device_secret"); + addColumn($db, 'user_devices', 'refresh_expires_at', "DATETIME NULL AFTER refresh_token_hash"); + addIndex($db, 'user_devices', 'idx_refresh_token', 'refresh_token_hash'); + + echo "\n[6] backfill invoices.batch_id from the queue\n"; + if (tableExists($db, 'invoices') && tableExists($db, 'invoice_processing_queue')) { + $backfilled = $db->exec(" + UPDATE invoices i + JOIN invoice_processing_queue q ON q.invoice_id = i.id + SET i.batch_id = q.batch_id + WHERE i.batch_id IS NULL AND q.batch_id IS NOT NULL + "); + echo " ~ backfilled {$backfilled} invoice(s)\n"; + } + + echo "\n[7] release rows stuck in 'processing' from the pre-fix code\n"; + if (tableExists($db, 'invoice_processing_queue')) { + $released = $db->exec(" + UPDATE invoice_processing_queue + SET status = 'pending', claimed_at = NULL, attempts = 0, + error_message = 'Released by queue-hardening migration' + WHERE status = 'processing' + "); + echo " ~ released {$released} row(s)\n"; + } + + echo "\n[8] recount failed_images from the queue (was never populated)\n"; + if (tableExists($db, 'invoice_batches')) { + $db->exec(" + UPDATE invoice_batches b + SET b.failed_images = ( + SELECT COUNT(*) FROM invoice_processing_queue q + WHERE q.batch_id = b.id AND q.status = 'failed' + ) + "); + echo " ~ failed_images recounted\n"; + + $db->exec(" + UPDATE invoice_batches b + SET b.processed_images = ( + SELECT COUNT(*) FROM invoice_processing_queue q + WHERE q.batch_id = b.id AND q.status = 'done' + ) + "); + echo " ~ processed_images recounted\n"; + } + + echo "\n=== Migration completed successfully ===\n"; + +} catch (\Throwable $e) { + echo "\n!!! MIGRATION FAILED: " . $e->getMessage() . "\n"; + exit(1); +}