Update: 2026-07-12 05:40:28

This commit is contained in:
Hamza-Ayed
2026-07-12 05:40:28 +03:00
parent 1fa517acc9
commit 24fb56f08f
75 changed files with 5051 additions and 9 deletions
+17
View File
@@ -14,6 +14,23 @@ DB_NAME=siro_main
DB_USER=siro_user
DB_PASS=<CHANGE_ME_STRONG_PASSWORD>
# =============================================================================
# Database Configuration - TRANSIT DATABASE (مواصلاتي — جامعات/مدارس/فنادق/شركات)
# =============================================================================
# قاعدة بيانات معزولة تماماً عن main/ride/tracking — ممنوع أي JOIN بينها وبينهم،
# الربط بينها وبين النظام الرئيسي عبر المعرّفات (passenger_id/driver_id) فقط.
DB_TRANSIT_HOST=localhost
DB_TRANSIT_PORT=3306
DB_TRANSIT_NAME=siroTransitDb
DB_TRANSIT_USER=siroTransitUser
DB_TRANSIT_PASS=<CHANGE_ME_STRONG_PASSWORD>
# مفتاح تشفير الرقم الجامعي (32 byte) — منفصل عن ENC_KEY لمزيد من العزل
TRANSIT_STUDENT_ID_KEY=<CHANGE_ME_32_CHAR_KEY>
# Origins مسموحة للوحة الويب (مشرف المؤسسة)
TRANSIT_ADMIN_ORIGINS=https://transit.siromove.com,https://admin.siromove.com
# رابط تفعيل السائق (deep link في تطبيق السائق)
APP_DEEP_LINK_BASE=https://siromove.com/driver/transit-activate
# =============================================================================
# Encryption Configuration - CRITICAL FOR SECURITY
# =============================================================================
+44
View File
@@ -0,0 +1,44 @@
<?php
// Admin/transit/org/admin_add.php — فريق سيرو يضيف مشرفاً جديداً لمؤسسة قائمة
// POST: org_id, name, phone, role? (owner|transport_manager|dispatcher)
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
require_once __DIR__ . '/../../../transit/functions.php';
requireTransitFields(['org_id', 'name', 'phone']);
$orgId = filterRequest('org_id', 'int');
$name = filterRequest('name');
$phone = normalizePhone(filterRequest('phone'));
$adminRole = filterRequest('role') ?: 'transport_manager';
$allowedRoles = ['owner', 'transport_manager', 'dispatcher'];
if (!in_array($adminRole, $allowedRoles)) jsonError('Invalid role', 400);
$chkOrg = $transit_con->prepare("SELECT id FROM transit_orgs WHERE id=? LIMIT 1");
$chkOrg->execute([$orgId]);
if (!$chkOrg->fetch()) jsonError('Organization not found', 404);
$phoneEnc = $encryptionHelper->encryptData($phone);
$chkDup = $transit_con->prepare(
"SELECT id FROM transit_org_admins WHERE org_id=? AND phone=? LIMIT 1"
);
$chkDup->execute([$orgId, $phoneEnc]);
if ($chkDup->fetch()) jsonError('An admin with this phone already exists for this organization', 409);
$transit_con->prepare(
"INSERT INTO transit_org_admins (org_id, name, phone, role, is_active) VALUES (?,?,?,?,1)"
)->execute([$orgId, $name, $phoneEnc, $adminRole]);
appLog("[ADMIN][TRANSIT][ORG][admin_add] org={$orgId} name={$name} role={$adminRole}");
jsonSuccess(['admin_id' => (int)$transit_con->lastInsertId()], 'Admin added successfully');
@@ -0,0 +1,39 @@
<?php
// Admin/transit/org/admin_toggle.php — تفعيل/تعليق مشرف مؤسسة (لفريق سيرو)
// POST: admin_id, is_active (1|0)
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
$adminId = filterRequest('admin_id', 'int');
$isActive = filterRequest('is_active', 'int');
if (!$adminId || $isActive === null) jsonError('admin_id and is_active are required', 400);
$st = $transit_con->prepare("SELECT id, org_id FROM transit_org_admins WHERE id=? LIMIT 1");
$st->execute([$adminId]);
$admin = $st->fetch();
if (!$admin) jsonError('Admin not found', 404);
$transit_con->prepare("UPDATE transit_org_admins SET is_active=? WHERE id=?")
->execute([$isActive ? 1 : 0, $adminId]);
// إبطال جلساته الحالية فوراً عند التعليق
if (!$isActive) {
$sessions = $transit_con->prepare("SELECT token_hash FROM transit_sessions WHERE admin_id=?");
$sessions->execute([$adminId]);
foreach ($sessions->fetchAll(PDO::FETCH_COLUMN) as $hash) {
if ($redis) $redis->del("transit:session:{$hash}");
}
$transit_con->prepare("DELETE FROM transit_sessions WHERE admin_id=?")->execute([$adminId]);
}
appLog("[ADMIN][TRANSIT][ORG][admin_toggle] admin={$adminId} is_active={$isActive}");
jsonSuccess(['admin_id' => $adminId, 'is_active' => (bool)$isActive]);
+31
View File
@@ -0,0 +1,31 @@
<?php
// Admin/transit/org/admins_list.php — قائمة مشرفي مؤسسة (لفريق سيرو)
// POST/GET: org_id
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
$orgId = filterRequest('org_id', 'int');
if (!$orgId) jsonError('org_id is required', 400);
$st = $transit_con->prepare(
"SELECT id, name, phone, role, is_active, created_at
FROM transit_org_admins WHERE org_id=? ORDER BY created_at ASC"
);
$st->execute([$orgId]);
$admins = $st->fetchAll();
foreach ($admins as &$a) {
if (!empty($a['phone'])) {
$a['phone'] = $encryptionHelper->decryptData($a['phone']) ?: null;
}
}
unset($a);
jsonSuccess(['admins' => $admins]);
+72
View File
@@ -0,0 +1,72 @@
<?php
// Admin/transit/org/create.php — فريق سيرو يضيف مؤسسة جديدة (جامعة/مدرسة/فندق/شركة/ناقل)
// + ينشئ أول مشرف (owner) لها مباشرة
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
require_once __DIR__ . '/../../../transit/functions.php';
requireTransitFields(['type', 'country', 'city', 'name_ar', 'name_en', 'admin_name', 'admin_phone']);
$type = filterRequest('type');
$country = strtoupper(substr(filterRequest('country'), 0, 2));
$city = filterRequest('city');
$nameAr = filterRequest('name_ar');
$nameEn = filterRequest('name_en');
$adminName = filterRequest('admin_name');
$adminPhone = normalizePhone(filterRequest('admin_phone'));
$adminRole = filterRequest('admin_role') ?: 'owner';
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
$allowedTypes = ['university', 'school', 'hotel', 'company', 'transporter'];
if (!in_array($type, $allowedTypes)) {
jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
}
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
$chk->execute([$nameAr, $country]);
if ($chk->fetch()) jsonError('Organization already exists', 409);
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
$transit_con->beginTransaction();
try {
$transit_con->prepare(
"INSERT INTO transit_orgs
(type, country, city, name_ar, name_en, contact_phone, contact_email, website, contract_status, trial_ends_at)
VALUES (?,?,?,?,?,?,?,?,'active',?)"
)->execute([
$type, $country, $city, $nameAr, $nameEn,
$contactPhoneEnc, filterRequest('contact_email'), filterRequest('website'),
$trialEndsAt,
]);
$orgId = (int)$transit_con->lastInsertId();
$transit_con->prepare(
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
$transit_con->commit();
} catch (Throwable $e) {
$transit_con->rollBack();
appLog('[ADMIN][TRANSIT][ORG][create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create organization', 500);
}
jsonSuccess([
'org_id' => $orgId,
'name_ar' => $nameAr,
'type' => $type,
'country' => $country,
], 'Organization created successfully');
+88
View File
@@ -0,0 +1,88 @@
<?php
// Admin/transit/org/details.php — تفاصيل وتحليلات مؤسسة واحدة (لفريق سيرو)
// POST/GET: org_id
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
$orgId = filterRequest('org_id', 'int');
if (!$orgId) jsonError('org_id is required', 400);
$st = $transit_con->prepare("SELECT * FROM transit_orgs WHERE id=? LIMIT 1");
$st->execute([$orgId]);
$org = $st->fetch();
if (!$org) jsonError('Organization not found', 404);
// فك تشفير هاتف التواصل للعرض الإداري فقط
if (!empty($org['contact_phone'])) {
$org['contact_phone'] = $encryptionHelper->decryptData($org['contact_phone']) ?: null;
}
// ── العدّادات الأساسية ───────────────────────────────────────
$counts = [
'drivers_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId")->fetchColumn(),
'drivers_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId AND status='active'")->fetchColumn(),
'vehicles_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId")->fetchColumn(),
'vehicles_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId AND is_active=1")->fetchColumn(),
'routes_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId")->fetchColumn(),
'routes_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId AND status='active'")->fetchColumn(),
'enrollments_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='active'")->fetchColumn(),
'enrollments_pending' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='pending'")->fetchColumn(),
];
// ── الرحلات: اليوم / هذا الأسبوع / هذا الشهر / إجمالي ──────────
$today = date('Y-m-d');
$weekStart = date('Y-m-d', strtotime('monday this week'));
$monthStart = date('Y-m-01');
$stTrips = $transit_con->prepare(
"SELECT
SUM(trip_date = ?) AS today_count,
SUM(trip_date >= ?) AS week_count,
SUM(trip_date >= ?) AS month_count,
COUNT(*) AS total_count,
SUM(status='completed') AS completed_count,
SUM(status='cancelled') AS cancelled_count,
SUM(status='no_show') AS no_show_count,
AVG(CASE WHEN status='completed' THEN delay_minutes END) AS avg_delay_minutes,
SUM(CASE WHEN status='completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
THEN TIMESTAMPDIFF(MINUTE, started_at, completed_at) ELSE 0 END) AS total_minutes_driven
FROM transit_trips WHERE org_id = ?"
);
$stTrips->execute([$today, $weekStart, $monthStart, $orgId]);
$tripStats = $stTrips->fetch();
$trips = [
'today' => (int)($tripStats['today_count'] ?? 0),
'this_week' => (int)($tripStats['week_count'] ?? 0),
'this_month' => (int)($tripStats['month_count'] ?? 0),
'total' => (int)($tripStats['total_count'] ?? 0),
'completed' => (int)($tripStats['completed_count'] ?? 0),
'cancelled' => (int)($tripStats['cancelled_count'] ?? 0),
'no_show' => (int)($tripStats['no_show_count'] ?? 0),
'avg_delay_minutes' => round((float)($tripStats['avg_delay_minutes'] ?? 0), 1),
'total_hours_driven' => round(((int)($tripStats['total_minutes_driven'] ?? 0)) / 60, 1),
];
// ── الخطوط مع ملخص لكل خط ─────────────────────────────────────
$stRoutes = $transit_con->prepare(
"SELECT r.id, r.name_ar, r.status, r.distance_km,
(SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count,
(SELECT COUNT(*) FROM transit_trips t WHERE t.route_id = r.id AND t.status='completed') AS completed_trips
FROM transit_routes r WHERE r.org_id = ? ORDER BY r.name_ar ASC"
);
$stRoutes->execute([$orgId]);
$routes = $stRoutes->fetchAll();
jsonSuccess([
'org' => $org,
'counts' => $counts,
'trips' => $trips,
'routes' => $routes,
]);
+72
View File
@@ -0,0 +1,72 @@
<?php
// Admin/transit/org/list.php — قائمة كل مؤسسات مواصلاتي (لفريق سيرو)
// GET/POST: country?, type?, contract_status?, search?, page?, per_page?
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
$country = filterRequest('country');
$type = filterRequest('type');
$contractStatus = filterRequest('contract_status');
$search = filterRequest('search');
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 30)));
$offset = ($page - 1) * $perPage;
$where = '1=1';
$params = [];
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
if ($contractStatus) { $where .= ' AND contract_status = ?'; $params[] = $contractStatus; }
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_orgs WHERE $where");
$countSt->execute($params);
$total = (int)$countSt->fetchColumn();
$params[] = $perPage;
$params[] = $offset;
$st = $transit_con->prepare(
"SELECT id, type, country, city, name_ar, name_en, logo_url,
contract_status, trial_ends_at, created_at
FROM transit_orgs
WHERE $where
ORDER BY created_at DESC
LIMIT ? OFFSET ?"
);
$st->execute($params);
$orgs = $st->fetchAll();
if ($orgs) {
$ids = implode(',', array_map('intval', array_column($orgs, 'id')));
$drivers = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_drivers WHERE org_id IN ($ids) GROUP BY org_id")
->fetchAll(PDO::FETCH_KEY_PAIR);
$vehicles = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_vehicles WHERE org_id IN ($ids) GROUP BY org_id")
->fetchAll(PDO::FETCH_KEY_PAIR);
$routes = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_routes WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
->fetchAll(PDO::FETCH_KEY_PAIR);
$enrollments = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_enrollments WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($orgs as &$o) {
$id = $o['id'];
$o['drivers_count'] = (int)($drivers[$id] ?? 0);
$o['vehicles_count'] = (int)($vehicles[$id] ?? 0);
$o['active_routes'] = (int)($routes[$id] ?? 0);
$o['active_enrollments'] = (int)($enrollments[$id] ?? 0);
}
unset($o);
}
jsonSuccess([
'orgs' => $orgs,
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
]);
+6
View File
@@ -28,6 +28,12 @@ class Database
'user' => 'DB_RIDE_USER',
'pass' => 'DB_RIDE_PASS',
],
'transit' => [
'name' => 'DB_TRANSIT_NAME',
'host' => 'DB_TRANSIT_HOST',
'user' => 'DB_TRANSIT_USER',
'pass' => 'DB_TRANSIT_PASS',
],
];
public static function get(string $name = 'main'): PDO
+49
View File
@@ -120,6 +120,55 @@ class FcmService
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
}
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
public function sendToTopic(
string $topic,
string $title,
string $body,
array $data = []
): array {
$accessToken = $this->getAccessToken();
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
if (!file_exists($this->serviceAccountFile)) {
return ['status' => 'error', 'message' => 'Service account file missing'];
}
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$projectId = $creds['project_id'];
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
$processedData = array_map(
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
array_merge($data, ['title' => $title, 'body' => $body])
);
$payload = [
'message' => [
'topic' => $topic,
'notification' => ['title' => $title, 'body' => $body],
'data' => $processedData,
'android' => ['priority' => 'HIGH'],
],
];
$ch = curl_init($fcmUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 200
? ['status' => 'success']
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
}
// ── Access Token مع Redis Cache ─────────────────────────
private function getAccessToken(): ?string
{
+5 -4
View File
@@ -17,15 +17,16 @@ class OtpService
}
// ── توليد وحفظ OTP ─────────────────────────────────────
public function generate(string $phone): string
// $digits: عدد الأرقام — الافتراضي 6، يمكن تمرير 3 لـ transit (100–999)
public function generate(string $phone, int $digits = 3): string
{
// OTP آمن (6 أرقام عشوائية)
$otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
$min = (int)str_pad('1', $digits, '0'); // digits=3 → 100 | digits=6 → 100000
$max = (int)str_pad('9', $digits, '9'); // digits=3 → 999 | digits=6 → 999999
$otp = str_pad((string)random_int($min, $max), $digits, '0', STR_PAD_LEFT);
if ($this->redis) {
$key = "otp:{$phone}";
$this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT));
// إعادة تعيين عداد المحاولات
$this->redis->del("otp:attempts:{$phone}");
}
+29
View File
@@ -234,3 +234,32 @@ function getInternalSocketKey(): string
return '';
}
/**
* تطبيع رقم الهاتف إلى الصيغة الدولية بدون + (E.164 بدون +)
* ناتج ثابت لأي مدخل من JO/SY/EG:
* الأردن → 9627XXXXXXXX
* سوريا → 9639XXXXXXX
* مصر → 20XXXXXXXXXX
* يُستخدم دائماً قبل التشفير وقبل الاستعلام.
*/
function normalizePhone(string $phone): string
{
$d = preg_replace('/\D+/', '', $phone);
// سوريا: 09X → 963X | 9X (9 أرقام) → 963X | 963... مكتمل
if (strlen($d) === 10 && str_starts_with($d, '09')) return '963' . substr($d, 1);
if (strlen($d) === 9 && str_starts_with($d, '9')) return '963' . $d;
if (strlen($d) === 12 && str_starts_with($d, '963')) return $d;
// الأردن: 07X → 962X | 7X (9 أرقام) → 962X | 962... مكتمل
if (strlen($d) === 10 && str_starts_with($d, '07')) return '962' . substr($d, 1);
if (strlen($d) === 9 && str_starts_with($d, '7')) return '962' . $d;
if (strlen($d) === 12 && str_starts_with($d, '962')) return $d;
// مصر: 01X → 20X | 201... مكتمل
if (strlen($d) === 11 && str_starts_with($d, '01')) return '20' . substr($d, 1);
if (strlen($d) === 13 && str_starts_with($d, '20')) return $d;
return $d; // رقم خارج النطاق — يُعاد كما هو
}
+65
View File
@@ -0,0 +1,65 @@
<?php
// transit/admin/dashboard.php — لوحة اليوم
require_once __DIR__ . '/../../transit/connect_admin.php';
$today = date('Y-m-d');
$stTrips = $transit_con->prepare(
"SELECT t.id, t.status, t.started_at, t.completed_at, t.delay_minutes, t.current_stop_seq,
r.name_ar AS route_name, sc.departure_time,
v.plate AS vehicle_plate,
d.name AS driver_name, d.phone AS driver_phone
FROM transit_trips t
JOIN transit_routes r ON r.id = t.route_id
JOIN transit_schedules sc ON sc.id = t.schedule_id
JOIN transit_vehicles v ON v.id = t.vehicle_id
JOIN transit_drivers d ON d.id = t.driver_id
WHERE t.org_id = ? AND t.trip_date = ?
ORDER BY sc.departure_time ASC"
);
$stTrips->execute([$transit_org_id, $today]);
$trips = $stTrips->fetchAll();
foreach ($trips as &$trip) {
$trip['live_position'] = null;
if ($trip['status'] === 'started') {
$pos = transitGetBusPosition((int)$trip['id']);
if ($pos) $trip['live_position'] = ['lat' => (float)$pos['lat'], 'lng' => (float)$pos['lng'], 'ts' => (int)$pos['ts']];
}
}
unset($trip);
$stStats = $transit_con->prepare(
"SELECT COUNT(*) total_trips,
SUM(status='started') active_now,
SUM(status='completed') completed_today,
SUM(status='no_show') no_show_today
FROM transit_trips WHERE org_id=? AND trip_date=?"
);
$stStats->execute([$transit_org_id, $today]);
$stats = $stStats->fetch();
$stMembers = $transit_con->prepare(
"SELECT COUNT(*) active_members FROM transit_enrollments WHERE org_id=? AND status='active'"
);
$stMembers->execute([$transit_org_id]);
$stBc = $transit_con->prepare(
"SELECT id, title_ar, body_ar, sent_at, target_type FROM transit_broadcasts
WHERE org_id=? ORDER BY created_at DESC LIMIT 5"
);
$stBc->execute([$transit_org_id]);
jsonSuccess([
'date' => $today,
'trips' => $trips,
'stats' => [
'total_trips' => (int)$stats['total_trips'],
'active_now' => (int)$stats['active_now'],
'completed_today' => (int)$stats['completed_today'],
'no_show_today' => (int)$stats['no_show_today'],
'active_members' => (int)$stMembers->fetchColumn(),
],
'recent_broadcasts' => $stBc->fetchAll(),
]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// transit/admin/login_request.php — الخطوة 1: هاتف المشرف → OTP
// POST: phone
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
$rawPhone = filterRequest('phone');
if (!$rawPhone) jsonError('Phone is required');
$phone = normalizePhone($rawPhone);
if (transitIsOtpLocked($phone)) {
jsonError('Too many attempts. Try again in 30 minutes.', 429);
}
$phoneEnc = $encryptionHelper->encryptData($phone);
$st = $transit_con->prepare(
"SELECT a.id, o.contract_status
FROM transit_org_admins a
JOIN transit_orgs o ON o.id = a.org_id
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
);
$st->execute([$phoneEnc]);
$admin = $st->fetch();
if (!$admin) jsonError('Invalid credentials', 401);
if ($admin['contract_status'] === 'terminated') jsonError('Account suspended', 403);
transitSendAdminOtp($phone);
jsonSuccess(null, 'OTP sent successfully');
+48
View File
@@ -0,0 +1,48 @@
<?php
// transit/admin/login_verify.php — الخطوة 2: OTP → session token
// POST: phone, otp
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
requireTransitFields(['phone', 'otp']);
$phone = normalizePhone(filterRequest('phone'));
$otp = preg_replace('/\D/', '', filterRequest('otp'));
if (!transitVerifyAdminOtp($phone, $otp)) jsonError('Invalid or expired OTP', 401);
$phoneEnc = $encryptionHelper->encryptData($phone);
$st = $transit_con->prepare(
"SELECT a.id, a.org_id, a.name, a.role, a.permissions,
o.name_ar, o.name_en, o.type, o.contract_status, o.logo_url
FROM transit_org_admins a
JOIN transit_orgs o ON o.id = a.org_id
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
);
$st->execute([$phoneEnc]);
$admin = $st->fetch();
if (!$admin) jsonError('Admin not found', 401);
$token = transitCreateSession((int)$admin['id'], (int)$admin['org_id']);
jsonSuccess([
'token' => $token,
'expires_in' => 86400,
'admin' => [
'id' => (int)$admin['id'],
'name' => $admin['name'],
'role' => $admin['role'],
'permissions' => json_decode($admin['permissions'] ?? '{}', true),
],
'org' => [
'id' => (int)$admin['org_id'],
'name_ar' => $admin['name_ar'],
'name_en' => $admin['name_en'],
'type' => $admin['type'],
'contract_status' => $admin['contract_status'],
'logo_url' => $admin['logo_url'],
],
], 'Login successful');
+32
View File
@@ -0,0 +1,32 @@
<?php
// transit/broadcast/send.php — المشرف يرسل إعلاناً للطلاب عبر FCM
require_once __DIR__ . '/../../transit/connect_admin.php';
requireTransitFields(['body_ar']);
$bodyAr = filterRequest('body_ar');
$titleAr = filterRequest('title_ar') ?: 'إعلان من الجامعة';
$targetType = filterRequest('target_type') ?: 'all';
$targetId = filterRequest('target_id', 'int') ?: null;
if (!in_array($targetType, ['all', 'route'])) $targetType = 'all';
$fcmTopic = ($targetType === 'route' && $targetId)
? transitRouteTopic($targetId)
: transitOrgTopic($transit_org_id);
$transit_con->prepare(
"INSERT INTO transit_broadcasts
(org_id, sent_by, target_type, target_id, title_ar, body_ar, fcm_topic, sent_at)
VALUES (?,?,?,?,?,?,?,NOW())"
)->execute([$transit_org_id, $transit_admin_id, $targetType, $targetId, $titleAr, $bodyAr, $fcmTopic]);
$broadcastId = (int)$transit_con->lastInsertId();
transitSendTopicNotification(
$fcmTopic, $titleAr, $bodyAr,
['type' => 'transit_broadcast', 'broadcast_id' => (string)$broadcastId]
);
jsonSuccess(['broadcast_id' => $broadcastId, 'fcm_topic' => $fcmTopic], 'Broadcast sent');
+33
View File
@@ -0,0 +1,33 @@
<?php
// ============================================================
// transit/connect_admin.php — بوابة لوحة الويب (مشرف المؤسسة)
// المصادقة عبر session token (هاتف + OTP) — مستقلة عن JWT
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
// CORS لواجهة الويب
$adminOrigins = array_map('trim', explode(',',
getenv('TRANSIT_ADMIN_ORIGINS') ?: 'https://transit.siromove.com,https://admin.siromove.com'
));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $adminOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
// اتصال Transit DB
try {
$transit_con = Database::get('transit');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
exit;
}
// التحقق من الـ session (يُعيد ['admin_id'=>X, 'org_id'=>Y])
$transit_admin = transitAuthAdmin();
$transit_admin_id = (int)$transit_admin['admin_id'];
$transit_org_id = (int)$transit_admin['org_id'];
+29
View File
@@ -0,0 +1,29 @@
<?php
// ============================================================
// transit/connect_app.php — بوابة التطبيق (راكب أو سائق باص)
// يستخدم JWT الرئيسي نفسه (الراكب مسجّل بالفعل كـ passenger)
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/functions.php';
// Rate limiting — نفس حد API العادي
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
// JWT المعتاد — يُستخرج منه passenger_id أو driver_id
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
$transit_user_id = $decoded->user_id ?? null;
$transit_user_role = $decoded->role ?? 'passenger'; // 'passenger' أو 'driver'
// اتصال قاعدة بيانات Transit فقط — ممنوع Database::get('main') في ملفات transit/
try {
$transit_con = Database::get('transit');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
exit;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// transit/driver/activate.php — السائق يفعّل حسابه (بدون JWT — قبل الدخول)
// POST: invite_token, phone, otp
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/../../core/Services/OtpService.php';
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
requireTransitFields(['invite_token','phone','otp']);
$inviteToken = filterRequest('invite_token');
$phone = normalizePhone(filterRequest('phone'));
$otp = preg_replace('/\D/', '', filterRequest('otp'));
if (!transitVerifyAdminOtp($phone, $otp)) jsonError('Invalid or expired OTP', 401);
$phoneEnc = $encryptionHelper->encryptData($phone);
$st = $transit_con->prepare(
"SELECT id, org_id, name, status FROM transit_drivers
WHERE invite_token=? AND phone=? LIMIT 1"
);
$st->execute([$inviteToken, $phoneEnc]);
$driver = $st->fetch();
if (!$driver) jsonError('Invalid invite token or phone mismatch', 404);
if ($driver['status'] === 'active') jsonError('Driver already activated', 409);
if ($driver['status'] === 'suspended') jsonError('Account suspended', 403);
$transit_con->prepare(
"UPDATE transit_drivers SET status='active', activated_at=NOW(), invite_token=NULL WHERE id=?"
)->execute([$driver['id']]);
jsonSuccess([
'driver_id' => (int)$driver['id'],
'org_id' => (int)$driver['org_id'],
'name' => $driver['name'],
'mode' => 'bus',
], 'Driver activated. Welcome to مواصلاتي!');
+35
View File
@@ -0,0 +1,35 @@
<?php
// transit/driver/invite.php — المشرف يضيف سائق جديد
require_once __DIR__ . '/../../transit/connect_admin.php';
requireTransitFields(['name', 'phone']);
$name = filterRequest('name');
$phone = normalizePhone(filterRequest('phone'));
$license = filterRequest('license_number');
// تشفير الهاتف مثل باقي أرقام الهواتف
$phoneEnc = $encryptionHelper->encryptData($phone);
$chk = $transit_con->prepare(
"SELECT id FROM transit_drivers WHERE phone=? AND org_id=? LIMIT 1"
);
$chk->execute([$phoneEnc, $transit_org_id]);
if ($chk->fetch()) jsonError('Driver phone already registered in this organization', 409);
$inviteToken = bin2hex(random_bytes(24));
$mainDriverId = filterRequest('main_driver_id');
$transit_con->prepare(
"INSERT INTO transit_drivers
(org_id,name,phone,license_number,main_driver_id,invite_token,invite_sent_at,status)
VALUES (?,?,?,?,?,?,NOW(),'invited')"
)->execute([$transit_org_id, $name, $phoneEnc, $license, $mainDriverId, $inviteToken]);
$driverId = (int)$transit_con->lastInsertId();
$appLink = getenv('APP_DEEP_LINK_BASE') ?: 'https://siromove.com/driver/transit-activate';
$message = "مرحباً {$name}، تمت إضافتك كسائق في منصة مواصلاتي.\nفعّل حسابك: {$appLink}?token={$inviteToken}";
sendWhatsAppFromServer($phone, $message);
jsonSuccess(['driver_id' => $driverId, 'invite_token' => $inviteToken], 'Driver invited successfully');
+27
View File
@@ -0,0 +1,27 @@
<?php
// transit/driver/list.php
require_once __DIR__ . '/../../transit/connect_admin.php';
$status = filterRequest('status') ?: 'active';
$allowed = ['all','invited','active','suspended'];
if (!in_array($status, $allowed)) $status = 'active';
$sql = "SELECT id, name, status, activated_at, created_at FROM transit_drivers WHERE org_id=?";
$params = [$transit_org_id];
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
$sql .= " ORDER BY name ASC";
$st = $transit_con->prepare($sql);
$st->execute($params);
$drivers = $st->fetchAll();
// فك تشفير هواتف السائقين للعرض
foreach ($drivers as &$d) {
if (!empty($d['phone'])) {
$d['phone'] = $encryptionHelper->decryptData($d['phone']) ?: '***';
}
}
unset($d);
jsonSuccess(['drivers' => $drivers]);
+30
View File
@@ -0,0 +1,30 @@
<?php
// transit/driver/me.php — السائق يتحقق هل هو سائق باص، ويحصل على معرّفه في مواصلاتي
// المصادقة: JWT السائق الرئيسي (نفس main_driver_id)
require_once __DIR__ . '/../../transit/connect_app.php';
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
$st = $transit_con->prepare(
"SELECT d.id, d.org_id, d.name, d.status,
o.name_ar AS org_name, o.type AS org_type
FROM transit_drivers d
JOIN transit_orgs o ON o.id = d.org_id
WHERE d.main_driver_id = ? LIMIT 1"
);
$st->execute([(string)$transit_user_id]);
$driver = $st->fetch();
if (!$driver) {
jsonSuccess(['is_bus_driver' => false]);
}
jsonSuccess([
'is_bus_driver' => true,
'driver_transit_id' => (int)$driver['id'],
'org_id' => (int)$driver['org_id'],
'org_name' => $driver['org_name'],
'org_type' => $driver['org_type'],
'status' => $driver['status'],
]);
+66
View File
@@ -0,0 +1,66 @@
<?php
// transit/enrollment/activate.php — الراكب يفعّل عضويته بالرقم الجامعي
require_once __DIR__ . '/../../transit/connect_app.php';
if ($transit_user_role !== 'passenger') jsonError('Only passengers can enroll', 403);
requireTransitFields(['org_id', 'student_id']);
$orgId = filterRequest('org_id', 'int');
$studentId = trim(filterRequest('student_id'));
$chkOrg = $transit_con->prepare(
"SELECT id, name_ar, contract_status FROM transit_orgs WHERE id=? LIMIT 1"
);
$chkOrg->execute([$orgId]);
$org = $chkOrg->fetch();
if (!$org) jsonError('Organization not found', 404);
if ($org['contract_status'] === 'terminated') jsonError('Organization account inactive', 403);
$chkEnroll = $transit_con->prepare(
"SELECT id, status FROM transit_enrollments WHERE org_id=? AND passenger_id=? LIMIT 1"
);
$chkEnroll->execute([$orgId, $transit_user_id]);
$existing = $chkEnroll->fetch();
if ($existing && in_array($existing['status'], ['active', 'pending'])) {
jsonError('Enrollment already exists', 409);
}
$encryptedId = $encryptionHelper->encryptData($studentId);
// مطابقة الكشف: هل الرقم موجود في كشف مستورد بدون passenger_id؟
$stRoster = $transit_con->prepare(
"SELECT id, member_name FROM transit_enrollments
WHERE org_id=? AND student_id=? AND passenger_id IS NULL LIMIT 1"
);
$stRoster->execute([$orgId, $encryptedId]);
$rosterRow = $stRoster->fetch();
if ($rosterRow) {
$transit_con->prepare(
"UPDATE transit_enrollments
SET passenger_id=?, status='active', verified_at=NOW(), verify_method='roster_phone_match'
WHERE id=?"
)->execute([$transit_user_id, $rosterRow['id']]);
jsonSuccess([
'enrollment_id' => (int)$rosterRow['id'],
'org_name' => $org['name_ar'],
'status' => 'active',
'verify_method' => 'roster_phone_match',
], 'تم التحقق من عضويتك بنجاح!');
}
// طلب يدوي ينتظر موافقة المشرف
$transit_con->prepare(
"INSERT INTO transit_enrollments
(org_id, passenger_id, student_id, verify_method, status)
VALUES (?,?,?,'manual_admin','pending')"
)->execute([$orgId, $transit_user_id, $encryptedId]);
jsonSuccess([
'enrollment_id' => (int)$transit_con->lastInsertId(),
'org_name' => $org['name_ar'],
'status' => 'pending',
], 'طلبك قيد المراجعة. سيتم إشعارك عند التفعيل.');
+39
View File
@@ -0,0 +1,39 @@
<?php
// transit/enrollment/approve.php — المشرف يوافق على طلب عضوية أو يرفضه
require_once __DIR__ . '/../../transit/connect_admin.php';
requireTransitFields(['enrollment_id', 'action']);
$enrollId = filterRequest('enrollment_id', 'int');
$action = filterRequest('action');
$expiresAt = filterRequest('expires_at');
if (!in_array($action, ['approve', 'reject'])) jsonError('Invalid action. Use: approve or reject', 400);
$st = $transit_con->prepare(
"SELECT id, passenger_id, status FROM transit_enrollments WHERE id=? AND org_id=? LIMIT 1"
);
$st->execute([$enrollId, $transit_org_id]);
$enroll = $st->fetch();
if (!$enroll) jsonError('Enrollment not found', 404);
if ($enroll['status'] !== 'pending') jsonError('Enrollment is not pending', 409);
$newStatus = ($action === 'approve') ? 'active' : 'suspended';
$transit_con->prepare(
"UPDATE transit_enrollments
SET status=?, verified_at=NOW(), verify_method='manual_admin', expires_at=?
WHERE id=?"
)->execute([$newStatus, $expiresAt, $enrollId]);
if ($action === 'approve' && $enroll['passenger_id']) {
transitSendTopicNotification(
'passenger_' . $enroll['passenger_id'],
'تم تفعيل عضويتك',
'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي',
['type' => 'transit_enrollment_approved']
);
}
jsonSuccess(['enrollment_id' => $enrollId, 'status' => $newStatus]);
@@ -0,0 +1,79 @@
<?php
// transit/enrollment/import_roster.php — المشرف يرفع كشف الطلاب (CSV)
// POST: file (CSV: student_id, name), semester?, notes?
require_once __DIR__ . '/../../transit/connect_admin.php';
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
jsonError('No valid file uploaded', 400);
}
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['csv', 'txt'])) jsonError('Only CSV files are supported', 400);
$semester = filterRequest('semester') ?: date('Y') . '-S1';
$notes = filterRequest('notes');
$rows = [];
if (($handle = fopen($_FILES['file']['tmp_name'], 'r')) !== false) {
$headers = null;
while (($line = fgetcsv($handle, 0, ',')) !== false) {
if ($headers === null) { $headers = array_map('strtolower', array_map('trim', $line)); continue; }
$row = array_combine($headers, $line);
if ($row) $rows[] = $row;
}
fclose($handle);
}
if (empty($rows)) jsonError('CSV file is empty or invalid', 400);
$totalRows = count($rows);
$transit_con->prepare(
"INSERT INTO transit_rosters (org_id, uploaded_by, filename, total_rows, semester, notes)
VALUES (?,?,?,?,?,?)"
)->execute([$transit_org_id, $transit_admin_id, $_FILES['file']['name'], $totalRows, $semester, $notes]);
$rosterId = (int)$transit_con->lastInsertId();
$insertSt = $transit_con->prepare(
"INSERT IGNORE INTO transit_enrollments
(org_id, passenger_id, student_id, member_name, verify_method, status, roster_id)
VALUES (?,NULL,?,?,'roster_manual','pending',?)"
);
$matchedRows = 0;
$newEnroll = 0;
foreach ($rows as $row) {
$sid = trim($row['student_id'] ?? $row['id'] ?? '');
$name = trim($row['name'] ?? $row['full_name'] ?? '');
if (!$sid) continue;
$encSid = $encryptionHelper->encryptData($sid);
$chk = $transit_con->prepare(
"SELECT id, passenger_id FROM transit_enrollments WHERE org_id=? AND student_id=? LIMIT 1"
);
$chk->execute([$transit_org_id, $encSid]);
$existing = $chk->fetch();
if ($existing) {
$transit_con->prepare(
"UPDATE transit_enrollments SET member_name=?, roster_id=? WHERE id=?"
)->execute([$name, $rosterId, $existing['id']]);
if ($existing['passenger_id']) $matchedRows++;
} else {
$insertSt->execute([$transit_org_id, $encSid, $name, $rosterId]);
$newEnroll++;
}
}
$transit_con->prepare(
"UPDATE transit_rosters SET matched_rows=?, new_enrollments=? WHERE id=?"
)->execute([$matchedRows, $newEnroll, $rosterId]);
jsonSuccess([
'roster_id' => $rosterId,
'total_rows' => $totalRows,
'new_enrollments' => $newEnroll,
'matched' => $matchedRows,
], 'Roster imported successfully');
+36
View File
@@ -0,0 +1,36 @@
<?php
// transit/enrollment/list.php — قائمة عضويات المؤسسة (للمشرف)
require_once __DIR__ . '/../../transit/connect_admin.php';
$status = filterRequest('status') ?: 'all';
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 50)));
$offset = ($page - 1) * $perPage;
$allowed = ['all', 'pending', 'active', 'suspended', 'expired'];
if (!in_array($status, $allowed)) $status = 'all';
$where = "org_id = ?";
$params = [$transit_org_id];
if ($status !== 'all') { $where .= " AND status = ?"; $params[] = $status; }
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_enrollments WHERE $where");
$countSt->execute($params);
$total = (int)$countSt->fetchColumn();
$params[] = $perPage;
$params[] = $offset;
$st = $transit_con->prepare(
"SELECT id, passenger_id, member_name, status, verify_method,
verified_at, expires_at, created_at
FROM transit_enrollments
WHERE $where ORDER BY created_at DESC LIMIT ? OFFSET ?"
);
$st->execute($params);
$enrollments = $st->fetchAll();
jsonSuccess([
'enrollments' => $enrollments,
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
]);
@@ -0,0 +1,18 @@
<?php
// transit/enrollment/my_enrollments.php — الراكب يستعرض عضوياته في كل المؤسسات
require_once __DIR__ . '/../../transit/connect_app.php';
if ($transit_user_role !== 'passenger') jsonError('Only passengers can view enrollments', 403);
$st = $transit_con->prepare(
"SELECT e.id, e.org_id, e.status, e.verify_method, e.expires_at, e.created_at,
o.name_ar AS org_name, o.name_en AS org_name_en, o.type AS org_type, o.logo_url
FROM transit_enrollments e
JOIN transit_orgs o ON o.id = e.org_id
WHERE e.passenger_id = ?
ORDER BY e.created_at DESC"
);
$st->execute([$transit_user_id]);
jsonSuccess(['enrollments' => $st->fetchAll()]);
+205
View File
@@ -0,0 +1,205 @@
<?php
// ============================================================
// transit/functions.php — دوال خاصة بمنصة مواصلاتي فقط
//
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
// • filterRequest() ← core/helpers.php
// • jsonSuccess() / jsonError() ← core/helpers.php
// • appLog() / securityLog() ← core/helpers.php
// • normalizePhone($phone) ← core/helpers.php — يطبّع الهاتف دائماً قبل التشفير أو الاستعلام
// • OtpService($redis) ← core/Services/OtpService.php
// • $encryptionHelper ← مهيَّأ في bootstrap.php
// • $redis ← مهيَّأ في bootstrap.php
// • sendWhatsAppFromServer() ← backend/functions.php
// • FcmService($redis) ← core/Services/FcmService.php
// ============================================================
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
function requireTransitFields(array $fields): void
{
foreach ($fields as $f) {
if (filterRequest($f) === null) {
jsonError("Missing required field: $f", 400);
}
}
}
// ── Session مشرف الويب (OTP-based — مستقلة عن JWT) ──────────
function transitCreateSession(int $adminId, int $orgId): string
{
global $redis;
$token = bin2hex(random_bytes(32)); // 64 hex chars
$hash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$con = Database::get('transit');
$con->prepare(
"INSERT INTO transit_sessions (admin_id, org_id, token_hash, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?)"
)->execute([$adminId, $orgId, $hash, $ip, $ua, $expiresAt]);
if ($redis) {
$redis->setEx(
"transit:session:{$hash}",
86400,
json_encode(['admin_id' => $adminId, 'org_id' => $orgId])
);
}
return $token;
}
function transitAuthAdmin(): array
{
global $redis;
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_TRANSIT_TOKEN'] ?? '';
$token = str_replace('Bearer ', '', $header);
if (!$token) jsonError('Missing admin session token', 401);
$hash = hash('sha256', $token);
if ($redis) {
$val = $redis->get("transit:session:{$hash}");
if ($val) {
$data = json_decode($val, true);
if ($data) return $data;
}
jsonError('Session expired or invalid', 401);
}
// Fallback MySQL
$con = Database::get('transit');
$st = $con->prepare(
"SELECT admin_id, org_id FROM transit_sessions
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
);
$st->execute([$hash]);
$row = $st->fetch();
if (!$row) jsonError('Session expired or invalid', 401);
return $row;
}
// ── OTP مشرف الويب (يستخدم OtpService الموجود) ─────────────
function transitSendAdminOtp(string $phone): void
{
global $redis;
$otpSvc = new OtpService($redis);
$otp = $otpSvc->generate($phone, 3); // 3 أرقام فقط (100–999) حسب سياسة مواصلاتي
$message = "رمز تسجيل الدخول لمنصة مواصلاتي: {$otp}\nصالح لمدة 5 دقائق.";
sendWhatsAppFromServer($phone, $message);
}
function transitVerifyAdminOtp(string $phone, string $otp): bool
{
global $redis;
$otpSvc = new OtpService($redis);
return $otpSvc->verify($phone, $otp);
}
function transitIsOtpLocked(string $phone): bool
{
global $redis;
$otpSvc = new OtpService($redis);
return $otpSvc->isLocked($phone);
}
// ── تشفير الرقم الجامعي / الوظيفي (يستخدم EncryptionHelper) ─
function transitEncryptStudentId(string $raw): string
{
global $encryptionHelper;
return $encryptionHelper->encryptData($raw);
}
function transitDecryptStudentId(string $enc): string
{
global $encryptionHelper;
return (string)$encryptionHelper->decryptData($enc);
}
// ── إشعارات FCM Topic للخطوط ────────────────────────────────
function transitRouteTopic(int $routeId): string
{
return 'transit_route_' . $routeId;
}
function transitOrgTopic(int $orgId): string
{
return 'transit_org_' . $orgId;
}
function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void
{
global $redis;
$fcm = new FcmService($redis);
$result = $fcm->sendToTopic($topic, $title, $body, $data);
if ($result['status'] !== 'success') {
appLog("[TRANSIT][FCM] Topic push failed on '{$topic}': " . json_encode($result), 'WARNING');
}
}
// ── موقع الباص في Redis سيرفر الموقع ($redisLocation) ────────
// موقع الباص يكتبه driver_socket على Redis سيرفر الموقع (بدون بادئة siro:)
// تماماً مثل موقع السائق العادي — لذلك نقرؤه/نكتبه عبر $redisLocation
// وليس $redis الرئيسي (الذي يضيف بادئة siro: ولا يراه السوكت).
function transitUpdateBusPosition(int $tripId, float $lat, float $lng): void
{
global $redisLocation;
if (!$redisLocation) return;
$redisLocation->hMSet("transit:trip:{$tripId}:pos", ['lat' => $lat, 'lng' => $lng, 'ts' => time()]);
$redisLocation->expire("transit:trip:{$tripId}:pos", 86400);
}
function transitGetBusPosition(int $tripId): ?array
{
global $redisLocation;
if (!$redisLocation) return null;
$pos = $redisLocation->hGetAll("transit:trip:{$tripId}:pos");
return ($pos && isset($pos['lat'])) ? $pos : null;
}
// ── ملكية الرحلة (Trip Ownership) ─────────────────────────────
// تُكتب عند بدء الرحلة على Redis سيرفر الموقع، ليتحقق منها driver_socket
// قبل قبول أي update_bus_location — يمنع أي سائق آخر من انتحال trip_id
// أو البث لخط لا يملكه. main_driver_id هو نفسه sub في JWT (الحساب في
// جدول driver الرئيسي)، وليس transit_drivers.id.
function transitSetTripOwner(int $tripId, string $mainDriverId, int $routeId): void
{
global $redisLocation;
if (!$redisLocation || !$mainDriverId) return;
$key = "transit:trip:{$tripId}:owner";
$redisLocation->hMSet($key, ['driver_id' => $mainDriverId, 'route_id' => $routeId]);
$redisLocation->expire($key, 86400);
}
function transitClearTripOwner(int $tripId): void
{
global $redisLocation;
if (!$redisLocation) return;
$redisLocation->del("transit:trip:{$tripId}:owner");
}
// ── days_mask helpers ────────────────────────────────────────
// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس
function transitDayActive(int $mask): bool
{
return (bool)(($mask >> (int)date('w')) & 1);
}
@@ -0,0 +1,39 @@
<?php
// transit/notification/subscribe.php — الراكب يشترك في إشعارات خط (FCM Topic)
require_once __DIR__ . '/../../transit/connect_app.php';
requireTransitFields(['route_id', 'fcm_token']);
$routeId = filterRequest('route_id', 'int');
$fcmToken = filterRequest('fcm_token');
$stopId = filterRequest('preferred_stop_id', 'int') ?: null;
// تحقق العضوية النشطة في المؤسسة المالكة للخط
$chk = $transit_con->prepare(
"SELECT e.id
FROM transit_enrollments e
JOIN transit_routes r ON r.org_id = e.org_id
WHERE r.id=? AND e.passenger_id=? AND e.status='active' LIMIT 1"
);
$chk->execute([$routeId, $transit_user_id]);
if (!$chk->fetch()) jsonError('Active enrollment required to subscribe', 403);
if ($stopId) {
$transit_con->prepare(
"UPDATE transit_enrollments
SET preferred_stop_id=?, preferred_route_id=?
WHERE passenger_id=? AND org_id=(SELECT org_id FROM transit_routes WHERE id=? LIMIT 1)"
)->execute([$stopId, $routeId, $transit_user_id, $routeId]);
}
$topic = transitRouteTopic($routeId);
// اشتراك FCM Topic يتم من التطبيق مباشرة (firebaseMessaging.subscribeToTopic)
// هنا نسجّل فقط المحطة المفضلة ونؤكد الموضوع
appLog("[TRANSIT][NOTIFY] passenger {$transit_user_id} subscribed to {$topic}");
jsonSuccess([
'topic' => $topic,
'preferred_stop_id' => $stopId,
], 'Subscribed to route notifications');
+24
View File
@@ -0,0 +1,24 @@
<?php
// transit/org/browse.php — الراكب يتصفح المؤسسات المتاحة للتفعيل
// POST: country?, type?, search?
require_once __DIR__ . '/../../transit/connect_app.php';
$country = filterRequest('country');
$type = filterRequest('type');
$search = filterRequest('search');
$where = "contract_status IN ('trial','active')";
$params = [];
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
$st = $transit_con->prepare(
"SELECT id, type, country, city, name_ar, name_en, logo_url
FROM transit_orgs WHERE $where ORDER BY name_ar ASC LIMIT 200"
);
$st->execute($params);
jsonSuccess(['orgs' => $st->fetchAll()]);
+59
View File
@@ -0,0 +1,59 @@
<?php
// transit/org/register.php — تسجيل مؤسسة جديدة (داخلي بـ X-Internal-Key)
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
$internalKey = getenv('SOCKET_INTERNAL_KEY') ?: '';
$requestKey = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? filterRequest('internal_key') ?? '';
if (!$internalKey || !hash_equals($internalKey, $requestKey)) jsonError('Forbidden', 403);
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
requireTransitFields(['type','country','city','name_ar','name_en','admin_name','admin_phone']);
$type = filterRequest('type');
$country = strtoupper(substr(filterRequest('country'), 0, 2));
$city = filterRequest('city');
$nameAr = filterRequest('name_ar');
$nameEn = filterRequest('name_en');
$adminName = filterRequest('admin_name');
$adminPhone = normalizePhone(filterRequest('admin_phone'));
$adminRole = filterRequest('admin_role') ?: 'owner';
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
$allowedTypes = ['university','school','hotel','company','transporter'];
if (!in_array($type, $allowedTypes)) jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
$chk->execute([$nameAr, $country]);
if ($chk->fetch()) jsonError('Organization already exists', 409);
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
$transit_con->beginTransaction();
try {
$transit_con->prepare(
"INSERT INTO transit_orgs (type,country,city,name_ar,name_en,contact_phone,contact_email,website,contract_status,trial_ends_at)
VALUES (?,?,?,?,?,?,?,?,'trial',?)"
)->execute([$type,$country,$city,$nameAr,$nameEn,$contactPhoneEnc,
filterRequest('contact_email'),filterRequest('website'),$trialEndsAt]);
$orgId = (int)$transit_con->lastInsertId();
$transit_con->prepare(
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
$transit_con->commit();
} catch (Throwable $e) {
$transit_con->rollBack();
appLog('[TRANSIT][ORG][register] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create organization', 500);
}
jsonSuccess(['org_id' => $orgId, 'name_ar' => $nameAr, 'type' => $type, 'country' => $country], 'Organization registered successfully');
+56
View File
@@ -0,0 +1,56 @@
<?php
// transit/route/add.php — إضافة خط جديد (مسودة)
require_once __DIR__ . '/../../transit/connect_admin.php';
$nameAr = filterRequest('name_ar');
if (!$nameAr) jsonError('name_ar is required');
$direction = filterRequest('direction') ?: 'outbound';
if (!in_array($direction, ['outbound','inbound','circular'])) $direction = 'outbound';
$stops = json_decode(filterRequest('stops') ?? '[]', true) ?: [];
$transit_con->beginTransaction();
try {
$transit_con->prepare(
"INSERT INTO transit_routes
(org_id,name_ar,name_en,direction,polyline,distance_km,duration_min,status,created_by)
VALUES (?,?,?,?,?,?,?,'draft',?)"
)->execute([
$transit_org_id, $nameAr,
filterRequest('name_en'),
$direction,
filterRequest('polyline'),
filterRequest('distance_km','float'),
filterRequest('duration_min','int'),
$transit_admin_id,
]);
$routeId = (int)$transit_con->lastInsertId();
$insertStop = $transit_con->prepare(
"INSERT INTO transit_stops
(route_id,org_id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major)
VALUES (?,?,?,?,?,?,?,?,?,?)"
);
foreach ($stops as $i => $s) {
if (empty($s['lat']) || empty($s['lng']) || empty($s['name_ar'])) continue;
$insertStop->execute([
$routeId, $transit_org_id, $i + 1,
$s['name_ar'], $s['name_en'] ?? null,
(float)$s['lat'], (float)$s['lng'],
(int)($s['radius'] ?? 150),
isset($s['eta_offset_min']) ? (int)$s['eta_offset_min'] : null,
(int)($s['is_major'] ?? 0),
]);
}
$transit_con->commit();
} catch (Throwable $e) {
$transit_con->rollBack();
appLog('[TRANSIT][ROUTE] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to save route', 500);
}
jsonSuccess(['route_id' => $routeId, 'stops_added' => count($stops)], 'Route saved as draft');
+34
View File
@@ -0,0 +1,34 @@
<?php
// transit/route/for_org.php — الراكب يستعرض خطوط مؤسسته (يشترط عضوية نشطة)
// POST: org_id
require_once __DIR__ . '/../../transit/connect_app.php';
if ($transit_user_role !== 'passenger') jsonError('Only passengers can browse routes', 403);
$orgId = filterRequest('org_id', 'int');
if (!$orgId) jsonError('org_id is required', 400);
$chk = $transit_con->prepare(
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
);
$chk->execute([$orgId, $transit_user_id]);
if (!$chk->fetch()) jsonError('Active enrollment required to view routes', 403);
$st = $transit_con->prepare(
"SELECT id, name_ar, name_en, direction, distance_km, duration_min, status
FROM transit_routes WHERE org_id=? AND status='active' ORDER BY name_ar ASC"
);
$st->execute([$orgId]);
$routes = $st->fetchAll();
if ($routes) {
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
$counts = $transit_con->query(
"SELECT route_id, COUNT(*) c FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
)->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
unset($r);
}
jsonSuccess(['routes' => $routes]);
+32
View File
@@ -0,0 +1,32 @@
<?php
// transit/route/get.php — تفاصيل خط + محطاته + جداوله
require_once __DIR__ . '/../../transit/connect_admin.php';
$routeId = filterRequest('route_id', 'int');
if (!$routeId) jsonError('route_id is required');
$st = $transit_con->prepare("SELECT * FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
$st->execute([$routeId, $transit_org_id]);
$route = $st->fetch();
if (!$route) jsonError('Route not found', 404);
$stStops = $transit_con->prepare(
"SELECT id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
);
$stStops->execute([$routeId]);
$route['stops'] = $stStops->fetchAll();
$stSch = $transit_con->prepare(
"SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active,
d.name AS driver_name, v.plate AS vehicle_plate
FROM transit_schedules s
LEFT JOIN transit_drivers d ON d.id=s.driver_id
LEFT JOIN transit_vehicles v ON v.id=s.vehicle_id
WHERE s.route_id=? AND s.org_id=? ORDER BY s.departure_time ASC"
);
$stSch->execute([$routeId, $transit_org_id]);
$route['schedules'] = $stSch->fetchAll();
jsonSuccess(['route' => $route]);
+28
View File
@@ -0,0 +1,28 @@
<?php
// transit/route/list.php
require_once __DIR__ . '/../../transit/connect_admin.php';
$status = filterRequest('status') ?: 'active';
if (!in_array($status, ['all','draft','active','suspended'])) $status = 'active';
$sql = "SELECT id, name_ar, name_en, direction, distance_km, duration_min, status, created_at FROM transit_routes WHERE org_id=?";
$params = [$transit_org_id];
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
$sql .= " ORDER BY name_ar ASC";
$st = $transit_con->prepare($sql);
$st->execute($params);
$routes = $st->fetchAll();
if ($routes) {
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
$cntSt = $transit_con->query(
"SELECT route_id, COUNT(*) cnt FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
);
$counts = $cntSt ? array_column($cntSt->fetchAll(), 'cnt', 'route_id') : [];
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
unset($r);
}
jsonSuccess(['routes' => $routes]);
+38
View File
@@ -0,0 +1,38 @@
<?php
// transit/schedule/add.php — ربط جدول زمني بخط + سائق + مركبة
require_once __DIR__ . '/../../transit/connect_admin.php';
requireTransitFields(['route_id', 'departure_time', 'days_mask']);
$routeId = filterRequest('route_id', 'int');
$departureTime = filterRequest('departure_time');
$daysMask = filterRequest('days_mask', 'int') ?? 62;
$driverId = filterRequest('driver_id', 'int') ?: null;
$vehicleId = filterRequest('vehicle_id', 'int') ?: null;
$validFrom = filterRequest('valid_from') ?: date('Y-m-d');
$validUntil = filterRequest('valid_until');
$chk = $transit_con->prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
$chk->execute([$routeId, $transit_org_id]);
if (!$chk->fetch()) jsonError('Route not found or access denied', 404);
if ($vehicleId) {
$chkV = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
$chkV->execute([$vehicleId, $transit_org_id]);
if (!$chkV->fetch()) jsonError('Vehicle not found or access denied', 404);
}
if ($driverId) {
$chkD = $transit_con->prepare("SELECT id FROM transit_drivers WHERE id=? AND org_id=? AND status='active' LIMIT 1");
$chkD->execute([$driverId, $transit_org_id]);
if (!$chkD->fetch()) jsonError('Driver not found or not active', 404);
}
$transit_con->prepare(
"INSERT INTO transit_schedules
(route_id, org_id, vehicle_id, driver_id, days_mask, departure_time, valid_from, valid_until, created_by)
VALUES (?,?,?,?,?,?,?,?,?)"
)->execute([$routeId, $transit_org_id, $vehicleId, $driverId, $daysMask, $departureTime, $validFrom, $validUntil, $transit_admin_id]);
jsonSuccess(['schedule_id' => (int)$transit_con->lastInsertId()], 'Schedule added');
+342
View File
@@ -0,0 +1,342 @@
-- =============================================================
-- schema_transit.sql — قاعدة بيانات منصة مواصلاتي (siroTransitDb)
-- عزل كامل عن main/ride/tracking — ممنوع أي JOIN خارجي
-- الربط بالنظام الرئيسي عبر passenger_id / driver_id فقط
-- =============================================================
SET FOREIGN_KEY_CHECKS = 0;
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
-- -----------------------------------------------------------------
-- 1. transit_orgs — المؤسسات (جامعات، مدارس، فنادق، شركات، ناقلون)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_orgs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`type` ENUM('university','school','hotel','company','transporter') NOT NULL,
`country` CHAR(2) NOT NULL COMMENT 'JO | SY | EG | ...',
`city` VARCHAR(80) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NOT NULL,
`logo_url` VARCHAR(500) DEFAULT NULL,
`campus_polygon` JSON DEFAULT NULL COMMENT 'حدود الحرم — مصفوفة [{lat,lng}]',
`website` VARCHAR(300) DEFAULT NULL,
`contact_phone` VARCHAR(30) DEFAULT NULL,
`contact_email` VARCHAR(150) DEFAULT NULL,
`contract_status` ENUM('trial','active','suspended','terminated') NOT NULL DEFAULT 'trial',
`trial_ends_at` DATE DEFAULT NULL,
`notes` TEXT DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_country_type` (`country`, `type`),
KEY `idx_contract_status` (`contract_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 2. transit_org_links — ربط ناقل ↔ مؤسسة (النمط الثاني)
-- جامعة حكومية لا تملك أسطولاً → تُخدَم بناقل مسجّل
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_org_links` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`institution_id` INT UNSIGNED NOT NULL COMMENT 'org_id للمؤسسة (جامعة/مدرسة)',
`transporter_id` INT UNSIGNED NOT NULL COMMENT 'org_id للناقل (type=transporter)',
`status` ENUM('active','inactive') NOT NULL DEFAULT 'active',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_link` (`institution_id`, `transporter_id`),
KEY `idx_transporter` (`transporter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 3. transit_org_admins — مشرفو المؤسسة (لوحة تحكم الويب)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_org_admins` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL,
`phone` VARCHAR(25) NOT NULL,
`role` ENUM('owner','transport_manager','dispatcher') NOT NULL DEFAULT 'transport_manager',
`permissions` JSON DEFAULT NULL COMMENT '{"routes":true,"drivers":true,"roster":true,"broadcast":true}',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 4. transit_sessions — جلسات تسجيل دخول مشرف الويب (OTP-based)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_sessions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`admin_id` INT UNSIGNED NOT NULL,
`org_id` INT UNSIGNED NOT NULL,
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
`ip` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(300) DEFAULT NULL,
`expires_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_token` (`token_hash`),
KEY `idx_admin` (`admin_id`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 5. transit_vehicles — الباصات والفانات المملوكة للمؤسسة
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`plate` VARCHAR(30) NOT NULL,
`make` VARCHAR(50) DEFAULT NULL,
`model` VARCHAR(50) DEFAULT NULL,
`year` SMALLINT DEFAULT NULL,
`color` VARCHAR(30) DEFAULT NULL,
`capacity` TINYINT NOT NULL DEFAULT 30,
`vehicle_type` ENUM('bus','minibus','van','other') NOT NULL DEFAULT 'bus',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`notes` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_plate` (`plate`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 6. transit_drivers — سائقو الباصات (يُنشئهم مشرف المؤسسة)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_drivers` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL,
`phone` VARCHAR(25) NOT NULL,
`license_number` VARCHAR(50) DEFAULT NULL,
`license_img_url` VARCHAR(500) DEFAULT NULL,
`main_driver_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول driver الرئيسي إن كان كابتن مشاوير أيضاً',
`invite_token` VARCHAR(64) DEFAULT NULL COMMENT 'رمز الدعوة للتفعيل الأولي',
`invite_sent_at` TIMESTAMP DEFAULT NULL,
`activated_at` TIMESTAMP DEFAULT NULL,
`status` ENUM('invited','active','suspended') NOT NULL DEFAULT 'invited',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
KEY `idx_org` (`org_id`),
KEY `idx_invite_token` (`invite_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 7. transit_routes — خطوط النقل
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_routes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) DEFAULT NULL,
`direction` ENUM('outbound','inbound','circular') NOT NULL DEFAULT 'outbound',
`polyline` TEXT DEFAULT NULL COMMENT 'Encoded polyline للمسار',
`distance_km` DECIMAL(7,2) DEFAULT NULL,
`duration_min` SMALLINT DEFAULT NULL,
`status` ENUM('draft','active','suspended') NOT NULL DEFAULT 'draft',
`created_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من أنشأه',
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من اعتمده (فريق سيرو)',
`approved_at` TIMESTAMP DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_org_status` (`org_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 8. transit_stops — محطات الخط مع جيوفينس
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_stops` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`route_id` INT UNSIGNED NOT NULL,
`org_id` INT UNSIGNED NOT NULL,
`sequence` TINYINT NOT NULL COMMENT 'ترتيب المحطة في الخط (1، 2، 3…)',
`name_ar` VARCHAR(120) NOT NULL,
`name_en` VARCHAR(120) DEFAULT NULL,
`latitude` DECIMAL(10,7) NOT NULL,
`longitude` DECIMAL(10,7) NOT NULL,
`geofence_radius` SMALLINT NOT NULL DEFAULT 150 COMMENT 'نصف قطر الجيوفينس بالمتر',
`eta_offset_min` SMALLINT DEFAULT NULL COMMENT 'الإزاحة الزمنية عن وقت الانطلاق بالدقائق',
`is_major` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'محطة رئيسية (تظهر بارزة للطالب)',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_route_seq` (`route_id`, `sequence`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 9. transit_schedules — جداول انطلاق الخطوط
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_schedules` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`route_id` INT UNSIGNED NOT NULL,
`org_id` INT UNSIGNED NOT NULL,
`vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'الباص المخصص لهذا الجدول (NULL = يُحدد يومياً)',
`driver_id` INT UNSIGNED DEFAULT NULL COMMENT 'السائق المخصص (NULL = يُحدد يومياً)',
`days_mask` TINYINT NOT NULL DEFAULT 62 COMMENT 'قناع الأيام: bit0=أحد…bit6=سبت (62=الأحد–الخميس)',
`departure_time` TIME NOT NULL,
`valid_from` DATE NOT NULL,
`valid_until` DATE DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_route_active` (`route_id`, `is_active`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 10. transit_trips — الرحلات اليومية الفعلية
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_trips` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`schedule_id` INT UNSIGNED NOT NULL,
`route_id` INT UNSIGNED NOT NULL,
`org_id` INT UNSIGNED NOT NULL,
`driver_id` INT UNSIGNED NOT NULL,
`vehicle_id` INT UNSIGNED NOT NULL,
`trip_date` DATE NOT NULL,
`status` ENUM('scheduled','started','completed','cancelled','no_show') NOT NULL DEFAULT 'scheduled',
`started_at` TIMESTAMP DEFAULT NULL,
`completed_at` TIMESTAMP DEFAULT NULL,
`delay_minutes` TINYINT DEFAULT 0,
`delay_reason` VARCHAR(300) DEFAULT NULL,
`current_stop_seq` TINYINT DEFAULT NULL COMMENT 'آخر محطة جيوفينس مرّ عليها الباص',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_schedule_date` (`schedule_id`, `trip_date`),
KEY `idx_route_date` (`route_id`, `trip_date`),
KEY `idx_org_date` (`org_id`, `trip_date`),
KEY `idx_driver_date` (`driver_id`, `trip_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 11. transit_enrollments — عضويات الطلاب/الموظفين في المؤسسة
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_enrollments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف في جدول passengers الرئيسي',
`student_id` VARCHAR(50) NOT NULL COMMENT 'الرقم الجامعي/الوظيفي (encrypted)',
`member_name` VARCHAR(120) DEFAULT NULL COMMENT 'الاسم من كشف الجامعة',
`preferred_stop_id` INT UNSIGNED DEFAULT NULL COMMENT 'محطتي المفضلة',
`preferred_route_id` INT UNSIGNED DEFAULT NULL,
`verify_method` ENUM('api','roster_phone_match','roster_manual','manual_admin') NOT NULL DEFAULT 'roster_manual',
`status` ENUM('pending','active','suspended','expired') NOT NULL DEFAULT 'pending',
`verified_at` TIMESTAMP DEFAULT NULL,
`expires_at` DATE DEFAULT NULL COMMENT 'نهاية الفصل الدراسي',
`roster_id` INT UNSIGNED DEFAULT NULL COMMENT 'الكشف الذي جاءت منه العضوية',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_org_passenger` (`org_id`, `passenger_id`),
KEY `idx_org_status` (`org_id`, `status`),
KEY `idx_passenger` (`passenger_id`),
KEY `idx_preferred_route` (`preferred_route_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 12. transit_rosters — سجلات استيراد كشوف الطلاب
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_rosters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`uploaded_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
`filename` VARCHAR(200) DEFAULT NULL,
`total_rows` INT DEFAULT 0,
`matched_rows` INT DEFAULT 0 COMMENT 'طلاب طابق هاتفهم حساباً موجوداً',
`new_enrollments` INT DEFAULT 0,
`expired_count` INT DEFAULT 0 COMMENT 'طلاب تخرجوا وأُوقفت عضويتهم',
`semester` VARCHAR(30) DEFAULT NULL COMMENT 'مثال: 2026-S1',
`notes` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 13. transit_guardians — أولياء الأمور (للمدارس)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_guardians` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`enrollment_id` INT UNSIGNED NOT NULL,
`org_id` INT UNSIGNED NOT NULL,
`guardian_passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف ولي الأمر في passengers الرئيسي',
`relation` ENUM('father','mother','guardian') NOT NULL DEFAULT 'guardian',
`notify_depart` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند انطلاق الباص',
`notify_board` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند صعود الطفل',
`notify_arrive` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند وصول الباص للمدرسة',
`notify_return` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند العودة للبيت',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_enrollment_guardian` (`enrollment_id`, `guardian_passenger_id`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 14. transit_broadcasts — إعلانات المشرف للمشتركين
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_broadcasts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`org_id` INT UNSIGNED NOT NULL,
`sent_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
`target_type` ENUM('all','route','role') NOT NULL DEFAULT 'all',
`target_id` INT UNSIGNED DEFAULT NULL COMMENT 'route_id عند target_type=route',
`title_ar` VARCHAR(200) DEFAULT NULL,
`body_ar` TEXT NOT NULL,
`fcm_topic` VARCHAR(150) DEFAULT NULL COMMENT 'الموضوع المستخدم للإرسال',
`sent_at` TIMESTAMP DEFAULT NULL,
`delivery_count` INT DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 15. transit_boardings — إثبات الصعود والنزول (مرحلة ثانية)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_boardings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`trip_id` INT UNSIGNED NOT NULL,
`enrollment_id` INT UNSIGNED NOT NULL,
`stop_id` INT UNSIGNED DEFAULT NULL,
`boarded_at` TIMESTAMP DEFAULT NULL,
`alighted_at` TIMESTAMP DEFAULT NULL,
`method` ENUM('geofence','qr','driver_tap') NOT NULL DEFAULT 'geofence',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_trip` (`trip_id`),
KEY `idx_enrollment` (`enrollment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 16. transit_otp_log — سجل OTPs لمشرفي الويب (تنظيف دوري)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `transit_otp_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`phone` VARCHAR(25) NOT NULL,
`otp_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للكود',
`expires_at` TIMESTAMP NOT NULL,
`used` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_phone` (`phone`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- -----------------------------------------------------------------
-- ملاحظات الـ Redis (keys تُكتب من PHP — ليست في SQL)
-- transit:trip:{trip_id}:pos → آخر موقع GPS للباص (Hash: lat,lng,ts)
-- transit:trip:{trip_id}:status → حالة الرحلة الحية
-- transit:otp:{phone} → OTP مشرف الويب (TTL 300s)
-- transit:session:{token_hash} → admin_id+org_id (TTL 86400s)
-- transit:driver_mode:{driver_id} → وضع السائق الحالي (bus/ride), TTL session
-- FCM Topics: transit_route_{route_id} ← كل مشتركي خط
-- -----------------------------------------------------------------
+33
View File
@@ -0,0 +1,33 @@
<?php
// transit/trip/delay.php — السائق يبلغ عن تأخير
require_once __DIR__ . '/../../transit/connect_app.php';
requireTransitFields(['trip_id', 'driver_transit_id', 'delay_minutes']);
$tripId = filterRequest('trip_id', 'int');
$driverId = filterRequest('driver_transit_id', 'int');
$delay = min((int)filterRequest('delay_minutes'), 120);
$reason = filterRequest('reason');
$st = $transit_con->prepare(
"SELECT t.id, t.route_id, r.name_ar AS route_name
FROM transit_trips t JOIN transit_routes r ON r.id=t.route_id
WHERE t.id=? AND t.driver_id=? AND t.status='started' LIMIT 1"
);
$st->execute([$tripId, $driverId]);
$trip = $st->fetch();
if (!$trip) jsonError('Active trip not found', 404);
$transit_con->prepare(
"UPDATE transit_trips SET delay_minutes=?, delay_reason=?, updated_at=NOW() WHERE id=?"
)->execute([$delay, $reason, $tripId]);
transitSendTopicNotification(
transitRouteTopic((int)$trip['route_id']),
'تأخير في الباص',
'خط ' . $trip['route_name'] . ' متأخر ' . $delay . ' دقيقة' . ($reason ? " — $reason" : ''),
['type' => 'transit_delay', 'trip_id' => (string)$tripId, 'delay' => (string)$delay]
);
jsonSuccess(['delay_minutes' => $delay], 'Delay reported and passengers notified');
+32
View File
@@ -0,0 +1,32 @@
<?php
// transit/trip/end.php — السائق ينهي الرحلة
require_once __DIR__ . '/../../transit/connect_app.php';
requireTransitFields(['trip_id', 'driver_transit_id']);
$tripId = filterRequest('trip_id', 'int');
$driverId = filterRequest('driver_transit_id', 'int');
$st = $transit_con->prepare(
"SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1"
);
$st->execute([$tripId, $driverId]);
$trip = $st->fetch();
if (!$trip) jsonError('Trip not found', 404);
if ($trip['status'] !== 'started') jsonError('Trip is not in started state', 409);
$transit_con->prepare(
"UPDATE transit_trips SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=?"
)->execute([$tripId]);
global $redis;
if ($redis) $redis->del("transit:trip:{$tripId}:status");
// موقع الباص + ملكية الرحلة على Redis سيرفر الموقع
transitClearTripOwner($tripId);
global $redisLocation;
if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
jsonSuccess(['trip_id' => $tripId, 'status' => 'completed'], 'Trip completed');
+53
View File
@@ -0,0 +1,53 @@
<?php
// transit/trip/live.php — الراكب يسأل عن موقع الباص الحي
require_once __DIR__ . '/../../transit/connect_app.php';
$tripId = filterRequest('trip_id', 'int');
$routeId = filterRequest('route_id', 'int');
if (!$tripId && $routeId) {
$st = $transit_con->prepare(
"SELECT id FROM transit_trips WHERE route_id=? AND trip_date=? AND status='started' LIMIT 1"
);
$st->execute([$routeId, date('Y-m-d')]);
$row = $st->fetch();
$tripId = $row ? (int)$row['id'] : 0;
}
if (!$tripId) jsonError('No active trip found', 404);
$st = $transit_con->prepare(
"SELECT t.id, t.org_id, t.status, t.delay_minutes, t.current_stop_seq, t.started_at,
r.name_ar AS route_name, sc.departure_time, d.name AS driver_name
FROM transit_trips t
JOIN transit_routes r ON r.id = t.route_id
JOIN transit_schedules sc ON sc.id = t.schedule_id
JOIN transit_drivers d ON d.id = t.driver_id
WHERE t.id=? LIMIT 1"
);
$st->execute([$tripId]);
$trip = $st->fetch();
if (!$trip) jsonError('Trip not found', 404);
// الراكب يحتاج عضوية نشطة في مؤسسة هذا الخط قبل رؤية موقع الباص
$chk = $transit_con->prepare(
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
);
$chk->execute([$trip['org_id'], $transit_user_id]);
if (!$chk->fetch()) jsonError('Active enrollment required', 403);
$stStops = $transit_con->prepare(
"SELECT s.id, s.sequence, s.name_ar, s.latitude, s.longitude,
s.geofence_radius, s.eta_offset_min, s.is_major
FROM transit_stops s
JOIN transit_trips t ON t.route_id = s.route_id
WHERE t.id=? ORDER BY s.sequence ASC"
);
$stStops->execute([$tripId]);
jsonSuccess([
'trip' => $trip,
'bus_position' => transitGetBusPosition($tripId),
'stops' => $stStops->fetchAll(),
]);
+51
View File
@@ -0,0 +1,51 @@
<?php
// transit/trip/start.php — السائق يبدأ الرحلة
require_once __DIR__ . '/../../transit/connect_app.php';
requireTransitFields(['trip_id', 'driver_transit_id', 'lat', 'lng']);
$tripId = filterRequest('trip_id', 'int');
$driverId = filterRequest('driver_transit_id', 'int');
$lat = (float)filterRequest('lat');
$lng = (float)filterRequest('lng');
$st = $transit_con->prepare(
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, d.main_driver_id
FROM transit_trips t
JOIN transit_routes r ON r.id = t.route_id
JOIN transit_drivers d ON d.id = t.driver_id
WHERE t.id=? AND t.driver_id=? LIMIT 1"
);
$st->execute([$tripId, $driverId]);
$trip = $st->fetch();
if (!$trip) jsonError('Trip not found', 404);
if ($trip['status'] === 'started') jsonError('Trip already started', 409);
if ($trip['status'] === 'completed') jsonError('Trip already completed', 409);
if ($trip['status'] === 'cancelled') jsonError('Trip was cancelled', 409);
if (!$trip['main_driver_id']) jsonError('Driver has no linked main account — cannot start live tracking', 422);
$transit_con->prepare(
"UPDATE transit_trips SET status='started', started_at=NOW(), current_stop_seq=1 WHERE id=?"
)->execute([$tripId]);
transitUpdateBusPosition($tripId, $lat, $lng);
// تخزين ملكية الرحلة — يتحقق منها driver_socket قبل أي بثّ حي للراكبين
transitSetTripOwner($tripId, (string)$trip['main_driver_id'], (int)$trip['route_id']);
global $redis;
if ($redis) {
$redis->set("transit:trip:{$tripId}:status", 'started');
$redis->expire("transit:trip:{$tripId}:status", 86400);
}
transitSendTopicNotification(
transitRouteTopic((int)$trip['route_id']),
'الباص انطلق الآن',
'خط ' . $trip['route_name'] . ' بدأ رحلته',
['type' => 'transit_started', 'trip_id' => (string)$tripId]
);
jsonSuccess(['trip_id' => $tripId, 'status' => 'started', 'started_at' => date('Y-m-d H:i:s')], 'Trip started');
+70
View File
@@ -0,0 +1,70 @@
<?php
// transit/trip/today.php — السائق يجلب رحلاته اليوم (تُنشأ تلقائياً من الجداول)
require_once __DIR__ . '/../../transit/connect_app.php';
$driverTransitId = filterRequest('driver_transit_id', 'int');
if (!$driverTransitId) jsonError('driver_transit_id is required', 400);
$chk = $transit_con->prepare(
"SELECT id, org_id FROM transit_drivers WHERE id=? AND status='active' LIMIT 1"
);
$chk->execute([$driverTransitId]);
$driver = $chk->fetch();
if (!$driver) jsonError('Driver not found or not active', 403);
$today = date('Y-m-d');
// أنشئ رحلات اليوم من الجداول النشطة إن لم تُنشأ بعد
$stScheds = $transit_con->prepare(
"SELECT id AS schedule_id, route_id, vehicle_id, days_mask
FROM transit_schedules
WHERE driver_id=? AND org_id=? AND is_active=1
AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)"
);
$stScheds->execute([$driverTransitId, $driver['org_id'], $today, $today]);
foreach ($stScheds->fetchAll() as $sch) {
if (!transitDayActive((int)$sch['days_mask'])) continue;
$ex = $transit_con->prepare("SELECT id FROM transit_trips WHERE schedule_id=? AND trip_date=? LIMIT 1");
$ex->execute([$sch['schedule_id'], $today]);
if (!$ex->fetch()) {
$transit_con->prepare(
"INSERT INTO transit_trips
(schedule_id, route_id, org_id, driver_id, vehicle_id, trip_date, status)
VALUES (?,?,?,?,?,?,'scheduled')"
)->execute([
$sch['schedule_id'], $sch['route_id'], $driver['org_id'],
$driverTransitId, $sch['vehicle_id'], $today,
]);
}
}
// اجلب رحلات اليوم مع التفاصيل
$stTrips = $transit_con->prepare(
"SELECT t.id, t.route_id, t.status, t.delay_minutes, t.current_stop_seq,
t.started_at, t.completed_at,
r.name_ar AS route_name, r.polyline,
sc.departure_time,
v.plate AS vehicle_plate, v.capacity
FROM transit_trips t
JOIN transit_routes r ON r.id = t.route_id
JOIN transit_schedules sc ON sc.id = t.schedule_id
LEFT JOIN transit_vehicles v ON v.id = t.vehicle_id
WHERE t.driver_id=? AND t.trip_date=?
ORDER BY sc.departure_time ASC"
);
$stTrips->execute([$driverTransitId, $today]);
$trips = $stTrips->fetchAll();
$stStops = $transit_con->prepare(
"SELECT sequence, name_ar, latitude, longitude, geofence_radius, eta_offset_min
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
);
foreach ($trips as &$trip) {
$stStops->execute([$trip['route_id']]);
$trip['stops'] = $stStops->fetchAll();
}
unset($trip);
jsonSuccess(['date' => $today, 'trips' => $trips]);
+38
View File
@@ -0,0 +1,38 @@
<?php
// transit/trip/update_position.php
// ⚠️ مهجور (fallback فقط): المسار الأساسي لموقع الباص هو WebSocket:
// سائق الباص يبعث socket.emit('update_bus_location', {...}) إلى driver_socket:2020
// الذي يخزّن الموقع ويبثّه حياً لكل ركاب الخط عبر passenger_socket.
// هذا الـ endpoint يكتب الموقع في Redis فقط ولا يبثّه للركاب — لا تستخدمه
// إلا كخطة بديلة عند تعذّر السوكت. الموقع يُخزَّن على Redis سيرفر الموقع.
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
try { $transit_con = Database::get('transit'); }
catch (Exception $e) { http_response_code(503); exit; }
$tripId = filterRequest('trip_id', 'int');
$driverId = filterRequest('driver_transit_id', 'int');
$lat = (float)filterRequest('lat');
$lng = (float)filterRequest('lng');
$stopSeq = filterRequest('current_stop_seq', 'int');
if (!$tripId || !$driverId || !$lat || !$lng) {
jsonError('Missing required fields', 400);
}
transitUpdateBusPosition($tripId, $lat, $lng);
if ($stopSeq !== null) {
$transit_con->prepare(
"UPDATE transit_trips SET current_stop_seq=?, updated_at=NOW() WHERE id=? AND driver_id=?"
)->execute([$stopSeq, $tripId, $driverId]);
global $redis;
if ($redis) {
$redis->publish("transit:trip:{$tripId}:stop", json_encode(['seq' => $stopSeq, 'ts' => time()]));
}
}
jsonSuccess(['ok' => true]);
+27
View File
@@ -0,0 +1,27 @@
<?php
// transit/vehicle/add.php — إضافة باص
require_once __DIR__ . '/../../transit/connect_admin.php';
$plate = strtoupper(filterRequest('plate') ?? '');
if (!$plate) jsonError('plate is required');
$capacity = filterRequest('capacity', 'int') ?? 30;
$vType = filterRequest('vehicle_type') ?: 'bus';
if (!in_array($vType, ['bus','minibus','van','other'])) $vType = 'bus';
$chk = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE plate=? LIMIT 1");
$chk->execute([$plate]);
if ($chk->fetch()) jsonError('Vehicle plate already registered', 409);
$transit_con->prepare(
"INSERT INTO transit_vehicles (org_id,plate,make,model,year,color,capacity,vehicle_type,notes)
VALUES (?,?,?,?,?,?,?,?,?)"
)->execute([
$transit_org_id, $plate,
filterRequest('make'), filterRequest('model'),
filterRequest('year','int'), filterRequest('color'),
$capacity, $vType, filterRequest('notes'),
]);
jsonSuccess(['vehicle_id' => (int)$transit_con->lastInsertId()], 'Vehicle added');
+12
View File
@@ -0,0 +1,12 @@
<?php
// transit/vehicle/list.php
require_once __DIR__ . '/../../transit/connect_admin.php';
$st = $transit_con->prepare(
"SELECT id, plate, make, model, year, color, capacity, vehicle_type, is_active, notes, created_at
FROM transit_vehicles WHERE org_id=? ORDER BY plate ASC"
);
$st->execute([$transit_org_id]);
jsonSuccess(['vehicles' => $st->fetchAll()]);