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:
co-authored by
Claude Opus 5
parent
41a06bba0c
commit
a0ab6c5155
@@ -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.');
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 & 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.
|
||||
|
||||
Reference in New Issue
Block a user