diff --git a/backend/Admin/notifications/broadcast.php b/backend/Admin/notifications/broadcast.php new file mode 100644 index 00000000..0768b136 --- /dev/null +++ b/backend/Admin/notifications/broadcast.php @@ -0,0 +1,122 @@ + 'failure', + 'message' => 'Forbidden. Super Admin access required to broadcast notifications.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$audience = filterRequest('audience'); +$title = filterRequest('title'); +$body = filterRequest('body'); + +// المواضيع المسموح بها فقط — يشترك بها التطبيقان (siro_driver / siro_rider). +// قصرها على قائمة ثابتة يمنع استخدام النقطة لبثّ رسائل إلى مواضيع عشوائية +// أو إلى توكن جهاز بعينه. +$ALLOWED_AUDIENCES = [ + 'drivers' => 'drivers', + 'passengers' => 'passengers', +]; + +if (!isset($ALLOWED_AUDIENCES[$audience])) { + jsonError('Invalid audience. Allowed: ' . implode(', ', array_keys($ALLOWED_AUDIENCES)), 400); +} + +$title = trim((string) $title); +$body = trim((string) $body); + +if ($title === '' || $body === '') { + jsonError('Both title and body are required.', 400); +} +if (mb_strlen($title) > 120) { + jsonError('Title is too long (max 120 characters).', 400); +} +if (mb_strlen($body) > 1000) { + jsonError('Body is too long (max 1000 characters).', 400); +} + +$topic = $ALLOWED_AUDIENCES[$audience]; + +// سجل التدقيق قبل الإرسال: نريد أثراً حتى لو فشل النداء أو انقطع. +securityLog("Broadcast notification requested", [ + 'user_id' => $user_id ?? 'unknown', + 'audience' => $audience, + 'title' => $title, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', +]); + +if (function_exists('logAudit')) { + try { + logAudit($con, (string) ($user_id ?? 'unknown'), 'إرسال إشعار جماعي', 'notification', $topic, [ + 'audience' => $audience, + 'title' => $title, + 'body' => $body, + ]); + } catch (Throwable $e) { + error_log("[Broadcast] audit log failed: " . $e->getMessage()); + } +} + +// الاستدعاء الداخلي لخدمة FCM +$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php'; +$payload = json_encode([ + 'target' => $topic, + 'title' => $title, + 'body' => $body, + 'isTopic' => true, + 'data' => ['category' => 'admin_broadcast'], +], JSON_UNESCAPED_UNICODE); + +$headers = ['Content-Type: application/json; charset=UTF-8']; +$internalKey = getenv('FCM_INTERNAL_API_KEY'); +if (!empty($internalKey)) { + $headers[] = 'X-API-KEY: ' . $internalKey; +} + +$ch = curl_init($fcmUrl); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$curlErr = curl_error($ch); +curl_close($ch); + +if ($response === false || $httpCode >= 400) { + error_log("[Broadcast] FCM call failed (HTTP $httpCode): " . ($curlErr ?: $response)); + jsonError("Notification service rejected the request (HTTP $httpCode).", 502); +} + +$decoded = json_decode((string) $response, true); + +jsonSuccess([ + 'audience' => $audience, + 'topic' => $topic, + 'title' => $title, + 'sent_by' => $user_id ?? null, + 'sent_at' => date('Y-m-d H:i:s'), + 'fcm_status' => $decoded['status'] ?? 'unknown', +], 'Broadcast delivered to the notification service.'); diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 7a74f7a1..944b8a96 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1253,3 +1253,76 @@ h1, h2, h3, h4, h5, h6 { .tariff-field .form-input { padding-left: 1rem; font-size: 0.9rem; } .tariff-field .form-input:disabled { opacity: 0.65; cursor: not-allowed; } + +/* Route approvals */ +.stop-list { + margin: 0; + padding-left: 1.2rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.85rem; +} + +.stop-list li { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.stop-list li span:first-child { color: var(--text-main); } + +/* Broadcast preview */ +.push-preview { + max-width: 420px; + padding: 1rem 1.15rem; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); +} + +.push-app { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-subtle); + margin-bottom: 0.5rem; +} + +.push-app i { color: var(--primary); } + +.push-title { + font-weight: 600; + color: var(--text-main); + margin-bottom: 0.2rem; + word-break: break-word; +} + +.push-body { + font-size: 0.86rem; + color: var(--text-muted); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* Bidirectional text: names, addresses and messages are often Arabic while the + UI chrome is English. `plaintext` lets each value pick its own direction + from its first strong character instead of inheriting the page's LTR. */ +.form-input, +.data-table td, +.push-title, +.push-body, +.kv-row strong, +.stop-list li span:first-child, +.kpi-tile-value { + unicode-bidi: plaintext; +} + +textarea.form-input { text-align: start; } diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index dd0c1814..7586c682 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -905,11 +905,19 @@ }, { id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', - subtitle: 'Registered transit organisations and their pending routes', - panels: [ - { title: 'Organisations', path: '/Admin/transit/org/list.php' }, - { title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.php' }, - ], + subtitle: 'Registered transit organisations', + panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }], + }, + { + id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals', + subtitle: 'Draft routes submitted by organisations, awaiting a decision', + custom: renderRouteApprovals, + }, + { + id: 'broadcast', superOnly: true, group: 'Administration', icon: 'ph-megaphone-simple', + title: 'Broadcast Notification', + subtitle: 'Push a notification to every captain or every passenger', + custom: renderBroadcast, }, { id: 'staff', superOnly: true, group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', @@ -1045,6 +1053,194 @@ }); } + // ── Route approvals ────────────────────────────────────────────────────── + // transit/route/approve.php accepts approve | suspend | reject and refuses a + // no-op transition, so each decision is confirmed against the route's stops. + async function renderRouteApprovals(host) { + host.innerHTML = '