Files
Siro/backend/nabeh/submit_complaint.php
Hamza-AyedandClaude Opus 5 8d7e3118b5 Migrate remaining encrypted-column lookups to the blind index
Completes the set of queries that matched a freshly encrypted value against a
stored one, which only works while encryption is deterministic. Each keeps its
original comparison and adds an index comparison in the same WHERE, so nothing
changes today.

- passenger sign-in by email, service-staff sign-in, Firebase token lookup
- driver lookup by phone and by national number
- admin ride lookup and ride monitor (both tables)
- nabeh: driver status, user resolution, ride history, complaint submission

transit_org_admins lives in the transit database and has no index column, so
login there falls back to decrypting the small set of active admins and
comparing normalised numbers.

Schema: adds users.email_bidx/phone_bidx and driver.national_bidx with their
indexes.

Verified that every :*_bidx placeholder introduced is actually bound — an
unbound one is a fatal error at request time, not a silent miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:36:32 +03:00

339 lines
15 KiB
PHP

<?php
/**
* Nabeh Integration — Submit Complaint with AI Analysis
*
* Called by Nabeh WhatsApp bot. Accepts a complaint from driver or passenger,
* auto-resolves user from phone, fetches full trip context (ride, ratings,
* driver/passenger profiles, behavior data), analyzes via Gemini AI,
* and stores in the complaint table.
*
* Auth: X-API-Key header → NABEH_API_KEY
*
* Input:
* phone (required) — User's phone number (resolve via resolve_user.php)
* ride_id (required) — The trip ID this complaint is about
* complaint_text (req) — Description of the issue
* audio_link (opt) — Voice note link (if user recorded one)
* user_type (opt) — 'driver' or 'passenger' (auto-detected if possible)
*
* Output:
* status, message, complaint_id, passenger_report, driver_report
*/
require_once __DIR__ . '/../core/bootstrap.php';
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-API-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['status' => 'failure', 'message' => 'Method not allowed']);
exit;
}
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$expectedKey = getenv('NABEH_API_KEY') ?: '';
if (empty($apiKey) || $apiKey !== $expectedKey) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$phone = preg_replace('/\D+/', '', $input['phone'] ?? '');
$rideId = trim($input['ride_id'] ?? '');
$complaintText = trim($input['complaint_text'] ?? '');
$audioLink = trim($input['audio_link'] ?? '');
$userType = trim($input['user_type'] ?? '');
if (empty($phone) || empty($rideId) || empty($complaintText)) {
http_response_code(400);
echo json_encode(['status' => 'failure', 'message' => 'phone, ride_id, and complaint_text are required']);
exit;
}
$mainDb = Database::get('main');
$rideDb = Database::get('ride');
global $encryptionHelper;
// ── Resolve user by phone ────────────────────────────────────
$encryptedPhone = $encryptionHelper->encryptData($phone);
global $blindIndex;
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
$driverRow = $mainDb->prepare("SELECT id, first_name, last_name FROM driver WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$driverRow->execute([':p' => $encryptedPhone, ':bidx' => $dBidx]);
$driver = $driverRow->fetch(PDO::FETCH_ASSOC);
$passengerRow = null;
if (!$driver) {
$passengerRow = $mainDb->prepare("SELECT id, first_name, last_name FROM passengers WHERE phone = :p OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
$passengerRow->execute([':p' => $encryptedPhone, ':bidx' => $pBidx]);
$passenger = $passengerRow->fetch(PDO::FETCH_ASSOC);
}
if (!$driver && !$passenger) {
http_response_code(404);
echo json_encode(['status' => 'failure', 'message' => 'User not found']);
exit;
}
$userId = $driver ? $driver['id'] : $passenger['id'];
$detectedType = $driver ? 'driver' : 'passenger';
if (empty($userType)) $userType = $detectedType;
// ── Validate ride exists ─────────────────────────────────────
$stmt = $rideDb->prepare("SELECT * FROM ride WHERE id = :id");
$stmt->execute([':id' => $rideId]);
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$ride) {
http_response_code(404);
echo json_encode(['status' => 'failure', 'message' => 'Ride not found']);
exit;
}
// ── Fetch full context ──────────────────────────────────────
$passengerId = $ride['passenger_id'];
$driverId = $ride['driver_id'];
/**
* Fetch user profile + full rating history (received + given)
*/
function getEnhancedProfile($db, $table, $id, $enc, $ratingReceivedTable, $ratingReceivedCol, $ratingGivenTable, $ratingGivenCol, $ratingsDb) {
$profile = ['info' => null, 'ratings_received' => [], 'ratings_given' => [], 'stats' => []];
// Profile info
$stmt = $db->prepare("SELECT id, first_name, last_name, created_at FROM $table WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$info = $stmt->fetch(PDO::FETCH_ASSOC);
if ($info) {
$fn = $enc->decryptData($info['first_name']);
$ln = $enc->decryptData($info['last_name']);
$info['full_name'] = trim("$fn $ln");
$info['account_age_days'] = $info['created_at'] ? round((time() - strtotime($info['created_at'])) / 86400) : 0;
unset($info['first_name'], $info['last_name']);
$profile['info'] = $info;
}
// Ratings received (others rated this user)
$stmt = $ratingsDb->prepare("
SELECT rating, comment, created_at
FROM $ratingReceivedTable
WHERE $ratingReceivedCol = :id
ORDER BY created_at DESC
LIMIT 10
");
$stmt->execute([':id' => $id]);
$profile['ratings_received'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Ratings given (this user rated others)
$stmt = $ratingsDb->prepare("
SELECT rating, comment, created_at
FROM $ratingGivenTable
WHERE $ratingGivenCol = :id
ORDER BY created_at DESC
LIMIT 10
");
$stmt->execute([':id' => $id]);
$profile['ratings_given'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Aggregate stats for received ratings
$stmt = $ratingsDb->prepare("
SELECT
COUNT(id) AS total,
AVG(rating) AS avg_rating,
SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS low_count,
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS mid_count,
SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS high_count
FROM $ratingReceivedTable
WHERE $ratingReceivedCol = :id
");
$stmt->execute([':id' => $id]);
$profile['stats'] = $stmt->fetch(PDO::FETCH_ASSOC);
return $profile;
}
// Driver profile: received ratings from ratingDriver (by driver_id), given ratings in ratingPassenger (by driverID)
$driverProfile = getEnhancedProfile(
$mainDb, 'driver', $driverId, $encryptionHelper,
'ratingDriver', 'driver_id', // received: passengers rate driver
'ratingPassenger', 'driverID', // given: driver rates passenger
$mainDb
);
// Passenger profile: received ratings from ratingPassenger (by passenger_id), given ratings in ratingDriver (by passenger_id)
$passengerProfile = getEnhancedProfile(
$mainDb, 'passengers', $passengerId, $encryptionHelper,
'ratingPassenger', 'passenger_id', // received: drivers rate passenger
'ratingDriver', 'passenger_id', // given: passenger rates driver
$mainDb
);
// Driver behavior data
$behavior = null;
$bStmt = $rideDb->prepare("SELECT max_speed, avg_speed, hard_brakes, behavior_score FROM driver_behavior WHERE trip_id = :trip AND driver_id = :did LIMIT 1");
$bStmt->execute([':trip' => $rideId, ':did' => $driverId]);
$behavior = $bStmt->fetch(PDO::FETCH_ASSOC) ?: null;
// ── Gemini AI Analysis ──────────────────────────────────────
$geminiKey = getenv('GEMINI_API_KEY');
if (!$geminiKey) {
http_response_code(500);
echo json_encode(['status' => 'failure', 'message' => 'AI service not configured']);
exit;
}
// Check existing complaints for the same ride
$existingStmt = $mainDb->prepare("SELECT id, statusComplaint FROM complaint WHERE ride_id = :rid ORDER BY id DESC LIMIT 1");
$existingStmt->execute([':rid' => $rideId]);
$existingComplaint = $existingStmt->fetch(PDO::FETCH_ASSOC);
$prompt = "
أنت خبير في حل النزاعات في خدمات نقل الركاب لتطبيق Siro. قم بتحليل الشكوى التالية بناءً على البيانات الشاملة:
**1. تفاصيل الرحلة:**
" . json_encode($ride, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "
**2. ملف الراكب (بيانات الحساب + سجل التقييمات):**
" . json_encode($passengerProfile, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "
**3. ملف السائق (بيانات الحساب + سجل التقييمات + سلوك القيادة):**
" . json_encode([
'info' => $driverProfile['info'],
'ratings_received' => $driverProfile['ratings_received'],
'ratings_given' => $driverProfile['ratings_given'],
'stats' => $driverProfile['stats'],
'behavior' => $behavior,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "
**4. الشكوى:**
- نص الشكوى: '" . $complaintText . "'
- رابط تسجيل صوتي: " . ($audioLink ?: 'لا يوجد') . "
- مقدم الشكوى: " . $userType . "
" . ($existingComplaint ? "- شكوى سابقة موجودة للرحلة: ID={$existingComplaint['id']}, status={$existingComplaint['statusComplaint']}" : '') . "
**تعليمات التحليل الذكي (التقييمات):**
- حلل سجل تقييمات السائق: هل يتكرر حصوله على تقييمات منخفضة (1-2)؟ ماذا تقول تعليقات الركاب السابقين عنه؟
- حلل سجل تقييمات الراكب: هل يميل لإعطاء تقييمات منخفضة للسائقين؟
- ادرس توزيع التقييمات: average + low/mid/high counts يعطي صورة عن سلوك كل طرف
- اربط التعليقات السابقة بمضمون الشكوى الحالية: هل هناك نمط متكرر؟
- استخدم عمر الحساب (account_age_days) لتقييم مصداقية المستخدم
**المطلوب:**
1. تحديد الطرف المخطئ على الأرجح بناءً على: تفاصيل الرحلة + تاريخ التقييمات + سلوك القيادة.
2. تحديد ما إذا كانت الشكوى حقيقية أم كيدية.
3. تصنيف الشكوى (سلوك السائق، مشكلة أجرة، مسار، حالة السيارة، غير ذلك).
4. اقتراح حلين واضحين ومحددين لخدمة العملاء.
5. كتابة تقرير مناسب لمقدم الشكوى (دون إحراج).
6. كتابة تقرير مناسب للطرف الآخر (مهذب ومحترم).
**الخرج المطلوب (JSON فقط، بالعربية):**
{
\"customerServiceSolutions\": [\"حل 1\", \"حل 2\"],
\"passengerReport\": {\"title\": \"...\", \"body\": \"...\"},
\"driverReport\": {\"title\": \"...\", \"body\": \"...\"},
\"fault_determination\": \"الراكب/السائق/كلاهما/غير واضح\",
\"complaint_nature\": \"حقيقية/كيدية/نزاع بسيط\",
\"complaint_type\": \"تصنيف الشكوى\"
}
";
$apiURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=$geminiKey";
$ch = curl_init($apiURL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['contents' => [['parts' => [['text' => $prompt]]]]]),
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr) {
http_response_code(500);
echo json_encode(['status' => 'failure', 'message' => 'AI service error: ' . $curlErr]);
exit;
}
$data = json_decode($response, true);
$rawText = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
$cleanJson = trim(preg_replace('/```json|```/', '', $rawText));
$analysis = json_decode($cleanJson, true);
if (!$analysis || !isset($analysis['passengerReport']) || !isset($analysis['driverReport'])) {
http_response_code(500);
echo json_encode(['status' => 'failure', 'message' => 'Failed to parse AI response']);
exit;
}
// ── Save to complaint table ──────────────────────────────────
$fullDesc = $complaintText;
if ($audioLink) $fullDesc .= "\n\n[audio: $audioLink]";
$stmt = $mainDb->prepare("
INSERT INTO complaint
(ride_id, passenger_id, driver_id, complaint_type, description,
date_filed, statusComplaint, resolution,
passenger_report, driver_report, cs_solutions,
fault_determination, complaint_nature, date_resolved)
VALUES
(:rid, :pid, :did, :ctype, :desc,
NOW(), 'Resolved', :res,
:preport, :dreport, :cssol,
:fault, :nature, NOW())
");
$stmt->execute([
':rid' => $rideId,
':pid' => $passengerId,
':did' => $driverId,
':ctype' => $analysis['complaint_type'] ?? 'General',
':desc' => $fullDesc,
':res' => $cleanJson,
':preport'=> json_encode($analysis['passengerReport'] ?? null, JSON_UNESCAPED_UNICODE),
':dreport'=> json_encode($analysis['driverReport'] ?? null, JSON_UNESCAPED_UNICODE),
':cssol' => json_encode($analysis['customerServiceSolutions'] ?? null, JSON_UNESCAPED_UNICODE),
':fault' => $analysis['fault_determination'] ?? 'N/A',
':nature' => $analysis['complaint_nature'] ?? 'N/A',
]);
$complaintId = $mainDb->lastInsertId();
// ── Notify customer service ──────────────────────────────────
$csPhone = getenv('SERVICE_PHONE1');
$sendFn = getenv('SEND_WHATSAPP_FN_PATH');
if (!empty($csPhone) && $sendFn && file_exists($sendFn)) {
require_once $sendFn;
if (function_exists('sendWhatsAppFromServer')) {
$csMsg = "*شكوى جديدة (#$complaintId)*\n"
. "*- الرحلة:* $rideId\n"
. "*- مقدمها:* $userType\n"
. "*- تصنيف:* {$analysis['complaint_type']}\n"
. "*- المخطئ:* {$analysis['fault_determination']}\n"
. "*- الحلول:* {$analysis['customerServiceSolutions'][0]} / {$analysis['customerServiceSolutions'][1]}";
sendWhatsAppFromServer($csPhone, $csMsg);
}
}
// ── Response ─────────────────────────────────────────────────
$report = $userType === 'driver' ? $analysis['driverReport'] : $analysis['passengerReport'];
echo json_encode([
'status' => 'success',
'message' => 'Complaint submitted and analyzed.',
'complaint_id'=> $complaintId,
'report' => $report,
'ai_result' => [
'fault_determination' => $analysis['fault_determination'],
'complaint_nature' => $analysis['complaint_nature'],
'complaint_type' => $analysis['complaint_type'],
],
], JSON_UNESCAPED_UNICODE);