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
+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.