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 = '
Loading draft routes…
'; + + let routes = []; + try { + const payload = await api('/Admin/transit/route/pending.php'); + routes = payload?.routes || []; + } catch (err) { + if (handleApiError(err, 'routes')) return; + host.innerHTML = `
${esc(err.message)}
`; + return; + } + + if (!routes.length) { + host.innerHTML = '
No routes are waiting for approval.
'; + return; + } + + host.innerHTML = routes.map((route, index) => ` +
+
+

+ ${esc(route.name_ar || route.name_en || 'Unnamed route')} + #${esc(route.id)} · ${esc(route.org_name || 'unknown organisation')} +

+
+ + +
+
+ +
+
${esc(route.direction || '—')}
Direction
+
${fmtNum(route.distance_km)} km
Distance
+
${fmtInt(route.duration_min)} min
Duration
+
${fmtInt(route.stops_count)}
Stops
+
${esc(route.country || '—')}
Country
+
${esc(fmtDate(route.created_at, true))}
Submitted
+
+ +
+

Stops

+ ${(route.stops || []).length ? ` +
    + ${route.stops.map((s) => ` +
  1. + ${esc(s.name_ar || 'Unnamed stop')} + ${Number(s.is_major) ? 'major' : ''} + ${esc(shortCoord(`${s.latitude},${s.longitude}`))} +
  2. `).join('')} +
` : '
This route has no stops recorded.
'} +
+
`).join(''); + + host.querySelectorAll('[data-route-action]').forEach((btn) => + btn.addEventListener('click', () => + decideRoute(routes[Number(btn.dataset.route)], btn.dataset.routeAction, host))); + } + + async function decideRoute(route, action, host) { + const verb = action === 'approve' ? 'approve' : 'reject'; + const consequence = action === 'approve' + ? 'The route goes live and passengers can ride it.' + : 'The organisation will have to resubmit the route.'; + + if (!confirm( + `${verb === 'approve' ? 'Approve' : 'Reject'} route "${route.name_ar || route.id}" ` + + `from ${route.org_name || 'this organisation'}?\n\n` + + `${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}` + )) return; + + try { + await api('/Admin/transit/route/approve.php', { + params: { route_id: route.id, action }, + }); + toast(`Route #${route.id} ${verb}ed.`, 'success'); + renderRouteApprovals(host); + } catch (err) { + if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger'); + } + } + + // ── Broadcast notifications ────────────────────────────────────────────── + // Goes through Admin/notifications/broadcast.php, never the internal FCM + // endpoint: the browser must not hold the internal API key. + function renderBroadcast(host) { + host.innerHTML = ` +
+ + This reaches every device at once and cannot be recalled. + The message is recorded in the audit log against your account. +
+ +
+

Compose

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Preview

+
+
Siro
+
Notification title
+
Message text
+
+
`; + + const title = $('bcTitle'); + const body = $('bcBody'); + const sync = () => { + $('bcPreviewTitle').textContent = title.value.trim() || 'Notification title'; + $('bcPreviewBody').textContent = body.value.trim() || 'Message text'; + }; + title.addEventListener('input', sync); + body.addEventListener('input', sync); + + $('bcSend').addEventListener('click', () => sendBroadcast(host)); + } + + async function sendBroadcast(host) { + if (!isSuperAdmin()) { + toast('Broadcasting is restricted to super admins.', 'warning'); + return; + } + + const audience = $('bcAudience').value; + const title = $('bcTitle').value.trim(); + const body = $('bcBody').value.trim(); + const status = $('bcStatus'); + const audienceLabel = audience === 'drivers' ? 'every captain' : 'every passenger'; + + if (!title || !body) { + toast('Enter both a title and a message.', 'warning'); + return; + } + + if (!confirm( + `Send this notification to ${audienceLabel} on the platform?\n\n` + + `${title}\n${body}\n\n` + + 'It is delivered immediately and cannot be recalled.' + )) return; + + busy($('bcSend'), true, 'Sending…'); + status.textContent = ''; + + try { + const result = await api('/Admin/notifications/broadcast.php', { + params: { audience, title, body }, + }); + status.textContent = `Sent to ${audienceLabel} at ${new Date().toLocaleTimeString()}`; + toast(`Notification delivered to ${audienceLabel}.`, 'success'); + $('bcTitle').value = ''; + $('bcBody').value = ''; + $('bcPreviewTitle').textContent = 'Notification title'; + $('bcPreviewBody').textContent = 'Message text'; + console.info('[broadcast]', result); + } catch (err) { + if (!handleApiError(err, 'broadcast')) toast(`Send failed: ${err.message}`, 'danger'); + } finally { + busy($('bcSend'), false, 'Review & send'); + } + } + // ── Kazan tariff editor ────────────────────────────────────────────────── // Only these columns are accepted by ride/kazan/update.php; anything else // sent would be silently dropped, so the form mirrors that list exactly.