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:
co-authored by
Claude Opus 5
parent
bc1b0129e8
commit
a0812dbd10
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user