Add broadcast notifications and transit route approvals

Broadcast: ride/firebase/send_fcm.php is an internal service guarded by a
shared secret, so the browser cannot call it — holding that key client-side
would expose it, and the endpoint cannot tell who the sender is. A new
Admin/notifications/broadcast.php sits in front of it: it runs behind
connect.php, requires super_admin, restricts the target to the two topics the
apps actually subscribe to ('drivers'/'passengers') so it cannot be used to
push to an arbitrary topic or a single device token, bounds the title and
body, writes an audit entry before dispatching, and only then forwards the
call internally with the shared secret.

The composer shows a live push preview and an explicit confirmation naming
the audience, since a broadcast cannot be recalled.

Route approvals: draft routes render with their stops, distance and stop
count, and approve/reject posts to transit/route/approve.php behind a
confirmation stating the consequence. Available to admins and super admins,
matching the endpoint's own role check.

Also render user-supplied text with unicode-bidi: plaintext — Arabic names,
addresses and messages were being laid out left-to-right inside the
English UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-25 02:15:02 +03:00
co-authored by Claude Opus 5
parent 41a06bba0c
commit a0ab6c5155
3 changed files with 396 additions and 5 deletions
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Admin/notifications/broadcast.php
* إرسال إشعار جماعي إلى كل السائقين أو كل الركاب.
*
* لماذا نقطة وسيطة بدل استدعاء ride/firebase/send_fcm.php من الواجهة؟
* - send_fcm.php داخلية ومحمية بمفتاح سرّي (FCM_INTERNAL_API_KEY)، ولا يجوز
* أن يحمل المتصفح هذا المفتاح لأنه سيُكشف لأي مستخدم.
* - send_fcm.php لا تعرف من المُرسِل، فلا تستطيع تقييد الصلاحية ولا التدقيق.
*
* هذه النقطة تفرض JWT + بصمة الجهاز (عبر connect.php) ودور super_admin، ثم
* تُمرّر الطلب داخلياً مع المفتاح السرّي وتسجّل العملية في سجل التدقيق.
*/
require_once __DIR__ . '/../../connect.php';
// إشعار جماعي يصل كل مستخدمي المنصة فوراً ولا يمكن سحبه بعد الإرسال.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => '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.');
+73
View File
@@ -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; }
+201 -5
View File
@@ -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 = '<div class="card"><div class="table-msg">Loading draft routes…</div></div>';
let routes = [];
try {
const payload = await api('/Admin/transit/route/pending.php');
routes = payload?.routes || [];
} catch (err) {
if (handleApiError(err, 'routes')) return;
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!routes.length) {
host.innerHTML = '<div class="card"><div class="table-msg">No routes are waiting for approval.</div></div>';
return;
}
host.innerHTML = routes.map((route, index) => `
<div class="card" data-route-card="${index}">
<div class="card-header">
<h3 class="card-title">
${esc(route.name_ar || route.name_en || 'Unnamed route')}
<span class="card-sub">#${esc(route.id)} · ${esc(route.org_name || 'unknown organisation')}</span>
</h3>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-route-action="reject" data-route="${index}"><i class="ph ph-x"></i> Reject</button>
<button class="btn btn-primary btn-sm" data-route-action="approve" data-route="${index}"><i class="ph ph-check"></i> <span>Approve</span></button>
</div>
</div>
<div class="kpi-tiles">
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.direction || '—')}</div><div class="kpi-tile-label">Direction</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtNum(route.distance_km)} km</div><div class="kpi-tile-label">Distance</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.duration_min)} min</div><div class="kpi-tile-label">Duration</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.stops_count)}</div><div class="kpi-tile-label">Stops</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.country || '—')}</div><div class="kpi-tile-label">Country</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(fmtDate(route.created_at, true))}</div><div class="kpi-tile-label">Submitted</div></div>
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">Stops</h4>
${(route.stops || []).length ? `
<ol class="stop-list">
${route.stops.map((s) => `
<li>
<span>${esc(s.name_ar || 'Unnamed stop')}</span>
${Number(s.is_major) ? '<span class="badge badge-primary">major</span>' : ''}
<span class="stamp">${esc(shortCoord(`${s.latitude},${s.longitude}`))}</span>
</li>`).join('')}
</ol>` : '<div class="table-msg">This route has no stops recorded.</div>'}
</div>
</div>`).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 = `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i>
<span><strong>This reaches every device at once and cannot be recalled.</strong>
The message is recorded in the audit log against your account.</span>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Compose</h3></div>
<div class="form-group">
<label class="form-label" for="bcAudience">Audience</label>
<select class="select-input" id="bcAudience" style="width:100%;">
<option value="drivers">All captains</option>
<option value="passengers">All passengers</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="bcTitle">Title <span class="stamp">max 120</span></label>
<input type="text" class="form-input" id="bcTitle" maxlength="120" placeholder="Notification title" style="padding-left:1rem;">
</div>
<div class="form-group">
<label class="form-label" for="bcBody">Message <span class="stamp">max 1000</span></label>
<textarea class="form-input decrypt-area" id="bcBody" rows="4" maxlength="1000" placeholder="Message text"></textarea>
</div>
<div class="api-base-row">
<button class="btn btn-primary btn-sm" id="bcSend"><i class="ph ph-paper-plane-tilt"></i> <span>Review &amp; send</span></button>
<span class="stamp" id="bcStatus"></span>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Preview</h3></div>
<div class="push-preview">
<div class="push-app"><i class="ph-fill ph-car"></i> Siro</div>
<div class="push-title" id="bcPreviewTitle">Notification title</div>
<div class="push-body" id="bcPreviewBody">Message text</div>
</div>
</div>`;
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.