Fix broadcast delivery; make transit organisations manageable

Broadcasts never reached anyone. The internal FCM call defaulted to
127.0.0.1, which inside the php container is the php container itself — the
web server runs in a separate nginx container, reachable by service name on
the Compose network. Every send failed the curl and returned a generic 502.
The default now points at nginx, the URL is overridable via FCM_INTERNAL_URL,
and the error carries the actual reason and target instead of a bare status.

Transit organisations were a read-only count table with nothing to act on.
The module now supports the operations an admin actually needs:
- open an organisation for its counts, routes, recent trips and admins
- create one, including the founding administrator create.php requires
- edit city, contact details, contract status and trial end, with a
  confirmation when the contract changes since suspending cuts off service
- add an administrator, and enable or disable an existing one

Verified end to end against the endpoints' real payload shapes, including
that an incomplete create form is rejected before any request is sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-25 17:10:51 +03:00
co-authored by Claude Opus 5
parent bc1b0129e8
commit a0812dbd10
3 changed files with 321 additions and 8 deletions
+6 -3
View File
@@ -76,7 +76,9 @@ if (function_exists('logAudit')) {
}
// الاستدعاء الداخلي لخدمة FCM
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://127.0.0.1/backend/ride/firebase/send_fcm.php';
// من داخل حاوية php لا يوجد خادم ويب على 127.0.0.1 — الويب في حاوية nginx
// منفصلة، وتُعرف داخل شبكة Compose باسم الخدمة. هذا كان سبب فشل كل إشعار.
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://nginx/backend/ride/firebase/send_fcm.php';
$payload = json_encode([
'target' => $topic,
'title' => $title,
@@ -106,8 +108,9 @@ $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);
$reason = $curlErr ?: (is_string($response) ? substr($response, 0, 200) : 'no response');
error_log("[Broadcast] FCM call failed (HTTP $httpCode) via $fcmUrl: $reason");
jsonError("Notification service unreachable at $fcmUrl — $reason", 502);
}
$decoded = json_decode((string) $response, true);
+2 -2
View File
@@ -15,7 +15,7 @@
<!-- ?v= must be bumped whenever css/main.css or js/app.js changes: the files
are served straight off a bind mount, so without it browsers keep
running the previously cached build after a deploy. -->
<link rel="stylesheet" href="css/main.css?v=2026-07-25-8">
<link rel="stylesheet" href="css/main.css?v=2026-07-25-9">
</head>
<body>
@@ -609,6 +609,6 @@
</div>
</div>
<script src="js/app.js?v=2026-07-25-8"></script>
<script src="js/app.js?v=2026-07-25-9"></script>
</body>
</html>
+313 -3
View File
@@ -11,7 +11,7 @@
// Bump together with the ?v= query in index.html. Shown in the UI and in the
// diagnostics report so "the deploy did nothing" can be answered with a fact
// rather than a guess about caching.
const BUILD = '2026-07-25-8';
const BUILD = '2026-07-25-9';
// ── Localisation ─────────────────────────────────────────────────────────
// Arabic is the operators' language; English is kept because several screens
@@ -1120,8 +1120,8 @@
},
{
id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations',
subtitle: 'Registered transit organisations',
panels: [{ title: 'Organisations', path: '/Admin/transit/org/list.php' }],
subtitle: 'Universities, schools and companies running their own transport — open one to manage it',
custom: renderTransitOrgs,
},
{
id: 'routes', group: 'Transit', icon: 'ph-path', title: 'Route Approvals',
@@ -1424,6 +1424,316 @@
}
}
// ── Transit organisations ────────────────────────────────────────────────
// A list of counts is not actionable on its own: an operator needs to open
// an organisation, see its routes and trips, adjust the contract, and manage
// who administers it.
const ORG_TYPES = ['university', 'school', 'hotel', 'company', 'transporter'];
const CONTRACT_STATES = ['trial', 'active', 'suspended', 'expired'];
async function renderTransitOrgs(host) {
host.innerHTML = `
<div class="filter-bar">
<div class="search-inline">
<input type="text" class="form-input" id="orgSearch" placeholder="Search by name">
<button class="btn btn-secondary btn-sm" id="orgSearchBtn"><i class="ph ph-magnifying-glass"></i></button>
</div>
<button class="btn btn-primary btn-sm" id="orgNewBtn"><i class="ph ph-plus"></i> <span>New organisation</span></button>
</div>
<div class="card" id="orgList"><div class="table-msg">Loading organisations…</div></div>`;
$('orgNewBtn').addEventListener('click', () => openOrgForm(null, host));
bindSearch($('orgSearch'), $('orgSearchBtn'),
(term) => loadOrgs(host, term), () => loadOrgs(host, ''));
loadOrgs(host, '');
}
async function loadOrgs(host, search) {
const panel = $('orgList');
panel.innerHTML = '<div class="table-msg">Loading organisations…</div>';
try {
const payload = await api('/Admin/transit/org/list.php', { params: { search } });
const orgs = payload?.orgs || [];
const total = payload?.pagination?.total ?? orgs.length;
if (!orgs.length) {
panel.innerHTML = '<div class="table-msg">No organisations registered yet.</div>';
return;
}
panel.innerHTML = `
<div class="card-header">
<h3 class="card-title">Organisations <span class="card-sub">${fmtInt(total)} total</span></h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr>
<th>Name</th><th>Type</th><th>City</th><th>Contract</th>
<th>Captains</th><th>Vehicles</th><th>Routes</th><th>Enrolled</th><th></th>
</tr></thead>
<tbody>
${orgs.map((o, i) => `
<tr class="row-clickable" data-org="${esc(o.id)}">
<td><strong>${esc(o.name_ar || o.name_en || '—')}</strong></td>
<td>${esc(humanize(o.type || '—'))}</td>
<td>${esc(o.city || '—')}</td>
<td><span class="badge ${badgeClass(o.contract_status)}">${esc(humanize(o.contract_status || 'unknown'))}</span></td>
<td>${fmtInt(o.drivers_count)}</td>
<td>${fmtInt(o.vehicles_count)}</td>
<td>${fmtInt(o.active_routes)}</td>
<td>${fmtInt(o.active_enrollments)}</td>
<td><button class="btn btn-secondary btn-sm" data-org-edit="${i}"><i class="ph ph-pencil-simple"></i></button></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
panel.querySelectorAll('[data-org]').forEach((row) =>
row.addEventListener('click', (e) => {
if (e.target.closest('[data-org-edit]')) return;
openOrgDetails(row.dataset.org, host);
}));
panel.querySelectorAll('[data-org-edit]').forEach((btn) =>
btn.addEventListener('click', () => openOrgForm(orgs[Number(btn.dataset.orgEdit)], host)));
} catch (err) {
if (handleApiError(err, 'transit-orgs')) return;
panel.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function openOrgDetails(orgId, host) {
const body = $('modalBodyContent');
body.innerHTML = '<div class="table-msg">Loading…</div>';
$('detailsModal').classList.add('active');
try {
const payload = await api('/Admin/transit/org/details.php', { params: { org_id: orgId } });
const org = payload?.org || {};
const counts = payload?.counts || {};
const routes = payload?.routes || [];
const trips = payload?.trips || [];
body.innerHTML = `
<div class="modal-head">
<h3>${esc(org.name_ar || org.name_en || `Organisation #${orgId}`)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="kpi-tiles">
${Object.entries(counts).map(([k, v]) =>
`<div class="kpi-tile"><div class="kpi-tile-value">${esc(formatValue(v, k))}</div><div class="kpi-tile-label">${esc(humanize(k))}</div></div>`).join('')}
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">Routes (${routes.length})</h4>
${routes.length ? `<div class="mini-list">${routes.map((r) => `
<div class="kv-row">
<span>${esc(r.name_ar || r.name_en || `#${r.id}`)}</span>
<strong><span class="badge ${badgeClass(r.status)}">${esc(humanize(r.status || ''))}</span></strong>
</div>`).join('')}</div>` : '<div class="table-msg">No routes yet.</div>'}
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">Recent trips (${trips.length})</h4>
<div class="panel-body" id="orgTrips"></div>
</div>
<div class="sub-panel">
<h4 class="sub-panel-title">Administrators</h4>
<div class="panel-body" id="orgAdmins"><div class="table-msg">Loading…</div></div>
<div class="api-base-row" style="margin-top:0.75rem;">
<input type="text" class="form-input" id="orgAdminName" placeholder="Name">
<input type="text" class="form-input" id="orgAdminPhone" placeholder="Phone">
<button class="btn btn-secondary btn-sm" id="orgAdminAdd"><i class="ph ph-user-plus"></i> Add</button>
</div>
</div>`;
renderPayload($('orgTrips'), trips);
loadOrgAdmins(orgId);
$('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId));
} catch (err) {
if (handleApiError(err, 'org-details')) return;
body.innerHTML = `<div class="modal-head"><h3>Organisation</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button></div>
<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function loadOrgAdmins(orgId) {
const panel = $('orgAdmins');
if (!panel) return;
try {
const payload = await api('/Admin/transit/org/admins_list.php', { params: { org_id: orgId } });
const admins = payload?.admins || [];
if (!admins.length) {
panel.innerHTML = '<div class="table-msg">No administrators yet.</div>';
return;
}
panel.innerHTML = `<div class="mini-list">${admins.map((a) => `
<div class="kv-row">
<span>${esc(a.name || '—')} <span class="stamp">${esc(humanize(a.role || ''))}</span></span>
<strong>
<button class="btn btn-secondary btn-sm" data-admin-toggle="${esc(a.id)}" data-active="${Number(a.is_active) ? 1 : 0}">
${Number(a.is_active) ? '<i class="ph ph-pause"></i> Disable' : '<i class="ph ph-play"></i> Enable'}
</button>
</strong>
</div>`).join('')}</div>`;
panel.querySelectorAll('[data-admin-toggle]').forEach((btn) =>
btn.addEventListener('click', () => toggleOrgAdmin(btn.dataset.adminToggle, Number(btn.dataset.active) ? 0 : 1, orgId)));
} catch (err) {
if (handleApiError(err, 'org-admins')) return;
panel.innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}
async function addOrgAdmin(orgId) {
const name = $('orgAdminName').value.trim();
const phone = $('orgAdminPhone').value.trim();
if (!name || !phone) {
toast('Enter both a name and a phone number.', 'warning');
return;
}
if (!confirm(`Give ${name} (${phone}) administrator access to this organisation?`)) return;
try {
await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } });
toast('Administrator added.', 'success');
$('orgAdminName').value = '';
$('orgAdminPhone').value = '';
loadOrgAdmins(orgId);
} catch (err) {
if (!handleApiError(err, 'org-admin-add')) toast(err.message, 'danger');
}
}
async function toggleOrgAdmin(adminId, isActive, orgId) {
if (!confirm(isActive ? 'Enable this administrator?' : 'Disable this administrator?')) return;
try {
await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } });
toast(isActive ? 'Administrator enabled.' : 'Administrator disabled.', 'success');
loadOrgAdmins(orgId);
} catch (err) {
if (!handleApiError(err, 'org-admin-toggle')) toast(err.message, 'danger');
}
}
// create.php needs the founding administrator; update.php only takes the
// fields it knows, so the form differs between the two modes.
function openOrgForm(org, host) {
const editing = !!org;
const body = $('modalBodyContent');
body.innerHTML = `
<div class="modal-head">
<h3>${editing ? esc(org.name_ar || 'Edit organisation') : 'New organisation'}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div>
<div class="tariff-grid">
${editing ? '' : `
<label class="tariff-field">
<span class="tariff-label">Type <em>required</em></span>
<select class="select-input" id="orgType">
${ORG_TYPES.map((t) => `<option value="${t}">${humanize(t)}</option>`).join('')}
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">Name (Arabic) <em>required</em></span>
<input type="text" class="form-input" id="orgNameAr">
</label>
<label class="tariff-field">
<span class="tariff-label">Name (English)</span>
<input type="text" class="form-input" id="orgNameEn">
</label>
<label class="tariff-field">
<span class="tariff-label">Country <em>2 letters</em></span>
<input type="text" class="form-input" id="orgCountry" value="JO" maxlength="2">
</label>
<label class="tariff-field">
<span class="tariff-label">Founding admin name <em>required</em></span>
<input type="text" class="form-input" id="orgAdminNameNew">
</label>
<label class="tariff-field">
<span class="tariff-label">Founding admin phone <em>required</em></span>
<input type="text" class="form-input" id="orgAdminPhoneNew">
</label>`}
<label class="tariff-field">
<span class="tariff-label">City</span>
<input type="text" class="form-input" id="orgCity" value="${editing ? esc(org.city ?? '') : ''}">
</label>
${editing ? `
<label class="tariff-field">
<span class="tariff-label">Contact email</span>
<input type="email" class="form-input" id="orgEmail" value="${esc(org.contact_email ?? '')}">
</label>
<label class="tariff-field">
<span class="tariff-label">Website</span>
<input type="text" class="form-input" id="orgWebsite" value="${esc(org.website ?? '')}">
</label>
<label class="tariff-field">
<span class="tariff-label">Contract status</span>
<select class="select-input" id="orgContract">
${CONTRACT_STATES.map((c) => `<option value="${c}" ${org.contract_status === c ? 'selected' : ''}>${humanize(c)}</option>`).join('')}
</select>
</label>
<label class="tariff-field">
<span class="tariff-label">Trial ends</span>
<input type="date" class="form-input" id="orgTrialEnds" value="${esc((org.trial_ends_at || '').slice(0, 10))}">
</label>` : ''}
</div>
<div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-primary btn-sm" id="orgSave">
<i class="ph ph-floppy-disk"></i> <span>${editing ? 'Save changes' : 'Create organisation'}</span>
</button>
</div>`;
$('detailsModal').classList.add('active');
$('orgSave').addEventListener('click', () => saveOrg(org, host));
}
async function saveOrg(org, host) {
const editing = !!org;
try {
if (editing) {
const params = {
org_id: org.id,
city: $('orgCity').value.trim(),
contact_email: $('orgEmail').value.trim(),
website: $('orgWebsite').value.trim(),
contract_status: $('orgContract').value,
trial_ends_at: $('orgTrialEnds').value,
};
if (params.contract_status !== org.contract_status &&
!confirm(`Change the contract from "${org.contract_status}" to "${params.contract_status}"? ` +
'Suspending stops the organisation using the service.')) return;
await api('/Admin/transit/org/update.php', { params });
toast('Organisation updated.', 'success');
} else {
const params = {
type: $('orgType').value,
name_ar: $('orgNameAr').value.trim(),
name_en: $('orgNameEn').value.trim(),
country: $('orgCountry').value.trim().toUpperCase(),
city: $('orgCity').value.trim(),
admin_name: $('orgAdminNameNew').value.trim(),
admin_phone: $('orgAdminPhoneNew').value.trim(),
};
if (!params.name_ar || !params.admin_name || !params.admin_phone) {
toast('Name, founding admin name and phone are required.', 'warning');
return;
}
await api('/Admin/transit/org/create.php', { params });
toast('Organisation created.', 'success');
}
closeModal();
loadOrgs(host, $('orgSearch')?.value.trim() || '');
} catch (err) {
if (!handleApiError(err, 'org-save')) toast(err.message, 'danger');
}
}
// ── Blacklist & removal ──────────────────────────────────────────────────
// Deletion here is a real DELETE against passengers/driver — the account and
// its login are gone. The console therefore demands the phone number be