Deploy: 2026-05-22 03:37:23
This commit is contained in:
326
backend/app/Controllers/SallaController.php
Normal file
326
backend/app/Controllers/SallaController.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Security;
|
||||
use App\Models\SallaMerchant;
|
||||
use App\Models\WhatsAppSession;
|
||||
|
||||
class SallaController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Redirect user to Salla OAuth login page.
|
||||
* Accessible via GET /api/integrations/salla/auth?token=JWT_TOKEN
|
||||
*/
|
||||
public function auth(Request $request, Response $response)
|
||||
{
|
||||
$token = $_GET['token'] ?? '';
|
||||
if (empty($token)) {
|
||||
$response->status(400)->html("<h3>Error: Token is required</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = Security::verifyJWT($token);
|
||||
if (!$payload) {
|
||||
$response->status(401)->html("<h3>Error: Invalid token</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
$companyId = $payload['company_id'];
|
||||
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
|
||||
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
|
||||
|
||||
$authUrl = "https://accounts.salla.sa/oauth2/auth?" . http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'read_orders read_customers',
|
||||
'state' => $companyId
|
||||
]);
|
||||
|
||||
header("Location: " . $authUrl);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Salla OAuth callback.
|
||||
* Accessible via GET /api/integrations/salla/callback?code=CODE&state=COMPANY_ID
|
||||
*/
|
||||
public function callback(Request $request, Response $response)
|
||||
{
|
||||
$code = $_GET['code'] ?? '';
|
||||
$companyId = $_GET['state'] ?? '';
|
||||
|
||||
if (empty($code) || empty($companyId)) {
|
||||
$response->status(400)->html("<h3>Error: Missing authorization code or state (company_id).</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
$clientId = getenv('SALLA_CLIENT_ID') ?: '69ea789c-f611-4ea7-a3ee-7ead41420225';
|
||||
$clientSecret = getenv('SALLA_CLIENT_SECRET') ?: '';
|
||||
$redirectUri = getenv('APP_URL') . '/api/integrations/salla/callback';
|
||||
|
||||
// 1. Swap authorization code for token
|
||||
$ch = curl_init('https://accounts.salla.sa/oauth2/token');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("Salla Token Error: HTTP $httpCode - Response: $res");
|
||||
$response->status(500)->html("<h3>Failed to exchange code for Salla token. Status code: $httpCode</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenData = json_decode($res, true);
|
||||
if (empty($tokenData['access_token'])) {
|
||||
$response->status(500)->html("<h3>Failed to parse Salla token response.</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
$accessToken = $tokenData['access_token'];
|
||||
$refreshToken = $tokenData['refresh_token'];
|
||||
$expiresIn = $tokenData['expires_in'] ?? 2592000;
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $expiresIn);
|
||||
|
||||
// 2. Fetch merchant/user info
|
||||
$chInfo = curl_init('https://accounts.salla.sa/oauth2/user/info');
|
||||
curl_setopt($chInfo, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($chInfo, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $accessToken
|
||||
]);
|
||||
curl_setopt($chInfo, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$resInfo = curl_exec($chInfo);
|
||||
$infoHttpCode = curl_getinfo($chInfo, CURLINFO_HTTP_CODE);
|
||||
curl_close($chInfo);
|
||||
|
||||
$storeName = 'Salla Store';
|
||||
$merchantId = '';
|
||||
if ($infoHttpCode === 200) {
|
||||
$infoData = json_decode($resInfo, true);
|
||||
$merchantId = $infoData['merchant']['id'] ?? $infoData['id'] ?? '';
|
||||
$storeName = $infoData['merchant']['name'] ?? 'Salla Store';
|
||||
}
|
||||
|
||||
if (empty($merchantId)) {
|
||||
error_log("Salla Info Fetch Error: HTTP $infoHttpCode - Response: $resInfo");
|
||||
$response->status(500)->html("<h3>Failed to fetch merchant profile from Salla.</h3>");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Save or update Salla merchant settings in DB
|
||||
SallaMerchant::saveSecure([
|
||||
'company_id' => (int)$companyId,
|
||||
'merchant_id' => (string)$merchantId,
|
||||
'store_name' => $storeName,
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt
|
||||
]);
|
||||
|
||||
// Redirect back to frontend
|
||||
header("Location: " . getenv('APP_URL') . "/?salla_connect=success");
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Salla connection status for current company.
|
||||
* Protected by AuthMiddleware.
|
||||
* Accessible via GET /api/integrations/salla/status
|
||||
*/
|
||||
public function status(Request $request, Response $response)
|
||||
{
|
||||
$merchant = SallaMerchant::findByCompany($request->company_id);
|
||||
if ($merchant) {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'connected' => true,
|
||||
'store_name' => $merchant['store_name'],
|
||||
'merchant_id' => $merchant['merchant_id'],
|
||||
'expires_at' => $merchant['expires_at']
|
||||
]);
|
||||
} else {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'connected' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Salla integration.
|
||||
* Protected by AuthMiddleware.
|
||||
* Accessible via POST /api/integrations/salla/disconnect
|
||||
*/
|
||||
public function disconnect(Request $request, Response $response)
|
||||
{
|
||||
SallaMerchant::deleteByCompany($request->company_id);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Salla integration disconnected successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Webhook from Salla.
|
||||
* Accessible via POST /api/webhooks/salla
|
||||
*/
|
||||
public function webhook(Request $request, Response $response)
|
||||
{
|
||||
// 1. Get raw input and signature
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
$signature = $request->getHeader('x-salla-signature') ?: '';
|
||||
|
||||
// 2. Verify signature
|
||||
$secret = getenv('SALLA_WEBHOOK_SECRET') ?: '406550c38b782a50e9a3e0687f564107f8a60135b072b1f819470dda5333a65d';
|
||||
$calculated = hash_hmac('sha256', $rawPayload, $secret);
|
||||
|
||||
if (!hash_equals($calculated, $signature)) {
|
||||
error_log("[Salla Webhook Error] Invalid signature. Received: $signature, Calculated: $calculated");
|
||||
$response->status(401)->json(['error' => 'Invalid signature']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Parse JSON payload
|
||||
$payload = json_decode($rawPayload, true);
|
||||
if (!$payload) {
|
||||
$response->status(400)->json(['error' => 'Invalid JSON']);
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $payload['event'] ?? '';
|
||||
$merchantId = $payload['merchant'] ?? '';
|
||||
|
||||
if (empty($event) || empty($merchantId)) {
|
||||
$response->status(400)->json(['error' => 'Missing event or merchant information']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Find the merchant config to get company_id
|
||||
$merchant = SallaMerchant::findByMerchantId((string)$merchantId);
|
||||
if (!$merchant) {
|
||||
error_log("[Salla Webhook Warning] Webhook received for unlinked merchant ID: " . $merchantId);
|
||||
$response->status(404)->json(['error' => 'Store is not linked to any company in Nabeh']);
|
||||
return;
|
||||
}
|
||||
|
||||
$companyId = (int)$merchant['company_id'];
|
||||
$orderData = $payload['data'] ?? [];
|
||||
|
||||
// 5. Process events
|
||||
if ($event === 'order.created' || $event === 'order.status.updated') {
|
||||
$this->handleOrderWebhook($companyId, $event, $orderData);
|
||||
}
|
||||
|
||||
$response->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process order webhook events and send automated WhatsApp message
|
||||
*/
|
||||
private function handleOrderWebhook(int $companyId, string $event, array $orderData)
|
||||
{
|
||||
$orderId = $orderData['id'] ?? $orderData['reference_id'] ?? '';
|
||||
$customerName = $orderData['customer']['name'] ?? 'عميلنا العزيز';
|
||||
$customerPhone = $orderData['customer']['mobile'] ?? $orderData['customer']['phone'] ?? '';
|
||||
$statusName = $orderData['status']['name'] ?? '';
|
||||
|
||||
if (empty($customerPhone) || empty($orderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format phone: remove leading zero or +, ensure only digits
|
||||
$customerPhone = preg_replace('/\D/', '', $customerPhone);
|
||||
if (substr($customerPhone, 0, 2) === '00') {
|
||||
$customerPhone = substr($customerPhone, 2);
|
||||
}
|
||||
// Normalize Saudi numbers starting with 05
|
||||
if (strlen($customerPhone) === 9 && $customerPhone[0] === '5') {
|
||||
$customerPhone = '966' . $customerPhone;
|
||||
} elseif (strlen($customerPhone) === 10 && substr($customerPhone, 0, 2) === '05') {
|
||||
$customerPhone = '966' . substr($customerPhone, 1);
|
||||
}
|
||||
|
||||
// Construct message based on event
|
||||
$message = '';
|
||||
if ($event === 'order.created') {
|
||||
$message = "مرحباً {$customerName}،\nتم استلام طلبك رقم ({$orderId}) بنجاح! شكرًا لتسوقك معنا. سنقوم بتحديثك فور تغيير حالة الطلب.";
|
||||
} elseif ($event === 'order.status.updated') {
|
||||
$message = "مرحباً {$customerName}،\nتم تحديث حالة طلبك رقم ({$orderId}) إلى: *{$statusName}*.";
|
||||
|
||||
// Check for shipping details
|
||||
$courier = $orderData['shipment']['courier_name'] ?? '';
|
||||
$tracking = $orderData['shipment']['tracking_link'] ?? '';
|
||||
if (!empty($courier) && !empty($tracking)) {
|
||||
$message .= "\n🚚 الشحن عبر: {$courier}\nرابط التتبع: {$tracking}";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send WhatsApp Message via active session
|
||||
$session = WhatsAppSession::findByCompany($companyId);
|
||||
if (!$session || $session['status'] !== 'connected') {
|
||||
error_log("[Salla Webhook Warning] Cannot send auto-notification: No active connected WhatsApp session for company: " . $companyId);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendWhatsAppNotification($session['session_key'], $customerPhone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a WhatsApp message through the gateway
|
||||
*/
|
||||
private function sendWhatsAppNotification(string $sessionKey, string $phone, string $message)
|
||||
{
|
||||
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
|
||||
if (substr($gatewayUrl, -4) === '/api') {
|
||||
$sendUrl = $gatewayUrl . '/messages/send';
|
||||
} else {
|
||||
$sendUrl = $gatewayUrl . '/api/messages/send';
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'session_key' => $sessionKey,
|
||||
'phone' => $phone,
|
||||
'message' => $message
|
||||
]);
|
||||
|
||||
$ch = curl_init($sendUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[Salla Webhook Gateway Error] Failed to send WhatsApp notification. Gateway response: " . $response);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user