Deploy: 2026-05-23 01:13:51

This commit is contained in:
Hamza-Ayed
2026-05-23 01:13:51 +03:00
parent 681ef6afab
commit 57859ebd20
11 changed files with 2426 additions and 108 deletions

View File

@@ -0,0 +1,49 @@
<?php
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('Access denied.');
}
require_once __DIR__ . '/app/bootstrap.php';
use App\Core\Database;
try {
$pdo = Database::getConnection();
echo "=== Running Database Migrations: Adding whatsapp_session_id ===\n";
// Check if the column already exists
$stmt = $pdo->prepare("
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'whatsapp_session_id'
");
$stmt->execute();
$columnExists = $stmt->fetch();
if (!$columnExists) {
echo "Adding column 'whatsapp_session_id' to 'users' table...\n";
// Add column
$pdo->exec("ALTER TABLE `users` ADD COLUMN `whatsapp_session_id` INT NULL DEFAULT NULL");
// Add foreign key constraint
$pdo->exec("
ALTER TABLE `users`
ADD CONSTRAINT `fk_users_whatsapp_session`
FOREIGN KEY (`whatsapp_session_id`)
REFERENCES `whatsapp_sessions` (`id`)
ON DELETE SET NULL
");
echo "✅ Column 'whatsapp_session_id' and foreign key created successfully!\n";
} else {
echo " Column 'whatsapp_session_id' already exists in 'users' table. Skipping.\n";
}
} catch (\Exception $e) {
echo "❌ Migration failed: " . $e->getMessage() . "\n";
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Flows\ConversationFlowEngine;
use App\Services\TTSService;
use App\Models\WhatsAppSession;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
class OTPController extends BaseController
{
/**
* Send OTP verification code via WhatsApp (Text or Voice Note)
* POST /api/otp/send
*/
public function send(Request $request, Response $response): void
{
$companyId = $request->company_id;
$body = $request->getBody();
$phone = $body['phone'] ?? '';
$type = $body['type'] ?? 'text'; // 'text' or 'voice'
$sessionId = $body['session_id'] ?? null;
$customCode = $body['code'] ?? null;
if (empty($phone)) {
$response->status(400)->json(['error' => 'Missing required parameter: phone']);
return;
}
// Clean phone number (remove non-digits except +)
$phone = preg_replace('/[^\d+]/', '', $phone);
// 1. Resolve WhatsApp Session
$session = null;
if ($sessionId) {
$session = WhatsAppSession::findSecure((int)$sessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(404)->json(['error' => 'WhatsApp session not found']);
return;
}
} else {
// Grab the first connected session of the company
$sessions = WhatsAppSession::findAllByCompany($companyId);
foreach ($sessions as $s) {
if ($s['status'] === 'connected') {
$session = $s;
break;
}
}
if (!$session && !empty($sessions)) {
$session = $sessions[0]; // fallback to first session if none is connected
}
}
if (!$session) {
$response->status(400)->json(['error' => 'No active WhatsApp sessions configured for this company.']);
return;
}
if ($session['status'] !== 'connected') {
$response->status(400)->json(['error' => 'WhatsApp session is not connected. Connect the session first.']);
return;
}
// 2. Check SaaS subscription quotas
if ($companyId !== 1) {
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
$response->status(402)->json(['error' => 'Active subscription plan required.']);
return;
}
if (!CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
$response->status(403)->json(['error' => 'Monthly request quota exceeded. Please upgrade your plan.']);
return;
}
if ($type === 'voice') {
if (!CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
$response->status(403)->json(['error' => 'Voice request quota exceeded. Please upgrade your plan.']);
return;
}
// Starter plan doesn't support Voice Notes
$features = json_decode($activeSub['features'] ?: '{}', true);
if (isset($features['voice']) && !$features['voice']) {
$response->status(403)->json(['error' => 'Voice OTP is not supported in your current subscription plan.']);
return;
}
}
}
// 3. Generate verification code
$code = $customCode ? trim($customCode) : (string)rand(1000, 9999);
// 4. Send Message
try {
if ($type === 'voice') {
// Spacing the digits to force slow Arabic pronunciation: e.g. "1 2 3 4"
$spacedCode = implode(' ', str_split($code));
$textToRead = "رمز التحقق الخاص بك هو: {$spacedCode}. أكرر، رمز التحقق هو: {$spacedCode}.";
$audioBase64 = TTSService::textToSpeechArabic($textToRead);
if (!$audioBase64) {
$response->status(500)->json(['error' => 'Failed to generate voice OTP audio.']);
return;
}
// Send voice note
ConversationFlowEngine::sendReply($session, $phone, '', null, $audioBase64, 'audio/mp3');
} else {
// Send text
$textMsg = "رمز التحقق الخاص بك لمتجر نابه هو: *{$code}* \n الرجاء عدم مشاركته مع أي شخص.";
ConversationFlowEngine::sendReply($session, $phone, $textMsg);
}
// Increment usage stats
if ($companyId !== 1) {
CompanySubscriptionUsage::incrementUsage($companyId, 'request');
if ($type === 'voice') {
CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
}
}
$response->json([
'status' => 'success',
'message' => 'OTP sent successfully',
'code' => $code,
'type' => $type
]);
} catch (\Exception $e) {
error_log("[OTP Controller Error] " . $e->getMessage());
$response->status(500)->json(['error' => 'Failed to send OTP message: ' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
use App\Models\User;
use App\Models\WhatsAppSession;
use App\Core\Database;
class StaffController extends BaseController
{
/**
* List all staff agents for the merchant company
* GET /api/staff
*/
public function index(Request $request, Response $response): void
{
$companyId = $request->company_id;
// Fetch users belonging to this company who are 'staff'
$staff = Database::select(
"SELECT u.id, u.name, u.email, u.role, u.status, u.whatsapp_session_id, w.name as session_name, w.phone as session_phone
FROM users u
LEFT JOIN whatsapp_sessions w ON u.whatsapp_session_id = w.id
WHERE u.company_id = ? AND u.role = 'staff'
ORDER BY u.id DESC",
[$companyId]
);
foreach ($staff as &$member) {
$member['email'] = Security::decrypt($member['email']);
if (!empty($member['session_phone'])) {
$member['session_phone'] = Security::decrypt($member['session_phone']);
}
}
$response->json([
'status' => 'success',
'data' => $staff
]);
}
/**
* Create a new customer service agent (staff)
* POST /api/staff
*/
public function store(Request $request, Response $response): void
{
$companyId = $request->company_id;
$errors = $this->validate($request, [
'name' => 'required|min:3',
'email' => 'required|email',
'password' => 'required|min:6'
]);
if (!empty($errors)) {
$response->json(['errors' => $errors], 400);
return;
}
$body = $request->getBody();
$email = strtolower(trim($body['email']));
// Check if user already exists
$existing = User::findByEmail($email);
if ($existing) {
$response->json(['errors' => ['email' => ['This email is already registered.']]], 409);
return;
}
// Validate session if assigned
$whatsappSessionId = isset($body['whatsapp_session_id']) && $body['whatsapp_session_id'] !== '' ? (int)$body['whatsapp_session_id'] : null;
if ($whatsappSessionId) {
$session = WhatsAppSession::findSecure($whatsappSessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(400)->json(['error' => 'Invalid WhatsApp session assigned']);
return;
}
}
try {
$userId = User::createSecure([
'company_id' => $companyId,
'name' => trim($body['name']),
'email' => $email,
'password' => $body['password'],
'role' => 'staff',
'status' => 'active',
'whatsapp_session_id' => $whatsappSessionId
]);
$response->json([
'status' => 'success',
'message' => 'Agent created successfully',
'data' => [
'id' => $userId,
'name' => trim($body['name']),
'email' => $email,
'role' => 'staff',
'whatsapp_session_id' => $whatsappSessionId
]
], 201);
} catch (\Exception $e) {
error_log("[Staff Controller Error] " . $e->getMessage());
$response->status(500)->json(['error' => 'Failed to create agent: ' . $e->getMessage()]);
}
}
/**
* Delete an agent
* DELETE /api/staff
*/
public function delete(Request $request, Response $response): void
{
$companyId = $request->company_id;
$body = $request->getBody();
$agentId = $body['agent_id'] ?? null;
if (!$agentId) {
$response->status(400)->json(['error' => 'Missing agent_id']);
return;
}
$user = User::find($agentId);
if (!$user || (int)$user['company_id'] !== (int)$companyId || $user['role'] !== 'staff') {
$response->status(404)->json(['error' => 'Agent not found']);
return;
}
User::delete((int)$agentId);
$response->json([
'status' => 'success',
'message' => 'Agent deleted successfully'
]);
}
/**
* Assign a specific WhatsApp session to an agent
* PUT /api/staff/assign
*/
public function assignSession(Request $request, Response $response): void
{
$companyId = $request->company_id;
$body = $request->getBody();
$agentId = $body['agent_id'] ?? null;
$whatsappSessionId = isset($body['whatsapp_session_id']) && $body['whatsapp_session_id'] !== '' ? (int)$body['whatsapp_session_id'] : null;
if (!$agentId) {
$response->status(400)->json(['error' => 'Missing agent_id']);
return;
}
$user = User::find($agentId);
if (!$user || (int)$user['company_id'] !== (int)$companyId || $user['role'] !== 'staff') {
$response->status(404)->json(['error' => 'Agent not found']);
return;
}
if ($whatsappSessionId) {
$session = WhatsAppSession::findSecure($whatsappSessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(400)->json(['error' => 'Invalid WhatsApp session']);
return;
}
}
try {
User::update((int)$agentId, [
'whatsapp_session_id' => $whatsappSessionId
]);
$response->json([
'status' => 'success',
'message' => 'WhatsApp session successfully assigned to agent'
]);
} catch (\Exception $e) {
$response->status(500)->json(['error' => 'Failed to assign session: ' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Models\CompanySubscription;
class SuperAdminController extends BaseController
{
/**
* Helper to verify if the requester is the Super Admin (Company ID 1 and role Admin)
*/
private function verifySuperAdmin(Request $request, Response $response): bool
{
if ((int)$request->company_id !== 1 || $request->role !== 'admin') {
$response->status(403)->json(['error' => 'Forbidden: Super Admin privileges required.']);
return false;
}
return true;
}
/**
* Get platform statistics and companies list
* GET /api/admin/stats
*/
public function getStats(Request $request, Response $response): void
{
if (!$this->verifySuperAdmin($request, $response)) {
return;
}
try {
// Overall stats
$companiesCount = Database::selectOne("SELECT COUNT(*) as count FROM companies")['count'] ?? 0;
$sessionsCount = Database::selectOne("SELECT COUNT(*) as count FROM whatsapp_sessions")['count'] ?? 0;
$connectedSessions = Database::selectOne("SELECT COUNT(*) as count FROM whatsapp_sessions WHERE status = 'connected'")['count'] ?? 0;
// Detailed list of all companies and their current subscriptions
$companies = Database::select("
SELECT
c.id,
c.name,
c.status,
cs.plan_id,
sp.name as plan_name,
cs.status as subscription_status,
cs.starts_at as subscription_starts,
cs.ends_at as subscription_ends,
(SELECT COUNT(*) FROM whatsapp_sessions WHERE company_id = c.id) as sessions_count,
(SELECT COUNT(*) FROM whatsapp_sessions WHERE company_id = c.id AND status = 'connected') as active_sessions,
COALESCE(cu.request_count, 0) as request_usage,
COALESCE(cu.voice_count, 0) as voice_usage,
COALESCE(cu.ocr_count, 0) as ocr_usage
FROM companies c
LEFT JOIN company_subscriptions cs ON cs.company_id = c.id AND cs.status = 'active'
LEFT JOIN subscription_plans sp ON cs.plan_id = sp.id
LEFT JOIN company_subscription_usage cu ON cu.company_id = c.id
AND cu.billing_start <= CURRENT_DATE()
AND cu.billing_end >= CURRENT_DATE()
ORDER BY c.id ASC
");
// Fetch list of available subscription plans
$plans = Database::select("SELECT id, name, price, max_sessions FROM subscription_plans ORDER BY price ASC");
$response->json([
'status' => 'success',
'data' => [
'stats' => [
'total_companies' => (int)$companiesCount,
'total_sessions' => (int)$sessionsCount,
'connected_sessions' => (int)$connectedSessions
],
'companies' => $companies,
'plans' => $plans
]
]);
} catch (\Exception $e) {
error_log("[SuperAdminController Error] " . $e->getMessage());
$response->status(500)->json(['error' => 'Failed to fetch platform stats: ' . $e->getMessage()]);
}
}
/**
* Subscribe or upgrade a company to a plan
* POST /api/admin/companies/subscribe
*/
public function subscribeCompany(Request $request, Response $response): void
{
if (!$this->verifySuperAdmin($request, $response)) {
return;
}
$body = $request->getBody();
$targetCompanyId = isset($body['company_id']) ? (int)$body['company_id'] : null;
$planId = isset($body['plan_id']) ? (int)$body['plan_id'] : null;
$durationDays = isset($body['duration_days']) ? (int)$body['duration_days'] : 30;
if (!$targetCompanyId || !$planId) {
$response->status(400)->json(['error' => 'Missing company_id or plan_id']);
return;
}
// Verify company exists
$companyExists = Database::selectOne("SELECT id FROM companies WHERE id = ?", [$targetCompanyId]);
if (!$companyExists) {
$response->status(404)->json(['error' => 'Company not found']);
return;
}
// Verify plan exists
$planExists = Database::selectOne("SELECT id FROM subscription_plans WHERE id = ?", [$planId]);
if (!$planExists) {
$response->status(404)->json(['error' => 'Subscription plan not found']);
return;
}
try {
// Subscribe the company
$subId = CompanySubscription::subscribeCompany($targetCompanyId, $planId, $durationDays, 'manual_admin', 'admin_' . $request->user_id);
// Clean active subscription cache for the company
if (class_exists('App\Core\Cache')) {
\App\Core\Cache::delete("company_subscription:{$targetCompanyId}");
\App\Core\Cache::delete("company_subscription_{$targetCompanyId}");
}
$response->json([
'status' => 'success',
'message' => 'Subscription updated successfully',
'subscription_id' => $subId
]);
} catch (\Exception $e) {
error_log("[SuperAdminController Error] " . $e->getMessage());
$response->status(500)->json(['error' => 'Failed to update subscription: ' . $e->getMessage()]);
}
}
}

View File

@@ -17,7 +17,17 @@ class WhatsAppController extends BaseController
public function status(Request $request, Response $response)
{
$companyId = $request->company_id; // Added by AuthMiddleware
$sessionId = $request->get('session_id') ?? null;
if ($sessionId) {
$session = WhatsAppSession::findSecure((int)$sessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(404)->json(['status' => 'error', 'message' => 'Session not found']);
return;
}
} else {
$session = WhatsAppSession::findOrCreate($companyId);
}
// Auto-heal logic: Check if the session is active in the Node.js gateway
if ($session['status'] === 'connected' && !empty($session['session_key'])) {
@@ -85,12 +95,121 @@ class WhatsAppController extends BaseController
}
/**
* Request a new connection/QR code from the Baileys service
* Get all WhatsApp sessions for the company
*/
public function listSessions(Request $request, Response $response)
{
$companyId = $request->company_id;
$sessions = WhatsAppSession::findAllByCompany($companyId);
$response->json([
'status' => 'success',
'data' => $sessions
]);
}
/**
* Create a new WhatsApp session
*/
public function createSession(Request $request, Response $response)
{
$companyId = $request->company_id;
$body = $request->getBody();
$name = !empty($body['name']) ? trim($body['name']) : 'WhatsApp Team';
// Fetch subscription limits
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
$maxSessions = 1;
if ($companyId === 1) {
$maxSessions = 10;
} elseif ($activeSub) {
$maxSessions = (int)$activeSub['max_sessions'];
}
$sessions = WhatsAppSession::findAllByCompany($companyId);
if (count($sessions) >= $maxSessions) {
$response->status(400)->json([
'status' => 'error',
'message' => "You have reached the maximum number of WhatsApp sessions allowed by your plan ({$maxSessions})."
]);
return;
}
$sessionKey = 'cmp_' . $companyId . '_' . bin2hex(random_bytes(4));
$id = WhatsAppSession::create([
'company_id' => $companyId,
'name' => $name,
'session_key' => $sessionKey,
'status' => 'disconnected'
]);
$newSession = WhatsAppSession::findSecure((int)$id);
$response->json([
'status' => 'success',
'data' => $newSession
]);
}
/**
* Delete an existing WhatsApp session
*/
public function deleteSession(Request $request, Response $response)
{
$companyId = $request->company_id;
$body = $request->getBody();
$sessionId = $body['session_id'] ?? null;
if (!$sessionId) {
$response->status(400)->json(['status' => 'error', 'message' => 'Missing session_id']);
return;
}
$session = WhatsAppSession::findSecure((int)$sessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(404)->json(['status' => 'error', 'message' => 'Session not found']);
return;
}
// Call Baileys Node.js Service to delete session from memory
$nodeUrl = 'http://127.0.0.1:3722/api/sessions/delete';
$payload = json_encode(['session_key' => $session['session_key']]);
$ch = curl_init($nodeUrl);
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, 5);
curl_exec($ch);
curl_close($ch);
WhatsAppSession::delete((int)$sessionId);
$response->json(['status' => 'success', 'message' => 'Session deleted successfully']);
}
/**
* Request a new connection/QR code from the Baileys service for a specific session
*/
public function requestQr(Request $request, Response $response)
{
$companyId = $request->company_id;
$body = $request->getBody();
$sessionId = $body['session_id'] ?? null;
if ($sessionId) {
$session = WhatsAppSession::findSecure((int)$sessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(404)->json(['status' => 'error', 'message' => 'Session not found']);
return;
}
} else {
$session = WhatsAppSession::findOrCreate($companyId);
}
// Temporarily set to connecting
WhatsAppSession::updateState($session['id'], ['status' => 'connecting']);
@@ -133,12 +252,23 @@ class WhatsAppController extends BaseController
}
/**
* Disconnect the current WhatsApp session
* Disconnect the WhatsApp session
*/
public function disconnect(Request $request, Response $response)
{
$companyId = $request->company_id;
$body = $request->getBody();
$sessionId = $body['session_id'] ?? null;
if ($sessionId) {
$session = WhatsAppSession::findSecure((int)$sessionId);
if (!$session || (int)$session['company_id'] !== (int)$companyId) {
$response->status(404)->json(['status' => 'error', 'message' => 'Session not found']);
return;
}
} else {
$session = WhatsAppSession::findByCompany($companyId);
}
if ($session && $session['status'] !== 'disconnected') {
// Call Baileys Node.js Service to disconnect

View File

@@ -32,6 +32,42 @@ class WhatsAppSession extends BaseModel
return $session;
}
/**
* Find secure session by ID
*/
public static function findSecure(int $id)
{
$session = Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE id = ? LIMIT 1",
[$id]
);
if ($session) {
$session['phone'] = $session['phone'] ? Security::decrypt($session['phone']) : null;
$session['qr_code'] = $session['qr_code'] ? Security::decrypt($session['qr_code']) : null;
}
return $session;
}
/**
* Get all WhatsApp sessions for a company
*/
public static function findAllByCompany(int $companyId): array
{
$sessions = Database::select(
"SELECT * FROM " . static::$table . " WHERE company_id = ? ORDER BY id ASC",
[$companyId]
);
foreach ($sessions as &$session) {
$session['phone'] = $session['phone'] ? Security::decrypt($session['phone']) : null;
$session['qr_code'] = $session['qr_code'] ? Security::decrypt($session['qr_code']) : null;
}
return $sessions;
}
/**
* Get a session by session_key (used by webhooks)
*/

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Services;
class TTSService
{
/**
* Convert text to speech using Google Translate TTS API (Free/No authentication required).
* Returns the MP3 audio file content base64-encoded.
*
* @param string $text Arabic text to convert (e.g. "رمز التحقق الخاص بك هو 5 4 2 1")
* @return string|null Base64 encoded audio string or null on failure
*/
public static function textToSpeechArabic(string $text): ?string
{
try {
$encodedText = urlencode($text);
$url = "https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ar&q={$encodedText}";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36');
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$audioBinary = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($httpCode !== 200 || empty($audioBinary)) {
error_log("[TTS Service Error] Failed to fetch audio from Google TTS. HTTP Code: {$httpCode}");
return null;
}
// Verify if the response is actually audio
if (strpos(strtolower($contentType), 'audio') === false && strlen($audioBinary) < 1000) {
error_log("[TTS Service Error] Response content-type was not audio: {$contentType}. Body: " . substr($audioBinary, 0, 100));
return null;
}
return base64_encode($audioBinary);
} catch (\Exception $e) {
error_log("[TTS Service Exception] " . $e->getMessage());
return null;
}
}
}

View File

@@ -12,7 +12,7 @@ try {
$pdo = Database::getConnection();
echo "=== 1. Subscription Plans ===\n";
$plans = Database::selectAll("SELECT * FROM subscription_plans");
$plans = Database::select("SELECT * FROM subscription_plans");
if (empty($plans)) {
echo "No subscription plans found.\n";
} else {
@@ -22,7 +22,7 @@ try {
}
echo "\n=== 2. Companies ===\n";
$companies = Database::selectAll("SELECT * FROM companies LIMIT 10");
$companies = Database::select("SELECT * FROM companies LIMIT 10");
if (empty($companies)) {
echo "No companies found.\n";
} else {
@@ -32,7 +32,7 @@ try {
}
echo "\n=== 3. Active Company Subscriptions ===\n";
$subs = Database::selectAll("
$subs = Database::select("
SELECT cs.*, p.name as plan_name
FROM company_subscriptions cs
JOIN subscription_plans p ON cs.plan_id = p.id
@@ -46,7 +46,7 @@ try {
}
echo "\n=== 4. WooCommerce Stores ===\n";
$stores = Database::selectAll("SELECT id, company_id, store_url, is_active, webhook_secret FROM woocommerce_stores");
$stores = Database::select("SELECT id, company_id, store_url, is_active, webhook_secret FROM woocommerce_stores");
if (empty($stores)) {
echo "No WooCommerce stores found.\n";
} else {

1027
backend/public/admin.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -774,9 +774,12 @@
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'chatbot' }" @click="activeDashboardTab = 'chatbot'; fetchChatbotSettings()" id="nav-chatbot-btn">
<span>🤖</span> <span x-text="lang === 'ar' ? 'روبوت الذكاء الاصطناعي' : 'AI Chatbot Settings'"></span>
</button>
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'integrations' }" @click="activeDashboardTab = 'integrations'; fetchEndpoints(); fetchSallaStatus()" id="nav-integrations-btn">
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'integrations' }" @click="activeDashboardTab = 'integrations'; fetchEndpoints(); fetchSallaStatus(); fetchWooCommerceStatus()" id="nav-integrations-btn">
<span>🔌</span> <span x-text="lang === 'ar' ? 'الربط البرمجي والمنصات (Integrations)' : 'API & Platform Integrations'"></span>
</button>
<button class="nav-item" :class="{ 'active': activeDashboardTab === 'staff' }" @click="activeDashboardTab = 'staff'; fetchStaff(); fetchWhatsappSessions()" id="nav-staff-btn">
<span>👥</span> <span x-text="lang === 'ar' ? 'الموظفين والوكلاء' : 'CS Agents & Team'"></span>
</button>
</div>
<!-- Right Dashboard Panels -->
@@ -797,107 +800,159 @@
<!-- Panel: WhatsApp Connection -->
<div class="panel" x-show="activeDashboardTab === 'whatsapp'" id="panel-whatsapp">
<h2 style="font-size: 1.4rem; margin-bottom: 1.5rem;">WhatsApp Integration</h2>
<div class="grid-two">
<!-- Connection Control Card -->
<div class="status-box">
<div class="status-badge" :class="{
'badge-disconnected': !whatsappSession || whatsappSession.status === 'disconnected',
'badge-connecting': whatsappSession && whatsappSession.status === 'connecting',
'badge-waiting_qr': whatsappSession && whatsappSession.status === 'waiting_qr',
'badge-connected': whatsappSession && whatsappSession.status === 'connected'
}">
<span x-text="whatsappSession ? whatsappSession.status : 'disconnected'"></span>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<h2 style="font-size: 1.4rem; margin: 0;" x-text="lang === 'ar' ? 'إدارة قنوات اتصال واتساب' : 'WhatsApp Session Management'"></h2>
<div style="display: flex; gap: 0.5rem; align-items: center;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<input type="text" x-model="newSessionName" :placeholder="lang === 'ar' ? 'اسم الرقم (مثال: الدعم)' : 'Session Name (e.g. Sales)'" class="form-input" style="max-width: 200px; padding: 0.5rem 0.8rem; font-size: 0.85rem;" id="new-session-name-input">
<button @click="createWhatsappSession()" class="btn btn-primary" style="padding: 0.5rem 1rem; font-size: 0.85rem;" id="btn-create-session" :disabled="actionLoading">
<span x-text="lang === 'ar' ? '+ إضافة خط جديد' : '+ Add New Line'"></span>
</button>
</div>
</div>
<h3 style="font-size: 1.25rem; margin-bottom: 0.5rem;">
<template x-if="whatsappSession && whatsappSession.status === 'connected'">
<span>WhatsApp Connected</span>
</template>
<template x-if="!whatsappSession || whatsappSession.status !== 'connected'">
<span>Session Inactive</span>
</template>
</h3>
<p class="text-muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;" x-text="lang === 'ar' ? 'قم بإضافة وإدارة أرقام واتساب متعددة لشركتك طبقاً لباقة اشتراكك. يمكنك ربط كل موظف بخط محدد لمتابعة المحادثات.' : 'Add and manage multiple WhatsApp connections. You can assign customer service agents to specific phone lines based on your plan features.'"></p>
<p class="text-muted" style="font-size: 0.9rem; margin-bottom: 1.5rem; max-width: 250px;">
<template x-if="whatsappSession && whatsappSession.phone">
<span>Active number: <strong x-text="whatsappSession.phone" style="color: var(--text-primary);"></strong></span>
<!-- Sessions Grid Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<template x-for="session in whatsappSessions" :key="session.id">
<div class="status-box" style="margin: 0; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid var(--card-border); background: var(--card-bg); border-radius: 16px;">
<div>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<div>
<h3 style="font-size: 1.1rem; font-weight: 700;" x-text="session.name"></h3>
<span style="font-family: monospace; font-size: 0.8rem; color: var(--text-muted);" x-text="session.session_key"></span>
</div>
<div class="status-badge" :class="{
'badge-disconnected': session.status === 'disconnected',
'badge-connecting': session.status === 'connecting',
'badge-waiting_qr': session.status === 'waiting_qr',
'badge-connected': session.status === 'connected'
}" style="margin: 0; font-size: 0.75rem; padding: 0.2rem 0.5rem;">
<span x-text="session.status"></span>
</div>
</div>
<p style="font-size: 0.9rem; margin-bottom: 1.5rem; color: var(--text-muted);" :style="lang === 'ar' ? 'text-align: right;' : 'text-align: left;'">
<template x-if="session.phone">
<span>📞 <strong x-text="session.phone" style="color: var(--text-main);"></strong></span>
</template>
<template x-if="!whatsappSession || !whatsappSession.phone">
<span>Start a connection session to link your phone.</span>
<template x-if="!session.phone">
<span x-text="lang === 'ar' ? 'الخط غير مرتبط برقم هاتف بعد.' : 'No phone linked yet. Scan QR code.'"></span>
</template>
</p>
</div>
<!-- Actions -->
<template x-if="!whatsappSession || whatsappSession.status === 'disconnected'">
<button @click="connectWhatsapp()" class="btn btn-primary" :disabled="actionLoading" id="btn-request-qr">
<span x-show="!actionLoading">Generate QR Code</span>
<span x-show="actionLoading" class="spinner"></span>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap;">
<!-- Connect/Scan QR Action -->
<template x-if="session.status === 'disconnected' || session.status === 'waiting_qr' || session.status === 'connecting'">
<button @click="connectWhatsapp(session.id)" class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" :disabled="actionLoading">
<span x-text="lang === 'ar' ? 'ربط / مسح رمز QR' : 'Link / Scan QR'"></span>
</button>
</template>
<template x-if="whatsappSession && whatsappSession.status !== 'disconnected'">
<button @click="disconnectWhatsapp()" class="btn btn-danger" :disabled="actionLoading" id="btn-disconnect-session">
<span x-show="!actionLoading">Disconnect Session</span>
<span x-show="actionLoading" class="spinner"></span>
<!-- Disconnect Action -->
<template x-if="session.status === 'connected'">
<button @click="disconnectWhatsapp(session.id)" class="btn btn-glass" style="padding: 0.4rem 0.8rem; font-size: 0.8rem; color: var(--warning);" :disabled="actionLoading">
<span x-text="lang === 'ar' ? 'قطع الاتصال' : 'Disconnect'"></span>
</button>
</template>
<!-- Delete Action -->
<button @click="deleteWhatsappSession(session.id)" class="btn btn-danger" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" :disabled="actionLoading">
<span x-text="lang === 'ar' ? 'حذف' : 'Delete'"></span>
</button>
</div>
</div>
</template>
<template x-if="whatsappSessions.length === 0">
<div style="grid-column: 1 / -1; text-align: center; padding: 3rem; color: var(--text-muted);" x-text="lang === 'ar' ? 'لا توجد قنوات واتساب نشطة. أضف خطاً جديداً أعلاه للبدء.' : 'No active WhatsApp channels configured. Create a session above to get started.'"></div>
</template>
</div>
<!-- QR Display Card -->
<div class="flex-center flex-column panel" style="background: rgba(10, 11, 20, 0.2); border-color: rgba(255, 255, 255, 0.03);" id="qr-display-container">
<template x-if="whatsappSession && whatsappSession.status === 'connecting'">
<div class="grid-two" style="align-items: start;">
<!-- QR Display Card (Active selected session) -->
<div class="card" style="margin: 0;" x-show="whatsappSession && (whatsappSession.status === 'connecting' || whatsappSession.status === 'waiting_qr' || whatsappSession.status === 'connected')">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<h3 style="font-size: 1.1rem; font-weight: 700;" x-text="(lang === 'ar' ? 'ربط الخط: ' : 'Linking Line: ') + (whatsappSession?.name || '')"></h3>
<button class="modal-close" style="font-size: 1.25rem;" @click="whatsappSession = null">&times;</button>
</div>
<div class="flex-center flex-column" style="background: rgba(10, 11, 20, 0.2); border: 1px solid var(--card-border); border-radius: 12px; padding: 2rem;">
<template x-if="whatsappSession?.status === 'connecting'">
<div class="text-center">
<div class="spinner spinner-large" style="margin-bottom: 1rem;"></div>
<p class="font-semibold">Establishing Connection...</p>
<p class="text-muted" style="font-size: 0.85rem; margin-top: 0.25rem;">Checking gateway processes and requesting channel</p>
<p class="font-semibold" x-text="lang === 'ar' ? 'جاري الاتصال بالبوابة...' : 'Connecting to Gateway...'"></p>
<p class="text-muted" style="font-size: 0.8rem; margin-top: 0.25rem;" x-text="lang === 'ar' ? 'يرجى الانتظار لحين جلب حالة الخط وطلب الرمز من البوابة.' : 'Preparing session and requesting QR code stream.'"></p>
</div>
</template>
<template x-if="whatsappSession && whatsappSession.status === 'waiting_qr'">
<template x-if="whatsappSession?.status === 'waiting_qr'">
<div class="text-center">
<p class="font-semibold">Scan QR Code</p>
<p class="text-muted" style="font-size: 0.85rem; margin-top: 0.25rem;">Scan using Link Devices inside WhatsApp</p>
<p class="font-semibold" x-text="lang === 'ar' ? 'امسح رمز الاستجابة السريعة (QR Code)' : 'Scan QR Code'"></p>
<p class="text-muted" style="font-size: 0.8rem; margin-top: 0.25rem; margin-bottom: 1rem;" x-text="lang === 'ar' ? 'افتح واتساب > الأجهزة المرتبطة > ربط جهاز' : 'Open WhatsApp > Linked Devices > Link a Device'"></p>
<div class="qr-wrapper">
<div class="qr-wrapper" style="background: #ffffff; padding: 1rem; border-radius: 12px; display: inline-block;">
<div id="qrcode-canvas" x-init="$nextTick(() => renderQr())"></div>
</div>
<!-- Diagnostics -->
<div style="font-size: 0.75rem; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.25rem;">
<template x-if="!whatsappSession.qr_code">
<span style="color: var(--danger-accent);">⚠️ Decryption issue: QR code string is empty.</span>
</template>
<template x-if="whatsappSession.qr_code">
<span style="color: var(--success-accent);">✓ Encrypted QR data retrieved successfully.</span>
</template>
<template x-if="typeof window.QRCode === 'undefined'">
<span style="color: var(--danger-accent);">⚠️ QRCode library failed to load (Integrity/CSP issue).</span>
</template>
</div>
<div style="font-size: 0.8rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;" class="text-muted">
<div style="font-size: 0.8rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 1rem;" class="text-muted">
<div class="spinner" style="width: 14px; height: 14px; border-width: 2px;"></div>
<span>Waiting for connection handshake...</span>
<span x-text="lang === 'ar' ? 'بانتظار إتمام المصادقة من الهاتف...' : 'Waiting for connection handshake...'"></span>
</div>
</div>
</template>
<template x-if="whatsappSession && whatsappSession.status === 'connected'">
<div class="text-center" style="padding: 2rem 0;">
<div style="font-size: 3rem; color: var(--success-accent); margin-bottom: 0.5rem; text-shadow: 0 0 20px var(--success-glow);"></div>
<p class="font-semibold" style="font-size: 1.15rem; color: var(--success-accent);">Gateway fully connected</p>
<p class="text-muted" style="font-size: 0.85rem; margin-top: 0.25rem; max-width: 250px; margin-left: auto; margin-right: auto;">
You can now create templates and launch marketing broadcast campaigns.
</p>
<template x-if="whatsappSession?.status === 'connected'">
<div class="text-center" style="padding: 1.5rem 0;">
<div style="font-size: 3rem; color: var(--success); margin-bottom: 0.5rem; text-shadow: 0 0 20px rgba(16,185,129,0.3);"></div>
<p class="font-semibold" style="font-size: 1.1rem; color: var(--success);" x-text="lang === 'ar' ? 'الخط متصل بالكامل برقم الهاتف!' : 'Line fully linked and connected!'"></p>
<p class="text-muted" style="font-size: 0.85rem; margin-top: 0.25rem;" x-text="lang === 'ar' ? 'الرقم متصل ويمكنه الآن إرسال الحملات ورموز التحقق واستقبال الطلبات.' : 'This line is active and ready to deliver campaign broadcasts and verification OTPs.'"></p>
</div>
</template>
</div>
</div>
<template x-if="!whatsappSession || whatsappSession.status === 'disconnected'">
<div class="text-center text-muted" style="padding: 3rem 0;">
<span style="font-size: 2.5rem; display: block; margin-bottom: 0.5rem;">🔌</span>
<p>No active WhatsApp link</p>
<!-- OTP Test Widget Card -->
<div class="card" style="margin: 0;">
<h3 style="font-size: 1.1rem; font-weight: 700; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<span>🔑</span>
<span x-text="lang === 'ar' ? 'أداة اختبار إرسال رمز التحقق (OTP)' : 'OTP Deliverability Test Tool'"></span>
</h3>
<form @submit.prevent="sendOtpTest()">
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'خط الإرسال (WhatsApp Line)' : 'Sender Line (WhatsApp)'"></label>
<select x-model="otpSessionId" required>
<option value="" x-text="lang === 'ar' ? '-- اختر الخط --' : '-- Choose Line --'"></option>
<template x-for="session in whatsappSessions" :key="session.id">
<option :value="session.id" x-text="session.name + (session.phone ? ' (' + session.phone + ')' : ' - ' + session.status)"></option>
</template>
</select>
</div>
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'رقم الهاتف المستلم (مع رمز الدولة)' : 'Recipient Phone (with country code)'"></label>
<input type="text" x-model="otpPhone" class="form-input" required placeholder="966500000000" id="otp-test-recipient">
</div>
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'نوع الرسالة' : 'OTP Type'"></label>
<select x-model="otpType">
<option value="text" x-text="lang === 'ar' ? 'رسالة نصية على الواتساب' : 'Text Message'"></option>
<option value="voice" x-text="lang === 'ar' ? 'رسالة صوتية (Voice Note OTP)' : 'Voice Note (Google TTS)'"></option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;" :disabled="actionLoading" id="btn-send-otp-test">
<span x-show="!actionLoading" x-text="lang === 'ar' ? 'إرسال رمز التحقق الآن' : 'Deliver OTP Code'"></span>
<span x-show="actionLoading" class="spinner"></span>
</button>
</form>
<template x-if="otpStatusMsg">
<div class="banner" :class="otpErrorCode ? 'banner-danger' : 'banner-success'" style="margin-top: 1rem; font-size: 0.85rem;" id="otp-test-banner">
<span x-text="otpStatusMsg"></span>
</div>
</template>
</div>
@@ -1171,6 +1226,84 @@
</div>
</div>
<!-- WooCommerce Integration Section -->
<div style="background: rgba(99, 102, 241, 0.05); border: 1px solid rgba(99, 102, 241, 0.15); border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem; box-shadow: 0 4px 20px rgba(99, 102, 241, 0.05);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<div style="display: flex; align-items: center; gap: 1rem; flex: 1;" :style="lang === 'ar' ? 'flex-direction: row-reverse; text-align: right;' : ''">
<div style="background: var(--primary); width: 48px; height: 48px; border-radius: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 15px rgba(99, 102, 241, 0.4);">
<span style="font-size: 1.5rem;">⚙️</span>
</div>
<div>
<h3 style="font-size: 1.2rem; margin: 0; display: flex; align-items: center; gap: 0.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<span x-text="lang === 'ar' ? 'ربط متجر ووكومرس (WooCommerce)' : 'WooCommerce Store Integration'"></span>
<template x-if="woocommerceStatus && woocommerceStatus.connected">
<span class="status-badge badge-connected" style="margin: 0; padding: 0.15rem 0.5rem; font-size: 0.7rem;" x-text="lang === 'ar' ? 'متصل' : 'Connected'"></span>
</template>
<template x-if="!woocommerceStatus || !woocommerceStatus.connected">
<span class="status-badge badge-disconnected" style="margin: 0; padding: 0.15rem 0.5rem; font-size: 0.7rem;" x-text="lang === 'ar' ? 'غير متصل' : 'Disconnected'"></span>
</template>
</h3>
<p class="text-muted" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;" x-text="lang === 'ar' ? 'قم بربط متجر WooCommerce لإرسال إشعارات تغيير حالة الطلبات للعملاء تلقائيًا عبر الواتساب.' : 'Link your WooCommerce store to trigger automated customer notifications via WhatsApp on order events.'"></p>
</div>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center;">
<template x-if="woocommerceLoading">
<span class="spinner"></span>
</template>
<template x-if="!woocommerceLoading && woocommerceStatus && woocommerceStatus.connected">
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<div style="text-align: right;" :style="lang === 'ar' ? 'text-align: right;' : 'text-align: left;'">
<span style="font-size: 0.8rem; color: var(--text-secondary);" x-text="lang === 'ar' ? 'المتجر المرتبط:' : 'Connected URL:'"></span>
<strong style="display: block; font-size: 0.95rem; color: #fff;" x-text="woocommerceStatus.store_url"></strong>
</div>
<button @click="disconnectWooCommerce()" class="btn btn-danger" style="width: auto; font-size: 0.9rem; padding: 0.6rem 1.2rem;" id="disconnect-woo-btn">
<span x-text="lang === 'ar' ? 'إلغاء الربط' : 'Disconnect'"></span>
</button>
</div>
</template>
</div>
</div>
<!-- WooCommerce Connect Form -->
<template x-if="!woocommerceStatus || !woocommerceStatus.connected">
<form @submit.prevent="connectWooCommerce()" style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 1.5rem;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
<div class="form-group" style="margin: 0;">
<label class="form-label" x-text="lang === 'ar' ? 'رابط المتجر (Store URL)' : 'Store URL'"></label>
<input type="url" class="form-input" x-model="wooForm.store_url" required placeholder="https://my-store.com">
</div>
<div class="form-group" style="margin: 0;">
<label class="form-label" x-text="lang === 'ar' ? 'Consumer Key (ck_...)' : 'Consumer Key (ck_...)'"></label>
<input type="text" class="form-input" x-model="wooForm.consumer_key" required placeholder="ck_...">
</div>
<div class="form-group" style="margin: 0;">
<label class="form-label" x-text="lang === 'ar' ? 'Consumer Secret (cs_...)' : 'Consumer Secret (cs_...)'"></label>
<input type="password" class="form-input" x-model="wooForm.consumer_secret" required placeholder="cs_...">
</div>
<div class="form-group" style="margin: 0;">
<label class="form-label" x-text="lang === 'ar' ? 'Webhook Secret (اختياري)' : 'Webhook Secret (Optional)'"></label>
<input type="text" class="form-input" x-model="wooForm.webhook_secret" placeholder="Secret code to secure webhook signature">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width: auto;" :disabled="woocommerceLoading" id="connect-woo-btn">
<span x-text="lang === 'ar' ? 'ربط ووكومرس وتفعيل الاشعارات' : 'Link WooCommerce Store'"></span>
</button>
</form>
</template>
<!-- WooCommerce Connected Details -->
<template x-if="woocommerceStatus && woocommerceStatus.connected">
<div style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 1rem; font-size: 0.85rem;">
<div style="background: rgba(255,255,255,0.02); border: 1px dashed var(--card-border-hover); border-radius: 8px; padding: 1rem; font-family: monospace;">
<p style="font-weight: 600; margin-bottom: 0.25rem; color: var(--text-main);" x-text="lang === 'ar' ? 'رابط الـ Webhook الخاص بمتجرك:' : 'Delivery URL for WooCommerce Webhook:'"></p>
<p style="word-break: break-all; color: var(--secondary);" x-text="woocommerceStatus.webhook_url"></p>
<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.8rem;" x-text="lang === 'ar' ? 'قم بإنشاء Webhooks في ووكومرس للأحداث (Order Created & Order Updated) والصق هذا الرابط هناك.' : 'Create Order Created and Order Updated webhooks in WooCommerce settings using this delivery URL.'"></p>
</div>
</div>
</template>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
@@ -1205,6 +1338,59 @@
</div>
</div>
<!-- Panel: Customer Service Team & Staff -->
<div class="panel" x-show="activeDashboardTab === 'staff'" id="panel-staff">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<h2 style="font-size: 1.4rem; margin: 0;" x-text="lang === 'ar' ? 'فريق خدمة العملاء والوكلاء (Staff)' : 'Customer Service Agents & Staff'"></h2>
<button class="btn btn-primary" style="width: auto;" @click="staffForm = { name: '', email: '', password: '', whatsapp_session_id: '' }; showAddStaffModal = true" id="add-agent-btn">
<span x-text="lang === 'ar' ? '+ إضافة موظف جديد' : '+ Add CS Agent'"></span>
</button>
</div>
<p class="text-muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;" x-text="lang === 'ar' ? 'قم بإضافة موظفي خدمة العملاء وتعيين كل منهم لرقم واتساب محدد. يستطيع كل موظف قراءة والرد على رسائل الرقم المخصص له فقط.' : 'Add agents to your team and bind them to specific WhatsApp lines. Each staff member can only view and manage chats for their assigned numbers.'"></p>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th x-text="lang === 'ar' ? 'الاسم' : 'Agent Name'"></th>
<th x-text="lang === 'ar' ? 'البريد الإلكتروني' : 'Email Address'"></th>
<th x-text="lang === 'ar' ? 'رقم الواتساب المعين' : 'Assigned WhatsApp Line'"></th>
<th style="width: 250px; text-align: center;" x-text="lang === 'ar' ? 'خيارات وتغيير التعيين' : 'Management & Assign'"></th>
</tr>
</thead>
<tbody>
<template x-for="agent in staff" :key="agent.id">
<tr>
<td class="font-semibold" x-text="agent.name"></td>
<td style="font-family: monospace; font-size: 0.85rem;" x-text="agent.email"></td>
<td>
<span class="badge badge-primary" x-show="agent.whatsapp_session_id" x-text="agent.session_name + (agent.session_phone ? ' (' + agent.session_phone + ')' : '')"></span>
<span class="badge badge-danger" x-show="!agent.whatsapp_session_id" x-text="lang === 'ar' ? 'غير معين' : 'Unassigned'"></span>
</td>
<td style="text-align: center; display: flex; gap: 0.5rem; justify-content: center; align-items: center;" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<!-- Assign Session Select -->
<select style="font-size: 0.8rem; padding: 0.3rem 0.5rem; width: auto;" :value="agent.whatsapp_session_id || ''" @change="assignSessionToStaff(agent.id, $event.target.value)">
<option value="" x-text="lang === 'ar' ? '-- بدون تعيين --' : '-- Unassigned --'"></option>
<template x-for="session in whatsappSessions" :key="session.id">
<option :value="session.id" x-text="session.name + (session.phone ? ' (' + session.phone + ')' : '')"></option>
</template>
</select>
<button class="btn btn-danger" style="width: auto; padding: 0.3rem 0.6rem; font-size: 0.8rem; margin: 0;" @click="deleteStaff(agent.id)">
<span x-text="lang === 'ar' ? 'حذف' : 'Delete'"></span>
</button>
</td>
</tr>
</template>
</tbody>
</table>
<template x-if="staff.length === 0">
<div class="empty-state" id="empty-staff-state" x-text="lang === 'ar' ? 'لم تقم بإضافة موظفي خدمة عملاء بعد.' : 'No customer service agents added yet.'"></div>
</template>
</div>
</div>
</div>
</div>
</div>
@@ -1245,6 +1431,48 @@
</div>
</div>
<!-- Modal: Add Staff / Agent -->
<div class="modal-overlay" x-show="showAddStaffModal" id="modal-add-staff" style="display: none;">
<div class="modal-card">
<div class="modal-header" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<h3 class="modal-title" x-text="lang === 'ar' ? 'إضافة موظف خدمة عملاء جديد' : 'Add Customer Service Agent'"></h3>
<button class="modal-close" @click="showAddStaffModal = false">&times;</button>
</div>
<form @submit.prevent="submitAddStaff()" id="form-add-staff">
<div class="modal-body">
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'اسم الموظف' : 'Agent Name'"></label>
<input type="text" class="form-input" x-model="staffForm.name" required placeholder="Ali Ahmed">
</div>
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'البريد الإلكتروني' : 'Email Address'"></label>
<input type="email" class="form-input" x-model="staffForm.email" required placeholder="ali@example.com">
</div>
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'كلمة المرور' : 'Password'"></label>
<input type="password" class="form-input" x-model="staffForm.password" required placeholder="******">
</div>
<div class="form-group">
<label class="form-label" x-text="lang === 'ar' ? 'تعيين خط واتساب' : 'Assign WhatsApp Line'"></label>
<select x-model="staffForm.whatsapp_session_id">
<option value="" x-text="lang === 'ar' ? '-- بدون تعيين --' : '-- Leave Unassigned --'"></option>
<template x-for="session in whatsappSessions" :key="session.id">
<option :value="session.id" x-text="session.name + (session.phone ? ' (' + session.phone + ')' : '')"></option>
</template>
</select>
</div>
</div>
<div class="modal-footer" :style="lang === 'ar' ? 'flex-direction: row-reverse' : ''">
<button type="button" class="btn btn-secondary" style="width: auto;" @click="showAddStaffModal = false" x-text="lang === 'ar' ? 'إلغاء' : 'Cancel'"></button>
<button type="submit" class="btn btn-primary" style="width: auto;" :disabled="actionLoading">
<span x-show="!actionLoading" x-text="lang === 'ar' ? 'إضافة الموظف' : 'Add Agent'"></span>
<span x-show="actionLoading" class="spinner"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal: Add/Edit Endpoint Integration -->
<div class="modal-overlay" x-show="showAddEndpointModal" id="modal-add-endpoint" style="display: none;">
<div class="modal-card">
@@ -1422,6 +1650,29 @@
// Dashboard States
activeDashboardTab: 'whatsapp',
whatsappSession: null,
whatsappSessions: [],
newSessionName: '',
staff: [],
showAddStaffModal: false,
staffForm: {
name: '',
email: '',
password: '',
whatsapp_session_id: ''
},
woocommerceStatus: null,
woocommerceLoading: false,
wooForm: {
store_url: '',
consumer_key: '',
consumer_secret: '',
webhook_secret: ''
},
otpPhone: '',
otpType: 'text',
otpSessionId: '',
otpStatusMsg: '',
otpErrorCode: '',
contacts: [],
selectedContactIds: [],
bulkGroupId: '',
@@ -1582,6 +1833,9 @@
this.user = null;
this.isLoggedIn = false;
this.whatsappSession = null;
this.whatsappSessions = [];
this.staff = [];
this.woocommerceStatus = null;
this.contacts = [];
this.templates = [];
this.campaigns = [];
@@ -1592,8 +1846,10 @@
},
initializeDashboard() {
this.fetchWhatsappSessions();
this.fetchWhatsappStatus();
this.fetchSallaStatus();
this.fetchWooCommerceStatus();
// Set up persistent background status check
this.startPolling();
@@ -1615,8 +1871,9 @@
async fetchWhatsappStatus() {
if (!this.token) return;
const queryParam = this.whatsappSession ? `?session_id=${this.whatsappSession.id}` : '';
try {
const response = await fetch('/api/whatsapp/status', {
const response = await fetch(`/api/whatsapp/status${queryParam}`, {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await response.json();
@@ -1625,13 +1882,88 @@
if (this.whatsappSession && this.whatsappSession.status === 'waiting_qr') {
this.$nextTick(() => this.renderQr());
}
if (this.whatsappSessions && this.whatsappSessions.length > 0 && this.whatsappSession) {
const idx = this.whatsappSessions.findIndex(s => s.id === this.whatsappSession.id);
if (idx !== -1) {
this.whatsappSessions[idx] = this.whatsappSession;
}
}
}
} catch (err) {
console.error('Failed to retrieve session status:', err);
}
},
async connectWhatsapp() {
async fetchWhatsappSessions() {
if (!this.token) return;
try {
const response = await fetch('/api/whatsapp/sessions', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.whatsappSessions = data.data || [];
}
} catch (err) {
console.error('Failed to retrieve sessions list:', err);
}
},
async createWhatsappSession() {
this.actionLoading = true;
try {
const response = await fetch('/api/whatsapp/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ name: this.newSessionName })
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.newSessionName = '';
await this.fetchWhatsappSessions();
} else {
alert(data.message || 'Failed to create WhatsApp session');
}
} catch (err) {
alert('Error communicating with backend Gateway API.');
} finally {
this.actionLoading = false;
}
},
async deleteWhatsappSession(sessionId) {
if (!confirm('Are you sure you want to delete this WhatsApp session? This will remove all associated connection settings.')) return;
this.actionLoading = true;
try {
const response = await fetch('/api/whatsapp/sessions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ session_id: sessionId })
});
const data = await response.json();
if (response.ok && data.status === 'success') {
if (this.whatsappSession && this.whatsappSession.id === sessionId) {
this.whatsappSession = null;
}
await this.fetchWhatsappSessions();
} else {
alert(data.message || 'Failed to delete session');
}
} catch (err) {
console.error('Error deleting session:', err);
} finally {
this.actionLoading = false;
}
},
async connectWhatsapp(sessionId) {
this.actionLoading = true;
try {
const response = await fetch('/api/whatsapp/qr', {
@@ -1639,10 +1971,16 @@
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
}
},
body: JSON.stringify({ session_id: sessionId })
});
const data = await response.json();
if (response.ok && data.status === 'success') {
const found = this.whatsappSessions.find(s => s.id === sessionId);
if (found) {
this.whatsappSession = found;
this.whatsappSession.status = 'connecting';
}
await this.fetchWhatsappStatus();
} else {
alert(data.message || 'Failed to initialize session');
@@ -1654,8 +1992,8 @@
}
},
async disconnectWhatsapp() {
if (!confirm('Are you sure you want to disconnect your WhatsApp link?')) return;
async disconnectWhatsapp(sessionId) {
if (!confirm('Are you sure you want to disconnect this WhatsApp link?')) return;
this.actionLoading = true;
try {
const response = await fetch('/api/whatsapp/disconnect', {
@@ -1663,14 +2001,214 @@
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ session_id: sessionId })
});
const data = await response.json();
if (response.ok && data.status === 'success') {
await this.fetchWhatsappSessions();
if (this.whatsappSession && this.whatsappSession.id === sessionId) {
await this.fetchWhatsappStatus();
}
}
} catch (err) {
console.error(err);
} finally {
this.actionLoading = false;
}
},
// Customer Service Staff methods
async fetchStaff() {
this.staffLoading = true;
try {
const response = await fetch('/api/staff', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.staff = data.data || [];
}
} catch (err) {
console.error('Error fetching staff list:', err);
} finally {
this.staffLoading = false;
}
},
async submitAddStaff() {
this.actionLoading = true;
try {
const response = await fetch('/api/staff', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify(this.staffForm)
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.showAddStaffModal = false;
this.staffForm = { name: '', email: '', password: '', whatsapp_session_id: '' };
await this.fetchStaff();
} else {
const errs = data.errors || {};
const firstErr = Object.values(errs)[0]?.[0] || data.error || 'Failed to create agent';
alert(firstErr);
}
} catch (err) {
alert('Network error while adding agent');
} finally {
this.actionLoading = false;
}
},
async deleteStaff(agentId) {
if (!confirm('Are you sure you want to remove this customer service agent?')) return;
this.actionLoading = true;
try {
const response = await fetch('/api/staff', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ agent_id: agentId })
});
const data = await response.json();
if (response.ok && data.status === 'success') {
await this.fetchStaff();
} else {
alert(data.error || 'Failed to delete agent');
}
} catch (err) {
console.error(err);
} finally {
this.actionLoading = false;
}
},
async assignSessionToStaff(agentId, sessionId) {
try {
const response = await fetch('/api/staff/assign', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({
agent_id: agentId,
whatsapp_session_id: sessionId ? parseInt(sessionId) : null
})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
await this.fetchStaff();
} else {
alert(data.error || 'Failed to assign session');
}
} catch (err) {
console.error('Error assigning session:', err);
}
},
// WooCommerce Integration methods
async fetchWooCommerceStatus() {
try {
const response = await fetch('/api/integrations/woocommerce/status', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.woocommerceStatus = data;
}
} catch (err) {
console.error('Error fetching WooCommerce status:', err);
}
},
async connectWooCommerce() {
this.woocommerceLoading = true;
try {
const response = await fetch('/api/integrations/woocommerce/connect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify(this.wooForm)
});
const data = await response.json();
if (response.ok && data.status === 'success') {
await this.fetchWooCommerceStatus();
} else {
alert(data.message || 'Failed to connect WooCommerce store');
}
} catch (err) {
alert('Network error while connecting WooCommerce');
} finally {
this.woocommerceLoading = false;
}
},
async disconnectWooCommerce() {
if (!confirm('Are you sure you want to disconnect WooCommerce integration? Webhooks will no longer notify customers.')) return;
this.woocommerceLoading = true;
try {
const response = await fetch('/api/integrations/woocommerce/disconnect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
}
});
const data = await response.json();
if (response.ok && data.status === 'success') {
await this.fetchWhatsappStatus();
this.wooForm = { store_url: '', consumer_key: '', consumer_secret: '', webhook_secret: '' };
await this.fetchWooCommerceStatus();
}
} catch (err) {
console.error(err);
} finally {
this.woocommerceLoading = false;
}
},
// OTP Testing Tool methods
async sendOtpTest() {
if (!this.otpSessionId) {
alert('Please select a WhatsApp line to send from.');
return;
}
this.actionLoading = true;
this.otpStatusMsg = '';
this.otpErrorCode = '';
try {
const response = await fetch('/api/otp/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({
phone: this.otpPhone,
type: this.otpType,
session_id: parseInt(this.otpSessionId)
})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
this.otpStatusMsg = this.lang === 'ar'
? `✓ تم إرسال رمز التحقق بنجاح! الرمز المرسل هو: ${data.code}`
: `✓ OTP delivered successfully! Code sent: ${data.code}`;
} else {
this.otpErrorCode = 'error';
this.otpStatusMsg = data.error || 'Failed to deliver OTP';
}
} catch (err) {
this.otpErrorCode = 'error';
this.otpStatusMsg = 'Network error while delivering OTP';
} finally {
this.actionLoading = false;
}

View File

@@ -28,6 +28,14 @@ $router->get('/', function ($request, $response) {
exit;
});
// Serve admin.html super admin panel on /admin path
$router->get('/admin', function ($request, $response) {
$response->setHeader('Content-Type', 'text/html; charset=utf-8');
$response->sendHeaders();
readfile(__DIR__ . '/admin.html');
exit;
});
// Health Check — no php_version or environment in production to avoid info disclosure
$router->get('/api/health', function ($request, $response) {
$response->json([
@@ -43,12 +51,28 @@ $router->post('/api/auth/register', [\App\Controllers\AuthController::class, 're
$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
// WhatsApp Gateway Routes
// WhatsApp Gateway & Multi-Session Routes
$router->get('/api/whatsapp/status', [\App\Controllers\WhatsAppController::class, 'status'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/qr', [\App\Controllers\WhatsAppController::class, 'requestQr'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->post('/api/whatsapp/disconnect', [\App\Controllers\WhatsAppController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/whatsapp/sessions', [\App\Controllers\WhatsAppController::class, 'listSessions'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/sessions', [\App\Controllers\WhatsAppController::class, 'createSession'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->delete('/api/whatsapp/sessions', [\App\Controllers\WhatsAppController::class, 'deleteSession'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/whatsapp/webhook', [\App\Controllers\WhatsAppController::class, 'webhook']); // No AuthMiddleware (Protected by WEBHOOK_SECRET internally)
// Customer Service Agents (Staff) Routes
$router->get('/api/staff', [\App\Controllers\StaffController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/staff', [\App\Controllers\StaffController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
$router->delete('/api/staff', [\App\Controllers\StaffController::class, 'delete'], [\App\Middlewares\AuthMiddleware::class]);
$router->put('/api/staff/assign', [\App\Controllers\StaffController::class, 'assignSession'], [\App\Middlewares\AuthMiddleware::class]);
// Text and Voice OTP Verification Routes
$router->post('/api/otp/send', [\App\Controllers\OTPController::class, 'send'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);
// Super Admin Routes
$router->get('/api/admin/stats', [\App\Controllers\SuperAdminController::class, 'getStats'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/admin/companies/subscribe', [\App\Controllers\SuperAdminController::class, 'subscribeCompany'], [\App\Middlewares\AuthMiddleware::class]);
// Phase 4 & 5: CRM, Templates & Campaigns Routes
$router->get('/api/contacts', [\App\Controllers\ContactController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/contacts', [\App\Controllers\ContactController::class, 'store'], [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\SubscriptionMiddleware::class]);