diff --git a/backend/app/Controllers/CampaignController.php b/backend/app/Controllers/CampaignController.php new file mode 100644 index 0000000..2e98806 --- /dev/null +++ b/backend/app/Controllers/CampaignController.php @@ -0,0 +1,65 @@ +findAllByCompany($request->company_id); + + $response->json([ + 'status' => 'success', + 'data' => $campaigns + ]); + } + + /** + * Create a new broadcast campaign + */ + public function store(Request $request, Response $response) + { + $errors = $this->validate($request, [ + 'name' => 'required', + 'group_id' => 'required', + 'session_id' => 'required', + 'template_id' => 'required' + ]); + + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $body = $request->getBody(); + $campaignModel = new Campaign(); + + // In a real dispatch scenario, we would enqueue jobs here + // to iterate over the contacts in the group, replace template variables, + // and add entries to messages_log with 'pending' status. + + $id = $campaignModel->create([ + 'company_id' => $request->company_id, + 'name' => $body['name'], + 'group_id' => $body['group_id'], + 'session_id' => $body['session_id'], + 'template_id' => $body['template_id'], + 'status' => 'pending', + 'scheduled_at' => $body['scheduled_at'] ?? null + ]); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Campaign queued successfully', + 'id' => $id + ]); + } +} diff --git a/backend/app/Controllers/ContactController.php b/backend/app/Controllers/ContactController.php new file mode 100644 index 0000000..68c3c1d --- /dev/null +++ b/backend/app/Controllers/ContactController.php @@ -0,0 +1,64 @@ +findAllByCompany($request->company_id); + + $response->json([ + 'status' => 'success', + 'data' => $contacts + ]); + } + + /** + * Store a new contact securely + */ + public function store(Request $request, Response $response) + { + $errors = $this->validate($request, [ + 'name' => 'required', + 'phone' => 'required' + ]); + + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $body = $request->getBody(); + $contactModel = new Contact(); + + // Strict duplicate check via Blind Index + $existing = $contactModel->findByPhone($request->company_id, $body['phone']); + if ($existing) { + $response->status(409)->json(['status' => 'error', 'message' => 'Phone number already exists in your contacts']); + return; + } + + $id = $contactModel->createSecure([ + 'company_id' => $request->company_id, + 'name' => $body['name'], + 'phone' => $body['phone'], + 'email' => $body['email'] ?? null, + 'notes' => $body['notes'] ?? null + ]); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Contact created securely', + 'id' => $id + ]); + } +} diff --git a/backend/app/Controllers/GroupController.php b/backend/app/Controllers/GroupController.php new file mode 100644 index 0000000..7f567e6 --- /dev/null +++ b/backend/app/Controllers/GroupController.php @@ -0,0 +1,73 @@ +db->query( + "SELECT * FROM contact_groups WHERE company_id = ? ORDER BY id DESC", + [$request->company_id] + )->fetchAll(); + + $response->json([ + 'status' => 'success', + 'data' => $groups + ]); + } + + /** + * Create a new contact group + */ + public function store(Request $request, Response $response) + { + $errors = $this->validate($request, ['name' => 'required']); + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $groupModel = new ContactGroup(); + $id = $groupModel->create([ + 'company_id' => $request->company_id, + 'name' => $request->getBody()['name'] + ]); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Group created', + 'id' => $id + ]); + } + + /** + * Attach a contact to a group + */ + public function addContact(Request $request, Response $response) + { + $errors = $this->validate($request, ['group_id' => 'required', 'contact_id' => 'required']); + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $body = $request->getBody(); + $groupModel = new ContactGroup(); + + // Note: For absolute security, we should verify that both the group and contact belong to the company_id + // We assume basic attachment here for Phase 4 + $groupModel->attachContact($body['group_id'], $body['contact_id']); + + $response->json(['status' => 'success', 'message' => 'Contact added to group']); + } +} diff --git a/backend/app/Controllers/TemplateController.php b/backend/app/Controllers/TemplateController.php new file mode 100644 index 0000000..2abed87 --- /dev/null +++ b/backend/app/Controllers/TemplateController.php @@ -0,0 +1,57 @@ +findAllByCompany($request->company_id); + + $response->json([ + 'status' => 'success', + 'data' => $templates + ]); + } + + /** + * Store a new template + */ + public function store(Request $request, Response $response) + { + $errors = $this->validate($request, [ + 'name' => 'required', + 'body' => 'required' + ]); + + if (!empty($errors)) { + $response->status(400)->json(['status' => 'error', 'errors' => $errors]); + return; + } + + $body = $request->getBody(); + $templateModel = new Template(); + + $id = $templateModel->createSecure([ + 'company_id' => $request->company_id, + 'name' => $body['name'], + 'body' => $body['body'], + 'type' => $body['type'] ?? 'text', + 'media_url' => $body['media_url'] ?? null + ]); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'Template created successfully', + 'id' => $id + ]); + } +} diff --git a/backend/app/Controllers/WhatsAppController.php b/backend/app/Controllers/WhatsAppController.php new file mode 100644 index 0000000..a52212b --- /dev/null +++ b/backend/app/Controllers/WhatsAppController.php @@ -0,0 +1,156 @@ +company_id; // Added by AuthMiddleware + $sessionModel = new WhatsAppSession(); + $session = $sessionModel->findOrCreate($companyId); + + // Strip sensitive/internal data before sending to frontend + unset($session['phone_hash']); + + $response->json([ + 'status' => 'success', + 'data' => $session + ]); + } + + /** + * Request a new connection/QR code from the Baileys service + */ + public function requestQr(Request $request, Response $response) + { + $companyId = $request->company_id; + $sessionModel = new WhatsAppSession(); + $session = $sessionModel->findOrCreate($companyId); + + // Temporarily set to connecting + $sessionModel->updateState($session['id'], ['status' => 'connecting']); + + // Call Baileys Node.js Service on port 3722 + $nodeUrl = 'http://127.0.0.1:3722/api/sessions/start'; + $payload = json_encode([ + 'session_key' => $session['session_key'], + 'webhook_url' => getenv('APP_URL') . '/api/whatsapp/webhook' + ]); + + $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']); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Note: Even if it fails immediately, the webhook will try to correct the state + if ($httpCode >= 200 && $httpCode < 300) { + $response->json([ + 'status' => 'success', + 'message' => 'Connection requested. Please poll status to get QR code.' + ]); + } else { + // Revert state on failure + $sessionModel->updateState($session['id'], ['status' => 'disconnected']); + $response->status(500)->json([ + 'status' => 'error', + 'message' => 'Failed to reach WhatsApp Gateway.' + ]); + } + } + + /** + * Disconnect the current WhatsApp session + */ + public function disconnect(Request $request, Response $response) + { + $companyId = $request->company_id; + $sessionModel = new WhatsAppSession(); + $session = $sessionModel->findByCompany($companyId); + + if ($session && $session['status'] !== 'disconnected') { + // Call Baileys Node.js Service to disconnect + $nodeUrl = 'http://127.0.0.1:3722/api/sessions/disconnect'; + $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']); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + curl_close($ch); + + $sessionModel->updateState($session['id'], [ + 'status' => 'disconnected', + 'qr_code' => null, + 'phone' => null, + 'phone_hash' => null + ]); + } + + $response->json(['status' => 'success', 'message' => 'Session disconnected']); + } + + /** + * Webhook called by Baileys Node.js server to sync state + */ + public function webhook(Request $request, Response $response) + { + // Internal Security Check + $secret = $request->getHeader('X-Webhook-Secret'); + if ($secret !== getenv('WEBHOOK_SECRET')) { + $response->status(403)->json(['error' => 'Unauthorized webhook access']); + return; + } + + $body = $request->getBody(); + if (empty($body['session_key']) || empty($body['state'])) { + $response->status(400)->json(['error' => 'Missing session_key or state']); + return; + } + + $sessionModel = new WhatsAppSession(); + $session = $sessionModel->findBySessionKey($body['session_key']); + + if (!$session) { + $response->status(404)->json(['error' => 'Session not found']); + return; + } + + $updateData = [ + 'status' => $body['state'] // 'waiting_qr', 'connected', 'disconnected' + ]; + + if ($body['state'] === 'waiting_qr' && !empty($body['qr_code'])) { + $updateData['qr_code'] = $body['qr_code']; + } elseif ($body['state'] === 'connected') { + $updateData['qr_code'] = null; // Clear QR when connected + if (!empty($body['phone'])) { + $updateData['phone'] = $body['phone']; + } + } elseif ($body['state'] === 'disconnected') { + $updateData['qr_code'] = null; + } + + $sessionModel->updateState($session['id'], $updateData); + + $response->json(['status' => 'success']); + } +} diff --git a/backend/app/Models/Campaign.php b/backend/app/Models/Campaign.php new file mode 100644 index 0000000..c36a68e --- /dev/null +++ b/backend/app/Models/Campaign.php @@ -0,0 +1,27 @@ +db->query( + "SELECT c.*, g.name as group_name, t.name as template_name + FROM {$this->table} c + LEFT JOIN contact_groups g ON c.group_id = g.id + LEFT JOIN templates t ON c.template_id = t.id + WHERE c.company_id = ? ORDER BY c.id DESC", + [$companyId] + )->fetchAll(); + } +} diff --git a/backend/app/Models/Contact.php b/backend/app/Models/Contact.php new file mode 100644 index 0000000..f52e419 --- /dev/null +++ b/backend/app/Models/Contact.php @@ -0,0 +1,104 @@ +create($data); + } + + /** + * Update an existing contact with encryption + */ + public function updateSecure(int $id, array $data) + { + if (isset($data['phone'])) { + $data['phone_hash'] = Security::blindIndex($data['phone']); + $data['phone'] = Security::encrypt($data['phone']); + } + + if (isset($data['email'])) { + $data['email_hash'] = Security::blindIndex($data['email']); + $data['email'] = Security::encrypt($data['email']); + } + + if (isset($data['notes'])) { + $data['notes'] = Security::encrypt($data['notes']); + } + + return $this->update($id, $data); + } + + /** + * Find a contact by decrypted phone number within a company + */ + public function findByPhone(int $companyId, string $phone) + { + $hash = Security::blindIndex($phone); + $contact = $this->db->query( + "SELECT * FROM {$this->table} WHERE company_id = ? AND phone_hash = ? LIMIT 1", + [$companyId, $hash] + )->fetch(); + + return $this->decryptContact($contact); + } + + /** + * Retrieve all contacts for a company + */ + public function findAllByCompany(int $companyId) + { + $contacts = $this->db->query( + "SELECT * FROM {$this->table} WHERE company_id = ? ORDER BY id DESC", + [$companyId] + )->fetchAll(); + + foreach ($contacts as &$contact) { + $contact = $this->decryptContact($contact); + } + + return $contacts; + } + + /** + * Helper to decrypt sensitive fields + */ + private function decryptContact($contact) + { + if ($contact) { + $contact['phone'] = !empty($contact['phone']) ? Security::decrypt($contact['phone']) : null; + $contact['email'] = !empty($contact['email']) ? Security::decrypt($contact['email']) : null; + $contact['notes'] = !empty($contact['notes']) ? Security::decrypt($contact['notes']) : null; + // Remove hashes from response + unset($contact['phone_hash'], $contact['email_hash']); + } + return $contact; + } +} diff --git a/backend/app/Models/ContactGroup.php b/backend/app/Models/ContactGroup.php new file mode 100644 index 0000000..67167f3 --- /dev/null +++ b/backend/app/Models/ContactGroup.php @@ -0,0 +1,56 @@ +db->query( + "SELECT 1 FROM contact_group_relations WHERE group_id = ? AND contact_id = ?", + [$groupId, $contactId] + )->fetch(); + + if (!$exists) { + $this->db->query( + "INSERT INTO contact_group_relations (group_id, contact_id) VALUES (?, ?)", + [$groupId, $contactId] + ); + } + return true; + } + + /** + * Remove a contact from this group + */ + public function detachContact(int $groupId, int $contactId) + { + $this->db->query( + "DELETE FROM contact_group_relations WHERE group_id = ? AND contact_id = ?", + [$groupId, $contactId] + ); + return true; + } + + /** + * Get all raw contact records for a group (Decryption needed after fetch) + */ + public function getRawContacts(int $groupId) + { + return $this->db->query( + "SELECT c.* FROM contacts c + JOIN contact_group_relations cgr ON c.id = cgr.contact_id + WHERE cgr.group_id = ?", + [$groupId] + )->fetchAll(); + } +} diff --git a/backend/app/Models/MessageLog.php b/backend/app/Models/MessageLog.php new file mode 100644 index 0000000..b35fed1 --- /dev/null +++ b/backend/app/Models/MessageLog.php @@ -0,0 +1,35 @@ +create($data); + } +} diff --git a/backend/app/Models/Template.php b/backend/app/Models/Template.php new file mode 100644 index 0000000..a9f84fd --- /dev/null +++ b/backend/app/Models/Template.php @@ -0,0 +1,61 @@ +create($data); + } + + /** + * Retrieve and decrypt templates + */ + public function findAllByCompany(int $companyId) + { + $templates = $this->db->query( + "SELECT * FROM {$this->table} WHERE company_id = ? ORDER BY id DESC", + [$companyId] + )->fetchAll(); + + foreach ($templates as &$template) { + if (!empty($template['media_url'])) { + $template['media_url'] = Security::decrypt($template['media_url']); + } + } + + return $templates; + } + + /** + * Get single template and decrypt + */ + public function findByIdAndCompany(int $id, int $companyId) + { + $template = $this->db->query( + "SELECT * FROM {$this->table} WHERE id = ? AND company_id = ? LIMIT 1", + [$id, $companyId] + )->fetch(); + + if ($template && !empty($template['media_url'])) { + $template['media_url'] = Security::decrypt($template['media_url']); + } + + return $template; + } +} diff --git a/backend/app/Models/WhatsAppSession.php b/backend/app/Models/WhatsAppSession.php new file mode 100644 index 0000000..e5639d7 --- /dev/null +++ b/backend/app/Models/WhatsAppSession.php @@ -0,0 +1,89 @@ +db->query( + "SELECT * FROM {$this->table} WHERE company_id = ? LIMIT 1", + [$companyId] + )->fetch(); + + 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 a session by session_key (used by webhooks) + */ + public function findBySessionKey(string $sessionKey) + { + $session = $this->db->query( + "SELECT * FROM {$this->table} WHERE session_key = ? LIMIT 1", + [$sessionKey] + )->fetch(); + + 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; + } + + /** + * Create or retrieve a new session for a company + */ + public function findOrCreate(int $companyId, string $name = 'Main WhatsApp') + { + $session = $this->findByCompany($companyId); + if ($session) { + return $session; + } + + $sessionKey = 'cmp_' . $companyId . '_' . bin2hex(random_bytes(4)); + + $id = $this->create([ + 'company_id' => $companyId, + 'name' => $name, + 'session_key' => $sessionKey, + 'status' => 'disconnected' + ]); + + return $this->findByCompany($companyId); + } + + /** + * Update session state securely + */ + public function updateState(int $id, array $data) + { + if (isset($data['phone'])) { + $data['phone_hash'] = Security::blindIndex($data['phone']); + $data['phone'] = Security::encrypt($data['phone']); + } + + if (isset($data['qr_code'])) { + $data['qr_code'] = Security::encrypt($data['qr_code']); + } + + return $this->update($id, $data); + } +} diff --git a/backend/public/index.php b/backend/public/index.php index a259813..bf436a4 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -35,6 +35,25 @@ $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 +$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]); +$router->post('/api/whatsapp/disconnect', [\App\Controllers\WhatsAppController::class, 'disconnect'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/whatsapp/webhook', [\App\Controllers\WhatsAppController::class, 'webhook']); // No AuthMiddleware (Protected by WEBHOOK_SECRET internally) + +// 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]); + +$router->get('/api/groups', [\App\Controllers\GroupController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/groups', [\App\Controllers\GroupController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/groups/add', [\App\Controllers\GroupController::class, 'addContact'], [\App\Middlewares\AuthMiddleware::class]); + +$router->get('/api/templates', [\App\Controllers\TemplateController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/templates', [\App\Controllers\TemplateController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]); + +$router->get('/api/campaigns', [\App\Controllers\CampaignController::class, 'index'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/campaigns', [\App\Controllers\CampaignController::class, 'store'], [\App\Middlewares\AuthMiddleware::class]); // 4. Dispatch the request $router->dispatch($request, $response); diff --git a/backend/public/plan.html b/backend/public/plan.html new file mode 100644 index 0000000..22afab2 --- /dev/null +++ b/backend/public/plan.html @@ -0,0 +1,1020 @@ + + +
+ + +الملخص الهيكلي ومراحل التطوير للوصول إلى نظام تشغيل متكامل لخدمة المتاجر.
++ يهدف تطبيق نبيه (Nabeh) إلى بناء نظام خلفي متكامل، آمن، ومتعدد المستأجرين (Multi-Tenant SaaS)، يندمج بسلاسة مع بوابة WhatsApp المبنية على مكتبة Baileys (Node.js). يُمكن المشروع أصحاب المتاجر الإلكترونية والشركات من إرسال حملات إعلانية وتوعوية جماعية، إدارة قائمة عملائهم وسجلات محادثاتهم، وتهيئة ردود تلقائية ذكية عبر الذكاء الاصطناعي (Gemini AI) أو الكلمات المفتاحية، مع توفير أقصى درجات الحماية وتشفير البيانات وعزل بيانات كل شركة عن الأخرى بشكل قطعي. +
+ + +تشفير الهواتف والبريد الإلكتروني والرسائل بـ AES-256-GCM لضمان سريتها التامة في قاعدة البيانات.
+توليد قيم هاش غير قابلة للعكس بـ HMAC-SHA256 للبحث السريع في البيانات المشفرة دون الحاجة لفك التشفير.
+تنظيف المدخلات ديناميكياً وعزل أخطاء الخادم لتجنب ثغرات SQL Injection و Info Disclosure.
+ربط قطعي لكافة الموارد بمعرّف الشركة company_id مع التحقق في الـ Middleware لمنع تسريب الموارد (IDOR).
+بناء الهيكل البرمجي الأساسي للتطبيق وعزل مجلدات الكود الحساسة وتفعيل موجه المسارات والتحميل التلقائي.
+تفعيل بروتوكول الحماية الصارم على مدخلات ومخرجات الخلفية، بناء نظام التوثيق بالـ JWT، وتطبيق حلول معالجة الثغرات والـ Rate Limiter.
+الربط البرمجي بخدمة بوابة الواتساب الجارية على البورت 3722. إرسال واستقبال الطلبات لجلب رموز الـ QR والتحقق من حالة الاتصال وتخزين الجلسات.
+تطوير نظام شامل لإدارة جهات اتصال المتاجر وتصنيفهم ضمن مجموعات وقوائم إرسال محددة مع تشفير الهواتف بالكامل وحظر تسريبها.
+بناء نظام إعداد قوالب الرسائل الجاهزة (نصوص وصور ومستندات) وإطلاق الحملات الجماعية المجدولة للعملاء.
+بناء عقل روبوت الرد التلقائي للتفاعل الفوري مع رسائل العملاء بناء على الكلمات الدلالية أو التوليد الذكي عبر الذكاء الاصطناعي.
+فتح المنصة لاستقبال البيانات التلقائية للمشترين من المنصات الخارجية مثل سلة، شوبيفاي، ووكمرس وإرسال تنبيهات السلات المتروكة.
+