Files
Hamza-AyedandClaude Opus 5 a0812dbd10 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>
2026-07-25 17:10:51 +03:00

126 lines
4.7 KiB
PHP

<?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
// من داخل حاوية 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,
'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) {
$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);
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.');