Update: 2026-07-30 02:27:45
This commit is contained in:
+71
-14
@@ -14,10 +14,52 @@ class AI
|
|||||||
|
|
||||||
private static int $maxRetries = 3;
|
private static int $maxRetries = 3;
|
||||||
|
|
||||||
|
/** Tenant the current extraction belongs to, for per-office cost attribution. */
|
||||||
|
private static ?string $tenantContext = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract Data from Invoice Image or PDF (Base64)
|
* Set the tenant whose quota/cost the next extraction(s) belong to.
|
||||||
|
* Workers should call this before extracting so ai_usage_log is attributable.
|
||||||
|
*/
|
||||||
|
public static function setTenantContext(?string $tenantId): void
|
||||||
|
{
|
||||||
|
self::$tenantContext = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress output that is safe to call from a web request.
|
||||||
|
* Writing to stdout mid-request would corrupt the JSON response body.
|
||||||
|
*/
|
||||||
|
private static function progress(string $msg): void
|
||||||
|
{
|
||||||
|
if (php_sapi_name() === 'cli') {
|
||||||
|
echo $msg . "\n";
|
||||||
|
}
|
||||||
|
@file_put_contents(
|
||||||
|
STORAGE_PATH . '/logs/worker.log',
|
||||||
|
'[' . date('Y-m-d H:i:s') . '] [AI] ' . $msg . "\n",
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the FIRST invoice found in an image or PDF (Base64).
|
||||||
|
*
|
||||||
|
* Kept for callers that only ever expect one invoice. Prefer
|
||||||
|
* extractInvoices() when an image might contain more than one.
|
||||||
*/
|
*/
|
||||||
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
|
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
|
||||||
|
{
|
||||||
|
$invoices = self::extractInvoices($base64Data, $mimeType);
|
||||||
|
return $invoices[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract EVERY invoice found in an image or PDF (Base64).
|
||||||
|
*
|
||||||
|
* @return array<int, array>|null
|
||||||
|
*/
|
||||||
|
public static function extractInvoices(string $base64Data, string $mimeType): ?array
|
||||||
{
|
{
|
||||||
$apiKey = env('GEMINI_API_KEY');
|
$apiKey = env('GEMINI_API_KEY');
|
||||||
if (!$apiKey) {
|
if (!$apiKey) {
|
||||||
@@ -64,7 +106,7 @@ class AI
|
|||||||
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
|
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
|
||||||
if ($attempt < self::$maxRetries) {
|
if ($attempt < self::$maxRetries) {
|
||||||
$wait = pow(2, $attempt) + rand(1, 3);
|
$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);
|
sleep($wait);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -78,7 +120,7 @@ class AI
|
|||||||
// Retry on 503 (overloaded) or 429 (rate limit)
|
// Retry on 503 (overloaded) or 429 (rate limit)
|
||||||
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
|
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
|
||||||
$wait = pow(2, $attempt) + rand(1, 3);
|
$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);
|
sleep($wait);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -106,18 +148,22 @@ class AI
|
|||||||
return null;
|
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
|
// Track token usage from Gemini response
|
||||||
$usage = $result['usageMetadata'] ?? [];
|
$usage = $result['usageMetadata'] ?? [];
|
||||||
if (!empty($usage)) {
|
if (!empty($usage)) {
|
||||||
self::logTokenUsage($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;
|
$totalCostUsd = $inputCost + $outputCost;
|
||||||
$totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD
|
$totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD
|
||||||
|
|
||||||
$db->prepare("
|
$params = [
|
||||||
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([
|
|
||||||
$inputTokens,
|
$inputTokens,
|
||||||
$outputTokens,
|
$outputTokens,
|
||||||
$totalTokens,
|
$totalTokens,
|
||||||
round($totalCostUsd, 8),
|
round($totalCostUsd, 8),
|
||||||
round($totalCostJod, 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) {
|
} catch (\Exception $e) {
|
||||||
// Never crash the main flow for logging
|
// Never crash the main flow for logging
|
||||||
error_log("[AI] Token usage log failed: " . $e->getMessage());
|
error_log("[AI] Token usage log failed: " . $e->getMessage());
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ final class AuthMiddleware
|
|||||||
exit;
|
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;
|
return $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,182 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* HMAC Request Signature Middleware
|
* HMAC Request Signature Middleware (per-device)
|
||||||
*
|
*
|
||||||
* Verifies that incoming requests are signed with a shared secret,
|
* Proves a request came from a device that completed login on this account, and
|
||||||
* preventing replay attacks and ensuring request integrity.
|
* that nobody altered it in flight. This is defence in depth on top of the JWT:
|
||||||
|
* a stolen bearer token alone is not enough to sign a request.
|
||||||
*
|
*
|
||||||
* Client must send:
|
* Client must send:
|
||||||
* X-Timestamp: Unix timestamp (seconds)
|
* X-Timestamp: milliseconds since epoch
|
||||||
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
* X-Signature: HMAC-SHA256("METHOD:path:timestamp[:json_body]", device_secret)
|
||||||
|
*
|
||||||
|
* The device_secret is issued at login and stored encrypted (reversibly) in
|
||||||
|
* user_devices - it CANNOT be bcrypt-hashed, because the server has to be able
|
||||||
|
* to recompute the same HMAC the client computed.
|
||||||
|
*
|
||||||
|
* Rollout: enforcement is controlled by HMAC_ENFORCE in .env.
|
||||||
|
* HMAC_ENFORCE=false (default) -> 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Middleware;
|
namespace App\Middleware;
|
||||||
|
|
||||||
use App\Core\Security;
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
|
||||||
final class HmacMiddleware
|
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
|
||||||
{
|
{
|
||||||
|
$enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true';
|
||||||
|
|
||||||
$headers = getallheaders();
|
$headers = getallheaders();
|
||||||
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
$signature = $headers['X-Signature'] ?? $headers['x-signature']
|
||||||
|
?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||||
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||||
|
|
||||||
// 1. Ensure both headers are present
|
// 1. Missing headers: hard fail only when enforcing.
|
||||||
if (empty($signature) || empty($timestamp)) {
|
if ($signature === '' || $timestamp === '') {
|
||||||
json_error('Missing HMAC signature or timestamp', 401);
|
if ($enforce) {
|
||||||
|
json_error('Missing request signature', 401);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate timestamp is numeric
|
// 2. Validate timestamp format.
|
||||||
if (!ctype_digit((string)$timestamp)) {
|
if (!ctype_digit((string)$timestamp)) {
|
||||||
json_error('Invalid timestamp format', 401);
|
json_error('Invalid timestamp format', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Replay attack prevention — reject stale requests
|
// 3. Replay prevention. The client sends milliseconds; older callers may
|
||||||
$age = abs(time() - (int)$timestamp);
|
// send seconds, so normalise by magnitude rather than guessing.
|
||||||
if ($age > $maxAgeSeconds) {
|
$ts = (int)$timestamp;
|
||||||
json_error('Request expired. Check your system clock.', 401);
|
$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
|
// 4. Look up this device's secret.
|
||||||
|
$deviceId = $decoded['device_id'] ?? null;
|
||||||
|
$userId = $decoded['user_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$deviceId || !$userId) {
|
||||||
|
if ($enforce) {
|
||||||
|
json_error('Signed requests require a registered device', 401);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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');
|
$body = file_get_contents('php://input');
|
||||||
$payload = $timestamp . '.' . $body;
|
|
||||||
$secret = env('HMAC_SECRET_KEY');
|
|
||||||
|
|
||||||
if (!$secret || strlen($secret) < 32) {
|
// index.php accepts both clean URLs (/api/v1/batches/create) and the
|
||||||
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
// ?route=v1/batches/create form, which produce different REQUEST_URIs for
|
||||||
json_error('Server configuration error', 500);
|
// 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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Verify using constant-time comparison (prevents timing attacks)
|
foreach ($candidates as $payload) {
|
||||||
if (!Security::verifySignature($payload, $signature, $secret)) {
|
$expected = hash_hmac('sha256', $payload, $secret);
|
||||||
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
if (hash_equals($expected, strtolower($signature))) {
|
||||||
|
return; // Verified.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||||
json_error('Invalid request signature', 401);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ final class QuotaMiddleware
|
|||||||
*
|
*
|
||||||
* @return array The current subscription data (for UI display)
|
* @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}";
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
$sub = Cache::get($cacheKey);
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
if ($sub === false || $sub === null) {
|
// $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();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
if ($sub === false || $sub === null) {
|
||||||
// Fetch subscription with plan info
|
// Fetch subscription with plan info
|
||||||
$stmt = $db->prepare("
|
$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
|
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_start'] = $newStart;
|
||||||
$sub['current_period_end'] = $newEnd;
|
$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}");
|
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'];
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
$needed = max(1, $needed);
|
||||||
|
|
||||||
if ($used >= $limit) {
|
if (($used + $needed) > $limit) {
|
||||||
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
$remaining = max(0, $limit - $used);
|
||||||
|
$message = $remaining === 0
|
||||||
|
? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.'
|
||||||
|
: 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.';
|
||||||
|
|
||||||
|
json_error($message, 429, [
|
||||||
'quota_type' => 'invoices',
|
'quota_type' => 'invoices',
|
||||||
'used' => $used,
|
'used' => $used,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
|
'requested' => $needed,
|
||||||
|
'remaining' => $remaining,
|
||||||
'plan' => $sub['plan_id'] ?? 'free',
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
'period_end' => $sub['current_period_end'],
|
'period_end' => $sub['current_period_end'],
|
||||||
@@ -99,6 +113,34 @@ final class QuotaMiddleware
|
|||||||
return $sub;
|
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.
|
* Increment the monthly invoice counter after a successful upload.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
|
* Processes a single invoice queue item by its ID.
|
||||||
*/
|
*/
|
||||||
@@ -34,37 +118,41 @@ class InvoiceProcessor
|
|||||||
return false;
|
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 {
|
try {
|
||||||
// Fetch the queue item and its batch info
|
$item = self::claimQueueItem($db, $queueId);
|
||||||
$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();
|
|
||||||
|
|
||||||
if (!$item) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$batchId = $item['batch_id'];
|
$batchId = (string)$item['batch_id'];
|
||||||
$tenantId = $item['tenant_id'];
|
$tenantId = (string)$item['batch_tenant_id'];
|
||||||
$companyId = $item['company_id'];
|
$companyId = (string)$item['batch_company_id'];
|
||||||
$userId = $item['uploaded_by'];
|
$userId = $item['uploaded_by'];
|
||||||
$imagePath = $item['image_path'];
|
$imagePath = (string)$item['image_path'];
|
||||||
|
|
||||||
self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId");
|
self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId");
|
||||||
|
|
||||||
// Mark as processing
|
// Check file exists. A missing file will never appear, so bury it
|
||||||
$db->prepare("UPDATE invoice_processing_queue SET status = 'processing' WHERE id = ?")->execute([$queueId]);
|
// immediately rather than burning the remaining retries.
|
||||||
|
|
||||||
// Check file exists
|
|
||||||
if (!file_exists($imagePath)) {
|
if (!file_exists($imagePath)) {
|
||||||
self::log("Queue ID $queueId: FILE NOT FOUND: $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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,19 +163,127 @@ class InvoiceProcessor
|
|||||||
$base64Data = base64_encode($fileContent);
|
$base64Data = base64_encode($fileContent);
|
||||||
|
|
||||||
// AI Extraction (this takes ~5-15 seconds)
|
// 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) {
|
if (empty($extractedInvoices)) {
|
||||||
self::log("Queue ID $queueId: AI extraction returned NULL (failed).");
|
self::log("Queue ID $queueId: AI extraction returned nothing.");
|
||||||
$db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'AI failed to extract data from image' WHERE id = ?")->execute([$queueId]);
|
self::failQueueItem($db, $item, 'AI failed to extract data from image');
|
||||||
return false;
|
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
|
// Save to database in a transaction
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
|
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 = ?, 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]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
$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());
|
||||||
|
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 push (silent data message + Live Activity update)
|
||||||
|
try {
|
||||||
|
$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();
|
||||||
|
// 'batch_progress' is the type NotificationService recognises for
|
||||||
|
// iOS Live Activity / Android Live Update payloads.
|
||||||
|
$notifier->sendDataNotification($currentBatch['uploaded_by'], [
|
||||||
|
'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) {
|
||||||
|
self::log("Queue ID $queueId: Push notification failed (non-critical): " . $pushErr->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} 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));
|
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
|
|
||||||
$supplierTin = $extracted['supplier']['tin'] ?? '';
|
$supplierTin = $extracted['supplier']['tin'] ?? '';
|
||||||
@@ -97,14 +293,14 @@ class InvoiceProcessor
|
|||||||
|
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO invoices (
|
INSERT INTO invoices (
|
||||||
id, tenant_id, company_id, uploaded_by, original_file_path, status,
|
id, tenant_id, company_id, batch_id, uploaded_by, original_file_path, status,
|
||||||
invoice_number, invoice_date, invoice_type, invoice_category,
|
invoice_number, invoice_date, invoice_type, invoice_category,
|
||||||
supplier_tin, supplier_name, supplier_address,
|
supplier_tin, supplier_name, supplier_address,
|
||||||
buyer_tin, buyer_name, buyer_national_id,
|
buyer_tin, buyer_name, buyer_national_id,
|
||||||
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
subtotal, tax_amount, discount_total, grand_total, currency_code,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, 'extracted',
|
?, ?, ?, ?, ?, ?, 'extracted',
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
NOW()
|
NOW()
|
||||||
@@ -112,7 +308,7 @@ class InvoiceProcessor
|
|||||||
");
|
");
|
||||||
|
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
$invoiceId, $tenantId, $companyId, $userId, $imagePath,
|
$invoiceId, $tenantId, $companyId, $batchId, $userId, $imagePath,
|
||||||
$invoiceNum, $validDate, $extracted['invoice_type'] ?? 'cash', $extracted['invoice_category'] ?? 'simplified',
|
$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($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'] ?? ''),
|
Encryption::encrypt($extracted['buyer']['tin'] ?? ''), Encryption::encrypt($extracted['buyer']['name'] ?? ''), Encryption::encrypt($extracted['buyer']['national_id'] ?? ''),
|
||||||
@@ -152,94 +348,111 @@ class InvoiceProcessor
|
|||||||
$line['tax_category'] ?? 'standard'
|
$line['tax_category'] ?? 'standard'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items.");
|
self::log("Invoice $invoiceId: saved " . count($extracted['lines']) . " line items.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark queue item done
|
return $invoiceId;
|
||||||
$db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, processed_at = NOW() WHERE id = ?")->execute([$invoiceId, $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->commit();
|
|
||||||
self::log("Queue ID $queueId: ✓ Invoice $invoiceId created and 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) {}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if entire batch is complete
|
|
||||||
self::checkBatchCompletion($batchId);
|
|
||||||
|
|
||||||
// Progress/Completion Push
|
|
||||||
try {
|
|
||||||
$stmt = $db->prepare("SELECT total_images, processed_images, 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
|
|
||||||
$notifier->sendDataNotification($currentBatch['uploaded_by'], [
|
|
||||||
'type' => 'invoice_processed',
|
|
||||||
'batch_id' => $batchId,
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'processed' => $currentBatch['processed_images'],
|
|
||||||
'total' => $currentBatch['total_images']
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $pushErr) {
|
|
||||||
self::log("Queue ID $queueId: Push notification failed (non-critical): " . $pushErr->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
self::log("Queue ID $queueId: UNHANDLED EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
public static function checkBatchCompletion(string $batchId): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$db = Database::getInstance();
|
$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]);
|
$stmt->execute([$batchId]);
|
||||||
$batch = $stmt->fetch();
|
$batch = $stmt->fetch();
|
||||||
|
|
||||||
if ($batch && $batch['processed_images'] >= $batch['total_images']) {
|
if (!$batch) {
|
||||||
$db->prepare("UPDATE invoice_batches SET status = 'done', completed_at = NOW() WHERE id = ?")->execute([$batchId]);
|
return;
|
||||||
self::log("Batch $batchId: COMPLETE ({$batch['processed_images']}/{$batch['total_images']})");
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
// Try to get the last invoice_id for this batch for completion navigation
|
// invoices.batch_id is populated now, so this is a direct lookup.
|
||||||
$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 = $db->prepare("SELECT id FROM invoices WHERE batch_id = ? ORDER BY created_at DESC LIMIT 1");
|
||||||
$invStmt->execute([$batchId]);
|
$invStmt->execute([$batchId]);
|
||||||
$lastInvoiceId = $invStmt->fetchColumn();
|
$lastInvoiceId = $invStmt->fetchColumn();
|
||||||
|
|
||||||
|
[$title, $body] = match ($finalStatus) {
|
||||||
|
'done' => [
|
||||||
|
'اكتملت معالجة الدفعة',
|
||||||
|
'تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.',
|
||||||
|
],
|
||||||
|
'partial_fail' => [
|
||||||
|
'اكتملت المعالجة مع أخطاء',
|
||||||
|
"تمت معالجة {$processed} فاتورة بنجاح، وفشلت {$failed}. يمكنك إعادة تصوير الفواتير الفاشلة.",
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'فشلت معالجة الدفعة',
|
||||||
|
"لم نتمكن من استخراج البيانات من {$failed} فاتورة. يرجى إعادة التصوير بإضاءة أفضل.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
$notifier = new NotificationService();
|
$notifier = new NotificationService();
|
||||||
$notifier->sendNotification(
|
$notifier->sendNotification(
|
||||||
$batch['uploaded_by'],
|
$batch['uploaded_by'],
|
||||||
"اكتملت معالجة الدفعة",
|
$title,
|
||||||
"تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.",
|
$body,
|
||||||
[
|
[
|
||||||
'type' => 'batch_complete',
|
'type' => 'batch_complete',
|
||||||
'batch_id' => $batchId,
|
'batch_id' => $batchId,
|
||||||
'invoice_id' => $lastInvoiceId ?: ''
|
'status' => $finalStatus,
|
||||||
|
'processed' => $processed,
|
||||||
|
'failed' => $failed,
|
||||||
|
'total' => $total,
|
||||||
|
'is_done' => 1,
|
||||||
|
'invoice_id' => $lastInvoiceId ?: '',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
self::log("Batch $batchId: Completion notification failed: " . $e->getMessage());
|
self::log("Batch $batchId: Completion notification failed: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
self::log("Batch $batchId: checkBatchCompletion error: " . $e->getMessage());
|
self::log("Batch $batchId: checkBatchCompletion error: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,27 +77,109 @@ class NotificationService
|
|||||||
public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool
|
public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool
|
||||||
{
|
{
|
||||||
$db = Database::getInstance();
|
$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) {
|
if ($deviceId) {
|
||||||
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL");
|
$sql .= " AND device_fingerprint = ?";
|
||||||
$stmt->execute([$userId, $deviceId]);
|
$params[] = $deviceId;
|
||||||
} else {
|
|
||||||
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
try {
|
||||||
if (empty($tokens)) return false;
|
$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;
|
$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++;
|
$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;
|
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
|
* Dispatch notification to Firebase via HTTP v1 API
|
||||||
*/
|
*/
|
||||||
@@ -108,11 +190,6 @@ class NotificationService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$accessToken = $this->getAccessToken();
|
|
||||||
if (!$accessToken) return false;
|
|
||||||
|
|
||||||
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
|
|
||||||
|
|
||||||
$message = [
|
$message = [
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'data' => array_map('strval', $data),
|
'data' => array_map('strval', $data),
|
||||||
@@ -138,7 +215,11 @@ class NotificationService
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
} else {
|
} 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'] = [
|
$message['android'] = [
|
||||||
'priority' => 'high'
|
'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();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
@@ -175,20 +268,57 @@ class NotificationService
|
|||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
]);
|
]);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
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);
|
$response = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("[NotificationService] FCM cURL error ($kind): $curlError");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($httpCode !== 200) {
|
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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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
|
* Get OAuth2 Access Token for Firebase using Service Account JWT
|
||||||
* Self-contained: no external libraries needed.
|
* Self-contained: no external libraries needed.
|
||||||
|
|||||||
@@ -47,16 +47,56 @@ try {
|
|||||||
$processed = 0;
|
$processed = 0;
|
||||||
$failed = 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("
|
$stmt = $db->prepare("
|
||||||
SELECT id FROM invoice_processing_queue
|
SELECT q.id
|
||||||
WHERE status = 'pending'
|
FROM invoice_processing_queue q
|
||||||
ORDER BY created_at ASC
|
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
|
LIMIT 20
|
||||||
");
|
");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$items = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
$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)) {
|
if (empty($items)) {
|
||||||
workerLog("No pending items. Exiting.");
|
workerLog("No pending items. Exiting.");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ if (!function_exists('env')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('input')) {
|
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;
|
static $inputData = null;
|
||||||
if ($inputData === null) {
|
if ($inputData === null) {
|
||||||
$json = file_get_contents('php://input');
|
$json = file_get_contents('php://input');
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ final class AuthMiddleware
|
|||||||
exit;
|
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;
|
return $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,182 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* HMAC Request Signature Middleware
|
* HMAC Request Signature Middleware (per-device)
|
||||||
*
|
*
|
||||||
* Verifies that incoming requests are signed with a shared secret,
|
* Proves a request came from a device that completed login on this account, and
|
||||||
* preventing replay attacks and ensuring request integrity.
|
* that nobody altered it in flight. This is defence in depth on top of the JWT:
|
||||||
|
* a stolen bearer token alone is not enough to sign a request.
|
||||||
*
|
*
|
||||||
* Client must send:
|
* Client must send:
|
||||||
* X-Timestamp: Unix timestamp (seconds)
|
* X-Timestamp: milliseconds since epoch
|
||||||
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
* X-Signature: HMAC-SHA256("METHOD:path:timestamp[:json_body]", device_secret)
|
||||||
|
*
|
||||||
|
* The device_secret is issued at login and stored encrypted (reversibly) in
|
||||||
|
* user_devices - it CANNOT be bcrypt-hashed, because the server has to be able
|
||||||
|
* to recompute the same HMAC the client computed.
|
||||||
|
*
|
||||||
|
* Rollout: enforcement is controlled by HMAC_ENFORCE in .env.
|
||||||
|
* HMAC_ENFORCE=false (default) -> 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Middleware;
|
namespace App\Middleware;
|
||||||
|
|
||||||
use App\Core\Security;
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
|
||||||
final class HmacMiddleware
|
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
|
||||||
{
|
{
|
||||||
|
$enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true';
|
||||||
|
|
||||||
$headers = getallheaders();
|
$headers = getallheaders();
|
||||||
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
$signature = $headers['X-Signature'] ?? $headers['x-signature']
|
||||||
|
?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||||
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||||
|
|
||||||
// 1. Ensure both headers are present
|
// 1. Missing headers: hard fail only when enforcing.
|
||||||
if (empty($signature) || empty($timestamp)) {
|
if ($signature === '' || $timestamp === '') {
|
||||||
json_error('Missing HMAC signature or timestamp', 401);
|
if ($enforce) {
|
||||||
|
json_error('Missing request signature', 401);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate timestamp is numeric
|
// 2. Validate timestamp format.
|
||||||
if (!ctype_digit((string)$timestamp)) {
|
if (!ctype_digit((string)$timestamp)) {
|
||||||
json_error('Invalid timestamp format', 401);
|
json_error('Invalid timestamp format', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Replay attack prevention — reject stale requests
|
// 3. Replay prevention. The client sends milliseconds; older callers may
|
||||||
$age = abs(time() - (int)$timestamp);
|
// send seconds, so normalise by magnitude rather than guessing.
|
||||||
if ($age > $maxAgeSeconds) {
|
$ts = (int)$timestamp;
|
||||||
json_error('Request expired. Check your system clock.', 401);
|
$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
|
// 4. Look up this device's secret.
|
||||||
|
$deviceId = $decoded['device_id'] ?? null;
|
||||||
|
$userId = $decoded['user_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$deviceId || !$userId) {
|
||||||
|
if ($enforce) {
|
||||||
|
json_error('Signed requests require a registered device', 401);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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');
|
$body = file_get_contents('php://input');
|
||||||
$payload = $timestamp . '.' . $body;
|
|
||||||
$secret = env('HMAC_SECRET_KEY');
|
|
||||||
|
|
||||||
if (!$secret || strlen($secret) < 32) {
|
// index.php accepts both clean URLs (/api/v1/batches/create) and the
|
||||||
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
// ?route=v1/batches/create form, which produce different REQUEST_URIs for
|
||||||
json_error('Server configuration error', 500);
|
// 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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Verify using constant-time comparison (prevents timing attacks)
|
foreach ($candidates as $payload) {
|
||||||
if (!Security::verifySignature($payload, $signature, $secret)) {
|
$expected = hash_hmac('sha256', $payload, $secret);
|
||||||
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
if (hash_equals($expected, strtolower($signature))) {
|
||||||
|
return; // Verified.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||||
json_error('Invalid request signature', 401);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ final class QuotaMiddleware
|
|||||||
*
|
*
|
||||||
* @return array The current subscription data (for UI display)
|
* @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}";
|
$cacheKey = "quota_sub_{$tenantId}";
|
||||||
$sub = Cache::get($cacheKey);
|
$sub = Cache::get($cacheKey);
|
||||||
|
|
||||||
if ($sub === false || $sub === null) {
|
// $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();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
if ($sub === false || $sub === null) {
|
||||||
// Fetch subscription with plan info
|
// Fetch subscription with plan info
|
||||||
$stmt = $db->prepare("
|
$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
|
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_start'] = $newStart;
|
||||||
$sub['current_period_end'] = $newEnd;
|
$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}");
|
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'];
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
|
||||||
|
$needed = max(1, $needed);
|
||||||
|
|
||||||
if ($used >= $limit) {
|
if (($used + $needed) > $limit) {
|
||||||
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
|
$remaining = max(0, $limit - $used);
|
||||||
|
$message = $remaining === 0
|
||||||
|
? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.'
|
||||||
|
: 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.';
|
||||||
|
|
||||||
|
json_error($message, 429, [
|
||||||
'quota_type' => 'invoices',
|
'quota_type' => 'invoices',
|
||||||
'used' => $used,
|
'used' => $used,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
|
'requested' => $needed,
|
||||||
|
'remaining' => $remaining,
|
||||||
'plan' => $sub['plan_id'] ?? 'free',
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
'period_end' => $sub['current_period_end'],
|
'period_end' => $sub['current_period_end'],
|
||||||
@@ -99,6 +113,34 @@ final class QuotaMiddleware
|
|||||||
return $sub;
|
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.
|
* Increment the monthly invoice counter after a successful upload.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$deviceId = $data['device_id'] ?? null;
|
$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) {
|
if ($deviceId && !$isReviewer) {
|
||||||
// Generate and send WhatsApp OTP
|
// Generate and send WhatsApp OTP
|
||||||
@@ -127,7 +132,9 @@ if ($deviceId) {
|
|||||||
$deviceName,
|
$deviceName,
|
||||||
$data['platform'] ?? 'web',
|
$data['platform'] ?? 'web',
|
||||||
$data['app_version'] ?? '1.0.0',
|
$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);
|
$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));
|
$refreshToken = bin2hex(random_bytes(32));
|
||||||
$refreshTokenHash = hash('sha256', $refreshToken);
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
|
$refreshTtlDays = $deviceId ? 60 : 7;
|
||||||
$stmt->execute([$refreshTokenHash, $user['id']]);
|
$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)
|
// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
|
||||||
if (!$deviceId) {
|
if (!$deviceId) {
|
||||||
|
|||||||
@@ -10,9 +10,26 @@ use App\Middleware\AuthMiddleware;
|
|||||||
$decoded = AuthMiddleware::check();
|
$decoded = AuthMiddleware::check();
|
||||||
$userId = $decoded['user_id'];
|
$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();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
|
$deviceId = $decoded['device_id'] ?? null;
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
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, 'تم تسجيل الخروج بنجاح');
|
json_success(null, 'تم تسجيل الخروج بنجاح');
|
||||||
|
|||||||
@@ -64,8 +64,12 @@ try {
|
|||||||
exit;
|
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']) {
|
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)
|
// 3. Generate OTP (6 digits)
|
||||||
@@ -102,16 +106,17 @@ try {
|
|||||||
$result = $whatsappService->sendMessage($phone, $message);
|
$result = $whatsappService->sendMessage($phone, $message);
|
||||||
|
|
||||||
if (!$result['success']) {
|
if (!$result['success']) {
|
||||||
error_log("ERROR: Failed to send OTP WhatsApp to phone: {$phone}");
|
// Internal provider details stay in the log, not in the HTTP response.
|
||||||
json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500, ['whatsapp_debug' => $result]);
|
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') {
|
if (env('APP_DEBUG', 'false') === 'true') {
|
||||||
error_log("DEV OTP for {$phone}: {$otp}");
|
error_log("DEV OTP for {$phone}: {$otp}");
|
||||||
}
|
}
|
||||||
|
|
||||||
json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
safe_error($e, 'auth/mobile_request_otp');
|
safe_error($e, 'auth/mobile_request_otp');
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ $stmt->execute([
|
|||||||
$platform,
|
$platform,
|
||||||
$appVersion,
|
$appVersion,
|
||||||
$pushToken,
|
$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)
|
// 6. Generate JWT (30 days for mobile)
|
||||||
@@ -153,11 +155,20 @@ $payload = [
|
|||||||
|
|
||||||
$token = JWT::encode($payload, $secret);
|
$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));
|
$refreshToken = bin2hex(random_bytes(32));
|
||||||
$refreshTokenHash = hash('sha256', $refreshToken);
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?");
|
$refreshExpiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600)); // 60 days
|
||||||
$stmt->execute([$refreshTokenHash, $userId]);
|
|
||||||
|
$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
|
// 8. Decrypt name for response
|
||||||
$userName = $user['name'];
|
$userName = $user['name'];
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Refresh Token Endpoint (Secure Cookie Based)
|
* Refresh Token Endpoint
|
||||||
|
*
|
||||||
|
* Two callers, two transports:
|
||||||
|
* - Web: HttpOnly cookie, token stored on users.refresh_token_hash
|
||||||
|
* - Mobile: JSON body {refresh_token, device_id}, token stored per device on
|
||||||
|
* user_devices.refresh_token_hash
|
||||||
|
*
|
||||||
|
* The mobile path used to be missing entirely (cookies only), so the app had no
|
||||||
|
* way to refresh and simply started failing with 401s once the JWT aged out.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Core\Database;
|
declare(strict_types=1);
|
||||||
use Firebase\JWT\JWT;
|
|
||||||
|
|
||||||
// 1. Get Refresh Token from HttpOnly Cookie
|
use App\Core\Database;
|
||||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
use App\Core\JWT;
|
||||||
|
use App\Core\Security;
|
||||||
|
use App\Middleware\RateLimitMiddleware;
|
||||||
|
|
||||||
|
// Refresh is unauthenticated by design, so rate limit it.
|
||||||
|
RateLimitMiddleware::check(20, 60);
|
||||||
|
|
||||||
|
$data = Security::sanitize(input());
|
||||||
|
|
||||||
|
$refreshToken = $_COOKIE['refresh_token'] ?? ($data['refresh_token'] ?? null);
|
||||||
|
$deviceId = $data['device_id'] ?? null;
|
||||||
|
|
||||||
if (!$refreshToken) {
|
if (!$refreshToken) {
|
||||||
json_error('Refresh token is required', 401);
|
json_error('Refresh token is required', 401);
|
||||||
@@ -16,30 +33,87 @@ if (!$refreshToken) {
|
|||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$refreshTokenHash = hash('sha256', $refreshToken);
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
|
|
||||||
// 2. Verify in DB
|
$user = null;
|
||||||
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1");
|
$isMobile = false;
|
||||||
$stmt->execute([$refreshTokenHash]);
|
|
||||||
$user = $stmt->fetch();
|
// 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) {
|
if (!$user) {
|
||||||
json_error('Invalid refresh token', 401);
|
json_error('Invalid refresh token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate New Access Token
|
// 3. Generate a new access token.
|
||||||
$secret = $_ENV['JWT_SECRET'] ?? null;
|
$secret = env('JWT_SECRET');
|
||||||
if (!$secret) {
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||||||
json_error('Server configuration error', 500);
|
json_error('Server configuration error', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'tenant_id' => $user['tenant_id'], // Now including tenant_id
|
'tenant_id' => $user['tenant_id'],
|
||||||
'role' => $user['role'],
|
'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([
|
json_success([
|
||||||
'access_token' => $token
|
'access_token' => $token,
|
||||||
|
// Web keeps receiving it via the HttpOnly cookie only.
|
||||||
|
'refresh_token' => $isMobile ? $newRefreshToken : null,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ if (isset($data['push_token'])) {
|
|||||||
$params[] = $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'])) {
|
if (isset($data['app_version'])) {
|
||||||
$updateFields[] = 'app_version = ?';
|
$updateFields[] = 'app_version = ?';
|
||||||
$params[] = $data['app_version'];
|
$params[] = $data['app_version'];
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ if ($errors) {
|
|||||||
|
|
||||||
$companyId = $data['company_id'];
|
$companyId = $data['company_id'];
|
||||||
$source = $data['source'] ?? 'mobile_scan';
|
$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
|
// 2. Permission check
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
@@ -52,24 +54,25 @@ if ($decoded['role'] !== 'super_admin' && $company['tenant_id'] !== $tenantId) {
|
|||||||
// Use the actual tenant of the company
|
// Use the actual tenant of the company
|
||||||
$targetTenantId = $company['tenant_id'];
|
$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') {
|
if ($decoded['role'] !== 'super_admin') {
|
||||||
try {
|
QuotaMiddleware::checkInvoiceQuota($targetTenantId, max(1, $expectedImages));
|
||||||
QuotaMiddleware::checkInvoiceQuota($targetTenantId);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
json_error('تم استنفاد رصيد الفواتير لهذا الشهر. قم بترقية باقتك.', 429);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Generate batch ID
|
// 4. Generate batch ID
|
||||||
$batchId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
$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("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO invoice_batches (id, tenant_id, company_id, uploaded_by, total_images, source, status)
|
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
|
// 6. Create upload directory
|
||||||
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
|
$uploadDir = STORAGE_PATH . '/invoices/' . $targetTenantId . '/' . $companyId . '/batches/' . $batchId;
|
||||||
|
|||||||
@@ -48,14 +48,19 @@ if ($batch['total_images'] == 0) {
|
|||||||
json_error('لا يمكن إنهاء دفعة فارغة', 400);
|
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("
|
$stmt = $db->prepare("
|
||||||
UPDATE invoice_batches
|
UPDATE invoice_batches
|
||||||
SET status = 'processing', updated_at = NOW()
|
SET status = 'processing', updated_at = NOW()
|
||||||
WHERE id = ?
|
WHERE id = ? AND status = 'uploading'
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId]);
|
$stmt->execute([$batchId]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() !== 1) {
|
||||||
|
json_error('تم إنهاء هذه الدفعة مسبقاً', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Send response IMMEDIATELY to mobile app
|
// 3. Send response IMMEDIATELY to mobile app
|
||||||
// We manually build the response instead of using json_success() because it calls exit()
|
// We manually build the response instead of using json_success() because it calls exit()
|
||||||
$responsePayload = json_encode([
|
$responsePayload = json_encode([
|
||||||
@@ -117,7 +122,16 @@ $bgLog = function(string $msg) {
|
|||||||
$bgLog("Background processing started for batch: $batchId");
|
$bgLog("Background processing started for batch: $batchId");
|
||||||
|
|
||||||
try {
|
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]);
|
$queueStmt->execute([$batchId]);
|
||||||
$items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN);
|
$items = $queueStmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
@@ -127,12 +141,16 @@ try {
|
|||||||
$bgLog("Processing queue item: $queueId");
|
$bgLog("Processing queue item: $queueId");
|
||||||
try {
|
try {
|
||||||
$success = InvoiceProcessor::processQueueItem((int)$queueId);
|
$success = InvoiceProcessor::processQueueItem((int)$queueId);
|
||||||
$bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED"));
|
$bgLog("Queue item $queueId: " . ($success ? "SUCCESS" : "FAILED/SKIPPED"));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$bgLog("Queue item $queueId EXCEPTION: " . $e->getMessage());
|
$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");
|
$bgLog("Background processing finished for batch: $batchId");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Background Worker Trigger (HTTP)
|
|
||||||
* POST /api/v1/batches/process-worker
|
|
||||||
*
|
|
||||||
* This endpoint is triggered by finalize.php to start processing in the background.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../bootstrap/init.php';
|
|
||||||
|
|
||||||
use App\Services\InvoiceProcessor;
|
|
||||||
use App\Core\Database;
|
|
||||||
|
|
||||||
// 1. Ignore user abort and set no time limit
|
|
||||||
ignore_user_abort(true);
|
|
||||||
set_time_limit(0);
|
|
||||||
|
|
||||||
// 2. Get batch ID
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
|
||||||
$batchId = $data['batch_id'] ?? null;
|
|
||||||
|
|
||||||
if (!$batchId) {
|
|
||||||
exit('No batch ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Process all pending items for this batch
|
|
||||||
$db = Database::getInstance();
|
|
||||||
$stmt = $db->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";
|
|
||||||
@@ -40,15 +40,21 @@ if (!$batch || ($decoded['role'] !== 'super_admin' && $batch['tenant_id'] !== $t
|
|||||||
|
|
||||||
// 2. Get items
|
// 2. Get items
|
||||||
$stmt = $db->prepare("
|
$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
|
FROM invoice_processing_queue
|
||||||
WHERE batch_id = ?
|
WHERE batch_id = ?
|
||||||
ORDER BY image_order ASC
|
ORDER BY image_order ASC, id ASC
|
||||||
");
|
");
|
||||||
$stmt->execute([$batchId]);
|
$stmt->execute([$batchId]);
|
||||||
$items = $stmt->fetchAll();
|
$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([
|
json_success([
|
||||||
'batch' => $batch,
|
'batch' => $batch,
|
||||||
'items' => $items
|
'items' => $items,
|
||||||
|
'is_terminal' => $isTerminal,
|
||||||
|
'should_poll' => !$isTerminal,
|
||||||
], 'تم جلب حالة الدفعة');
|
], 'تم جلب حالة الدفعة');
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ $userId = $decoded['user_id'];
|
|||||||
|
|
||||||
// 1. Validate request
|
// 1. Validate request
|
||||||
$batchId = $_POST['batch_id'] ?? null;
|
$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) {
|
if (!$batchId || !isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||||
$uploadError = $_FILES['image']['error'] ?? 'No file';
|
$uploadError = $_FILES['image']['error'] ?? 'No file';
|
||||||
@@ -46,11 +48,20 @@ if ($batch['status'] !== 'uploading') {
|
|||||||
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
|
json_error('لا يمكن إضافة صور لدفعة تمت معالجتها', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Validate file type
|
// 3. Validate file type.
|
||||||
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
|
// Sniff the real type off the temp file - $_FILES[...]['type'] is supplied by the
|
||||||
$mimeType = $_FILES['image']['type'];
|
// client and can claim anything.
|
||||||
if (!in_array($mimeType, $allowedTypes)) {
|
$allowedTypes = [
|
||||||
json_error('نوع الملف غير مدعوم. المسموح: صور و PDF', 422);
|
'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)
|
// 4. Validate file size (max 10MB)
|
||||||
@@ -59,6 +70,16 @@ if ($_FILES['image']['size'] > $maxSize) {
|
|||||||
json_error('حجم الصورة أكبر من 10 ميغابايت', 422);
|
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
|
// 5. Save file
|
||||||
$companyId = $batch['company_id'];
|
$companyId = $batch['company_id'];
|
||||||
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
|
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/batches/' . $batchId;
|
||||||
@@ -66,7 +87,7 @@ if (!is_dir($uploadDir)) {
|
|||||||
mkdir($uploadDir, 0755, true);
|
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);
|
$fileName = sprintf('img_%03d_%s.%s', $imageOrder, bin2hex(random_bytes(4)), $extension);
|
||||||
$targetPath = $uploadDir . '/' . $fileName;
|
$targetPath = $uploadDir . '/' . $fileName;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
$fileName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $extension;
|
||||||
$targetFile = $uploadDir . $fileName;
|
$targetFile = $uploadDir . $fileName;
|
||||||
|
|
||||||
@@ -94,13 +118,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Run AI Extraction
|
// 5. Run AI Extraction
|
||||||
$mimeType = $_FILES['invoice']['type'];
|
$mimeType = $detectedMime;
|
||||||
$fileContent = file_get_contents($targetFile);
|
$fileContent = file_get_contents($targetFile);
|
||||||
$base64Data = base64_encode($fileContent);
|
$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));
|
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO invoices (
|
INSERT INTO invoices (
|
||||||
@@ -115,9 +143,14 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save Extracted Data
|
// 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();
|
$db->beginTransaction();
|
||||||
|
|
||||||
$extractedInvoices = $extracted['invoices'] ?? [$extracted];
|
|
||||||
$savedIds = [];
|
$savedIds = [];
|
||||||
|
|
||||||
foreach ($extractedInvoices as $inv) {
|
foreach ($extractedInvoices as $inv) {
|
||||||
|
|||||||
@@ -88,16 +88,25 @@ try {
|
|||||||
date('Y-m-d H:i:s')
|
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, [
|
AuditLogger::log('user.created', 'user', null, null, [
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'email' => $data['email'],
|
'email' => $data['email'],
|
||||||
'role' => $data['role'],
|
'role' => $data['role'],
|
||||||
], $decoded);
|
], $decoded);
|
||||||
|
|
||||||
|
json_success(null, 'تم إضافة المستخدم بنجاح');
|
||||||
} catch (\Exception $e) {
|
} 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);
|
json_error('البريد الإلكتروني مسجل مسبقاً', 409);
|
||||||
}
|
}
|
||||||
|
error_log('[users/create] ' . $msg);
|
||||||
json_error('حدث خطأ أثناء حفظ البيانات', 500);
|
json_error('حدث خطأ أثناء حفظ البيانات', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = 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 {
|
kotlinOptions {
|
||||||
@@ -77,3 +80,10 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
<!-- Security (Biometrics) -->
|
<!-- Security (Biometrics) -->
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
|
||||||
|
<!-- Notifications. POST_NOTIFICATIONS is mandatory from Android 13 (API 33);
|
||||||
|
without it the runtime prompt never appears and no notification is ever
|
||||||
|
shown. VIBRATE is used by the high-importance channel. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="مُصادَق"
|
android:label="مُصادَق"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -41,6 +47,31 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- Home screen widget. The layout and provider-info XML already existed
|
||||||
|
but the receiver was never registered, so Android never offered the
|
||||||
|
widget at all. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".MusadaqWidgetProvider"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_musadaq_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- FCM: the channel a notification lands on must already exist on
|
||||||
|
Android 8+, otherwise the system silently drops it. The server sends
|
||||||
|
channel_id "high_importance_channel"; MainActivity creates it, and
|
||||||
|
this meta-data is the fallback for payloads without a channel. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="high_importance_channel" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@mipmap/launcher_icon" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -1,5 +1,233 @@
|
|||||||
package com.musadaq.app
|
package com.musadaq.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity()
|
/**
|
||||||
|
* Hosts the Flutter engine plus the two native bridges the app expects:
|
||||||
|
*
|
||||||
|
* - `com.musadaq.liveactivity` — Android's answer to an iOS Live Activity.
|
||||||
|
* On Android 16 (API 36) an ongoing progress notification can be promoted to
|
||||||
|
* a "Live Update", which surfaces it in the status bar chip much like the
|
||||||
|
* Dynamic Island. On older releases the same notification is shown as a plain
|
||||||
|
* ongoing progress notification, which is the best the platform offers.
|
||||||
|
*
|
||||||
|
* - `com.musadaq.widget` — data feed for the home screen widget. HomeWidgetService
|
||||||
|
* has always called this channel; nothing implemented it, so every call
|
||||||
|
* silently threw MissingPluginException and the widget stayed empty.
|
||||||
|
*/
|
||||||
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Must match PushNotificationService.channelId and the manifest meta-data. */
|
||||||
|
const val PUSH_CHANNEL_ID = "high_importance_channel"
|
||||||
|
|
||||||
|
/** Separate low-importance channel: progress must not buzz on every image. */
|
||||||
|
const val PROGRESS_CHANNEL_ID = "upload_progress_channel"
|
||||||
|
|
||||||
|
const val PROGRESS_NOTIFICATION_ID = 4711
|
||||||
|
|
||||||
|
private const val LIVE_ACTIVITY_CHANNEL = "com.musadaq.liveactivity"
|
||||||
|
private const val WIDGET_CHANNEL = "com.musadaq.widget"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressActive = false
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
createNotificationChannels()
|
||||||
|
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
LIVE_ACTIVITY_CHANNEL
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"start" -> {
|
||||||
|
val company = call.argument<String>("companyName") ?: ""
|
||||||
|
val total = call.argument<Int>("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<String>("companyName") ?: currentCompany,
|
||||||
|
current = call.argument<Int>("current") ?: 0,
|
||||||
|
total = call.argument<Int>("total") ?: 1,
|
||||||
|
failed = call.argument<Int>("failed") ?: 0,
|
||||||
|
isDone = call.argument<Boolean>("isDone") ?: false,
|
||||||
|
statusText = call.argument<String>("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<String, Any?> ?: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <receiver> 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<String, Any?>) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
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
|
// START: FlutterFire Configuration
|
||||||
id("com.google.gms.google-services") version("4.3.15") apply false
|
id("com.google.gms.google-services") version("4.3.15") apply false
|
||||||
// END: FlutterFire Configuration
|
// END: FlutterFire Configuration
|
||||||
|
|||||||
@@ -2,83 +2,15 @@
|
|||||||
// MusadaqLiveActivity.swift
|
// MusadaqLiveActivity.swift
|
||||||
// MusadaqLiveActivity
|
// 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 Foundation
|
||||||
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<Entry>) -> ()) {
|
|
||||||
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<Void> {
|
|
||||||
// // 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: "🤩")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ import SwiftUI
|
|||||||
import ActivityKit
|
import ActivityKit
|
||||||
|
|
||||||
// ─── 1. Data Model ───────────────────────────────────
|
// ─── 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 {
|
struct InvoiceBatchAttributes: ActivityAttributes {
|
||||||
public struct ContentState: Codable, Hashable {
|
public struct ContentState: Codable, Hashable {
|
||||||
var current: Int
|
var current: Int
|
||||||
var total: Int
|
var total: Int
|
||||||
var isDone: Bool
|
var isDone: Bool
|
||||||
|
var failed: Int = 0
|
||||||
|
var statusText: String = ""
|
||||||
}
|
}
|
||||||
var companyName: 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 {
|
struct InvoiceBatchLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: InvoiceBatchAttributes.self) { context in
|
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
|
.foregroundColor(Color(red: 0.831, green: 0.659, blue: 0.263)) // #D4A843
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(context.state.isDone ? "✅ تم الرفع بنجاح" : "مُصادَق — جارٍ الرفع...")
|
Text(headline(for: context.state))
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
ProgressView(
|
ProgressView(
|
||||||
value: Double(context.state.current),
|
value: Double(context.state.current + context.state.failed),
|
||||||
total: Double(context.state.total)
|
total: Double(max(context.state.total, 1))
|
||||||
)
|
)
|
||||||
.tint(Color(red: 0.831, green: 0.659, blue: 0.263))
|
.tint(context.state.failed > 0
|
||||||
Text("\(context.state.current) / \(context.state.total) فاتورة — \(context.attributes.companyName)")
|
? 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)
|
.font(.caption2)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
@@ -66,8 +99,10 @@ struct InvoiceBatchLiveActivity: Widget {
|
|||||||
.font(.caption.bold()).foregroundColor(.white)
|
.font(.caption.bold()).foregroundColor(.white)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
ProgressView(value: Double(context.state.current),
|
// Divide-by-zero guard: total arrives as 0 if a push lands
|
||||||
total: Double(context.state.total))
|
// 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))
|
.tint(Color(red: 0.831, green: 0.659, blue: 0.263))
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
|||||||
@@ -2,53 +2,15 @@
|
|||||||
// MusadaqLiveActivityControl.swift
|
// MusadaqLiveActivityControl.swift
|
||||||
// MusadaqLiveActivity
|
// 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 Foundation
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,32 +46,32 @@ PODS:
|
|||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- Firebase/CoreOnly (12.12.0):
|
- Firebase/CoreOnly (12.13.0):
|
||||||
- FirebaseCore (~> 12.12.0)
|
- FirebaseCore (~> 12.13.0)
|
||||||
- Firebase/Messaging (12.12.0):
|
- Firebase/Messaging (12.13.0):
|
||||||
- Firebase/CoreOnly
|
- Firebase/CoreOnly
|
||||||
- FirebaseMessaging (~> 12.12.0)
|
- FirebaseMessaging (~> 12.13.0)
|
||||||
- firebase_core (4.7.0):
|
- firebase_core (4.9.0):
|
||||||
- Firebase/CoreOnly (= 12.12.0)
|
- Firebase/CoreOnly (= 12.13.0)
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_messaging (16.2.0):
|
- firebase_messaging (16.2.2):
|
||||||
- Firebase/Messaging (= 12.12.0)
|
- Firebase/Messaging (= 12.13.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
- FirebaseCore (12.12.1):
|
- FirebaseCore (12.13.0):
|
||||||
- FirebaseCoreInternal (~> 12.12.0)
|
- FirebaseCoreInternal (~> 12.13.0)
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
- GoogleUtilities/Logger (~> 8.1)
|
- GoogleUtilities/Logger (~> 8.1)
|
||||||
- FirebaseCoreInternal (12.12.0):
|
- FirebaseCoreInternal (12.13.0):
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
- FirebaseInstallations (12.12.0):
|
- FirebaseInstallations (12.13.0):
|
||||||
- FirebaseCore (~> 12.12.0)
|
- FirebaseCore (~> 12.13.0)
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||||
- PromisesObjC (~> 2.4)
|
- PromisesObjC (~> 2.4)
|
||||||
- FirebaseMessaging (12.12.0):
|
- FirebaseMessaging (12.13.0):
|
||||||
- FirebaseCore (~> 12.12.0)
|
- FirebaseCore (~> 12.13.0)
|
||||||
- FirebaseInstallations (~> 12.12.0)
|
- FirebaseInstallations (~> 12.13.0)
|
||||||
- GoogleDataTransport (~> 10.1)
|
- GoogleDataTransport (~> 10.1)
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
- GoogleUtilities/Environment (~> 8.1)
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
@@ -84,6 +84,8 @@ PODS:
|
|||||||
- Mantle
|
- Mantle
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageWebPCoder
|
- SDWebImageWebPCoder
|
||||||
|
- flutter_local_notifications (0.0.1):
|
||||||
|
- Flutter
|
||||||
- flutter_secure_storage_darwin (10.0.0):
|
- flutter_secure_storage_darwin (10.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -141,15 +143,12 @@ PODS:
|
|||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (3.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (3.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
- ObjectBox (4.4.1)
|
- ObjectBox (5.3.0-beta.4)
|
||||||
- objectbox_flutter_libs (0.0.1):
|
- objectbox_flutter_libs (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- ObjectBox (= 4.4.1)
|
- ObjectBox (= 5.3.0-beta.4)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- printing (1.0.0):
|
- printing (1.0.0):
|
||||||
@@ -187,13 +186,13 @@ DEPENDENCIES:
|
|||||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
- 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`)
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- freerasp (from `.symlinks/plugins/freerasp/ios`)
|
- freerasp (from `.symlinks/plugins/freerasp/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/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`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- printing (from `.symlinks/plugins/printing/ios`)
|
- printing (from `.symlinks/plugins/printing/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
@@ -245,6 +244,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_image_compress_common:
|
flutter_image_compress_common:
|
||||||
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
||||||
|
flutter_local_notifications:
|
||||||
|
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
freerasp:
|
freerasp:
|
||||||
@@ -257,8 +258,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
printing:
|
printing:
|
||||||
@@ -285,28 +284,28 @@ SPEC CHECKSUMS:
|
|||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
|
Firebase: 7d62445aeabdaea36f7d372f33052fed9a72514f
|
||||||
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
|
firebase_core: 0013f886fbd0b4950865551eaab47784424bfdb5
|
||||||
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
|
firebase_messaging: b875e4088ddd9ecd1834f6c89f6e0a1ffc7d98f0
|
||||||
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
|
FirebaseCore: 58905958aa00a061397a0fd759ae4b55bddb3576
|
||||||
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
|
FirebaseCoreInternal: 37bee58388fc6d183f0ab1b32d69ae44f2cf8aad
|
||||||
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
|
FirebaseInstallations: 134bde50e477628ded76070efdb12d515d53f948
|
||||||
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
|
FirebaseMessaging: 30564b85d2f81a96f9d312bd23acf8186ff092ae
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||||
|
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
freerasp: d77275f774facb901f52e9608e5bd34768728363
|
freerasp: d77275f774facb901f52e9608e5bd34768728363
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
|
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
|
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
@@ -317,7 +316,7 @@ SPEC CHECKSUMS:
|
|||||||
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
|
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
|
||||||
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079
|
PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,234 @@
|
|||||||
|
import ActivityKit
|
||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
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
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
|
/// The Live Activity currently on screen, if any.
|
||||||
|
private var currentActivity: Any?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
if let controller = window?.rootViewController as? FlutterViewController {
|
||||||
|
registerLiveActivityChannel(with: controller.binaryMessenger)
|
||||||
|
}
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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<InvoiceBatchAttributes>.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<InvoiceBatchAttributes> 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<InvoiceBatchAttributes> 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<InvoiceBatchAttributes> else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentActivity = nil
|
||||||
|
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
<string>تطبيق مُصادَق قد يحتاج للوصول إلى الموقع الجغرافي لتحسين تجربة المستخدم وتوفير خدمات مخصصة حسب المنطقة.</string>
|
<string>تطبيق مُصادَق قد يحتاج للوصول إلى الموقع الجغرافي لتحسين تجربة المستخدم وتوفير خدمات مخصصة حسب المنطقة.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- remote-notification is required for the silent data pushes that carry
|
||||||
|
batch progress. Without it iOS drops content-available payloads while the
|
||||||
|
app is backgrounded, so progress froze the moment the user left the app. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -6,5 +6,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>group.com.musadaq.app</string>
|
<string>group.com.musadaq.app</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Required for APNs registration. Without this key the app never receives
|
||||||
|
an APNs device token, so FirebaseMessaging.getToken() fails on iOS and
|
||||||
|
push_token was being stored as null for every iPhone. -->
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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/';
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'api_config.dart';
|
||||||
import 'hmac_interceptor.dart';
|
import 'hmac_interceptor.dart';
|
||||||
import '../storage/secure_storage.dart';
|
import '../storage/secure_storage.dart';
|
||||||
|
|
||||||
class DioClient {
|
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;
|
late final Dio dio;
|
||||||
|
|
||||||
DioClient() {
|
DioClient() {
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:dio/dio.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 {
|
class HmacInterceptor extends Interceptor {
|
||||||
final SecureStorage secureStorage;
|
final SecureStorage secureStorage;
|
||||||
|
|
||||||
HmacInterceptor(this.secureStorage);
|
HmacInterceptor(this.secureStorage);
|
||||||
|
|
||||||
|
/// Guards against several parallel 401s all firing a refresh at once.
|
||||||
|
static Future<bool>? _inFlightRefresh;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
void onRequest(
|
||||||
|
RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
final token = await secureStorage.getToken();
|
final token = await secureStorage.getToken();
|
||||||
final deviceSecret = await secureStorage.getDeviceSecret();
|
final deviceSecret = await secureStorage.getDeviceSecret();
|
||||||
|
|
||||||
@@ -21,34 +38,122 @@ class HmacInterceptor extends Interceptor {
|
|||||||
if (deviceSecret != null) {
|
if (deviceSecret != null) {
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
|
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
|
||||||
// Create signature payload
|
final method = options.method.toUpperCase();
|
||||||
String payload = '${options.method}:${options.path}:$timestamp';
|
final path = canonicalPath(options.path);
|
||||||
|
|
||||||
// Include body in signature if present
|
var payload = '$method:$path:$timestamp';
|
||||||
if (options.data != null && options.data is Map) {
|
if (options.data is Map) {
|
||||||
payload += ':${jsonEncode(options.data)}';
|
payload += ':${jsonEncode(options.data)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate HMAC-SHA256
|
final hmac = Hmac(sha256, utf8.encode(deviceSecret));
|
||||||
final key = utf8.encode(deviceSecret);
|
final digest = hmac.convert(utf8.encode(payload));
|
||||||
final bytes = utf8.encode(payload);
|
|
||||||
final hmac = Hmac(sha256, key);
|
|
||||||
final digest = hmac.convert(bytes);
|
|
||||||
|
|
||||||
// Attach headers
|
|
||||||
options.headers['X-Timestamp'] = timestamp;
|
options.headers['X-Timestamp'] = timestamp;
|
||||||
options.headers['X-Signature'] = digest.toString();
|
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
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 401) {
|
final status = err.response?.statusCode;
|
||||||
// Handle Token Expiry / Unauthorized
|
final path = err.requestOptions.path;
|
||||||
// TODO: Trigger logout or token refresh
|
|
||||||
|
// 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<bool> _refreshSession() {
|
||||||
|
return _inFlightRefresh ??= _doRefresh().whenComplete(() {
|
||||||
|
_inFlightRefresh = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,74 +3,215 @@ import 'package:dio/dio.dart';
|
|||||||
import '../network/dio_client.dart';
|
import '../network/dio_client.dart';
|
||||||
import '../utils/logger.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 {
|
class InvoiceUploadService {
|
||||||
final Dio _dio = DioClient().client;
|
final Dio _dio = DioClient().client;
|
||||||
|
|
||||||
/// Uploads a batch of images to the server
|
/// How many times a single image upload is retried before giving up on it.
|
||||||
/// Returns the batchId if successful, null otherwise.
|
static const int _maxUploadAttempts = 3;
|
||||||
Future<String?> uploadBatch({
|
|
||||||
|
/// 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<BatchUploadResult> uploadBatch({
|
||||||
required String companyId,
|
required String companyId,
|
||||||
required List<File> images,
|
required List<File> images,
|
||||||
required Function(int current, int total) onProgress,
|
required void Function(int current, int total) onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
|
String? batchId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create Batch
|
// 1. Create Batch
|
||||||
AppLogger.print('Creating new batch for company: $companyId');
|
AppLogger.print('Creating new batch for company: $companyId');
|
||||||
final createResponse = await _dio.post('batches/create', data: {
|
final createResponse = await _dio.post('batches/create', data: {
|
||||||
'company_id': companyId,
|
'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,
|
'total_images': images.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createResponse.statusCode != 200 && createResponse.statusCode != 201) {
|
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');
|
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++) {
|
for (int i = 0; i < images.length; i++) {
|
||||||
final file = images[i];
|
final file = images[i];
|
||||||
final fileName = file.path.split('/').last;
|
final ok = await _uploadSingleImage(
|
||||||
|
batchId: batchId,
|
||||||
FormData formData = FormData.fromMap({
|
file: file,
|
||||||
'batch_id': batchId,
|
order: i + 1,
|
||||||
'image': await MultipartFile.fromFile(file.path, filename: fileName),
|
onError: (msg) => lastError = msg,
|
||||||
'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
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
uploaded++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
AppLogger.error('Image ${i + 1} failed after $_maxUploadAttempts attempts', lastError);
|
||||||
|
}
|
||||||
|
|
||||||
onProgress(i + 1, images.length);
|
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
|
// 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: {
|
final finalizeResponse = await _dio.post('batches/finalize', data: {
|
||||||
'batch_id': batchId,
|
'batch_id': batchId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (finalizeResponse.statusCode != 200) {
|
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!');
|
AppLogger.print('Batch finalized successfully!');
|
||||||
return batchId;
|
return BatchUploadResult(
|
||||||
|
success: true,
|
||||||
|
batchId: batchId,
|
||||||
|
failedUploads: failed,
|
||||||
|
uploadedCount: uploaded,
|
||||||
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
AppLogger.error('Upload batch failed (DioException)', e.response?.data);
|
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) {
|
} catch (e) {
|
||||||
AppLogger.error('Upload batch failed', e);
|
AppLogger.error('Upload batch failed', e);
|
||||||
|
return BatchUploadResult(
|
||||||
|
success: false,
|
||||||
|
batchId: batchId,
|
||||||
|
errorMessage: 'حدث خطأ غير متوقع أثناء الرفع',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _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;
|
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 'فشل الاتصال بالخادم.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> start({
|
||||||
|
required String companyName,
|
||||||
|
required int total,
|
||||||
|
}) async {
|
||||||
|
_installHandler();
|
||||||
|
try {
|
||||||
|
final token = await _channel.invokeMethod<String>('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<void> 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<void> 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;
|
||||||
|
}
|
||||||
@@ -1,29 +1,175 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
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';
|
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 {
|
class PushNotificationService {
|
||||||
static final FirebaseMessaging _fcm = FirebaseMessaging.instance;
|
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<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
// 1. Request permissions (iOS)
|
if (_initialized) return;
|
||||||
NotificationSettings settings = await _fcm.requestPermission(
|
_initialized = true;
|
||||||
|
|
||||||
|
// 1. Request permission (iOS + Android 13+).
|
||||||
|
final settings = await _fcm.requestPermission(
|
||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
AppLogger.print('User granted permission: ${settings.authorizationStatus}');
|
AppLogger.print('User granted permission: ${settings.authorizationStatus}');
|
||||||
|
|
||||||
// 2. Handle foreground messages
|
// 2. Create the Android channel BEFORE any notification arrives. On Android
|
||||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
// 8+ a notification addressed to a non-existent channel is discarded.
|
||||||
AppLogger.print('Received foreground message: ${message.notification?.title}');
|
await _local
|
||||||
// You can show a local notification here if needed
|
.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<void> _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<dynamic, dynamic> 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<String?> getToken() async {
|
static Future<String?> getToken() async {
|
||||||
try {
|
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');
|
AppLogger.print('FCM Token: $token');
|
||||||
return token;
|
return token;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -31,4 +177,22 @@ class PushNotificationService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send the current token to the server. Safe to call when not signed in.
|
||||||
|
static Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,158 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
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 {
|
class UploadProgressService extends GetxService {
|
||||||
var isUploading = false.obs;
|
var isUploading = false.obs;
|
||||||
var progress = 0.0.obs;
|
var progress = 0.0.obs;
|
||||||
var companyName = ''.obs;
|
var companyName = ''.obs;
|
||||||
var totalImages = 0.obs;
|
var totalImages = 0.obs;
|
||||||
var currentImageIndex = 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<void> startUpload(String company, int total) async {
|
||||||
|
_dismissTimer?.cancel();
|
||||||
isUploading.value = true;
|
isUploading.value = true;
|
||||||
companyName.value = company;
|
companyName.value = company;
|
||||||
totalImages.value = total;
|
totalImages.value = total;
|
||||||
currentImageIndex.value = 0;
|
currentImageIndex.value = 0;
|
||||||
|
failedImages.value = 0;
|
||||||
|
errorMessage.value = '';
|
||||||
progress.value = 0.0;
|
progress.value = 0.0;
|
||||||
status.value = 'uploading';
|
status.value = 'uploading';
|
||||||
|
|
||||||
|
await _liveActivity.start(companyName: company, total: total);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(double p, int current) {
|
void updateProgress(double p, int current) {
|
||||||
progress.value = p;
|
progress.value = p;
|
||||||
currentImageIndex.value = current;
|
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() {
|
void startProcessing() {
|
||||||
status.value = 'processing';
|
status.value = 'processing';
|
||||||
progress.value = 0.5;
|
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';
|
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;
|
currentImageIndex.value = processed;
|
||||||
totalImages.value = total;
|
totalImages.value = total;
|
||||||
|
failedImages.value = failed;
|
||||||
|
_liveActivity.update(
|
||||||
|
current: processed,
|
||||||
|
total: total,
|
||||||
|
failed: failed,
|
||||||
|
statusText: 'جارٍ استخراج البيانات',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All images processed successfully.
|
||||||
void complete() {
|
void complete() {
|
||||||
status.value = 'done';
|
status.value = 'done';
|
||||||
progress.value = 1.0;
|
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;
|
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() {
|
@override
|
||||||
isUploading.value = false;
|
void onClose() {
|
||||||
|
_dismissTimer?.cancel();
|
||||||
|
super.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class SecureStorage {
|
|||||||
static const String _keyDeviceSecret = 'device_secret';
|
static const String _keyDeviceSecret = 'device_secret';
|
||||||
static const String _keyUserId = 'user_id';
|
static const String _keyUserId = 'user_id';
|
||||||
static const String _keyEmail = 'user_email';
|
static const String _keyEmail = 'user_email';
|
||||||
|
static const String _keyRefreshToken = 'refresh_token';
|
||||||
|
static const String _keyDeviceId = 'device_id';
|
||||||
|
|
||||||
Future<void> saveToken(String token) async {
|
Future<void> saveToken(String token) async {
|
||||||
await _storage.write(key: _keyToken, value: token);
|
await _storage.write(key: _keyToken, value: token);
|
||||||
@@ -24,6 +26,25 @@ class SecureStorage {
|
|||||||
return await _storage.read(key: _keyDeviceSecret);
|
return await _storage.read(key: _keyDeviceSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Long-lived token used to mint a new access token without re-login.
|
||||||
|
Future<void> saveRefreshToken(String token) async {
|
||||||
|
await _storage.write(key: _keyRefreshToken, value: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> 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<void> saveDeviceId(String deviceId) async {
|
||||||
|
await _storage.write(key: _keyDeviceId, value: deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getDeviceId() async {
|
||||||
|
return await _storage.read(key: _keyDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> saveEmail(String email) async {
|
Future<void> saveEmail(String email) async {
|
||||||
await _storage.write(key: _keyEmail, value: email);
|
await _storage.write(key: _keyEmail, value: email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import '../../../core/utils/app_snackbar.dart';
|
|||||||
import '../../../core/services/push_notification_service.dart';
|
import '../../../core/services/push_notification_service.dart';
|
||||||
|
|
||||||
class AuthController extends GetxController {
|
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 Dio _dio = DioClient().client;
|
||||||
final SecureStorage _storage = SecureStorage();
|
final SecureStorage _storage = SecureStorage();
|
||||||
|
|
||||||
@@ -96,6 +100,12 @@ class AuthController extends GetxController {
|
|||||||
// Save secure data
|
// Save secure data
|
||||||
await _storage.saveToken(data['access_token']);
|
await _storage.saveToken(data['access_token']);
|
||||||
await _storage.saveDeviceSecret(data['device_secret']);
|
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) {
|
if (data['user']['email'] != null) {
|
||||||
await _storage.saveEmail(data['user']['email']);
|
await _storage.saveEmail(data['user']['email']);
|
||||||
}
|
}
|
||||||
@@ -103,7 +113,7 @@ class AuthController extends GetxController {
|
|||||||
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
||||||
|
|
||||||
// Navigate to Biometric Setup (unless it's the reviewer)
|
// 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);
|
Get.offAllNamed(AppRoutes.MAIN);
|
||||||
} else {
|
} else {
|
||||||
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
||||||
@@ -170,6 +180,10 @@ class AuthController extends GetxController {
|
|||||||
if (data['device_secret'] != null) {
|
if (data['device_secret'] != null) {
|
||||||
await _storage.saveDeviceSecret(data['device_secret']);
|
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) {
|
if (data['user']['email'] != null) {
|
||||||
await _storage.saveEmail(data['user']['email']);
|
await _storage.saveEmail(data['user']['email']);
|
||||||
@@ -178,7 +192,7 @@ class AuthController extends GetxController {
|
|||||||
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
|
||||||
|
|
||||||
// Navigate to Dashboard for reviewer, else Biometric Setup
|
// Navigate to Dashboard for reviewer, else Biometric Setup
|
||||||
if (email == 'reviewer@musadaq.jo') {
|
if (email == _reviewerEmail) {
|
||||||
Get.offAllNamed(AppRoutes.MAIN);
|
Get.offAllNamed(AppRoutes.MAIN);
|
||||||
} else {
|
} else {
|
||||||
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ class MainShellView extends StatefulWidget {
|
|||||||
|
|
||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
final MainShellController _shellController = Get.find<MainShellController>();
|
final MainShellController _shellController = Get.find<MainShellController>();
|
||||||
|
// 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 =
|
final UploadProgressService _progressService =
|
||||||
Get.put(UploadProgressService());
|
Get.find<UploadProgressService>();
|
||||||
|
|
||||||
// 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4)
|
// 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4)
|
||||||
final List<Widget> _pages = const [
|
final List<Widget> _pages = const [
|
||||||
@@ -179,13 +182,37 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
final status = _progressService.status.value;
|
final status = _progressService.status.value;
|
||||||
final progress = _progressService.progress.value;
|
final progress = _progressService.progress.value;
|
||||||
|
|
||||||
Color accentColor =
|
// Terminal states must look different from in-progress ones, otherwise a
|
||||||
status == 'done' ? const Color(0xFF10B981) : const Color(0xFF0F4C81);
|
// failed or partially failed batch is indistinguishable from a success.
|
||||||
String statusText = status == 'uploading'
|
final bool isTerminal =
|
||||||
? 'جاري رفع الصور...'
|
status == 'done' || status == 'partial' || status == 'failed';
|
||||||
: (status == 'processing'
|
|
||||||
? 'جاري استخراج البيانات...'
|
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(
|
return Card(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
@@ -199,9 +226,8 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
status == 'done'
|
terminalIcon != null
|
||||||
? const Icon(Icons.check_circle,
|
? Icon(terminalIcon, color: accentColor, size: 24)
|
||||||
color: Color(0xFF10B981), size: 24)
|
|
||||||
: const SizedBox(
|
: const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -213,10 +239,14 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(statusText,
|
Text(statusText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold, fontSize: 13)),
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: status == 'failed' ? accentColor : null)),
|
||||||
Text(
|
Text(
|
||||||
'${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}',
|
detailText,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: isDark ? Colors.white38 : Colors.grey),
|
color: isDark ? Colors.white38 : Colors.grey),
|
||||||
@@ -224,6 +254,16 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
Text(
|
||||||
'${(progress * 100).toInt()}%',
|
'${(progress * 100).toInt()}%',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart' hide FormData, MultipartFile;
|
import 'package:get/get.dart' hide FormData, MultipartFile;
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:path_provider/path_provider.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/utils/app_snackbar.dart';
|
||||||
import '../../../core/services/image_processing_service.dart';
|
import '../../../core/services/image_processing_service.dart';
|
||||||
import '../../../core/services/invoice_upload_service.dart';
|
import '../../../core/services/invoice_upload_service.dart';
|
||||||
|
import '../../../core/services/live_activity_service.dart';
|
||||||
import '../../../core/network/dio_client.dart';
|
import '../../../core/network/dio_client.dart';
|
||||||
|
|
||||||
class ScannerController extends GetxController {
|
class ScannerController extends GetxController {
|
||||||
@@ -31,50 +32,87 @@ class ScannerController extends GetxController {
|
|||||||
final UploadProgressService _progressService =
|
final UploadProgressService _progressService =
|
||||||
Get.find<UploadProgressService>();
|
Get.find<UploadProgressService>();
|
||||||
|
|
||||||
|
/// Kept so the listener is torn down with the controller — otherwise it
|
||||||
|
/// outlives the screen and calls Get.toNamed() from a disposed controller.
|
||||||
|
StreamSubscription<RemoteMessage>? _fcmSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
fetchCompanies();
|
fetchCompanies();
|
||||||
_initFcmListener();
|
_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() {
|
void _initFcmListener() {
|
||||||
|
_fcmSubscription =
|
||||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
final type = data['type'];
|
final type = data['type'];
|
||||||
final batchId = data['batch_id'];
|
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') {
|
if (type == 'invoice_processed' || type == 'batch_progress') {
|
||||||
processedImagesCount.value =
|
processedImagesCount.value = processed;
|
||||||
int.tryParse(data['processed'].toString()) ?? 0;
|
totalImagesCount.value = total;
|
||||||
totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0;
|
|
||||||
|
|
||||||
// Update global progress service
|
_progressService.updateProcessingProgress(processed, total,
|
||||||
_progressService.updateProcessingProgress(
|
failed: failed);
|
||||||
processedImagesCount.value, totalImagesCount.value);
|
|
||||||
|
|
||||||
// If it's a single invoice, we can navigate directly
|
// The server marks the last progress push of a batch as done, which lets
|
||||||
if (totalImagesCount.value == 1 && data['invoice_id'] != null) {
|
// 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;
|
isBatchDone.value = true;
|
||||||
_progressService.complete();
|
_finishBatch(
|
||||||
|
status: failed > 0 ? 'partial_fail' : 'done',
|
||||||
// Open invoice details
|
processed: processed,
|
||||||
Get.toNamed('/invoice-detail', arguments: data['invoice_id']);
|
failed: failed,
|
||||||
|
total: total,
|
||||||
|
items: _itemsFromPush(data),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (type == 'batch_complete') {
|
} else if (type == 'batch_complete') {
|
||||||
isBatchDone.value = true;
|
isBatchDone.value = true;
|
||||||
_progressService.complete();
|
_finishBatch(
|
||||||
|
status: (data['status'] ?? 'done').toString(),
|
||||||
// Optionally navigate to invoices list or specific invoice
|
processed: processed,
|
||||||
if (data['invoice_id'] != null && data['invoice_id'].toString().isNotEmpty) {
|
failed: failed,
|
||||||
Get.toNamed('/invoice-detail', arguments: data['invoice_id']);
|
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<String, dynamic> data) {
|
||||||
|
final invoiceId = data['invoice_id'];
|
||||||
|
if (invoiceId == null || invoiceId.toString().isEmpty) return const [];
|
||||||
|
return [
|
||||||
|
{'invoice_id': invoiceId}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchCompanies() async {
|
Future<void> fetchCompanies() async {
|
||||||
isLoadingCompanies.value = true;
|
isLoadingCompanies.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -92,24 +130,36 @@ class ScannerController extends GetxController {
|
|||||||
Future<void> addImage(String imagePath) async {
|
Future<void> addImage(String imagePath) async {
|
||||||
File originalFile = File(imagePath);
|
File originalFile = File(imagePath);
|
||||||
capturedImages.add(originalFile);
|
capturedImages.add(originalFile);
|
||||||
int index = capturedImages.length - 1;
|
|
||||||
|
|
||||||
if (imagePath.toLowerCase().endsWith('.pdf')) {
|
if (imagePath.toLowerCase().endsWith('.pdf')) {
|
||||||
AppLogger.print('Added PDF file, skipping image processing: $imagePath');
|
AppLogger.print('Added PDF file, skipping image processing: $imagePath');
|
||||||
return;
|
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) {
|
.then((processedFile) {
|
||||||
if (processedFile != null && index < capturedImages.length) {
|
final index = capturedImages.indexOf(originalFile);
|
||||||
|
if (processedFile != null && index != -1) {
|
||||||
capturedImages[index] = processedFile;
|
capturedImages[index] = processedFile;
|
||||||
AppLogger.print('Finished processing image in background.');
|
AppLogger.print('Finished processing image in background.');
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
AppLogger.error('Failed to process image in background', 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<Future<void>> _pendingImageProcessing = [];
|
||||||
|
|
||||||
Future<void> pickPdfFile() async {
|
Future<void> pickPdfFile() async {
|
||||||
try {
|
try {
|
||||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||||
@@ -177,7 +227,7 @@ class ScannerController extends GetxController {
|
|||||||
isProcessing.value = true;
|
isProcessing.value = true;
|
||||||
uploadProgress.value = 0.0;
|
uploadProgress.value = 0.0;
|
||||||
|
|
||||||
_progressService.startUpload(selectedCompanyName.value, 1);
|
await _progressService.startUpload(selectedCompanyName.value, 1);
|
||||||
|
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
final fileName = file.path.split('/').last;
|
final fileName = file.path.split('/').last;
|
||||||
@@ -201,11 +251,12 @@ class ScannerController extends GetxController {
|
|||||||
AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح');
|
AppSnackbar.showSuccess('تم بنجاح', response.data['message'] ?? 'تم استيراد البيانات بنجاح');
|
||||||
Get.back();
|
Get.back();
|
||||||
} else {
|
} else {
|
||||||
_progressService.fail();
|
final msg = response.data['message'] ?? 'فشل استيراد ملف الإكسل';
|
||||||
AppSnackbar.showError('خطأ', response.data['message'] ?? 'فشل استيراد ملف الإكسل');
|
_progressService.fail(msg);
|
||||||
|
AppSnackbar.showError('خطأ', msg);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_progressService.fail();
|
_progressService.fail('حدث خطأ أثناء رفع ملف الإكسل');
|
||||||
AppLogger.error('Excel upload failed', e);
|
AppLogger.error('Excel upload failed', e);
|
||||||
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل');
|
AppSnackbar.showError('خطأ', 'حدث خطأ أثناء رفع ملف الإكسل');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -233,26 +284,42 @@ class ScannerController extends GetxController {
|
|||||||
isProcessing.value = true;
|
isProcessing.value = true;
|
||||||
uploadProgress.value = 0.0;
|
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(
|
AppLogger.print(
|
||||||
'Uploading batch of ${capturedImages.length} images to company ${selectedCompanyId.value}...');
|
'Waiting for ${_pendingImageProcessing.length} image(s) to finish processing...');
|
||||||
|
await Future.wait(List.of(_pendingImageProcessing));
|
||||||
|
}
|
||||||
|
|
||||||
|
final imagesToUpload = List<File>.of(capturedImages);
|
||||||
|
|
||||||
|
AppLogger.print(
|
||||||
|
'Uploading batch of ${imagesToUpload.length} images to company ${selectedCompanyId.value}...');
|
||||||
|
|
||||||
// Start global progress
|
// 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
|
// Register the Live Activity push token so the server can update the
|
||||||
final batchId = await _uploadService.uploadBatch(
|
// lock-screen activity while the app is closed.
|
||||||
|
await _registerLiveActivityToken();
|
||||||
|
|
||||||
|
final result = await _uploadService.uploadBatch(
|
||||||
companyId: selectedCompanyId.value,
|
companyId: selectedCompanyId.value,
|
||||||
images: capturedImages,
|
images: imagesToUpload,
|
||||||
onProgress: (current, total) {
|
onProgress: (current, total) {
|
||||||
uploadProgress.value = current / total;
|
uploadProgress.value = total > 0 ? current / total : 0.0;
|
||||||
_progressService.updateProgress(uploadProgress.value, current);
|
_progressService.updateProgress(uploadProgress.value, current);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (batchId != null) {
|
if (result.success && result.batchId != null) {
|
||||||
|
final batchId = result.batchId!;
|
||||||
currentBatchId.value = batchId;
|
currentBatchId.value = batchId;
|
||||||
totalImagesCount.value = capturedImages.length;
|
totalImagesCount.value = result.uploadedCount;
|
||||||
processedImagesCount.value = 0;
|
processedImagesCount.value = 0;
|
||||||
|
isBatchDone.value = false;
|
||||||
|
|
||||||
capturedImages.clear();
|
capturedImages.clear();
|
||||||
uploadProgress.value = 0.0;
|
uploadProgress.value = 0.0;
|
||||||
@@ -262,16 +329,29 @@ class ScannerController extends GetxController {
|
|||||||
|
|
||||||
_progressService.startProcessing();
|
_progressService.startProcessing();
|
||||||
Get.back(); // Go back to dashboard, progress will show in overlay
|
Get.back(); // Go back to dashboard, progress will show in overlay
|
||||||
|
|
||||||
|
if (result.isPartial) {
|
||||||
|
// Be honest: some photos never reached the server.
|
||||||
|
AppSnackbar.showWarning(
|
||||||
|
'تم البدء مع تنبيه',
|
||||||
|
'تم رفع ${result.uploadedCount} صورة، وفشل رفع ${result.failedUploads}. جاري استخراج البيانات للمرفوعة.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
AppSnackbar.showSuccess(
|
AppSnackbar.showSuccess(
|
||||||
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
|
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
|
||||||
|
}
|
||||||
|
|
||||||
_startPolling(batchId);
|
_startPolling(batchId);
|
||||||
} else {
|
} else {
|
||||||
_progressService.fail();
|
// Surface the server's actual reason (quota, bad file, no network)
|
||||||
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
// instead of one generic failure message.
|
||||||
|
final message =
|
||||||
|
result.errorMessage ?? 'فشل رفع الفواتير، يرجى المحاولة لاحقاً';
|
||||||
|
_progressService.fail(message);
|
||||||
|
AppSnackbar.showError('خطأ', message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_progressService.fail();
|
_progressService.fail('حدث خطأ غير متوقع أثناء الرفع');
|
||||||
AppLogger.error('Failed to upload batch/single', e);
|
AppLogger.error('Failed to upload batch/single', e);
|
||||||
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
|
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
|
||||||
} finally {
|
} 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<void> _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) {
|
void _startPolling(String batchId) {
|
||||||
bool firstPoll = true;
|
bool firstPoll = true;
|
||||||
|
int consecutiveErrors = 0;
|
||||||
|
final deadline = DateTime.now().add(_pollTimeout);
|
||||||
|
|
||||||
// Check status periodically
|
|
||||||
Future.doWhile(() async {
|
Future.doWhile(() async {
|
||||||
// Wait before checking
|
|
||||||
// First poll is after 8 seconds (AI takes time), subsequent are 5 seconds
|
// First poll is after 8 seconds (AI takes time), subsequent are 5 seconds
|
||||||
await Future.delayed(Duration(seconds: firstPoll ? 8 : 5));
|
await Future.delayed(Duration(seconds: firstPoll ? 8 : 5));
|
||||||
firstPoll = false;
|
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;
|
if (currentBatchId.value != batchId || isBatchDone.value) return false;
|
||||||
|
|
||||||
try {
|
if (DateTime.now().isAfter(deadline)) {
|
||||||
final res = await DioClient().client.get('batches/status', queryParameters: {'batch_id': batchId});
|
AppLogger.error('Polling timed out for batch $batchId', null);
|
||||||
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 (batch['status'] == 'done') {
|
|
||||||
isBatchDone.value = true;
|
isBatchDone.value = true;
|
||||||
_progressService.complete();
|
_progressService.fail(
|
||||||
|
'استغرقت المعالجة وقتاً أطول من المتوقع. تحقق من قائمة الفواتير بعد قليل.');
|
||||||
|
AppSnackbar.showWarning('تأخر في المعالجة',
|
||||||
|
'ما زالت الدفعة قيد المعالجة على الخادم. راجع قائمة الفواتير بعد قليل.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a single invoice, find the invoice_id and navigate
|
try {
|
||||||
if (totalImagesCount.value == 1 && items.isNotEmpty) {
|
final res = await DioClient()
|
||||||
final invoiceId = items.first['invoice_id'];
|
.client
|
||||||
if (invoiceId != null) {
|
.get('batches/status', queryParameters: {'batch_id': batchId});
|
||||||
Get.toNamed('/invoice-detail', arguments: invoiceId);
|
|
||||||
}
|
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;
|
||||||
|
_finishBatch(
|
||||||
|
status: batchStatus,
|
||||||
|
processed: processed,
|
||||||
|
failed: failed,
|
||||||
|
total: total,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
return false; // Stop polling
|
return false; // Stop polling
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveErrors++;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
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) {
|
void selectCompany(String id, String name) {
|
||||||
selectedCompanyId.value = id;
|
selectedCompanyId.value = id;
|
||||||
selectedCompanyName.value = name;
|
selectedCompanyName.value = name;
|
||||||
|
|||||||
@@ -19,21 +19,29 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// 1. Initialize Firebase & Notifications
|
// 1. Initialize Firebase
|
||||||
await Firebase.initializeApp();
|
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);
|
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);
|
Get.put(DeviceSecurityService(), permanent: true);
|
||||||
|
|
||||||
// 4. Register global services
|
// 5. Register global services
|
||||||
Get.put(UploadProgressService(), permanent: true);
|
Get.put(UploadProgressService(), permanent: true);
|
||||||
Get.put(HomeWidgetService(), permanent: true);
|
Get.put(HomeWidgetService(), permanent: true);
|
||||||
Get.put(ShorebirdUpdateService(), 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());
|
runApp(const MusadaqApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -550,6 +550,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1485,6 +1517,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.9"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ dependencies:
|
|||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
firebase_core: ^4.7.0
|
firebase_core: ^4.7.0
|
||||||
firebase_messaging: ^16.2.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) ────────────────────────
|
# ─── Code Push (OTA Updates) ────────────────────────
|
||||||
shorebird_code_push: ^2.0.0
|
shorebird_code_push: ^2.0.0
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Create Test Account for App Reviewers
|
* CLI-ONLY migration script.
|
||||||
|
* Moved out of the public webroot on 2026-07-30 - it used to be reachable
|
||||||
|
* unauthenticated over HTTP. Run with: php scripts/legacy_migrations/<file>
|
||||||
*/
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
http_response_code(404);
|
||||||
|
exit('Not Found');
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/../../app/bootstrap/init.php';
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Encryption;
|
use App\Core\Encryption;
|
||||||
|
|
||||||
@@ -31,7 +35,12 @@ try {
|
|||||||
|
|
||||||
$userName = "App Reviewer";
|
$userName = "App Reviewer";
|
||||||
$userEmail = "reviewer@musadaq.jo";
|
$userEmail = "reviewer@musadaq.jo";
|
||||||
$userPassword = "Reviewer2026!";
|
// Password must be supplied on the command line - never hardcoded.
|
||||||
|
// php scripts/legacy_migrations/create_test_account.php '<password>'
|
||||||
|
$userPassword = $argv[1] ?? '';
|
||||||
|
if (strlen($userPassword) < 12) {
|
||||||
|
exit("Usage: php create_test_account.php '<password>' (min 12 chars)\n");
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Encrypt data
|
// 3. Encrypt data
|
||||||
$encryptedTenantName = Encryption::encrypt($tenantName);
|
$encryptedTenantName = Encryption::encrypt($tenantName);
|
||||||
@@ -68,10 +77,7 @@ try {
|
|||||||
$db->commit();
|
$db->commit();
|
||||||
echo "<h3 style='color:green'>✅ تم إنشاء الحساب التجريبي بنجاح!</h3>";
|
echo "<h3 style='color:green'>✅ تم إنشاء الحساب التجريبي بنجاح!</h3>";
|
||||||
echo "<p><b>البريد الإلكتروني:</b> $userEmail</p>";
|
echo "<p><b>البريد الإلكتروني:</b> $userEmail</p>";
|
||||||
echo "<p><b>كلمة المرور:</b> $userPassword</p>";
|
echo "<p><b>كلمة المرور:</b> (the one you passed on the command line)</p>";
|
||||||
|
|
||||||
// Delete this file for security
|
|
||||||
@unlink(__FILE__);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Manual Migration Runner for 008_invoice_lines_enhance
|
* CLI-ONLY migration script.
|
||||||
|
* Moved out of the public webroot on 2026-07-30 - it used to be reachable
|
||||||
|
* unauthenticated over HTTP. Run with: php scripts/legacy_migrations/<file>
|
||||||
*/
|
*/
|
||||||
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\Database;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = Database::getInstance();
|
$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
|
// Split by semicolon and execute
|
||||||
$queries = array_filter(array_map('trim', explode(';', $sql)));
|
$queries = array_filter(array_map('trim', explode(';', $sql)));
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/bootstrap/init.php';
|
/**
|
||||||
|
* CLI-ONLY migration script.
|
||||||
|
* Moved out of the public webroot on 2026-07-30 - it used to be reachable
|
||||||
|
* unauthenticated over HTTP. Run with: php scripts/legacy_migrations/<file>
|
||||||
|
*/
|
||||||
|
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\Database;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/bootstrap/init.php';
|
/**
|
||||||
|
* CLI-ONLY migration script.
|
||||||
|
* Moved out of the public webroot on 2026-07-30 - it used to be reachable
|
||||||
|
* unauthenticated over HTTP. Run with: php scripts/legacy_migrations/<file>
|
||||||
|
*/
|
||||||
|
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\Database;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +73,17 @@ try {
|
|||||||
FOREIGN KEY (batch_id) REFERENCES invoice_batches(id) ON DELETE CASCADE
|
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 "Created invoice_processing_queue table.\n";
|
||||||
|
|
||||||
echo "Phase 2 Migrations completed successfully.\n";
|
echo "Phase 2 Migrations completed successfully.\n";
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migration: Queue Hardening (2026-07-30)
|
||||||
|
*
|
||||||
|
* Brings the live schema in line with the fixed queue/processing code:
|
||||||
|
* - invoice_processing_queue.claimed_at -> 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user