From a0812dbd109def2d7aff219892eb8c184fdd206a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 17:10:51 +0300 Subject: [PATCH] Fix broadcast delivery; make transit organisations manageable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/Admin/notifications/broadcast.php | 9 +- dashboard/siro-admin/index.html | 4 +- dashboard/siro-admin/js/app.js | 316 +++++++++++++++++++++- 3 files changed, 321 insertions(+), 8 deletions(-) diff --git a/backend/Admin/notifications/broadcast.php b/backend/Admin/notifications/broadcast.php index 0768b136..077554c3 100644 --- a/backend/Admin/notifications/broadcast.php +++ b/backend/Admin/notifications/broadcast.php @@ -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); diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 281f3770..756f0e4c 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - + @@ -609,6 +609,6 @@ - + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index fc843700..87b424b6 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -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 = ` +
+
+ + +
+ +
+
Loading organisations…
`; + + $('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 = '
Loading organisations…
'; + 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 = '
No organisations registered yet.
'; + return; + } + + panel.innerHTML = ` +
+

Organisations ${fmtInt(total)} total

+
+
+ + + + + + + ${orgs.map((o, i) => ` + + + + + + + + + + + `).join('')} + +
NameTypeCityContractCaptainsVehiclesRoutesEnrolled
${esc(o.name_ar || o.name_en || '—')}${esc(humanize(o.type || '—'))}${esc(o.city || '—')}${esc(humanize(o.contract_status || 'unknown'))}${fmtInt(o.drivers_count)}${fmtInt(o.vehicles_count)}${fmtInt(o.active_routes)}${fmtInt(o.active_enrollments)}
+
`; + + 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 = `
${esc(err.message)}
`; + } + } + + async function openOrgDetails(orgId, host) { + const body = $('modalBodyContent'); + body.innerHTML = '
Loading…
'; + $('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 = ` + + +
+ ${Object.entries(counts).map(([k, v]) => + `
${esc(formatValue(v, k))}
${esc(humanize(k))}
`).join('')} +
+ +
+

Routes (${routes.length})

+ ${routes.length ? `
${routes.map((r) => ` +
+ ${esc(r.name_ar || r.name_en || `#${r.id}`)} + ${esc(humanize(r.status || ''))} +
`).join('')}
` : '
No routes yet.
'} +
+ +
+

Recent trips (${trips.length})

+
+
+ +
+

Administrators

+
Loading…
+
+ + + +
+
`; + + renderPayload($('orgTrips'), trips); + loadOrgAdmins(orgId); + $('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId)); + } catch (err) { + if (handleApiError(err, 'org-details')) return; + body.innerHTML = ` +
${esc(err.message)}
`; + } + } + + 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 = '
No administrators yet.
'; + return; + } + panel.innerHTML = `
${admins.map((a) => ` +
+ ${esc(a.name || '—')} ${esc(humanize(a.role || ''))} + + + +
`).join('')}
`; + + 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 = `
${esc(err.message)}
`; + } + } + + 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 = ` + +
+ ${editing ? '' : ` + + + + + + `} + + ${editing ? ` + + + + ` : ''} +
+
+ +
`; + + $('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