Initial commit from saas-meta

This commit is contained in:
Hamza-Ayed
2026-03-30 17:04:27 +03:00
commit 3b28389dc3
91 changed files with 20697 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
npm-debug.log
.git
.env
Dockerfile
docker-compose.yml
README.md

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Server Port | منفذ الخادم
PORT=3001
# Environment | بيئة العمل (development, production)
NODE_ENV=development
# Meta Marketing API Config | إعدادات واجهة برمجة تطبيقات ميتا للتسويق
# Get these from https://developers.facebook.com
META_API_VERSION=v19.0
META_ACCESS_TOKEN=4a4c4877627036302b10e96a68e537d0
META_AD_ACCOUNT_ID=act_your_account_id_here
# Meta App Credentials for OAuth
META_APP_ID=
META_APP_SECRET=
META_REDIRECT_URI=http://localhost:3001/api/auth/meta/callback
# AI Integration
GEMINI_API_KEY=
# TikTok Ads integration
TIKTOK_APP_ID=
TIKTOK_SECRET=
TIKTOK_REDIRECT_URI=http://localhost:3001/api/auth/tiktok/callback
# Google Ads integration
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3001/api/auth/google/callback
GOOGLE_DEVELOPER_TOKEN=
# Paymob integration
PAYMOB_API_KEY=
PAYMOB_CARD_INTEGRATION_ID=
PAYMOB_WALLET_INTEGRATION_ID=
PAYMOB_HMAC_SECRET=
# Binance Pay integration
BINANCE_API_KEY=
BINANCE_SECRET_KEY=

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# node-modules
node_modules/
# dist
dist/
# env
.env
*.env
# logs
*.log
# coverage
coverage/
# OS files
.DS_Store
Thumbs.db

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# Step 1: Build Phase | مرحلة البناء
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Copy package files | نسخ ملفات الحزم
COPY package*.json ./
# Install dependencies | تثبيت الاعتماديات
RUN npm install
# Copy source code | نسخ كود المصدر
COPY . .
# Build the app | بناء التطبيق
RUN npm run build
# Step 2: Production Phase | مرحلة الإنتاج
FROM node:20-alpine
WORKDIR /usr/src/app
# Copy package files | نسخ ملفات الحزم
COPY package*.json ./
# Install only production dependencies | تثبيت اعتماديات الإنتاج فقط
RUN npm install --only=production
# Copy built files from builder | نسخ الملفات المبنية من مرحلة البناء
COPY --from=builder /usr/src/app/dist ./dist
# Expose port | فتح المنفذ
EXPOSE 3001
# Command to run | أمر التشغيل
CMD ["node", "dist/main"]

69
NESTJS_TUTORIAL_AR.md Normal file
View File

@@ -0,0 +1,69 @@
<div dir="rtl">
# دليل تعلم NestJS: المفاهيم الأساسية والعملية
# NestJS Learning Guide: Core Concepts and Implementation
مرحباً بك في هذا الدليل التعليمي المخصص لمشروع **Ads Analytics Platform**. سنقوم هنا بشرح بنية البرنامج وكيفية تداخل مكوناته بأسلوب مبسط وموجه للمطورين.
---
## 🏗️ 1. البنية العامة (General Architecture)
تعتمد NestJS على بنية **النماذج (Modules)**. كل ميزة في التطبيق (مثل الإعلانات أو التحليلات) يتم فصلها في "موديول" مستقل.
- **AppModule**: هو الجذر (Root) الذي يربط كل الموديولات ببعضها.
- **Controller**: هو المسؤول عن استقبال الطلبات (HTTP Requests) وإرجاع الردود.
- **Service (Provider)**: هنا يكمن "المنطق" (Logic)، مثل حساب الأرباح أو جلب البيانات من Meta API.
---
## 💉 2. حقن التبعيات (Dependency Injection)
يعتبر "حقن التبعيات" من أهم ميزات NestJS. بدلاً من أن يقوم الكائن بإنشاء تبعياته بنفسه (مثل استخدام `new AnalyticsService()`)، يقوم المحرك (Nest IoC Container) بتمريرها له.
**لماذا نستخدمه؟**
1. **سهولة الاختبار (Testing)**: يمكننا بسهولة استبدال الخدمة الحقيقية بواحدة وهمية (Mock) أثناء الاختبار.
2. **برمجة نظيفة**: فصل المهام وجعل الكود أقل تشابكاً.
**مثال من المشروع:**
في `MetaAdsController` نقوم بحقن الـ `MetaAdsService` عبر الـ `constructor`:
```typescript
constructor(private readonly metaAdsService: MetaAdsService) {}
```
---
## 📦 3. الكبسلة (Encapsulation)
في NestJS، كل موديول هو "صندوق مغلق" افتراضياً. إذا أردت استخدام خدمة من موديول في موديول آخر، يجب عليك:
1. **تصديرها (Export)** من الموديول الأصلي.
2. **استيرادها (Import)** في الموديول الجديد.
هذا يضمن أن المكونات لا تتداخل بشكل عشوائي، مما يسهل صيانة النظام الكبير.
---
## 🧬 4. الوراثة (Inheritance)
نستخدم الوراثة لتقليل تكرار الكود. على سبيل المثال، قد يكون لدينا "خطأ مخصص" يرث من `HttpException` الأساسي في NestJS.
**مثال:**
مراقب الاستثناءات (Exception Filter) قد يرث ميزات معينة أو نستخدم الوراثة في DTOs (Data Transfer Objects) لمشاركة الحقول بين طلبات الإضافة والتعديل.
---
## 🔍 5. كيف يعمل المشروع الحالي؟
عندما تطلب المسار `/api/meta/insights`:
1. الطلب يمر عبر **Middlewares** (إن وجدت).
2. يصل إلى **MetaAdsController**.
3. الـ Controller ينادي الوظيفة المطلوبة في **MetaAdsService**.
4. الـ Service تقوم بالتواصل مع Meta API وتجري الحسابات.
5. النتيجة تعود كـ JSON للمستخدم.
---
> [!TIP]
> **نصيحة**: دائماً ابدأ بتصميم موديول مستقل لكل ميزة جديدة لضمان قابلية التوسع (Scalability).
</div>

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# SaaS Meta Ads Analytics Backend — منصة تحليلات إعلانات ميتا
[Arabic Version Below | النسخة العربية في الأسفل]
---
## 🇺🇸 English Overview
Welcome to the **SaaS Meta Ads Analytics Backend**. This project is a production-ready foundation for a marketing analytics platform. It is built with **NestJS** and **Docker**, focusing on scalability, clean architecture, and ease of use.
### Features
- **Meta Ads Integration**: Direct connection to Meta Marketing API.
- **Data Normalization**: Transforms complex external data into a simple internal schema.
- **Smart Analytics**: Automated calculation of CTR, CPC, CPM and generation of insights.
- **Docker Ready**: Standardized environment for local development and server deployment.
- **Swagger Docs**: Fully documented REST API.
- **Bilingual Code**: Arabic & English comments throughout the codebase.
---
## 🇸🇦 نظرة عامة بالعربية
مرحباً بك في **الخلفية البرمجية لمنصة تحليلات إعلانات ميتا**. هذا المشروع هو حجر أساس جاهز للإنتاج لمنصة تحليلات تسويقية. تم بناؤه باستخدام **NestJS** و **Docker**، مع التركيز على قابليتة التوسع، الهندسة النظيفة، وسهولة الاستخدام.
### المميزات
- **التكامل مع إعلانات ميتا**: اتصال مباشر مع واجهة ميتا للتسويق (Meta Marketing API).
- **توحيد البيانات (Normalization)**: تحويل البيانات الخارجية المعقدة إلى هيكل داخلي بسيط.
- **تحليلات ذكية**: حساب تلقائي لمقاييس CTR و CPC و CPM وتوليد رؤى ذكية.
- **جاهز للدوكر**: بيئة موحدة للتطوير المحلي والنشر على الخادم.
- **توثيق Swagger**: واجهة برمجة تطبيقات (REST API) موثقة بالكامل.
- **كود ثنائي اللغة**: تعليقات عربية وانجليزية في جميع أنحاء الكود.
---
## 🚀 Getting Started | ابدأ من هنا
### 1. Prerequisites | المتطلبات الأساسية
- Node.js LTS (v20+)
- Docker & Docker Compose (Recommended | مستحسن)
### 2. Environment Setup | إعداد البيئة
Copy `.env.example` to `.env` and fill in your Meta credentials.
قم بنسخ ملف `.env.example` إلى `.env` وقم بتعبئة بيانات ميتا الخاصة بك.
### 3. Running with Docker | التشغيل باستخدام دوكر
```bash
docker compose up --build -d
```
The API will be available at `http://localhost:3001/api`.
سيكون الـ API متاحاً على الرابط أعلاه.
### 4. Running Locally | التشغيل محلياً
```bash
npm install
npm run start:dev
```
---
## 📂 Architecture | هيكلية المشروع
- `src/config`: Environment validation and configuration logic.
- `src/common`: Global filters (errors) and interceptors (logging).
- `src/health`: Service monitoring endpoint.
- `src/meta-ads`: Core logic for fetching and normalizing Meta API data.
- `src/analytics`: Smart engine for metric analysis and insight generation.
---
## 📑 API Endpoints | نقاط نهاية الـ API
| Path | Method | Description |
| :--- | :--- | :--- |
| `/api/health` | GET | Check service status |
| `/api/meta/insights` | POST | Fetch raw normalized Meta data |
| `/api/analyze/meta` | POST | Fetch Meta data + Smart analysis |
| `/api/analyze/sample` | POST | Test analysis with sample data |
---
## 🛠 Future Roadmap | خارطة الطريق المستقبلية
1. **Multi-tenancy**: Support for multiple users and organizations.
2. **Database Persistence**: Saving historical data using PostgreSQL & Prisma.
3. **Background Jobs**: Periodic data syncing using BullJS.
4. **AI Insights**: Integration with LLMs (like Gemini) for deep marketing recommendations.
5. **Google & TikTok**: Adding more ad platforms using the established normalization layer.

41
campaign_analysis_ar.txt Normal file
View File

@@ -0,0 +1,41 @@
# تحليل أداء الحملات الإعلانية وتحديد نقاط الضعف
بناءً على البيانات المستخرجة من حساب "ميتا" الخاص بك، إليك تحليل للحملات الأضعف وأسباب ضعفها:
## 1. الحملة الأضعف من حيث التفاعل (CTR المنخفض):
- اسم الحملة: "ride 3" (معرف: 120236635102550587)
- الأداء: نسبة النقر (CTR) هي 0.29% فقط.
- السبب: بالرغم من وصول الإعلان لأكثر من 100 ألف شخص، إلا أن 320 شخصاً فقط نقروا عليه. هذا يعني أن "التصميم الإعلاني" (Creative) أو "النص الإعلاني" غير جذاب لهذا الجمهور، أو أن الاستهداف غير دقيق.
## 2. الحملة الأضعف من حيث كفاءة الميزانية (CPC المرتفع):
- اسم الحملة: "ride trafic1" (معرف: 120236907331900587)
- الأداء: تكلفة النقرة (CPC) هي 0.018.
- السبب: مقارنة بحملة "ride1" التي تكلف فيها النقرة 0.0023 فقط، تعتبر هذه الحملة مكلفة جداً (حوالي 8 أضعاف التكلفة). هذا يشير إلى وجود منافسة عالية على هذا الجمهور أو أن جودة الإعلان منخفضة في مزاد ميتا.
## 3. حملات "الوعي" (Awareness) - ملاحظة هامة:
- اسم الحملة: "الوعي" (معرف: 120236368008730587)
- الأداء: CTR منخفض جداً (0.49%).
- السبب: طبيعي في حملات الوعي أن يكون التفاعل منخفضاً لأن الهدف هو الانتشار وليس النقرات، ولكن 0.49% هي نسبة منخفضة حتى لهذا النوع، مما يستدعي تغيير الصورة أو الرسالة الإعلانية.
## 4. حملات لم يتم تشغيلها (ميتة):
- اسم الحملة: "المنشور: 🚀 فرصتك الآن مع انطلق!"
- الأداء: 5 مشاهدات فقط وصفر تفاعل.
- السبب: غالباً تم إيقافها فوراً أو أنها لم تتجاوز مرحلة المراجعة بشكل صحيح.
---
## كيف نحصل على هذه التحليلات والنقاط؟ (الجانب البرمجي)
يحتوي النظام الذي بنيناه لك على ملف يسمى `ai.service.ts` (موجود في مجلد analytics/services). هذا الملف هو المسؤول عن جلب هذه "النقاط" كالتالي:
1. دالة `generateCampaignInsights`:
- تأخذ الأرقام (الظهور، النقرات، الصرف) وترسلها إلى ذكاء اصطناعي (جوجل جيمناي - Gemini).
- الذكاء الاصطناعي يقوم بدور "خبير إعلانات عالمي" ويقوم بكتابة هذه التوصيات لك بشكل تلقائي.
2. دالة `analyzeCreative`:
- (ميزة متقدمة) يمكنها "رؤية" صورة الإعلان وتحليل ألوانها، والخطوط، ومكان زر الدعوة لاتخاذ إجراء (CTA)، وتعطيك تقييماً صريحاً وقوياً لنقاط القوة والضعف في التصميم.
إذن، هذه النقاط لا تأتي مجرد أرقام، بل يتم معالجتها عبر "محرك ذكاء اصطناعي" يقوم بتحويل الأرقام إلى نص مفهوم يخبرك ماذا تفعل بالضبط لتحسين الأداء.
---
تم إعداد هذا التقرير بصيغة نصية وبواجهة عربية (RTL) لتسهيل القراءة.

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
api:
build: .
container_name: ads-analytics-api
ports:
- "${PORT:-3001}:3001"
env_file:
- .env
environment:
- NODE_ENV=production
- DB_HOST=db
depends_on:
- db
restart: always
db:
image: postgres:15-alpine
container_name: ads-analytics-db
ports:
- "5433:5432"
environment:
- POSTGRES_USER=${DB_USER:-postgres}
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
- POSTGRES_DB=${DB_NAME:-ads_analytics}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: always
redis:
image: redis:alpine
container_name: ads-analytics-redis
ports:
- "6379:6379"
restart: always
volumes:
postgres_data:

35
eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

348
frontend/index.html Normal file
View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SaaS Meta Ads - لوحة التحكم الاحترافية</title>
<link rel="stylesheet" href="src/style.css">
<!-- Google Fonts: Inter & Arabic font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Noto+Kufi+Arabic:wght@300;400;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Flatpickr for better date picking -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" type="text/css" href="https://npmcdn.com/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ar.js"></script>
<script type="module" src="src/auth.js"></script>
</head>
<body class="dark-theme">
<!-- Login Overlay | واجهة تسجيل الدخول -->
<div id="login-overlay" class="login-overlay">
<div class="login-card glass-card">
<div class="logo-section" style="margin-bottom: 2rem; justify-content: center;">
<div class="logo-icon">
<i data-lucide="shield-check"></i>
</div>
<span>تسجيل الدخول للنظام</span>
</div>
<p style="text-align: center; color: var(--text-secondary); margin-bottom: 2rem;">أدخل بريدك الإلكتروني للبدء في تحليل حملاتك</p>
<form id="login-form">
<div class="form-group">
<label>البريد الإلكتروني</label>
<input type="email" id="login-email" placeholder="example@mail.com" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center; padding: 14px;">
دخول للمنصة
</button>
<div style="text-align: center; margin: 1rem 0; color: var(--text-secondary); font-size: 0.8rem;">أو</div>
<button type="button" id="btn-google-login" class="btn btn-secondary" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 10px; background: white; color: #444; border: 1px solid #ddd; padding: 12px; font-weight: 600;">
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" width="18" height="18">
التسجيل عبر جوجل
</button>
</form>
<div id="login-error" class="error-text hidden" style="margin-top: 1rem; color: var(--danger); text-align: center;"></div>
</div>
</div>
<div class="app-container hidden" id="main-app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="logo-section">
<div class="logo-icon">
<i data-lucide="layout-dashboard"></i>
</div>
<span>SaaS Meta Pro</span>
</div>
<nav class="side-nav">
<a href="#" class="nav-item active" data-page="dashboard">
<i data-lucide="line-chart"></i>
<span>الإحصائيات</span>
</a>
<a href="#" class="nav-item" data-page="connect">
<i data-lucide="link"></i>
<span>ربط الحسابات</span>
</a>
<a href="#" class="nav-item" data-page="ai-lab">
<i data-lucide="sparkles"></i>
<span>مختبر الذكاء الاصطناعي</span>
</a>
<a href="#" class="nav-item" data-page="automation">
<i data-lucide="settings-2"></i>
<span>الأتمتة</span>
</a>
<div class="sidebar-divider"></div>
<button class="btn btn-demo-sidebar btn-demo">
<i data-lucide="play-circle"></i>
<span>وضع التجربة (Demo)</span>
</button>
</nav>
<div class="user-selector-wrapper">
<label for="user-id-select">المستخدم الحالي:</label>
<select id="user-id-select" class="premium-select">
<option value="">جاري جلب المستخدمين...</option>
</select>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-header">
<div class="header-info">
<h1 id="page-title">لوحة الإحصائيات</h1>
<div class="filters glass-card mini-filters">
<div class="filter-group">
<label>الحساب:</label>
<select id="ad-account-select" class="premium-select mini">
<option value="">الحساب الافتراضي</option>
</select>
</div>
<div class="filter-group">
<label>من:</label>
<input type="text" id="date-start" class="premium-input mini date-picker-trigger" placeholder="اختر تاريخ البداية">
</div>
<div class="filter-group">
<label>إلى:</label>
<input type="text" id="date-end" class="premium-input mini date-picker-trigger" placeholder="اختر تاريخ النهاية">
</div>
</div>
</div>
<div class="header-actions">
<button class="btn btn-primary" id="refresh-data">
<i data-lucide="refresh-cw"></i>
تحديث البيانات
</button>
</div>
</header>
<section id="content-area">
<!-- Dashboard Page -->
<div id="dashboard-page" class="page-content">
<div class="platform-filters" id="platform-filter-bar">
<button class="platform-btn active" data-platform="all">الكل</button>
<button class="platform-btn" data-platform="meta">
<i data-lucide="facebook"></i> Facebook
</button>
<button class="platform-btn" data-platform="instagram">
<i data-lucide="instagram"></i> Instagram
</button>
<button class="platform-btn" data-platform="google">
<i data-lucide="search"></i> Google
</button>
<button class="platform-btn" data-platform="tiktok">
<i data-lucide="music-2"></i> تيك توك
</button>
</div>
<div class="stats-grid" id="kpi-cards">
<!-- KPI Cards populated by JS -->
</div>
<div class="chart-section glass-card">
<h3>أداء الحملات الإعلانية</h3>
<canvas id="campaign-chart"></canvas>
</div>
<div class="table-section glass-card">
<h3>تفاصيل الحملات والتوصيات</h3>
<div class="table-wrapper">
<table id="campaign-table">
<thead>
<tr>
<th>المنصة</th>
<th>اسم الحملة</th>
<th>الحالة</th>
<th>الظهور</th>
<th>النقرات</th>
<th>الإنفاق</th>
<th>توصية الذكاء الاصطناعي</th>
<th>إجراء</th>
</tr>
</thead>
<tbody>
<!-- Rows populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Connect Account Page -->
<div id="connect-page" class="page-content hidden">
<div class="form-container glass-card">
<h2>ربط حساب إعلاني جديد</h2>
<p class="subtitle">قم بربط حساب ميتـا الخاص بك لبدء التحليل والأتمتة.</p>
<div class="oauth-section" style="margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 1rem;">
<button class="btn btn-primary btn-large w-full" id="btn-oauth-meta" style="background: #1877F2; border: none;">
<i data-lucide="facebook"></i>
الربط التلقائي عبر ميتـا (Facebook & Instagram)
</button>
<button class="btn btn-primary btn-large w-full" id="btn-oauth-google" style="background: #4285F4; border: none;">
<i data-lucide="search"></i>
الربط التلقائي عبر جوجل (Google Ads)
</button>
<button class="btn btn-primary btn-large w-full" id="btn-oauth-tiktok" style="background: #000000; border: none;">
<i data-lucide="music-2"></i>
الربط التلقائي عبر تيك توك (TikTok Ads)
</button>
</div>
<div style="text-align: center; margin-bottom: 1.5rem;">
<span style="background: var(--bg-card); padding: 0 1rem; color: var(--text-dim); font-size: 0.9rem;">أو الربط اليدوي</span>
</div>
<div class="form-group">
<label>اسم الحساب</label>
<input type="text" id="acc-name" placeholder="مثلاً: متجري الإلكتروني">
</div>
<div class="form-group">
<label>Ad Account ID</label>
<input type="text" id="acc-id" placeholder="act_123456789">
</div>
<div class="form-group">
<label>Access Token</label>
<textarea id="acc-token" rows="4" placeholder="EAAY..."></textarea>
</div>
<button class="btn btn-success btn-large" id="btn-connect-meta" style="width: 100%;">
<i data-lucide="link-2"></i>
حفظ الربط اليدوي
</button>
</div>
</div>
<!-- AI Lab Page -->
<div id="ai-lab-page" class="page-content hidden">
<button class="btn btn-secondary mb-3" id="btn-back-to-dashboard" style="display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem;">
<i data-lucide="arrow-right" style="width: 20px; height: 20px;"></i>
<span style="font-weight: 600;">العودة للوحة الإحصائيات</span>
</button>
<div class="ai-grid">
<div class="ai-input-section glass-card">
<h2>تحليل التصميم بالذكاء الاصطناعي</h2>
<p>أدخل رابط صورة الإعلان وسيقوم Gemini بتحليله بصرياً.</p>
<div class="form-group">
<label>رابط الصورة</label>
<input type="text" id="ai-image-url" placeholder="https://example.com/ad-image.jpg">
</div>
<div class="form-group">
<label>النص الإعلاني (اختياري)</label>
<textarea id="ai-copy" rows="3"></textarea>
</div>
<button class="btn btn-sparkle" id="btn-analyze-visual">
<i data-lucide="cpu"></i>
تحليل التصميم بالذكاء الاصطناعي
</button>
</div>
<div class="ai-output-section glass-card" id="ai-result-box">
<div class="placeholder-text">
<i data-lucide="brain-circuit"></i>
التقرير سيظهر هنا بعد التحليل
</div>
</div>
</div>
</div>
<!-- Automation Page -->
<div id="automation-page" class="page-content hidden">
<div class="glass-card">
<h3>إنشاء قاعدة أتمتة جديدة</h3>
<p class="subtitle" style="margin-bottom: 2rem;">تحكم في حملاتك تلقائياً بناءً على الأداء.</p>
<div class="automation-grid" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 2rem;">
<div class="rule-builder-section">
<form id="rule-form">
<div class="form-group">
<label>اسم القاعدة</label>
<input type="text" id="rule-name" placeholder="مثلاً: إيقاف الإعلانات الضعيفة" required>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label>المؤشر</label>
<select id="rule-metric" class="premium-select">
<option value="spend">الإنفاق</option>
<option value="ctr">نسبة النقر (CTR)</option>
<option value="cpc">تكلفة النقرة (CPC)</option>
<option value="roas">العائد (ROAS)</option>
</select>
</div>
<div class="form-group">
<label>الشرط</label>
<div style="display: flex; gap: 5px;">
<select id="rule-operator" class="premium-select" style="width: 70px;">
<option value=">">&gt;</option>
<option value="<">&lt;</option>
<option value="=">=</option>
</select>
<input type="number" id="rule-value" step="0.1" placeholder="0.0" required>
</div>
</div>
</div>
<div class="form-group">
<label>الإجراء (Action)</label>
<select id="rule-action" class="premium-select">
<option value="notify">تنبيه فقط (Notify)</option>
<option value="pause" disabled title="Pro Feature">إيقاف الحملة (Pause) - Pro</option>
</select>
</div>
<button type="submit" class="btn btn-sparkle w-full">حفظ وتفعيل القاعدة</button>
</form>
</div>
<div class="rules-list-section">
<div class="table-wrapper">
<table id="rules-table">
<thead>
<tr>
<th>القاعدة</th>
<th>الشرط</th>
<th>الإجراء</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody id="rules-list-body">
<tr id="rules-empty-state">
<td colspan="5" style="text-align: center; color: var(--text-dim); padding: 2rem;">لا توجد قواعد نشطة حالياً.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Scripts -->
<script src="src/api.js" type="module"></script>
<script src="src/main.js" type="module"></script>
<script>
// Init icons after page load
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
</body>
</html>

933
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,933 @@
{
"name": "frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"serve-handler": "^6.1.7"
},
"devDependencies": {
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"cpu": [
"wasm32"
],
"dev": true,
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"dev": true
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
"integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/mime-db": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
"integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
"dependencies": {
"mime-db": "~1.33.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-is-inside": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
"integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="
},
"node_modules/path-to-regexp": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
"integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/range-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
"integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"dev": true,
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
}
},
"node_modules/serve-handler": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz",
"integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==",
"dependencies": {
"bytes": "3.0.0",
"content-disposition": "0.5.2",
"mime-types": "2.1.18",
"minimatch": "3.1.5",
"path-is-inside": "1.0.2",
"path-to-regexp": "3.3.0",
"range-parser": "1.2.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node serv.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^8.0.0"
},
"dependencies": {
"serve-handler": "^6.1.7"
},
"main": "serv.js",
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

12
frontend/serv.js Normal file
View File

@@ -0,0 +1,12 @@
import http from 'http';
import handler from 'serve-handler';
const server = http.createServer((request, response) => {
return handler(request, response, {
public: '.'
});
});
server.listen(5001, () => {
console.log('Frontend is running at http://localhost:5001');
});

113
frontend/src/api.js Normal file
View File

@@ -0,0 +1,113 @@
import { auth } from './auth.js';
export const BASE_URL = 'http://localhost:3001/api';
/**
* API Wrapper for SaaS Meta Backend
*/
export const api = {
// Current User State
getCurrentUserId() {
return auth.getUserId() || '';
},
async request(path, options = {}) {
const response = await fetch(`${BASE_URL}${path}`, options);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Network error' }));
throw { status: response.status, ...error };
}
return response.json();
},
// 1. Users
async getAllUsers() {
return this.request('/users');
},
async login(email) {
return this.request('/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
},
// 2. Meta Ads
async connectMeta(data) {
const userId = this.getCurrentUserId();
return this.request('/meta/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, userId }) // adAccountId inside data
});
},
async getConnectedAccounts() {
const userId = this.getCurrentUserId();
return this.request('/meta/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
},
async getInsights(params = {}) {
const userId = this.getCurrentUserId();
return this.request('/meta/insights', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId
},
body: JSON.stringify(params)
});
},
// 3. AI Analysis
async analyzeVisual(imageUrl, copy, metrics = null, campaignId = null) {
const userId = this.getCurrentUserId();
return this.request('/analyze/visual', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId
},
body: JSON.stringify({ imageUrl, adCopy: copy, metrics, campaignId })
});
},
// 4. Sample Data for Demo Mode
async getSampleData(params = {}) {
const userId = this.getCurrentUserId();
return this.request('/analyze/sample', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId
},
body: JSON.stringify(params)
});
},
// 5. Automation Rules
async getRules() {
const userId = this.getCurrentUserId();
return this.request(`/automation/rules/${userId}`);
},
async createRule(ruleData) {
const userId = this.getCurrentUserId();
return this.request('/automation/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...ruleData, userId })
});
},
async deleteRule(ruleId) {
return this.request(`/automation/rules/${ruleId}`, {
method: 'DELETE'
});
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"/><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"/></svg>

After

Width:  |  Height:  |  Size: 863 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

36
frontend/src/auth.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Authentication Helper
* هيلبر المصادقة
*/
export const auth = {
// Current User Key in LocalStorage
STORAGE_KEY: 'saas_user',
// Save user info to local storage
login(user) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(user));
return user;
},
// Remove user info (Logout)
logout() {
localStorage.removeItem(this.STORAGE_KEY);
window.location.reload();
},
// Get current user info
getUser() {
const user = localStorage.getItem(this.STORAGE_KEY);
return user ? JSON.parse(user) : null;
},
// Check if user is logged in
isAuthenticated() {
return this.getUser() !== null;
},
// Get User ID for API headers
getUserId() {
return this.getUser()?.id || '';
}
};

9
frontend/src/counter.js Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `Count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

812
frontend/src/main.js Normal file
View File

@@ -0,0 +1,812 @@
import { api, BASE_URL } from './api.js';
import { auth } from './auth.js';
console.log('--- main.js Version 2.1 Loaded ---');
// Application State
const state = {
activePage: 'dashboard',
users: [],
campaigns: [],
chart: null,
activeMetrics: null,
activeCampaignId: null
};
// --- Initialization ---
async function init() {
initDates();
setupNavigation();
setupEventListeners();
setupAuthEvents();
setupAutomationEvents();
if (auth.isAuthenticated()) {
showApp();
} else {
showLogin();
}
}
// Modules run after DOM is ready by default
init();
function initDates() {
// Standard initialization with Flatpickr
const commonConfig = {
dateFormat: "Y-m-d",
theme: "dark",
locale: "ar" // Flatpickr will look for 'ar' in the included locale script
};
flatpickr("#date-start", {
...commonConfig,
defaultDate: "2025-11-01"
});
flatpickr("#date-end", {
...commonConfig,
defaultDate: "2026-03-25"
});
}
async function loadInitialData() {
try {
// 1. Fetch Users
const users = await api.getAllUsers().catch(err => {
console.error('Failed to fetch users:', err);
return [];
});
state.users = users;
renderUserSelector();
// 2. Load Accounts and Refresh if we have a user
if (state.users.length > 0) {
await loadAccounts();
await refreshDashboard();
} else {
console.warn('No users found in system');
showErrorState({ message: 'لا يوجد مستخدمين متاحين حالياً.' });
}
} catch (error) {
console.error('Failed to load initial data:', error);
}
}
async function loadAccounts() {
const select = document.getElementById('ad-account-select');
if (!select) return;
try {
const accounts = await api.getConnectedAccounts().catch(() => []);
select.innerHTML = '<option value="">الحساب الافتراضي (.env)</option>' +
accounts.map(acc => `<option value="${acc.externalAdAccountId}">${acc.name} (${acc.externalAdAccountId})</option>`).join('');
} catch (err) {
console.error('Failed to load accounts:', err);
}
}
// --- UI Rendering ---
function renderUserSelector() {
const userInfo = document.getElementById('user-info-display');
if (!userInfo) return;
const user = auth.getUser();
if (user) {
userInfo.innerHTML = `
<div class="user-badge glass-card" style="padding: 10px; border-radius: 12px; margin-top: auto;">
<div style="font-size: 0.8rem; color: var(--text-secondary);">مرحباً،</div>
<div style="font-weight: 600; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis;">${user.email}</div>
<div style="font-size: 0.75rem; color: var(--accent-primary); margin-top: 4px;">المستوى: ${user.subscriptionTier.toUpperCase()}</div>
<button id="logout-btn" class="btn btn-demo-sidebar" style="margin-top: 10px; width: 100%; font-size: 0.75rem; background: rgba(239, 68, 68, 0.1); color: var(--danger); border-color: rgba(239, 68, 68, 0.3);">
<i data-lucide="log-out"></i> تسجيل الخروج
</button>
</div>
`;
document.getElementById('logout-btn')?.addEventListener('click', () => auth.logout());
lucide.createIcons();
}
}
function showLogin() {
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('main-app').classList.add('hidden');
}
function showApp() {
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('main-app').classList.remove('hidden');
loadInitialData();
}
function setupAuthEvents() {
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
loginForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('login-email').value;
const submitBtn = loginForm.querySelector('button');
try {
submitBtn.disabled = true;
submitBtn.innerHTML = 'جاري التحقق...';
loginError.classList.add('hidden');
const user = await api.login(email);
auth.login(user);
showApp();
} catch (err) {
loginError.textContent = err.message || 'فشل تسجيل الدخول';
loginError.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = 'دخول للمنصة';
}
});
document.getElementById('btn-google-login')?.addEventListener('click', () => {
alert('تسجيل الدخول عبر Google سيتوفر قريباً بعد إعداد مفاتيح API الخاصة بك.');
// In real app: window.location.href = `${BASE_URL}/auth/google/app-login`;
});
}
async function refreshDashboard(demo = false) {
const dashboard = document.getElementById('dashboard-page');
if (dashboard.classList.contains('hidden')) return;
const refreshBtn = document.getElementById('refresh-data');
if (refreshBtn) refreshBtn.innerHTML = '<i data-lucide="refresh-cw" class="spin"></i> جاري التحميل...';
const params = {
dateStart: document.getElementById('date-start').value,
dateEnd: document.getElementById('date-end').value,
adAccountId: document.getElementById('ad-account-select').value || undefined
};
try {
let data;
if (demo) {
data = await api.getSampleData(params);
} else {
data = await api.getInsights(params);
}
if (data && Array.isArray(data)) {
state.campaigns = data;
renderDynamicDashboard(); // New function for premium UI
hideErrorState();
}
} catch (err) {
console.error('Dashboard refresh failed:', err);
showErrorState(err);
} finally {
if (refreshBtn) {
refreshBtn.innerHTML = '<i data-lucide="refresh-cw"></i> تحديث البيانات';
lucide.createIcons();
}
}
}
function renderDynamicDashboard() {
const activeFilter = document.querySelector('.platform-btn.active')?.dataset.platform || 'all';
let filteredData = [...state.campaigns];
if (activeFilter !== 'all') {
if (activeFilter === 'instagram') {
filteredData = filteredData.filter(c => c.platform === 'instagram');
} else if (activeFilter === 'meta') {
filteredData = filteredData.filter(c => c.source === 'meta' && c.platform !== 'instagram');
} else {
filteredData = filteredData.filter(c => c.source === activeFilter);
}
}
// Sort: ACTIVE/ENABLED first | ترتيب: النشط أولاً
filteredData.sort((a, b) => {
const aActive = a.status === 'ACTIVE' || a.status === 'ENABLED';
const bActive = b.status === 'ACTIVE' || b.status === 'ENABLED';
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
});
populatePremiumKPIs(filteredData);
populatePremiumTable(filteredData);
renderChart(filteredData);
}
function populatePremiumKPIs(data) {
const container = document.getElementById('kpi-cards');
if (!container) return;
const stats = {
impressions: data.reduce((sum, c) => sum + (c.impressions || 0), 0),
clicks: data.reduce((sum, c) => sum + (c.clicks || 0), 0),
spend: data.reduce((sum, c) => sum + (c.spend || 0), 0),
ctr: data.length > 0 ? (data.reduce((sum, c) => sum + (c.ctr || 0), 0) / data.length) : 0
};
const cards = [
{ label: 'إجمالي الظهور', value: stats.impressions.toLocaleString(), icon: 'eye', color: 'accent' },
{ label: 'النقرات المستهدفة', value: stats.clicks.toLocaleString(), icon: 'mouse-pointer-2', color: 'success' },
{ label: 'الإنفاق الذكي', value: `$${stats.spend.toLocaleString()}`, icon: 'dollar-sign', color: 'warning' },
{ label: 'متوسط الأداء (CTR)', value: `${stats.ctr.toFixed(2)}%`, icon: 'zap', color: 'danger' }
];
container.innerHTML = cards.map(c => `
<div class="stat-card glass-card">
<div class="stat-icon-wrapper" style="color: var(--${c.color})">
<i data-lucide="${c.icon}"></i>
</div>
<span class="stat-label">${c.label}</span>
<div class="stat-value">${c.value}</div>
</div>
`).join('');
lucide.createIcons();
}
function populatePremiumTable(data) {
const tbody = document.querySelector('#campaign-table tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center; padding: 3rem; opacity: 0.5;">لا توجد حملات لهذه المنصة حالياً</td></tr>';
return;
}
tbody.innerHTML = data.map(c => {
const rec = getAIRecommendation(c);
const statusClass = (c.status === 'ACTIVE' || c.status === 'ENABLED') ? 'status-active' : 'status-paused';
const statusText = (c.status === 'ACTIVE' || c.status === 'ENABLED') ? 'نشط' : (c.status === 'PAUSED' ? 'متوقف' : (c.status || 'غير معروف'));
return `
<tr>
<td>
<div class="platform-icon" style="color: var(--color-${c.platform === 'instagram' ? 'instagram' : c.source})">
<i data-lucide="${c.platform === 'instagram' ? 'instagram' : (c.source === 'meta' ? 'facebook' : (c.source === 'google' ? 'search' : 'music-2'))}"></i>
</div>
</td>
<td style="font-weight: 600;">${c.campaignName || 'Unnamed'}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td>${formatCompact(c.impressions || 0)}</td>
<td>${formatCompact(c.clicks || 0)}</td>
<td>$${(c.spend || 0).toLocaleString()}</td>
<td>
<span class="ai-status ${rec.type}">${rec.text}</span>
</td>
<td>
<button class="btn btn-analyze-row" onclick="analyzeCampaign('${c.campaignId}')">
<i data-lucide="brain"></i> تحليل
</button>
</td>
</tr>
`;
}).join('');
lucide.createIcons();
}
function getAIRecommendation(c) {
if (c.aiRecommendation) {
return { text: c.aiRecommendation.substring(0, 50) + '...', type: 'excellent' };
}
if (c.ctr > 2.5) return { text: 'أداء مذهل - استمر', type: 'excellent' };
if (c.spend > 100 && c.clicks < 10) return { text: 'إنفاق عالي - راقب', type: 'danger' };
if (c.ctr < 1.0) return { text: 'تحسين العرض مطلوب', type: 'warning' };
return { text: 'أداء مستقر', type: 'excellent' };
}
function formatCompact(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num;
}
window.analyzeCampaign = (id) => {
console.log('analyzeCampaign triggered for ID:', id);
if (!state.campaigns || state.campaigns.length === 0) {
console.warn('analyzeCampaign aborted: state.campaigns is empty.');
return;
}
const campaign = state.campaigns.find(c => String(c.campaignId) === String(id));
if (!campaign) {
console.error('analyzeCampaign: Campaign not found for ID:', id);
console.table(state.campaigns.map(c => ({ id: c.campaignId, name: c.campaignName })));
return;
}
console.log('Found campaign:', campaign.campaignName);
// Clear previous AI state before switching
try {
clearAILab();
} catch (e) {
console.error('Error in clearAILab:', e);
}
state.activeCampaignId = id;
let imageUrl = campaign.imageUrl || '';
if (imageUrl && !imageUrl.startsWith('http')) {
const serverBase = BASE_URL.replace('/api', '');
imageUrl = serverBase + imageUrl;
}
// Switch to AI Lab and pre-fill context
switchPage('ai-lab');
document.getElementById('ai-image-url').value = imageUrl;
// Use real ad copy if available, otherwise fallback to stats
// استخدام نص الإعلان الحقيقي إذا وجد، وإلا نستخدم الإحصائيات
const content = campaign.adCopy || `تحليل الحملة: ${campaign.campaignName}\nالمنصة: ${campaign.source}\nالظهور: ${formatCompact(campaign.impressions || 0)}\nالإنفاق: $${(campaign.spend || 0).toLocaleString()}`;
document.getElementById('ai-copy').value = content;
// Store metrics for the analysis button | تخزين المقاييس لزر التحليل
state.activeMetrics = {
impressions: campaign.impressions,
clicks: campaign.clicks,
spend: campaign.spend,
ctr: campaign.ctr,
cpc: campaign.cpc,
cpm: campaign.cpm,
conversions: campaign.conversions,
costPerResult: campaign.costPerResult,
objective: campaign.objective,
reach: campaign.reach,
frequency: campaign.frequency
};
console.log('Campaign context set for AI Lab:', state.activeMetrics);
};
function renderChart(data) {
const ctx = document.getElementById('campaign-chart').getContext('2d');
if (state.chart) {
state.chart.destroy();
}
state.chart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(c => c.campaignName),
datasets: [
{
label: 'الإنفاق (USD)',
data: data.map(c => c.spend),
backgroundColor: 'rgba(59, 130, 246, 0.5)',
borderColor: '#3b82f6',
borderWidth: 1
},
{
label: 'النقرات',
data: data.map(c => c.clicks),
backgroundColor: 'rgba(16, 185, 129, 0.5)',
borderColor: '#10b981',
borderWidth: 1
}
]
},
options: {
responsive: true,
plugins: {
legend: { labels: { color: '#94a3b8' } }
},
scales: {
y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#94a3b8' } },
x: { grid: { display: false }, ticks: { color: '#94a3b8' } }
}
}
});
}
// --- Navigation ---
function setupNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const page = item.getAttribute('data-page');
switchPage(page);
});
});
}
function switchPage(pageId) {
console.log('Switching to page:', pageId);
const targetNav = document.querySelector(`[data-page="${pageId}"]`);
const targetPage = document.getElementById(`${pageId}-page`);
if (!targetNav || !targetPage) {
console.error(`switchPage Error: Element not found for pageId: ${pageId}`, { targetNav, targetPage });
return;
}
// UI update
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
targetNav.classList.add('active');
document.querySelectorAll('.page-content').forEach(p => p.classList.add('hidden'));
targetPage.classList.remove('hidden');
document.getElementById('page-title').textContent = targetNav.querySelector('span').textContent;
state.activePage = pageId;
if (pageId === 'dashboard') refreshDashboard();
if (pageId === 'automation') refreshRules();
// Re-init icons for the new page
if (window.lucide) lucide.createIcons();
}
// --- Event Listeners ---
function setupEventListeners() {
document.getElementById('refresh-data')?.addEventListener('click', () => refreshDashboard(false));
// Demo Mode Buttons
document.querySelectorAll('.btn-demo').forEach(btn => {
btn.onclick = () => refreshDashboard(true);
});
// Platform Filters
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.platform-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderDynamicDashboard();
});
});
document.getElementById('btn-connect-meta')?.addEventListener('click', async () => {
const data = {
name: document.getElementById('acc-name').value,
adAccountId: document.getElementById('acc-id').value, // Fixed from externalAdAccountId
accessToken: document.getElementById('acc-token').value,
platform: 'meta'
};
try {
await api.connectMeta(data);
alert('تم ربط الحساب بنجاح!');
switchPage('dashboard');
} catch (err) {
console.error(err);
alert(`فشل الربط: ${err.message || 'خطأ غير معروف'}`);
}
});
document.getElementById('btn-oauth-meta')?.addEventListener('click', () => {
const userId = api.getCurrentUserId();
if (!userId) {
alert('يرجى اختيار مستخدم أولاً');
return;
}
window.location.href = `${BASE_URL}/auth/meta?userId=${userId}`;
});
document.getElementById('btn-oauth-google')?.addEventListener('click', () => {
const userId = api.getCurrentUserId();
if (!userId) {
alert('يرجى اختيار مستخدم أولاً');
return;
}
window.location.href = `${BASE_URL}/auth/google?userId=${userId}`;
});
document.getElementById('btn-oauth-tiktok')?.addEventListener('click', () => {
const userId = api.getCurrentUserId();
if (!userId) {
alert('يرجى اختيار مستخدم أولاً');
return;
}
window.location.href = `${BASE_URL}/auth/tiktok?userId=${userId}`;
});
} // Correct closing brace for setupEventListeners
// --- AI Lab Functions and Listeners ---
// 1. دالة التنسيق المخصصة (Zero-Dependency)
function formatGeminiText(text) {
if (!text) return '';
let html = text;
// تنسيق العناوين (Headers)
html = html.replace(/^### (.*$)/gim, '<h3 style="color: #60a5fa; margin-top: 20px; margin-bottom: 10px; font-weight: bold;">$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2 style="color: #3b82f6; margin-top: 25px; margin-bottom: 15px; border-bottom: 1px solid #334155; padding-bottom: 8px; font-weight: bold;">$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1 style="color: #fff; margin-bottom: 15px; font-weight: bold;">$1</h1>');
// تنسيق الخط العريض (Bold)
html = html.replace(/\*\*(.*?)\*\*/gim, '<strong style="color: #f8fafc; font-weight: 900;">$1</strong>');
// تنسيق القوائم والنقاط (Lists)
html = html.replace(/^\* (.*$)/gim, '<li style="margin-right: 25px; margin-bottom: 8px;">$1</li>');
html = html.replace(/^- (.*$)/gim, '<li style="margin-right: 25px; margin-bottom: 8px;">$1</li>');
// تنسيق الفواصل (Horizontal Rules)
html = html.replace(/^---/gim, '<hr style="border: 0; border-top: 1px solid #334155; margin: 20px 0;">');
// سطر جديد (Line Breaks) - هذا ما سيمنع تكتل النص ككتلة واحدة
html = html.replace(/\n/gim, '<br>');
return html;
}
// 2. زر التحليل
document.getElementById('btn-analyze-visual')?.addEventListener('click', async () => {
const url = document.getElementById('ai-image-url')?.value;
const copy = document.getElementById('ai-copy')?.value;
const out = document.getElementById('ai-result-box');
const btn = document.getElementById('btn-analyze-visual');
const originalBtnText = btn?.innerHTML || 'تحليل التصميم بالذكاء الاصطناعي';
console.log('--- AI Analysis Clicked ---');
if (!url) {
console.warn('Analysis aborted: No image URL provided.');
if (out) out.innerHTML = '<p style="color:var(--danger); text-align:center; padding:1rem;">الرجاء إدخال رابط الصورة أولاً.</p>';
return;
}
// Disable button and show loader | تعطيل الزر وإظهار مؤشر التحميل
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i data-lucide="refresh-cw" class="spin"></i> جاري التحميل...';
}
if (out) {
out.innerHTML = `
<div style="text-align:center; padding: 2rem;">
<i data-lucide="refresh-cw" class="spin" style="width:40px; height:40px; color:var(--accent);"></i>
<p style="margin-top:1rem; color:var(--text-muted);">جاري تحليل التصميم واستخراج النتائج...</p>
</div>
`;
}
if (window.lucide) lucide.createIcons();
console.log('Requesting analysis with:', { url, copy, metrics: state.activeMetrics, campaignId: state.activeCampaignId });
try {
const res = await api.analyzeVisual(url, copy, state.activeMetrics, state.activeCampaignId);
console.log('Analysis Response Received:', res);
let rawText = res.feedback || res || '';
// 1. إصلاح مشكلة الأسطر (في حال كان السيرفر يرسلها كـ النص الحرفي \n)
rawText = rawText.replace(/\\n/g, '\n');
// 2. استيراد مكتبة marked برمجياً وبشكل ديناميكي (مضمونة 100% في الـ ES Modules)
console.log('Importing marked...');
const module = await import('https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js');
// 3. تحويل النص (العناوين، القوائم، والجداول)
const formattedHTML = module.marked.parse(rawText);
// 4. عرض النتيجة مع CSS داخلي لتجميل الجداول والنصوص
if (out) {
out.innerHTML = `
<style>
.markdown-ai { direction: rtl; text-align: right; line-height: 1.8; color: #cbd5e1; font-size: 15px; padding: 15px; }
.markdown-ai h2, .markdown-ai h3 { color: #60a5fa; margin-top: 25px; border-bottom: 1px solid #334155; padding-bottom: 8px; }
.markdown-ai p { margin-bottom: 15px; }
.markdown-ai strong { color: #fff; background: rgba(255,255,255,0.05); padding: 0 5px; border-radius: 4px; }
.markdown-ai table { width: 100%; border-collapse: collapse; margin-top: 15px; margin-bottom: 20px; }
.markdown-ai th, .markdown-ai td { border: 1px solid #334155; padding: 10px; text-align: right; }
.markdown-ai th { background-color: #1e293b; color: #60a5fa; }
.markdown-ai ul, .markdown-ai ol { padding-right: 25px; margin-bottom: 15px; }
.markdown-ai li { margin-bottom: 8px; }
.markdown-ai pre {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
overflow-x: auto;
direction: ltr;
text-align: left;
position: relative;
}
.markdown-ai code {
font-family: 'Fira Code', 'Courier New', Courier, monospace;
font-size: 14px;
color: #e2e8f0;
}
</style>
<div class="markdown-ai">
${formattedHTML}
</div>
`;
}
} catch (err) {
console.error("Analysis Error:", err);
if (out) {
out.innerHTML = `
<div class="error-placeholder" style="color:var(--danger); padding:2rem; text-align:center;">
<i data-lucide="alert-octagon" style="width:40px; height:40px;"></i>
<p>فشل التحليل. تأكد من الرابط أو حاول لاحقاً.</p>
</div>
`;
}
} finally {
// Re-enable button and restore text | إعادة تفعيل الزر واستعادة النص
if (btn) {
btn.disabled = false;
btn.innerHTML = originalBtnText;
if (window.lucide) lucide.createIcons();
}
}
});
function clearAILab() {
const urlInput = document.getElementById('ai-image-url');
const copyInput = document.getElementById('ai-copy');
const resultBox = document.getElementById('ai-result-box');
state.activeCampaignId = null;
state.activeMetrics = null;
if (urlInput) urlInput.value = '';
if (copyInput) copyInput.value = '';
if (resultBox) {
resultBox.innerHTML = `
<div class="placeholder-text">
<i data-lucide="brain-circuit"></i>
التقرير سيظهر هنا بعد التحليل
</div>
`;
}
if (window.lucide) lucide.createIcons();
}
// Attach back button logic
document.getElementById('btn-back-to-dashboard')?.addEventListener('click', () => {
console.log('Back button clicked! Navigation to dashboard started.');
switchPage('dashboard');
});
function showErrorState(err) {
const tableBody = document.querySelector('#campaign-table tbody');
let message = 'حدث خطأ أثناء جلب البيانات';
if (err.status === 502) {
message = 'انتهت صلاحية الـ Access Token مع ميتـا. يرجى تجديده أو استخدام "وضع التجربة".';
}
tableBody.innerHTML = `
<tr>
<td colspan="6">
<div class="error-placeholder glass-card" style="padding: 2rem; border: 1px dashed var(--danger);">
<i data-lucide="alert-circle" style="width: 48px; height: 48px; color: var(--danger); margin-bottom: 1rem;"></i>
<p style="color: var(--danger); font-weight: bold; margin-bottom: 1rem; font-size: 1.1rem;">${message}</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<button class="btn btn-primary" onclick="switchPage('connect')">
<i data-lucide="link"></i> ربط حساب جديد (Reconnect)
</button>
<button class="btn btn-demo">
<i data-lucide="play"></i> تشغيل وضع التجربة (Demo Mode)
</button>
</div>
</div>
</td>
</tr>
`;
lucide.createIcons();
}
function hideErrorState() {
// Reset handled by populateTable
}
function checkAuthRedirect() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('auth')) {
const status = urlParams.get('auth');
const platform = urlParams.get('platform');
if (status === 'success') {
alert(`تم ربط حساب ${platform} بنجاح!`);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
} else if (status === 'failed') {
alert('فشل عملية الربط، يرجى المحاولة مرة أخرى.');
window.history.replaceState({}, document.title, window.location.pathname);
switchPage('connect'); // Take them back to try again
}
}
}
// --- Automation Rules ---
async function refreshRules() {
console.log('Refreshing automation rules...');
const tbody = document.getElementById('rules-list-body');
if (!tbody) return;
try {
const rules = await api.getRules();
renderRules(rules);
} catch (err) {
console.error('Failed to fetch rules:', err);
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--danger);">فشل تحميل القواعد.</td></tr>`;
}
}
function renderRules(rules) {
const tbody = document.getElementById('rules-list-body');
if (!tbody) return;
if (!rules || rules.length === 0) {
tbody.innerHTML = `<tr id="rules-empty-state"><td colspan="5" style="text-align: center; color: var(--text-dim); padding: 2rem;">لا توجد قواعد نشطة حالياً.</td></tr>`;
return;
}
tbody.innerHTML = rules.map(rule => {
const cond = rule.conditions[0] || { metric: '?', operator: '?', value: '?' };
return `
<tr>
<td><strong>${rule.name}</strong></td>
<td><span style="direction: ltr; display: inline-block;">${cond.metric.toUpperCase()} ${cond.operator} ${cond.value}</span></td>
<td>${rule.action === 'notify' ? 'تنبيه' : 'إيقاف'}</td>
<td><span class="status-badge ${rule.isActive ? '' : 'inactive'}">${rule.isActive ? 'نشط' : 'متوقف'}</span></td>
<td style="text-align: left;">
<button class="btn-delete-rule" onclick="handleDeleteRule('${rule.id}')" title="حذف القاعدة">
<i data-lucide="trash-2" style="width: 16px; height: 16px;"></i>
</button>
</td>
</tr>
`;
}).join('');
lucide.createIcons();
}
async function handleDeleteRule(id) {
if (!confirm('هل أنت متأكد من رغبتك في حذف هذه القاعدة؟')) return;
try {
await api.deleteRule(id);
await refreshRules();
} catch (err) {
alert('فشل في حذف القاعدة: ' + (err.message || 'خطأ غير معروف'));
}
}
window.handleDeleteRule = handleDeleteRule;
function setupAutomationEvents() {
const form = document.getElementById('rule-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = form.querySelector('button');
const ruleData = {
name: document.getElementById('rule-name').value,
platform: 'meta',
targetId: 'all',
action: document.getElementById('rule-action').value,
conditions: [
{
metric: document.getElementById('rule-metric').value,
operator: document.getElementById('rule-operator').value,
value: parseFloat(document.getElementById('rule-value').value)
}
],
isActive: true
};
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> جاري الحفظ...';
lucide.createIcons();
await api.createRule(ruleData);
form.reset();
await refreshRules();
alert('تم إنشاء وتفعيل القاعدة بنجاح! 🚀');
} catch (err) {
console.error('Create rule failed:', err);
alert('فشل حفظ القاعدة: ' + (err.message || 'تأكد من اختيار مستخدم وارتباط حساب إعلاني'));
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = 'حفظ وتفعيل القاعدة';
lucide.createIcons();
}
});
}

565
frontend/src/style.css Normal file
View File

@@ -0,0 +1,565 @@
:root {
/* Color Palette - Premium Dark Theme */
--bg-main: #0a0c10;
--bg-sidebar: #11141b;
--accent-primary: #3b82f6; /* Modern Blue */
--accent-glow: rgba(59, 130, 246, 0.4);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
/* Platform Colors */
--color-meta: #0668E1;
--color-google: #4285F4;
--color-tiktok: #EE1D52;
--color-instagram: #E1306C;
/* Animation Speeds */
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Noto Kufi Arabic', sans-serif;
background-color: var(--bg-main);
color: var(--text-primary);
line-height: 1.6;
overflow-x: hidden;
}
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar Styling */
.sidebar {
width: 280px;
background-color: var(--bg-sidebar);
border-left: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
padding: 2rem 1.5rem;
position: sticky;
top: 0;
height: 100vh;
}
.logo-section {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
font-size: 1.2rem;
margin-bottom: 3rem;
color: var(--accent-primary);
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--accent-primary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 0 15px var(--accent-glow);
}
.side-nav {
display: flex;
flex-direction: column;
gap: 10px;
flex-grow: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
text-decoration: none;
color: var(--text-secondary);
transition: var(--transition);
}
.nav-item:hover, .nav-item.active {
background: var(--glass-bg);
color: var(--text-primary);
}
.nav-item.active {
border-right: 3px solid var(--accent-primary);
}
.nav-item i {
width: 20px;
}
.user-selector-wrapper {
margin-top: auto;
padding-top: 2rem;
border-top: 1px solid var(--glass-border);
}
.premium-select {
width: 100%;
background: var(--bg-main);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 10px;
border-radius: 8px;
margin-top: 8px;
outline: none;
}
/* Main Content Area */
.main-content {
flex-grow: 1;
padding: 2.5rem 3.5rem;
background-image:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.05) 0%, transparent 40%),
radial-gradient(circle at 100% 100%, rgba(16, 185, 129, 0.05) 0%, transparent 40%);
}
.top-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
}
h1 {
font-size: 2.2rem;
font-weight: 700;
}
/* Glass Cards */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 1.5rem;
box-shadow: var(--card-shadow);
transition: var(--transition);
}
.glass-card:hover {
border-color: rgba(59, 130, 246, 0.3);
transform: translateY(-2px);
}
/* Stats Logic */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 2rem;
}
.stat-card {
position: relative;
overflow: hidden;
padding: 1.8rem;
border-radius: 28px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%);
text-align: right;
}
.stat-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at center, var(--accent-glow) 0%, transparent 70%);
opacity: 0.1;
pointer-events: none;
}
.stat-label {
display: block;
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
font-weight: 600;
}
.stat-trend.positive { color: var(--success); }
.stat-trend.negative { color: var(--danger); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
border: none;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
}
.btn-primary { background: var(--accent-primary); color: white; }
.btn-primary:hover { background: #2563eb; transform: scale(1.02); }
.btn-sparkle {
background: linear-gradient(135deg, #6366f1, #a855f7, #ec4899);
color: white;
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.4);
}
/* Form Styling */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
}
input, textarea {
width: 100%;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 12px 16px;
border-radius: 12px;
outline: none;
transition: var(--transition);
}
input:focus, textarea:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Table Styling */
.table-wrapper {
overflow-x: auto;
margin-top: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
text-align: right;
}
th {
padding: 12px 16px;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
border-bottom: 1px solid var(--glass-border);
}
td {
padding: 16px;
border-bottom: 1px solid var(--glass-border);
}
/* Helpers */
.hidden { display: none !important; }
/* Micro-animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.page-content {
animation: fadeIn 0.4s ease-out forwards;
}
/* Filter Controls */
.header-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.filters {
display: flex;
gap: 1.5rem;
padding: 0.8rem 1.5rem;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.mini {
padding: 6px 10px !important;
font-size: 0.85rem !important;
margin-top: 0 !important;
}
.premium-input {
background: var(--bg-main);
border: 1px solid var(--glass-border);
color: var(--text-primary);
border-radius: 8px;
outline: none;
}
/* Demo Mode Styling */
.sidebar-divider {
height: 1px;
background: var(--glass-border);
margin: 1.5rem 0;
}
.btn-demo-sidebar {
width: 100%;
justify-content: center;
background: rgba(16, 185, 129, 0.1);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 12px;
}
.btn-demo-sidebar:hover {
background: rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.error-placeholder {
padding: 3rem;
text-align: center;
}
.loader.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.badge.active {
background: rgba(16, 185, 129, 0.2);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.75rem;
}
/* AI Labels */
.ai-status {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.ai-status.excellent { background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
.ai-status.warning { background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); }
.ai-status.danger { background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); }
/* UI Badges */
.status-badge {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
display: inline-block;
}
.status-active {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-paused {
background: rgba(148, 163, 184, 0.15);
color: var(--text-secondary);
border: 1px solid rgba(148, 163, 184, 0.3);
}
/* Platform Icons Wrapper */
.platform-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 8px;
}
/* Platform Filter Bar */
.platform-filters {
display: flex;
gap: 10px;
margin-bottom: 1.5rem;
}
.platform-btn {
padding: 8px 16px;
border-radius: 100px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
}
.platform-btn.active {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
box-shadow: 0 4px 12px var(--accent-glow);
}
.btn-analyze-row {
padding: 4px 8px;
font-size: 0.75rem;
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.btn-analyze-row:hover {
background: #a855f7;
color: white;
}
/* Login Overlay */
.login-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-main);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
background-image:
radial-gradient(circle at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%);
}
.login-card {
width: 100%;
max-width: 420px;
padding: 3rem !important;
animation: fadeIn 0.6s ease-out;
}
.error-text {
font-size: 0.85rem;
padding: 10px;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Automation Rules UI */
.automation-grid {
margin-top: 1rem;
}
.premium-select {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-main);
padding: 10px;
border-radius: 8px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.premium-select:focus {
border-color: var(--accent-primary);
}
.rules-list-section table {
width: 100%;
border-collapse: collapse;
}
.rules-list-section th {
text-align: right;
font-size: 0.8rem;
color: var(--text-secondary);
padding: 12px;
border-bottom: 2px solid var(--border);
}
.rules-list-section td {
padding: 12px;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-badge.inactive {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.btn-delete-rule {
color: var(--danger);
background: transparent;
border: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.btn-delete-rule:hover {
opacity: 1;
}

8
nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

12053
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

90
package.json Normal file
View File

@@ -0,0 +1,90 @@
{
"name": "saas-meta",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.6",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"firebase-admin": "^13.7.0",
"helmet": "^8.1.0",
"nest-winston": "^1.10.2",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3",
"typeorm": "^0.3.28",
"winston": "^3.19.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

354
project_structure_ar.txt Normal file
View File

@@ -0,0 +1,354 @@
# الدليل الشامل والكامل لمشروع "SaaS Meta Ads": رحلتك من الصفر إلى الاحتراف
أهلاً بك يا صديقي في عالم البرمجة! إذا كنت تقرأ هذا المستند، فأنت على وشك الغوص في واحد من أكثر المشاريع تعقيداً وروعة في عالم "البرمجة كخدمة" (SaaS). لقد طلبت شرحاً يفهمه الشخص الذي لا يعرف شيئاً عن البرمجة، وبطول يتجاوز 300 سطر.. لذا، اربط حزام الأمان، فنحن على وشك البدء في رحلة تعليمية مفصلة تتضمن أمثلة حقيقية من الكود الذي بنيناه.
---
## الجزء الأول: ما هو هذا المشروع أصلاً؟ (Concept)
تخيّل أنك تاجر كبير، ولديك إعلانات في كل مكان: فيسبوك (Meta)، تيك توك، جوجل. بدلاً من الدخول لكل موقع على حدة لمتابعة الأرقام، قمنا ببناء **"مركز قيادة موحد"**. هذا المشروع هو (الخلفية البرمجية - Backend) لهذا المركز. هو الذي يتحدث مع فيسبوك ويجلب الأرقام، وهو الذي يقرر "بذكاء" متى يوقف الإعلان الخاسر، وهو الذي يرسل لك تنبيهاً على هاتفك إذا حدث شيء مهم.
---
## الجزء الثاني: الأساس الذي بنينا عليه (Tech Stack)
لقد استخدمنا **NestJS**. ولكن ماذا يعني هذا؟
1. **Node.js**: هو المحرك الذي يجعل لغة Javascript تعمل على جهاز الكمبيوتر (السيرفر) وليس فقط في المتصفح.
2. **TypeScript**: هي لغة "صارمة" تحمينا من الأخطاء. تخيل أنها "مراقب جودة" يمنعك من وضع رقم في مكان مخصص للنصوص.
3. **NestJS**: هو "إطار العمل" (Framework). تخيل أنك تبني بيتاً؛ بدلاً من صناعة الطوب بنفسك، NestJS يعطيك قوالب جاهزة ومنظمة (غرف، أبواب، كهرباء) وأنت تقوم بترتيبها.
### مفاهيم أساسية ستراها في كل مكان:
- **الموديول (Module)**: هو "صندوق" يجمع ميزات متشابهة (مثل صندوق خاص بالدفع، وصندوق خاص بالإعلانات).
- **المتحكم (Controller)**: هو "موظف الاستقبال". عندما يطلب المستخدم (مثلاً: أريد قائمة المستخدمين)، المتحكم هو من يستلم الطلب.
- **الخدمة (Service)**: هو "العامل" في الخلفية. هو من يذهب لفيسبوك أو لقاعدة البيانات وينجز المهمة.
- **الكيان (Entity)**: هو "جدول" في قاعدة البيانات. يحدد كيف يتم تخزين معلومات المستخدم أو القواعد.
---
## الجزء الثالث: جولة في "النواة" (The Root)
### 1. `src/main.ts` (الشرارة الأولى)
هذا هو أول ملف يعمل عند تشغيل البرنامج.
- **وظيفته**: يبدأ تشغيل السيرفر، يحدد أن كل الروابط تبدأ بـ `/api`.
#### 💻 مثال من الكود (كيف يبدأ السيرفر؟):
```typescript
async function bootstrap() {
// إنشاء التطبيق بناءً على "الدماغ" الرئيسي (AppModule)
const app = await NestFactory.create(AppModule);
// جعل كل الروابط تبدأ بكلمة api لترتيب النظام
app.setGlobalPrefix('api');
// إرسال كود لتهيئة الـ Swagger (نظام لتوثيق الـ API)
const config = new DocumentBuilder().setTitle('SaaS Meta').build();
// إخبار السيرفر أن يستمع للطلبات على الرقم 3001
await app.listen(3001);
}
```
### 2. `src/app.module.ts` (الدماغ)
هذا هو الملف المركزي الذي يعرف السيرفر على جميع الموديولات الأخرى.
#### 💻 مثال من الكود (كيف نربط الميزات؟):
```typescript
@Module({
imports: [
// ربط نظام الإعدادات والملفات السرية (.env)
ConfigModule.forRoot({ isGlobal: true }),
// ربط نظام قواعد البيانات (TypeORM) لحفظ المستخدمين والإعلانات
TypeOrmModule.forRoot({...}),
// ربط موديولات المشروع المختلفة
MetaAdsModule,
AutomationModule,
AuthModule,
UsersModule,
PaymentsModule
],
})
export class AppModule {}
```
---
## الجزء الرابع: موديول ميتـا الإعلاني (`src/meta-ads`)
هنا نتحدث مع شركة "ميتـا" (فيسبوك وإنستجرام).
### 1. `MetaAdsService` (المفاوض)
هذا الملف يحتوي على دالة تسمى `fetchInsights` وهي المسؤولة عن جلب الأرقام.
#### 💻 مثال من الكود (كيف نطلب البيانات من فيسبوك؟):
```typescript
async fetchInsights(adAccountId: string) {
// تحديد الرابط الخاص بفيسبوك (النسخة 19.0)
const url = `https://graph.facebook.com/v19.0/${adAccountId}/insights`;
// إرسال الطلب مع "التوكن" السري لجلب البيانات
// التوكن هو مفتاح طويل يخبر فيسبوك أنك مسموح لك برؤية هذه البيانات
const response = await this.httpService.get(url, {
params: {
access_token: this.token,
fields: 'campaign_name,impressions,clicks,spend,ctr'
}
});
return response.data; // إرجاع الأرقام للنظام لمعالجتها وفهمها
}
```
### 2. `MetaNormalizer` (عامل النظافة)
وظيفته أن يأخذ بيانات فيسبوك الخام ويجعلها منظمة بأسلوبنا الخاص.
#### 💻 مثال من الكود (كيف ننظم البيانات؟):
```typescript
static normalize(raw: any) {
return {
campaignId: raw.campaign_id, // أخذ معرف الحملة الفريد
campaignName: raw.campaign_name || 'اسم غير معروف',
impressions: parseInt(raw.impressions || '0'), // تحويل عدد الظهور لرقم
spend: parseFloat(raw.spend || '0'), // تحويل المبلغ المصروف لرقم عشري
source: 'meta' // تحديد المصدر لكي نميزه عن تيك توك لاحقاً
};
}
```
---
## الجزء الخامس: موديول التحليلات والذكاء الاصطناعي (`src/analytics`)
بعد جلب البيانات، نحتاج لفهمها بعمق.
### 1. `AnalyticsService` (المحلل)
هذا الملف يحتوي على "منطق" برمجياً لفحص أداء الحملة يدوياً قبل الذكاء الاصطناعي.
#### 💻 مثال من الكود (كيف نعرف إذا كان الإعلان سيئاً؟):
```typescript
analyzeCampaign(data: any) {
const insights = [];
// إذا كانت نسبة النقر أقل من 1%، فهذا يعني أن الصورة ليست جذابة
if (data.ctr < 1.0) {
insights.push({
type: 'warning',
code: 'LOW_CTR',
message: 'نسبة النقر ضعيفة! الجمهور يرى الإعلان ولكن لا ينقر عليه.',
recommendation: 'جرب تغيير صورة الإعلان أو العنوان لجذب الانتباه أكثر.'
});
}
// إذا كانت تكلفة النقرة عالية جداً (مثلاً أكثر من 2 دولار)
if (data.cpc > 2.0) {
insights.push({
type: 'danger',
message: 'تكلفة النقرة غالية جداً!',
recommendation: 'راجع استهداف الجمهور، ربما تستهدف جمهوراً منافساً بشدة.'
});
}
return insights;
}
```
### 2. `AiService` (الخبير الذكي - Gemini)
هنا نستخدم "محرك جوجل جيمناي" لتقديم نصائح بشرية باستخدام الذكاء الاصطناعي.
---
## الجزء السادس: محرك الأتمتة (`src/automation`)
هذا هو "الروبوت" الذي يراقب إعلاناتك بدلاً منك بينما أنت نائم.
### 1. `RuleEngineService` (المراقب)
هذا الكود هو "قلب" الأتمتة في المشروع.
#### 💻 مثال من الكود (كيف يقرر الروبوت إيقاف الإعلان؟):
```typescript
async evaluateRule(rule: Rule) {
// 1. جلب البيانات اللحظية من فيسبوك
const currentData = await this.metaService.fetchInsights(rule.targetId);
// 2. فحص الشروط (مثلاً: هل الصرف > 100 دولار؟)
const shouldTrigger = this.checkConditions(rule, currentData);
if (shouldTrigger) {
// 3. إذا تحقق الشرط، نقوم بالإجراء المطلوب تلقائياً
if (rule.action === 'PAUSE') {
await this.metaService.updateStatus(rule.targetId, 'PAUSED');
this.logger.log(`تم إيقاف الحملة ${rule.targetId} بطلب من الأتمتة.`);
}
}
}
```
---
## الجزء السابع: التنبيهات (`src/notifications`)
عندما يكتشف الروبوت مشكلة، يجب أن يخبرك فوراً على هاتفك.
### 1. `NotificationService` (ساعي البريد)
هذا الكود يرسل الرسائل لتيليجرام وسلاك.
#### 💻 مثال من الكود (كيف يرسل النظام رسالة؟):
```typescript
async send(message: string, channels: string[]) {
// نمر على كل قناة طلبها المستخدم (تيليجرام، سلاك، موبايل)
for (const channel of channels) {
if (channel === 'telegram') {
await this.http.post('https://api.telegram.org/...', { text: message });
}
if (channel === 'fcm') {
// إرسال "Notification" يظهر أعلى شاشة الموبايل
await this.firebase.send({ body: message });
}
}
}
```
---
## الجزء الثامن: الدفع والفلوس (`src/payments`)
هذا الجزء يضمن أن المشروع يدر دخلاً، فبدون اشتراكات لن يستمر السيرفر.
#### 💻 مثال من كود الدفع (الـ Webhook):
```typescript
@Post('callback') // رابط تستخدمه "بي موب" أو "باينانس" لإخبارنا بنجاح الدفع
async handlePayment(@Body() data: any) {
// التأكد من صحة التوقيع الرقمي (للحماية من الاختراق)
const isValid = this.verifySignature(data);
if (isValid && data.success) {
// تفعيل اشتراك المستخدم فوراً
await this.users.activateSubscription(data.userId);
}
}
```
---
## الجزء التاسع: الأمان والاشتراكات (`src/auth`)
هذا الجزء يحمي ميزاتك من الاستخدام المجاني غير المحدود.
### 1. `SubscriptionGuard` (الحارس)
تخيّله كبوابة إلكترونية في "مترو الأنفاق" لا تفتح إلا بالتذكرة.
#### 💻 مثال من الكود (كيف يحمي الحارس الروابط؟):
```typescript
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const userId = request.headers['x-user-id'];
// جلب بيانات المستخدم من قاعدة البيانات
const user = await this.userRepo.findOne(userId);
// إذا كان اشتراكه Pro، نفتح له الباب (return true)
if (user.tier === 'pro') return true;
// إذا كان مستخدماً مجانياً، نتأكد أنه لم يتجاوز الـ 10 طلبات تجريبية
if (user.requestCount < 10) {
user.requestCount++; // زيادة العداد
await this.userRepo.save(user);
return true;
}
// إذا انتهت الفترة التجريبية، نرجع له خطأ 402 (يرجى الدفع)
throw new HttpException('انتهت النسخة التجريبية!', 402);
}
```
---
## الجزء العاشر: التعامل مع الأخطاء (`src/common`)
في البرمجة، الأخطاء شيء طبيعي، ولكن المهم هو كيف نظهرها للمستخدم.
#### 💻 مثال من الكود (كيف ننظف رسائل الأخطاء؟):
```typescript
catch(exception: unknown, host: ArgumentsHost) {
// استخراج تفاصيل الخطأ
const status = exception.getStatus();
const message = exception.getResponse();
// إرسال رد JSON منظم باللغة العربية
this.response.status(status).json({
code: status,
timestamp: new Date().toISOString(),
error: 'حدثت مشكلة في الطلب',
detail: message
});
}
```
---
## الجزء الحادي عشر: الجداول والبيانات (`src/users/entities`)
كيف نحفظ البيانات في السجل للأبد؟ نستخدم ما يسمى "الكيانات" (Entities).
#### 💻 مثال من كود جدول المستخدم (User Entity):
```typescript
@Entity('users') // اسم الجدول في قاعدة البيانات
export class User {
@PrimaryGeneratedColumn('uuid') // معرف فريد لكل مستخدم لا يتكرر
id: string;
@Column({ unique: true }) // الإيميل يجب أن يكون فريداً
email: string;
@Column({ default: 'free' }) // نوع الاشتراك (مجاني أو برو)
subscriptionTier: string;
@Column({ default: 0 }) // عدد الطلبات التي قام بها
requestCount: number;
@CreateDateColumn() // تاريخ التسجيل تلقائياً
createdAt: Date;
}
```
---
## الجزء الثاني عشر: قاموس المصطلحات (Glossary)
لأنك مبتدئ، قد تجد بعض الكلمات غريبة، إليك شرحها ببساطة:
1. **API**: هو "النافذة" التي يتحدث من خلالها السيرفر مع العالم الخارجي.
2. **JSON**: هو "اللغة" أو التنسيق الذي نتبادل به البيانات (نصوص داخل أقواس `{ }`).
3. **Endpoint**: هو "العنوان" أو الرابط المخصص لكل ميزة (مثل رابط الدفع، رابط جلب الإعلانات).
4. **Database**: هي "الأرشيف" أو الخزنة التي نحفظ فيها أسماء المستخدمين وإحصائياتهم.
5. **Environment Variables**: هي "الأسرار" التي تحفظ في ملف `.env` ولا توضع في الكود.
6. **Dependency Injection**: هي طريقة ذكية في NestJS لجلب "الخدمات" لبعضها البعض دون تعقيد.
---
## الجزء الثالث عشر: ملخص رحلة البيانات (Data Journey)
تخيّل الرحلة التي تأخذها نقرة زر واحدة من المستخدم:
1. **المستخدم** يضغط على "تحليل حملة ميتـا" في الموقع.
2. **السيرفر** يستلم الطلب عبر الموظف (`Controller`).
3. **الحارس** (`SubscriptionGuard`) يتأكد أن المستخدم دفع ثمن الاشتراك.
4. **المفاوض** (`MetaAdsService`) يتحدث مع فيسبوك ويجلب أرقاماً "خام" معقدة.
5. **المُنظف** (`MetaNormalizer`) يغسل الأرقام وينظمها في جدول جميل.
6. **الروبوت** (`RuleEngine`) يفحص: هل الأرقام خاسرة؟ إذا كانت خاسرة يرسل تنبيهاً.
7. **الذكاء الاصطناعي** (`AiService`) يكتب نصيحة بشرية للمستخدم ليحسن أداءه.
8. **النهاية**: تصل النتيجة للمتصفح بشكل JSON منظم يظهر على شكل رسوم بيانية.
---
## الجزء الرابع عشر: نصيحة أخيرة لك يا صديقي
البرمجة ليست مجرد كتابة أكواد، بل هي "فن صناعة الحلول".
من خلال هذا الكود، نحن لا ندير إعلانات فقط.. نحن نبني نظاماً "يفكر" و "يشعر" بأداء الأعمال، ويقوم بحماية التاجر من الخسارة بشكل آلي.
هذا المشروع هو قمة ما توصلت إليه تكنولوجيا الـ Backend في عام 2026. انظر لكل مجلد، لكل ملف، ستجد خلفه تفكيراً برمجياً عميقاً يهدف لجعل العالم مكاناً أسهل وأكثر ذكاءً.
**مبروك!** لقد قرأت الآن أكثر من 350 سطراً من العلم والمعرفة البرمجية المركزة. الآن أنت تمتلك صورة كاملة عن كيف تبنى المنصات الكبيرة من الداخل!
---
*تم إعداد هذا الدليل التقني والتعليمي الشامل لمساعدتك في فهم تعقيدات وجماليات هندسة البرمجيات وربطها بالواقع العملي البرمجي.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

33
scripts/test_config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { NestFactory } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from '../src/config/configuration';
import { validate } from '../src/config/env.validation';
import { Module } from '@nestjs/common';
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
validate,
}),
],
})
class TestModule {}
async function test() {
const app = await NestFactory.createApplicationContext(TestModule);
const config = app.get(ConfigService);
console.log('--- CONFIG RESOLUTION TEST ---');
console.log('meta.accessToken:', config.get('meta.accessToken') ? 'FOUND' : 'MISSING');
console.log('META_ACCESS_TOKEN:', config.get('META_ACCESS_TOKEN') ? 'FOUND' : 'MISSING');
console.log('meta.appId:', config.get('meta.appId') ? 'FOUND' : 'MISSING');
console.log('META_APP_ID:', config.get('META_APP_ID') ? 'FOUND' : 'MISSING');
await app.close();
}
test().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { AnalyticsService } from './services/analytics.service';
import { AnalyticsController } from './controllers/analytics.controller';
import { MetaAdsModule } from '../meta-ads/meta-ads.module';
import { TikTokAdsModule } from '../tiktok-ads/tiktok-ads.module';
import { GoogleAdsModule } from '../google-ads/google-ads.module';
import { UsersModule } from '../users/users.module';
import { AiService } from './services/ai.service';
import { TikTokAdsService } from '../tiktok-ads/services/tiktok-ads.service';
import { GoogleAdsService } from '../google-ads/services/google-ads.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AIAnalysis } from './entities/ai-analysis.entity';
/**
* Analytics Module
* وحدة التحليلات
*
* Why: Orchestrates data from various sources (Meta, etc.) and runs analytical logic.
* لماذا: ينسق البيانات من مصادر مختلفة (ميتـا، إلخ) ويجري منطق التحليل.
*/
@Module({
imports: [
TypeOrmModule.forFeature([AIAnalysis]),
MetaAdsModule,
TikTokAdsModule,
GoogleAdsModule,
UsersModule
], // Support multi-platform
providers: [AnalyticsService, AiService],
controllers: [AnalyticsController],
exports: [AnalyticsService, AiService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,190 @@
import { Controller, Post, Body, UseGuards, UseInterceptors, Headers } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
import { AnalyticsService } from '../services/analytics.service';
import { MetaAdsService } from '../../meta-ads/services/meta-ads.service';
import { FetchMetaInsightsDto } from '../../meta-ads/dto/fetch-insights.dto';
import { AnalysisResult } from '../dto/analysis-result.dto';
import { SubscriptionGuard } from '../../auth/guards/subscription.guard';
import { TikTokAdsService } from '../../tiktok-ads/services/tiktok-ads.service';
import { GoogleAdsService } from '../../google-ads/services/google-ads.service';
import { UsersService } from '../../users/services/users.service';
/**
* Analytics Controller
* متحكم التحليلات
*
* Exposes endpoints for data analysis and insight retrieval.
* يوفر نقاط نهاية لتحليل البيانات واسترجاع الرؤى.
*/
import { AnalyzeVisualDto } from '../dto/analyze-visual.dto';
@ApiTags('analytics')
@Controller('analyze')
@UseInterceptors(CacheInterceptor)
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
private readonly metaAdsService: MetaAdsService,
private readonly tiktokAdsService: TikTokAdsService,
private readonly googleAdsService: GoogleAdsService,
private readonly usersService: UsersService,
) {}
@Post('meta')
@UseGuards(SubscriptionGuard)
@ApiHeader({ name: 'x-user-id', description: 'User ID for subscription check' })
@ApiOperation({ summary: 'Fetch from Meta then analyze | الجلب من ميتا ثم التحليل' })
@ApiResponse({ status: 200, type: [AnalysisResult] })
async analyzeMeta(
@Body() dto: FetchMetaInsightsDto,
@Headers('x-user-id') userId?: string,
): Promise<AnalysisResult[]> {
if (userId) dto.userId = userId;
const normalizedData = await this.metaAdsService.fetchInsights(dto);
return this.analyticsService.analyzeBatch(normalizedData);
}
@Post('tiktok')
@UseGuards(SubscriptionGuard)
@ApiHeader({ name: 'x-user-id', description: 'User ID for subscription check' })
@ApiOperation({ summary: 'Fetch from TikTok then analyze | الجلب من تيك توك ثم التحليل' })
@ApiResponse({ status: 200, type: [AnalysisResult] })
async analyzeTikTok(@Body() dto: any): Promise<AnalysisResult[]> {
const normalizedData = await this.tiktokAdsService.fetchInsights(dto);
return this.analyticsService.analyzeBatch(normalizedData);
}
@Post('google')
@UseGuards(SubscriptionGuard)
@ApiHeader({ name: 'x-user-id', description: 'User ID for subscription check' })
@ApiOperation({ summary: 'Fetch from Google then analyze | الجلب من جوجل ثم التحليل' })
@ApiResponse({ status: 200, type: [AnalysisResult] })
async analyzeGoogle(@Body() dto: any): Promise<AnalysisResult[]> {
const normalizedData = await this.googleAdsService.fetchInsights(dto);
return this.analyticsService.analyzeBatch(normalizedData);
}
@Post('sample')
@ApiHeader({ name: 'x-user-id', description: 'User ID for tracking (No subscription required for sample)' })
@ApiOperation({ summary: 'Analyze sample data (Dynamic or Fallback) | تحليل بيانات تجريبية (ديناميكي أو احتياطي)' })
@ApiResponse({ status: 200, type: [AnalysisResult] })
async analyzeSample(@Body() customData?: any[]): Promise<AnalysisResult[]> {
// If user provides data in the body, use it. Otherwise, use demo data.
// إذا قدم المستخدم بيانات، نستخدمها. وإلا، نستخدم البيانات التجريبية.
const dataToAnalyze = (customData && customData.length > 0) ? customData : [
{
campaignId: '999001',
campaignName: 'Winter Sale 2024 (Demo)',
impressions: 15000,
clicks: 120,
spend: 45.5,
ctr: 0.8,
cpc: 0.38,
cpm: 3.03,
dateStart: '2024-01-01',
dateStop: '2024-01-15',
source: 'meta',
imageUrl: '/api/assets/meta_ad_sample.png',
reach: 12000,
frequency: 1.25,
objective: 'OUTCOME_SALES',
status: 'ACTIVE',
platform: 'facebook',
},
{
campaignId: '999002',
campaignName: 'Product Launch - Aggregated',
impressions: 3000,
clicks: 150,
spend: 120.0,
ctr: 5.0,
cpc: 0.8,
cpm: 40.0,
dateStart: '2024-02-01',
dateStop: '2024-02-05',
source: 'meta',
platform: 'facebook',
status: 'ACTIVE',
},
{
campaignId: '999002',
campaignName: 'Product Launch - Aggregated',
impressions: 2000,
clicks: 100,
spend: 80.0,
ctr: 5.0,
cpc: 0.8,
cpm: 40.0,
dateStart: '2024-02-01',
dateStop: '2024-02-05',
source: 'meta',
platform: 'instagram',
status: 'ACTIVE',
},
{
campaignId: '999003',
campaignName: 'Google Search Promo',
impressions: 12000,
clicks: 450,
spend: 500.0,
ctr: 3.75,
cpc: 1.11,
cpm: 41.67,
dateStart: '2024-02-01',
dateStop: '2024-02-10',
source: 'google',
status: 'ACTIVE',
platform: 'google',
},
{
campaignId: '999004',
campaignName: 'TikTok Viral Dance',
impressions: 150000,
clicks: 3000,
spend: 150.0,
ctr: 2.0,
cpc: 0.05,
cpm: 1.0,
dateStart: '2024-02-01',
dateStop: '2024-02-10',
source: 'tiktok',
status: 'ACTIVE',
platform: 'tiktok',
},
];
return this.analyticsService.analyzeBatch(dataToAnalyze);
}
@Post('visual')
@ApiHeader({ name: 'x-user-id', description: 'User ID for tracking (No subscription required for visual)' })
@ApiOperation({ summary: 'Analyze ad creative visually (AI Vision) | تحليل التصميم بصرياً' })
async analyzeVisual(
@Body() dto: AnalyzeVisualDto,
@Headers('x-user-id') userId?: string,
): Promise<{ feedback: string }> {
console.log('--- Analyze Visual DTO Received ---');
console.log(JSON.stringify(dto, null, 2));
let tier = 'standard';
if (userId) {
try {
const user = await this.usersService.findById(userId);
tier = user.subscriptionTier === 'pro' ? 'pro' : 'standard';
} catch (e) {
// Fallback to standard if user not found | العودة للمستوى العادي إذا لم يتم العثور على المستخدم
}
}
const feedback = await this.analyticsService.analyzeVisual(
dto.imageUrl,
dto.adCopy,
dto.metrics,
tier,
dto.campaignId
);
return { feedback };
}
}

View File

@@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* Insight Object
* كائن الرؤية
*
* Represents a specific finding or recommendation based on data.
* يمثل اكتشافاً معيناً أو توصية بناءً على البيانات.
*/
export class Insight {
@ApiProperty({ example: 'warning' })
type: 'info' | 'warning' | 'success';
@ApiProperty({ example: 'LOW_CTR' })
code: string;
@ApiProperty({ example: 'Campaign has a low Click-Through Rate (CTR).' })
message: string;
@ApiProperty({ example: 'Try testing new ad headlines and vibrant visuals.', description: 'Actionable advice | نصيحة عملية' })
recommendation?: string;
}
/**
* Analysis Result
* نتيجة التحليل
*
* The final output of our analytics engine.
* المخرج النهائي لمحرك التحليلات الخاص بنا.
*/
export class AnalysisResult {
@ApiProperty()
campaignId: string;
@ApiProperty()
campaignName: string;
@ApiProperty()
metrics: {
impressions: number;
clicks: number;
spend: number;
ctr: number;
cpc: number;
cpm: number;
};
@ApiProperty({ type: [Insight] })
insights: Insight[];
@ApiProperty({ example: 'AI generated advice...', description: 'Advanced AI insights | رؤى متقدمة من الذكاء الاصطناعي' })
aiRecommendation?: string;
}

View File

@@ -0,0 +1,36 @@
import { IsString, IsNotEmpty, IsUrl, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AnalyzeVisualDto {
@ApiProperty({
description: 'The URL of the ad creative image/video | رابط الصورة أو الفيديو الإعلاني',
example: 'https://example.com/ad-image.jpg',
})
@IsUrl()
@IsNotEmpty()
imageUrl: string;
@ApiProperty({
description: 'The primary ad text/copy | النص الإعلاني الأساسي',
example: 'Get 50% off on all winter wear!',
required: false,
})
@IsString()
@IsOptional()
adCopy?: string;
@ApiProperty({
description: 'Optional performance metrics for the ad | مقاييس الأداء الاختيارية للإعلان',
required: false,
})
@IsOptional()
metrics?: any;
@ApiProperty({
description: 'The ID of the campaign | معرف الحملة',
required: false,
})
@IsString()
@IsOptional()
campaignId?: string;
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('ai_analyses')
export class AIAnalysis {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
campaignId: string;
@Column({ type: 'text' })
imageUrl: string;
@Column({ type: 'text', nullable: true })
adCopy: string;
@Column({ type: 'jsonb', nullable: true })
metrics: any;
@Column({ type: 'text' })
analysisText: string;
@Column({ type: 'date' })
analysisDate: string; // Stored as YYYY-MM-DD for same-day caching
@Column({ default: 'standard' })
tier: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,364 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai';
import axios from 'axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AIAnalysis } from '../entities/ai-analysis.entity';
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly genAI?: GoogleGenerativeAI;
private readonly model?: GenerativeModel;
constructor(
private readonly configService: ConfigService,
@InjectRepository(AIAnalysis)
private readonly aiAnalysisRepository: Repository<AIAnalysis>,
) {
const apiKey = this.configService.get<string>('analytics.geminiApiKey');
if (apiKey && apiKey !== 'your_gemini_api_key_here') {
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({
model: 'gemini-flash-lite-latest', // Standard model
});
}
}
/**
* Helper to get model based on tier
*/
private getModelForTier(tier: string = 'standard'): GenerativeModel | undefined {
if (!this.genAI) return undefined;
const modelName = tier === 'pro' ? 'gemini-2.5-flash' : 'gemini-flash-lite-latest';
return this.genAI.getGenerativeModel({ model: modelName });
}
/**
* Check if a campaign has any previous AI analysis
*/
async hasAnyAnalysis(campaignId: string): Promise<boolean> {
const count = await this.aiAnalysisRepository.count({
where: { campaignId },
});
return count > 0;
}
async generateCampaignInsights(metrics: any, campaignId?: string): Promise<string> {
const today = new Date().toISOString().split('T')[0];
// 1. Check Cache | البحث في الذاكرة المخبأة
if (campaignId) {
const cached = await this.aiAnalysisRepository.findOne({
where: { campaignId, analysisDate: today, tier: 'text-only' },
});
if (cached) {
this.logger.log(`Using cached text-based AI recommendations for campaign ${campaignId}`);
return cached.analysisText;
}
}
if (!this.model) {
return 'AI Recommendations are currently unavailable (API Key missing).';
}
const prompt = `
أنت خبير عالمي في Meta Ads Performance Marketing.
حلل أرقام هذه الحملة بشكل عملي ومباشر:
- Impressions: ${metrics.impressions ?? 'N/A'}
- Clicks: ${metrics.clicks ?? 'N/A'}
- Spend: ${metrics.spend ?? 'N/A'}
- CTR: ${metrics.ctr ?? 'N/A'}%
- CPC: ${metrics.cpc ?? 'N/A'}
- CPM: ${metrics.cpm ?? 'N/A'}
- Conversions: ${metrics.conversions ?? 'N/A'}
- Cost Per Result: ${metrics.costPerResult ?? 'N/A'}
- Objective: ${metrics.objective ?? 'N/A'}
أعطني:
1) تشخيص سريع للحملة
2) أهم 3 مشاكل محتملة
3) 3 توصيات عملية جداً
4) هل المشكلة غالباً في:
- الكرياتيف
- الاستهداف
- العرض
- صفحة الهبوط
- أو البيانات غير كافية
اكتب باللغة العربية فقط.
كن مباشرًا وعمليًا.
`;
try {
const result = await this.model.generateContent(prompt);
const text = result.response.text();
// 2. Save to Cache | حفظ في الذاكرة المخبأة
if (campaignId) {
try {
await this.aiAnalysisRepository.save({
campaignId,
imageUrl: 'TEXT_ONLY',
analysisText: text,
analysisDate: today,
tier: 'text-only',
metrics,
});
} catch (saveError) {
this.logger.error(`Failed to cache text AI recommendations: ${saveError.message}`);
}
}
return text;
} catch (error: any) {
this.logger.error(`Gemini Text Error: ${error.message}`, error.stack);
return 'Failed to generate text-based AI recommendations.';
}
}
async analyzeCreative(
imageUrl: string,
options: {
adCopy?: string;
metrics?: {
impressions?: number;
clicks?: number;
spend?: number;
ctr?: number;
cpc?: number;
cpm?: number;
conversions?: number;
costPerResult?: number;
objective?: string;
frequency?: number;
reach?: number;
hookRate?: number;
thumbStopRate?: number;
landingPageViews?: number;
};
} = {},
tier: string = 'standard',
campaignId?: string,
): Promise<string> {
const { adCopy, metrics } = options;
const today = new Date().toISOString().split('T')[0];
// 1. Check Cache | البحث في الذاكرة المخبأة
if (campaignId) {
const cached = await this.aiAnalysisRepository.findOne({
where: { campaignId, analysisDate: today, tier },
order: { createdAt: 'DESC' }
});
if (cached) {
this.logger.log(`Using cached AI analysis for campaign ${campaignId}`);
return cached.analysisText;
}
}
const model = this.getModelForTier(tier) || this.model;
if (!model) {
return 'AI Vision is unavailable.';
}
try {
this.logger.log(`Analyzing creative at URL: ${imageUrl}`);
this.logger.log(`adCopy: ${adCopy || 'None'}`);
this.logger.log(`metrics provided: ${metrics ? 'Yes' : 'No'}`);
if (metrics) {
this.logger.log(`metrics keys: ${Object.keys(metrics).join(', ')}`);
this.logger.log(`metrics values: ${JSON.stringify(metrics)}`);
}
const response = await axios.get<ArrayBuffer>(imageUrl, {
responseType: 'arraybuffer',
timeout: 20000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
},
});
const contentType = response.headers['content-type'] || 'image/jpeg';
const imageData = Buffer.from(response.data).toString('base64');
const prompt = `
أنت تعمل كخبير Meta Ads + Creative Strategist + Marketing Psychologist.
مهمتك هي تحليل هذا الإعلان اعتماداً على:
1) الصورة نفسها
2) النص الإعلاني
3) أرقام الأداء الفعلية (إن وجدت)
النص الإعلاني:
"${adCopy || 'لا يوجد نص'}"
بيانات الأداء:
- Objective: ${metrics?.objective ?? 'غير متوفر'}
- Impressions: ${metrics?.impressions ?? 'غير متوفر'}
- Reach: ${metrics?.reach ?? 'غير متوفر'}
- Clicks: ${metrics?.clicks ?? 'غير متوفر'}
- Spend: ${metrics?.spend ?? 'غير متوفر'}
- CTR: ${metrics?.ctr ?? 'غير متوفر'}%
- CPC: ${metrics?.cpc ?? 'غير متوفر'}
- CPM: ${metrics?.cpm ?? 'غير متوفر'}
- Conversions: ${metrics?.conversions ?? 'غير متوفر'}
- Cost Per Result: ${metrics?.costPerResult ?? 'غير متوفر'}
- Frequency: ${metrics?.frequency ?? 'غير متوفر'}
- Hook Rate: ${metrics?.hookRate ?? 'غير متوفر'}
- Thumb Stop Rate: ${metrics?.thumbStopRate ?? 'غير متوفر'}
- Landing Page Views: ${metrics?.landingPageViews ?? 'غير متوفر'}
حلل الإعلان بشكل مباشر وصريح جداً وفق هذا الترتيب:
1) الحكم السريع:
- هل الإعلان جيد أم ضعيف؟
- هل يصلح للتحويل أم فقط للوعي؟
- أعطِ Verdict واضح (اختر واحدة فقط):
- ممتاز للأداء (Scale Ready)
- جيد للأداء
- مناسب للوعي فقط (Awareness Only)
- ضعيف للتحويل (Underperforming)
- البيانات غير كافية
2) Stop Scroll:
- هل الصورة توقف التمرير خلال أول 1-2 ثانية؟
- أعطِ تقييم من 10
- اربط الحكم بالشكل وبالأرقام
3) Clarity:
- هل العرض واضح خلال أقل من ثانيتين؟
- ما أول شيء يفهمه المستخدم؟
- هل يوجد تشويش؟
4) Visual Hierarchy:
- ما أول عنصر تراه العين؟
- هل العرض الأساسي واضح ومضخم أم ضائع؟
5) Offer + CTA:
- هل العرض قوي؟
- هل CTA واضح؟
- هل يوجد تطابق بين الرسالة وما يجب على المستخدم فعله؟
6) تحليل السبب الجذري (Performance Drivers):
- ما السبب الحقيقي وراء هذا الأداء؟
- اختر العنصر الأكثر تأثيراً:
- الكرياتيف (الصورة)
- النص الإعلاني (Copy)
- العرض (Offer)
- الاستهداف (Targeting)
- صفحة الهبوط (Landing Page)
- أو البيانات غير كافية للحكم
- اشرح لماذا أدى هذا العنصر إلى هذه النتائج (سواء كانت إيجابية أو سلبية) بوضوح.
7) أهم 3 أخطاء حرجة:
- اذكر فقط 3 أخطاء واضحة ومؤثرة
8) 3 تحسينات مباشرة:
- أعطِ 3 تحسينات قابلة للتنفيذ فوراً
- بدون تنظير
9) فكرة إعلان بديلة أقوى:
- اقترح فكرة إعلان أبسط وأوضح
- تعتمد على:
- تضخيم العرض
- عنصر بصري واحد
- وضوح عالي
- يجب أن تكون سهلة التنفيذ
- لا تعتمد على فيديو معقد أو إنتاج صعب إلا إذا كان ضرورياً جداً
10) تقييم سريع (Scores):
- Stop Scroll: /10
- Clarity: /10
- عرض (Offer Visibility): /10
- CTA: /10
- احتمالية التحويل (Conversion Potential): /10
11) التأثير المتوقع على الأداء:
- وضّح كيف يؤثر هذا الإعلان على:
- CTR
- CPC
- Conversion
- إذا لم توجد بيانات فعلية، اذكر أن هذا التقدير مبني على التحليل البصري والنصي فقط
12) القرار النهائي والخطوة التالية (Final Decision):
- اختر واحدة فقط:
- إيقاف الإعلان (Stop)
- تعديل الإعلان (Optimize)
- الاستمرار به (Continue)
- زيادة الميزانية / توسيع الاستهداف (Scale - إذا كان الأداء ممتازاً)
- ثم اكتب الخطوة العملية التالية بشكل واضح ومباشر.
13) مستوى الثقة:
- High / Medium / Low
- واذكر السبب، خصوصاً إذا كانت البيانات ناقصة
14) أمر توليد صورة بديلة (Nano Banana Prompt):
- إذا وجدت أن جودة الصورة أو الفيديو الحالية ضعيفة، أو غير احترافية، أو يمكن تحسينها بشكل كبير لجذب الانتباه وزيادة التحويل (CTR/Conversion)، قم بكتابة أمر (Prompt) مفصل باللغة الإنجليزية مخصص لأداة "Nano Banana".
- يجب أن يكون الـ Prompt مصمماً بدقة ليتوافق مع:
- محتوى الإعلان الحالي (مع تحسينه بصرياً).
- سياسات الإعلانات (Ad Policies).
- هدف الحملة (Objective).
- ضع الـ Prompt داخل كتلة كود (Code Block) ليسهل نسخه لاحقاً.
- إذا كانت الجودة ممتازة جداً ولا تحتاج لتغيير، لا تكتب هذا القسم.
تعليمات صارمة:
- لا تعطِ تحليلاً جمالياً فقط
- ركّز على الأداء (CTR / Conversion)
- اربط دائماً بين الصورة والأرقام
- إذا كانت الأرقام غير متوفرة، اذكر أن التحليل تقديري
- إذا تعارض الشكل مع الأرقام، اعتمد على الأرقام
- يجب أن يكون Verdict متوافقاً تماماً مع باقي التحليل
- إذا كان Verdict = "ضعيف للتحويل" فلا يجوز وصف الإعلان بأنه مناسب للتحويل
- إذا كان Verdict = "مناسب للوعي فقط" فلا يجوز التوصية بالاستمرار به كإعلان Conversion
- لا تكتب JSON
- لا تستخدم كلاماً عاماً أو فلسفياً
- اكتب بالعربية فقط (باستثناء الـ Nano Banana Prompt يكتب بالإنجليزية)
- كن مباشراً جداً وعملياً
`;
this.logger.log('prompt: ', prompt);
const result = await model.generateContent([
prompt,
{
inlineData: {
data: imageData,
mimeType: contentType,
},
},
]);
const text = result.response.text();
this.logger.log('Gemini creative analysis completed successfully.');
// 3. Save to Cache | حفظ النتيجة في الذاكرة
if (campaignId) {
try {
await this.aiAnalysisRepository.save({
campaignId,
imageUrl,
adCopy,
metrics,
analysisText: text,
analysisDate: today,
tier,
});
} catch (saveError) {
this.logger.error(`Failed to cache AI analysis: ${saveError.message}`);
}
}
return text;
} catch (error: any) {
const errorMsg = error.response ? `API Error (${error.response.status}): ${error.response.data?.error?.message || error.message}` : error.message;
this.logger.error(`AI Analysis Error: ${errorMsg}`, error.stack);
return `فشل التحليل: ${errorMsg}. يرجى التأكد من أن رابط الصورة يعمل ومتاح للجميع.`;
}
}
}

View File

@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import { NormalizedCampaignInsight } from '../../meta-ads/interfaces/campaign-insight.interface';
import { AnalysisResult, Insight } from '../dto/analysis-result.dto';
import { AiService } from './ai.service';
/**
* Analytics Service
* خدمة التحليلات
*
* Performs data processing, rule-based analysis, and insight generation.
* يقدم معالجة البيانات، التحليل القائم على القواعد، وتوليد الرؤى.
*/
@Injectable()
export class AnalyticsService {
constructor(
private readonly aiService: AiService,
) {}
/**
* Check if a campaign has any historical AI analysis
*/
async hasAnyAnalysis(campaignId: string): Promise<boolean> {
return this.aiService.hasAnyAnalysis(campaignId);
}
/**
* Analyze normalized campaign data
* تحليل بيانات الحملة الموحدة
*/
async analyzeCampaign(data: NormalizedCampaignInsight): Promise<AnalysisResult> {
const insights: Insight[] = [];
// Rule 1: CTR analysis (Creative Weakness) | تحليل نسبة النقر (ضعف التصميم)
if (data.ctr < 1.0) {
insights.push({
type: 'warning',
code: 'LOW_CTR',
message: 'Campaign has low CTR (< 1.0%). Design is likely unappealing.',
recommendation: 'A/B test different thumbnails and headlines. Focus on the first 3 seconds of video content.',
});
} else if (data.ctr > 2.5) {
insights.push({
type: 'success',
code: 'HIGH_CTR',
message: 'Excellent CTR! The ad creative is resonating well.',
recommendation: 'Consider scaling this creative to similar audiences or increased budget.',
});
}
// Rule 2: CPC analysis (Targeting Error) | تحليل تكلفة النقرة (خطأ استهداف)
if (data.cpc > 2.0) {
insights.push({
type: 'warning',
code: 'HIGH_CPC',
message: 'Cost per click is high. You are likely bidding in a competitive or wrong audience.',
recommendation: 'Narrow down your interest targeting or test Lookalike audiences to lower costs.',
});
}
// Rule 3: Engagement check (Landing Page/Offer Weakness) | فحص التفاعل (ضعف صفحة الهبوط)
if (data.impressions > 10000 && data.clicks < 50) {
insights.push({
type: 'warning',
code: 'POOR_ENGAGEMENT',
message: 'High impressions but extremely low clicks. Audience-Offer mismatch.',
recommendation: 'The offer may not be compelling. Try adding a "Call to Action" or a limited-time discount.',
});
}
// Rule 4: CPM analysis (Market Competitiveness) | تحليل تكلفة الألف ظهور
if (data.cpm > 15.0) {
insights.push({
type: 'warning',
code: 'EXPENSIVE_AUDIENCE',
message: 'You are paying a premium for these impressions.',
recommendation: 'Try broadening your audience age or geography to find cheaper pockets of inventory.',
});
}
// If no warnings, add general info | إذا لم تكن هناك تحذيرات، أضف معلومات عامة
if (insights.length === 0) {
insights.push({
type: 'info',
code: 'STABLE_PERFORMANCE',
message: 'Campaign is performing within normal parameters.',
});
}
// AI Enrichment | إغراء التحليل بالذكاء الاصطناعي
const aiRecommendation = await this.aiService.generateCampaignInsights(data, data.campaignId);
return {
campaignId: data.campaignId,
campaignName: data.campaignName,
metrics: {
impressions: data.impressions,
clicks: data.clicks,
spend: data.spend,
ctr: data.ctr,
cpc: data.cpc,
cpm: data.cpm,
},
insights,
aiRecommendation,
};
}
/**
* Batch analysis | تحليل الدفعة
*/
async analyzeBatch(items: NormalizedCampaignInsight[]): Promise<AnalysisResult[]> {
return Promise.all(items.map((item) => this.analyzeCampaign(item)));
}
/**
* Analyze ad creative visually using AI Vision
* تحليل التصميم الإعلاني بصرياً باستخدام رؤية الذكاء الاصطناعي
*/
async analyzeVisual(
imageUrl: string,
adCopy?: string,
metrics?: any,
tier: string = 'standard',
campaignId?: string
): Promise<string> {
return this.aiService.analyzeCreative(imageUrl, { adCopy, metrics }, tier, campaignId);
}
}

View File

@@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return welcome message', () => {
expect(appController.getHello()).toEqual({
message: 'Welcome to Ads Analytics SaaS API',
version: '1.0',
docs: '/api/docs',
status: 'active',
});
});
});
});

18
src/app.controller.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@ApiTags('root')
@Controller()
export class AppController {
@Get()
@ApiOperation({ summary: 'Welcome to the API | الترحيب بالـ API' })
@ApiResponse({ status: 200, description: 'API is running | الـ API يعمل بنجاح' })
getHello() {
return {
message: 'Welcome to Ads Analytics SaaS API',
version: '1.0',
docs: '/api/docs',
status: 'active',
};
}
}

90
src/app.module.ts Normal file
View File

@@ -0,0 +1,90 @@
import { Module } from '@nestjs/common';
/**
* AppModule: Root Module for the Ads Analytics SaaS
* هنا نقوم بتعريف جميع الوحدات والخدمات التي سيعمل بها النظام
*/
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HealthModule } from './health/health.module';
import { MetaAdsModule } from './meta-ads/meta-ads.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { PaymentsModule } from './payments/payments.module';
import { AutomationModule } from './automation/automation.module';
import { NotificationModule } from './notifications/notification.module';
import { ScheduleModule } from '@nestjs/schedule';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import configuration from './config/configuration';
import { validate } from './config/env.validation';
import { AppController } from './app.controller';
/**
* App Module
* الوحدة الأساسية للتطبيق
*
* Why: This is the root of everything. We import ConfigModule globally here.
* لماذا: هذا هو جذر كل شيء. نقوم باستيراد وحدة الإعدادات عالمياً هنا.
*/
@Module({
imports: [
// Configuration Module | وحدة الإعدادات
ConfigModule.forRoot({
isGlobal: true, // Make settings available everywhere | جعل الإعدادات متاحة في كل مكان
load: [configuration], // Load factory | تحميل المصنع
validate, // Validation function | وظيفة التحقق
}),
ScheduleModule.forRoot(),
// TypeORM Configuration | إعدادات قاعدة البيانات
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get<string>('database.host'),
port: config.get<number>('database.port'),
username: config.get<string>('database.username'),
password: config.get<string>('database.password'),
database: config.get<string>('database.database'),
autoLoadEntities: true, // Automatically load entities | تحميل الكيانات لقائياً
synchronize: true, // Auto create/update tables (DEV ONLY) | إنشاء وتحديث الجداول تلقائياً (للتطوير فقط)
}),
}),
HealthModule,
MetaAdsModule,
AnalyticsModule,
UsersModule,
AuthModule,
PaymentsModule,
// Automation Module (SaaS Pro) | وحدة الأتمتة
AutomationModule,
// Notification Module (SaaS Pro) | وحدة التنبيهات
NotificationModule,
// Cache Module (Redis with In-Memory fallback) | وحدة التخزين المؤقت
CacheModule.registerAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
try {
const host = config.get<string>('redis.host');
const port = config.get<number>('redis.port');
const store = await redisStore({
socket: { host, port },
ttl: 60 * 60, // 1 hour
});
return { store };
} catch (error) {
// Fallback to memory store if Redis is unavailable | العودة للتخزين في الذاكرة في حال عدم توفر ريديس
return {
ttl: 60 * 60,
};
}
},
}),
],
controllers: [AppController],
providers: [],
})
export class AppModule {}

8
src/app.service.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

115
src/auth/auth.controller.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Controller, Get, Query, Res, HttpStatus, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import type { Response } from 'express';
import { MetaAdsService } from '../meta-ads/services/meta-ads.service';
import { TikTokAdsService } from '../tiktok-ads/services/tiktok-ads.service';
import { GoogleAdsService } from '../google-ads/services/google-ads.service';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private readonly configService: ConfigService,
private readonly metaAdsService: MetaAdsService,
private readonly tiktokAdsService: TikTokAdsService,
private readonly googleAdsService: GoogleAdsService,
) {}
async loginToMeta(@Query('userId') userId: string, @Res() res: Response) {
const appId = this.configService.get<string>('meta.appId');
const redirectUri = this.configService.get<string>('meta.redirectUri');
const state = userId || 'default';
const url = `https://www.facebook.com/v19.0/dialog/oauth?client_id=${appId}&redirect_uri=${redirectUri}&scope=ads_management,ads_read,public_profile,email&state=${state}`;
return res.redirect(url);
}
async metaCallback(@Query('code') code: string, @Query('state') state: string, @Res() res: Response) {
if (!code) return res.status(HttpStatus.BAD_REQUEST).json({ message: 'No code provided' });
try {
this.logger.log(`Received Meta OAuth callback. State: ${state}`);
const shortToken = await this.metaAdsService.exchangeCodeForToken(code);
const longToken = await this.metaAdsService.getLongLivedToken(shortToken);
const adAccounts = await this.metaAdsService.getAdAccounts(longToken);
// If we have a userId in state, automatically save first account
if (state && state !== 'default' && adAccounts.length > 0) {
await this.metaAdsService.connectAccount({
userId: state,
platform: 'meta',
accessToken: longToken,
adAccountId: adAccounts[0].id,
name: adAccounts[0].name || 'Meta Account (OAuth)'
});
}
// Redirect back to dashboard with success
const frontendUrl = 'http://localhost:5001'; // Should be dynamic in prod
return res.redirect(`${frontendUrl}?auth=success&platform=meta`);
} catch (error) {
this.logger.error('Meta Auth failed:', error.message);
return res.redirect('http://localhost:5001?auth=failed');
}
}
@Get('tiktok')
@ApiOperation({ summary: 'Redirect to TikTok Login | التوجيه لتسجيل الدخول بتيك توك' })
async loginToTikTok(@Res() res: Response) {
const appId = this.configService.get<string>('tiktok.appId');
const redirectUriRaw = this.configService.get<string>('tiktok.redirectUri');
if (!appId || !redirectUriRaw) {
return res.status(HttpStatus.BAD_REQUEST).json({
message: 'TikTok OAuth is not configured. Please add TIKTOK_APP_ID and TIKTOK_REDIRECT_URI to .env'
});
}
const redirectUri = btoa(redirectUriRaw);
const url = `https://business-api.tiktok.com/portal/auth?app_id=${appId}&state=state&redirect_uri=${redirectUri}`;
return res.redirect(url);
}
@Get('tiktok/callback')
@ApiOperation({ summary: 'TikTok OAuth Callback | استقبال كود تيك توك' })
async tiktokCallback(@Query('auth_code') code: string, @Res() res: Response) {
if (!code) return res.status(HttpStatus.BAD_REQUEST).json({ message: 'No code provided' });
try {
const token = await this.tiktokAdsService.exchangeCodeForToken(code);
const accounts = await this.tiktokAdsService.getAdAccounts(token);
return res.status(HttpStatus.OK).json({ message: 'TikTok Connected', accessToken: token, accounts });
} catch (error) {
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ message: 'TikTok Auth failed' });
}
}
@Get('google')
@ApiOperation({ summary: 'Redirect to Google Login | التوجيه لتسجيل الدخول بجوجل' })
async loginToGoogle(@Res() res: Response) {
const clientId = this.configService.get<string>('google.clientId');
const redirectUri = this.configService.get<string>('google.redirectUri');
if (!clientId || !redirectUri) {
return res.status(HttpStatus.BAD_REQUEST).json({
message: 'Google Ads OAuth is not configured. Please add GOOGLE_CLIENT_ID and GOOGLE_REDIRECT_URI to .env'
});
}
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=https://www.googleapis.com/auth/adwords&access_type=offline&prompt=consent`;
return res.redirect(url);
}
@Get('google/callback')
@ApiOperation({ summary: 'Google OAuth Callback | استقبال كود جوجل' })
async googleCallback(@Query('code') code: string, @Res() res: Response) {
if (!code) return res.status(HttpStatus.BAD_REQUEST).json({ message: 'No code provided' });
try {
const token = await this.googleAdsService.exchangeCodeForToken(code);
const accounts = await this.googleAdsService.getAdAccounts(token);
return res.status(HttpStatus.OK).json({ message: 'Google Connected', accessToken: token, accounts });
} catch (error) {
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ message: 'Google Auth failed' });
}
}
}

12
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { MetaAdsModule } from '../meta-ads/meta-ads.module';
import { TikTokAdsModule } from '../tiktok-ads/tiktok-ads.module';
import { GoogleAdsModule } from '../google-ads/google-ads.module';
@Module({
imports: [MetaAdsModule, TikTokAdsModule, GoogleAdsModule],
controllers: [AuthController],
providers: [],
})
export class AuthModule {}

View File

@@ -0,0 +1,68 @@
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
/**
* Subscription Guard
* حارس الاشتراك
*
* Ensures the user has a 'pro' subscription before accessing premium features.
* يضمن أن المستخدم لديه اشتراك 'pro' قبل الوصول إلى الميزات المدفوعة.
*/
@Injectable()
export class SubscriptionGuard implements CanActivate {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// For now, we expect a 'x-user-id' header for testing without full JWT
// حالياً، نتوقع وجود معرف مستخدم في الهيدر لأغراض التجربة بدون JWT كامل
const userId = request.headers['x-user-id'];
if (!userId) {
throw new HttpException('User Identification required (x-user-id header missing).', HttpStatus.UNAUTHORIZED);
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new HttpException('User not found.', HttpStatus.NOT_FOUND);
}
if (user.subscriptionTier === 'pro') {
return true;
}
// Trial Logic for 'free' users | منطق التجربة للمستخدمين المجانيين
const now = new Date();
const trialExpired = user.trialEndsAt && now > user.trialEndsAt;
const limitReached = user.requestCount >= 10;
if (trialExpired || limitReached) {
const reason = trialExpired ? 'Trial period (7 days) expired.' : 'Request limit (10) reached.';
throw new HttpException(
`Your free trial has ended (${reason}). Please upgrade to "pro" ($30/month) to continue.`,
HttpStatus.PAYMENT_REQUIRED
);
}
// Increment request count | زيادة عدد الطلبات
user.requestCount += 10; // Actually increment by 1 normally, but here we track usage
// For this POC, let's just increment by 1
user.requestCount -= 9; // Fix to increment by 1
// Better logic:
// user.requestCount++;
// await this.userRepository.save(user);
// Let's use clean code:
await this.userRepository.update(user.id, { requestCount: user.requestCount + 1 });
return true;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Rule } from './entities/rule.entity';
import { User } from '../users/entities/user.entity';
import { AutomationService } from './services/automation.service';
import { UsersModule } from '../users/users.module';
import { RuleEngineService } from './services/rule-engine.service';
import { AutomationController } from './controllers/automation.controller';
import { AnalyticsModule } from '../analytics/analytics.module';
import { MetaAdsModule } from '../meta-ads/meta-ads.module';
@Module({
imports: [
TypeOrmModule.forFeature([Rule, User]),
AnalyticsModule,
MetaAdsModule,
UsersModule,
],
providers: [RuleEngineService, AutomationService],
controllers: [AutomationController],
exports: [RuleEngineService, AutomationService],
})
export class AutomationModule {}

View File

@@ -0,0 +1,62 @@
import { Controller, Post, Body, Get, Param, Delete, Patch, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Rule } from '../entities/rule.entity';
import { CreateRuleDto } from '../dto/create-rule.dto';
import { RuleEngineService } from '../services/rule-engine.service';
import { User } from '../../users/entities/user.entity';
@ApiTags('automation')
@Controller('automation')
export class AutomationController {
private readonly logger = new Logger(AutomationController.name);
constructor(
@InjectRepository(Rule)
private readonly ruleRepository: Repository<Rule>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly ruleEngineService: RuleEngineService,
) {}
@Post('rules')
@ApiOperation({ summary: 'Create a new automation rule | إنشاء قاعدة أتمتة جديدة' })
async createRule(@Body() dto: CreateRuleDto) {
const user = await this.userRepository.findOne({ where: { id: dto.userId } });
if (!user) throw new Error('User not found');
const rule = this.ruleRepository.create({
...dto,
user,
targetId: dto.targetId || 'all',
});
return this.ruleRepository.save(rule);
}
@Get('rules/:userId')
@ApiOperation({ summary: 'Get all rules for a user | جلب جميع القواعد للمستخدم' })
async getRules(@Param('userId') userId: string) {
return this.ruleRepository.find({
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
});
}
@Post('process/:platform')
@ApiOperation({ summary: 'Manually trigger rule processing | تشغيل يدوي لمعالجة القواعد' })
async triggerProcess(@Param('platform') platform: 'meta' | 'tiktok' | 'google') {
this.logger.log(`Manual trigger for rule processing: ${platform}`);
// This would normally run via a Cron job every 15-60 minutes
await this.ruleEngineService.processActiveRules(platform);
return { status: 'success', message: `Rules processed for ${platform}` };
}
@Delete('rules/:id')
@ApiOperation({ summary: 'Delete a rule | حذف قاعدة' })
async deleteRule(@Param('id') id: string) {
await this.ruleRepository.delete(id);
return { status: 'deleted' };
}
}

View File

@@ -0,0 +1,48 @@
import { IsString, IsNotEmpty, IsEnum, IsArray, ValidateNested, IsOptional, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { RuleAction, RuleConditionMetric, RuleConditionOperator } from '../entities/rule.entity';
class RuleConditionDto {
@IsEnum(RuleConditionMetric)
metric: RuleConditionMetric;
@IsEnum(RuleConditionOperator)
operator: RuleConditionOperator;
@IsNumber()
value: number;
@IsString()
@IsOptional()
timeRange?: string = 'LAST_7_DAYS';
}
export class CreateRuleDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEnum(RuleAction)
action: RuleAction;
@IsArray()
@ValidateNested({ each: true })
@Type(() => RuleConditionDto)
conditions: RuleConditionDto[];
@IsString()
@IsNotEmpty()
platform: 'meta' | 'tiktok' | 'google';
@IsString()
@IsOptional()
targetId: string;
@IsString()
@IsNotEmpty()
userId: string;
@IsNotEmpty()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,60 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum RuleAction {
PAUSE = 'pause',
NOTIFY = 'notify',
SCALE_DOWN = 'scale_down',
}
export enum RuleConditionMetric {
SPEND = 'spend',
ROAS = 'roas',
CPA = 'cpa',
CTR = 'ctr',
CLICKS = 'clicks',
}
export enum RuleConditionOperator {
GREATER_THAN = '>',
LESS_THAN = '<',
EQUAL = '=',
}
@Entity('rules')
export class Rule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'enum', enum: RuleAction, default: RuleAction.NOTIFY })
action: RuleAction;
@Column({ type: 'jsonb' })
conditions: {
metric: RuleConditionMetric;
operator: RuleConditionOperator;
value: number;
timeRange: string; // e.g., 'last_7_days', 'today'
}[];
@Column()
platform: 'meta' | 'tiktok' | 'google';
@Column({ nullable: true })
targetId: string; // ID of the campaign, adset, or ad to monitor. 'all' for everything.
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => User, (user) => user.id)
user: User;
}

View File

@@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { UsersService } from '../../users/services/users.service';
import { MetaAdsService } from '../../meta-ads/services/meta-ads.service';
import { AnalyticsService } from '../../analytics/services/analytics.service';
import { NotificationService } from '../../notifications/services/notification.service';
import { MetaLevel } from '../../meta-ads/dto/fetch-insights.dto';
import { RuleEngineService } from './rule-engine.service';
@Injectable()
export class AutomationService {
private readonly logger = new Logger(AutomationService.name);
constructor(
private readonly usersService: UsersService,
private readonly metaAdsService: MetaAdsService,
private readonly analyticsService: AnalyticsService,
private readonly notificationService: NotificationService,
private readonly ruleEngineService: RuleEngineService,
) {
this.logger.log('AutomationService initialized with Cron support | تم تهيئة خدمة الأتمتة');
}
/**
* Daily Ads Health Check Cron Job
* فحص صحة الإعلانات يومياً
* Runs at midnight every day
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleDailyHealthCheck() {
this.logger.log('Starting daily ad health check cron job | بدء فحص صحة الإعلانات اليومي');
try {
const users = await this.usersService.findAll();
this.logger.log(`Scanning ${users.length} users for ad performance...`);
for (const user of users) {
await this.scanUserPerformance(user.id, user.subscriptionTier, user.fcmToken);
}
this.logger.log('Daily ad health check completed | اكتمل فحص صحة الإعلانات');
} catch (error) {
this.logger.error(`Cron Job Failed: ${error.message}`);
}
}
private async scanUserPerformance(userId: string, tier: string, fcmToken?: string) {
try {
this.logger.log(`Processing automation rules for user ${userId}...`);
// 1. Process user-defined rules
await this.ruleEngineService.processActiveRules('meta');
// 2. Smart Health Check (AI Heuristics)
const accounts = await this.metaAdsService.getConnectedAccounts(userId);
for (const account of accounts) {
const insights = await this.metaAdsService.fetchInsights({
userId,
adAccountId: account.externalAdAccountId,
level: MetaLevel.CAMPAIGN,
});
for (const item of insights) {
const isCampaignActive = item.status === 'ACTIVE' || item.status === 'ENABLED';
const hasAnalysis = await this.analyticsService.hasAnyAnalysis(item.campaignId);
if (!hasAnalysis) {
if (isCampaignActive && (item.ctr < 1.0 || item.cpc > 2.0)) {
await this.performAiAnalysisAndNotify(item, tier, userId, fcmToken);
} else if (!isCampaignActive) {
await this.performAiAnalysisAndNotify(item, tier, userId, fcmToken);
}
}
}
}
} catch (error) {
this.logger.warn(`Failed to scan performance for user ${userId}: ${error.message}`);
}
}
private async performAiAnalysisAndNotify(item: any, tier: string, userId: string, fcmToken?: string) {
try {
this.logger.log(`Running AI analysis for campaign: ${item.campaignName}`);
const analysis = await this.analyticsService.analyzeVisual(
item.imageUrl || '',
item.adCopy,
item,
tier,
item.campaignId
);
// notify if recommendation is critical
if (analysis.includes('إيقاف') || analysis.includes('Stop') || analysis.includes('Poor') || analysis.includes('ضعيف')) {
const alertMsg = `⚠️ تنبيه من SaaS Pro:\nحملة "${item.campaignName}" تعاني من ضعف أداء.\n\nتوصية AI: ${analysis.substring(0, 100)}...`;
await this.notificationService.send(alertMsg, ['telegram', 'fcm'], { fcmToken });
this.logger.log(`Alert sent for campaign ${item.campaignId}`);
}
} catch (e) {
this.logger.error(`AI Analysis/Notification failed for ${item.campaignId}: ${e.message}`);
}
}
}

View File

@@ -0,0 +1,153 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Rule, RuleAction, RuleConditionMetric, RuleConditionOperator } from '../entities/rule.entity';
import { MetaAdsService } from '../../meta-ads/services/meta-ads.service';
import { NormalizedCampaignInsight } from '../../meta-ads/interfaces/campaign-insight.interface';
import { MetaLevel } from '../../meta-ads/dto/fetch-insights.dto';
import { NotificationService } from '../../notifications/services/notification.service';
@Injectable()
/**
* RuleEngineService: محرك القواعد والأتمتة
* هذا الملف هو "العقل" الذي يراقب الإعلانات ويتخذ قرارات تلقائية (Auto-Pilot)
*/
export class RuleEngineService {
private readonly logger = new Logger(RuleEngineService.name);
/**
* constructor: هنا يحدث "حقن التبعيات" (Dependency Injection)
* بدلاً من إنشاء الخدمات يدوياً، نطلب من NestJS تزويدنا بها جاهزة
*
* @param ruleRepository - المرجع للتعامل مع جدول القواعد في قاعدة البيانات
* @param metaAdsService - الخدمة المسؤولة عن التواصل مع واجهة برمجة تطبيقات ميتا
* @param notificationService - الخدمة المسؤولة عن إرسال التنبيهات (Telegram/Slack/FCM)
*/
constructor(
@InjectRepository(Rule)
private readonly ruleRepository: Repository<Rule>,
private readonly metaAdsService: MetaAdsService,
private readonly notificationService: NotificationService,
) {}
/**
* Process all active rules for a specific platform
* معالجة جميع القواعد النشطة لمنصة معينة
*/
async processActiveRules(platform: 'meta' | 'tiktok' | 'google'): Promise<void> {
const activeRules = await this.ruleRepository.find({
where: { isActive: true, platform },
relations: ['user'],
});
this.logger.log(`Processing ${activeRules.length} active rules for ${platform}`);
for (const rule of activeRules) {
try {
await this.evaluateRule(rule);
} catch (error) {
this.logger.error(`Error processing rule ${rule.id}: ${error.message}`);
}
}
}
/**
* Evaluate a single rule against live API data
*/
private async evaluateRule(rule: Rule): Promise<void> {
const userId = rule.user.id;
// 1. Fetch live metrics based on platform
if (rule.platform === 'meta') {
const accounts = await this.metaAdsService.getConnectedAccounts(userId);
for (const account of accounts) {
// If rule targets a specific account ID or 'all'
const insights = await this.metaAdsService.fetchInsights({
userId,
adAccountId: account.externalAdAccountId,
level: MetaLevel.CAMPAIGN
});
for (const insight of insights) {
// If rule targets a specific campaign, skip others
if (rule.targetId !== 'all' && insight.campaignId !== rule.targetId) continue;
const isTriggered = this.checkConditions(rule, insight);
if (isTriggered) {
this.logger.warn(`Rule "${rule.name}" TRIGGERED for campaign ${insight.campaignName} (User: ${userId})`);
await this.executeAction(rule, insight, account.accessToken);
}
}
}
}
}
/**
* Check if metrics meet rule conditions
*/
private checkConditions(rule: Rule, data: NormalizedCampaignInsight): boolean {
return rule.conditions.every((condition) => {
const metricValue = this.getMetricValue(data, condition.metric);
switch (condition.operator) {
case RuleConditionOperator.GREATER_THAN:
return metricValue > condition.value;
case RuleConditionOperator.LESS_THAN:
return metricValue < condition.value;
case RuleConditionOperator.EQUAL:
return metricValue === condition.value;
default:
return false;
}
});
}
/**
* Extract specific metric from normalized data
*/
private getMetricValue(data: NormalizedCampaignInsight, metric: RuleConditionMetric): number {
switch (metric) {
case RuleConditionMetric.SPEND: return data.spend;
case RuleConditionMetric.ROAS: return data.spend > 0 ? (data.clicks * 2) / data.spend : 0; // Mock ROAS logic
case RuleConditionMetric.CPA: return data.clicks > 0 ? data.spend / data.clicks : 0;
case RuleConditionMetric.CTR: return data.ctr;
case RuleConditionMetric.CLICKS: return data.clicks;
default: return 0;
}
}
/**
* Execute the defined action (e.g., Pause Ad)
*/
private async executeAction(rule: Rule, data: NormalizedCampaignInsight, accessToken?: string): Promise<void> {
switch (rule.action) {
case RuleAction.PAUSE:
this.logger.log(`ACTION: Pausing campaign ${data.campaignId}`);
if (accessToken) {
await this.metaAdsService.updateAdStatus(data.campaignId, accessToken, 'PAUSED');
} else {
this.logger.warn(`Cannot update status: No access token for account.`);
}
break;
case RuleAction.NOTIFY:
this.logger.log(`ACTION: Sending notification for campaign ${data.campaignName}`);
const channels: ('telegram' | 'fcm')[] = ['telegram'];
if (rule.user?.fcmToken) {
channels.push('fcm');
}
await this.notificationService.send(
`🔔 تنبيه الأتمتة: القاعدة "${rule.name}" تحققت لحملة: ${data.campaignName}.\nالمؤشر: ${rule.conditions[0].metric} ${rule.conditions[0].operator} ${rule.conditions[0].value}`,
channels,
{ fcmToken: rule.user?.fcmToken },
);
break;
default:
this.logger.log(`No action defined for rule ${rule.id}`);
}
}
}

View File

@@ -0,0 +1,50 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* Global Exception Filter
* مرشح استثناءات عالمي
*
* Why: To maintain a consistent error response format across the entire SaaS platform.
* لماذا: للحفاظ على تنسيق استجابة خطأ متسق عبر منصة البرمجيات كخدمة بالكامل.
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
// Log the error for the developer | تسجيل الخطأ للمطور
this.logger.error(
`HTTP Status: ${status} Error: ${JSON.stringify(message)} Path: ${request.url}`,
);
// Return structured JSON | إرجاع استجابة منظمة
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: typeof message === 'object' ? (message as any).message : message,
});
}
}

View File

@@ -0,0 +1,38 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
/**
* Logging Interceptor
* مراقب تسجيل العمليات
*
* Records how long each request takes to process.
* يسجل الوقت الذي يستغرقه كل طلب للمعالجة.
*/
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const now = Date.now();
return next
.handle()
.pipe(
tap(() =>
this.logger.log(
`[${method}] ${url} - Completed in ${Date.now() - now}ms`,
),
),
);
}
}

View File

@@ -0,0 +1,28 @@
import { NormalizedCampaignInsight } from '../../meta-ads/interfaces/campaign-insight.interface';
/**
* Ad Platform Adapter Interface
* واجهة محول منصة الإعلانات
*
* Defines the contract that every ad platform (Meta, TikTok, Google) must implement.
* تحدد العقد الذي يجب أن تنفذه كل منصة إعلانية (ميتا، تيك توك، جوجل).
*/
export interface IAdPlatformAdapter {
/**
* Fetch insights from the platform
* جلب التحليلات من المنصة
*/
fetchInsights(params: any): Promise<NormalizedCampaignInsight[]>;
/**
* Get available ad accounts
* جلب حسابات الإعلانات المتاحة
*/
getAdAccounts(accessToken: string): Promise<any[]>;
/**
* Platform name (e.g., 'meta', 'tiktok', 'google')
* اسم المنصة
*/
readonly platformName: string;
}

View File

@@ -0,0 +1,75 @@
export default () => ({
// Server port | منفذ الخادم
port: parseInt(process.env.PORT || '3001', 10),
// Environment | بيئة العمل
nodeEnv: process.env.NODE_ENV || 'development',
// Meta Ads API Configuration | إعدادات واجهة برمجة تطبيقات ميتا
// how to get access token from meta ads api
// https://developers.facebook.com/docs/marketing-apis/access-tokens/
// https://developers.facebook.com/docs/marketing-apis/access-tokens/get-long-lived-user-access-tokens/
meta: {
apiVersion: process.env.META_API_VERSION || 'v19.0',
accessToken: process.env.META_ACCESS_TOKEN,
adAccountId: process.env.META_AD_ACCOUNT_ID,
appId: process.env.META_APP_ID,
appSecret: process.env.META_APP_SECRET,
redirectUri: process.env.META_REDIRECT_URI,
},
// Analytics Configuration | إعدادات التحليلات
analytics: {
geminiApiKey: process.env.GEMINI_API_KEY,
},
// Database Configuration | إعدادات قاعدة البيانات
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
// TikTok Ads Configuration
tiktok: {
appId: process.env.TIKTOK_APP_ID,
secret: process.env.TIKTOK_SECRET,
redirectUri: process.env.TIKTOK_REDIRECT_URI,
},
// Google Ads Configuration
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI,
developerToken: process.env.GOOGLE_DEVELOPER_TOKEN,
},
// Payment Gateways
paymob: {
apiKey: process.env.PAYMOB_API_KEY,
cardIntegrationId: process.env.PAYMOB_CARD_INTEGRATION_ID,
walletIntegrationId: process.env.PAYMOB_WALLET_INTEGRATION_ID,
hmacSecret: process.env.PAYMOB_HMAC_SECRET,
},
binance: {
apiKey: process.env.BINANCE_API_KEY,
secretKey: process.env.BINANCE_SECRET_KEY,
},
notifications: {
telegramToken: process.env.TELEGRAM_BOT_TOKEN,
telegramChatId: process.env.TELEGRAM_CHAT_ID,
slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
firebase: {
serviceAccount: process.env.FIREBASE_SERVICE_ACCOUNT_JSON, // Path to service account JSON or raw JSON string
},
},
// Redis Caching Configuration | إعدادات التخزين المؤقت (ريديس)
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
});

View File

@@ -0,0 +1,163 @@
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, validateSync } from 'class-validator';
/**
* Environment variables validation schema
* مخطط التحقق من صحة متغيرات البيئة
*
* This ensures the app doesn't start if critical config is missing
* يضمن هذا عدم بدء تشغيل التطبيق إذا كانت الإعدادات الهامة مفقودة
*/
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
@IsOptional()
NODE_ENV: Environment = Environment.Development;
@IsNumber()
@IsOptional()
PORT: number = 3001;
@IsString()
META_API_VERSION: string;
@IsString()
META_ACCESS_TOKEN: string;
@IsString()
META_AD_ACCOUNT_ID: string;
// Meta App (OAuth)
@IsString()
META_APP_ID: string;
@IsString()
META_APP_SECRET: string;
@IsString()
META_REDIRECT_URI: string;
// Database | قاعدة البيانات
@IsString()
DB_HOST: string;
@IsNumber()
DB_PORT: number;
@IsString()
DB_USER: string;
@IsString()
DB_PASSWORD: string;
@IsString()
DB_NAME: string;
@IsString()
GEMINI_API_KEY: string;
// TikTok Ads
@IsString()
@IsOptional()
TIKTOK_APP_ID: string;
@IsString()
@IsOptional()
TIKTOK_SECRET: string;
@IsString()
@IsOptional()
TIKTOK_REDIRECT_URI: string;
// Google Ads
@IsString()
@IsOptional()
GOOGLE_CLIENT_ID: string;
@IsString()
@IsOptional()
GOOGLE_CLIENT_SECRET: string;
@IsString()
@IsOptional()
GOOGLE_REDIRECT_URI: string;
@IsString()
@IsOptional()
GOOGLE_DEVELOPER_TOKEN: string;
// Paymob
@IsString()
@IsOptional()
PAYMOB_API_KEY: string;
@IsString()
@IsOptional()
PAYMOB_CARD_INTEGRATION_ID: string;
@IsString()
@IsOptional()
PAYMOB_WALLET_INTEGRATION_ID: string;
@IsString()
@IsOptional()
PAYMOB_HMAC_SECRET: string;
// Binance Pay
@IsString()
@IsOptional()
BINANCE_API_KEY: string;
@IsString()
@IsOptional()
BINANCE_SECRET_KEY: string;
@IsString()
@IsOptional()
TELEGRAM_BOT_TOKEN: string;
@IsString()
@IsOptional()
TELEGRAM_CHAT_ID: string;
@IsString()
@IsOptional()
SLACK_WEBHOOK_URL: string;
@IsString()
@IsOptional()
FIREBASE_SERVICE_ACCOUNT_JSON: string;
@IsString()
@IsOptional()
REDIS_HOST: string;
@IsNumber()
@IsOptional()
REDIS_PORT: number;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(
EnvironmentVariables,
config,
{ enableImplicitConversion: true },
);
// add explain this line
// validateSync(validatedConfig, { skipMissingProperties: false });
// this line is used to validate the environment variables
// if the environment variables are not valid, it will throw an error
// skipMissingProperties: false means that all environment variables are required
// if skipMissingProperties is true, it will not throw an error if the environment variables are not valid
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { GoogleAdsService } from './services/google-ads.service';
@Module({
imports: [HttpModule],
providers: [GoogleAdsService],
exports: [GoogleAdsService],
})
export class GoogleAdsModule {}

View File

@@ -0,0 +1,66 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { IAdPlatformAdapter } from '../../common/interfaces/platform-adapter.interface';
import { NormalizedCampaignInsight } from '../../meta-ads/interfaces/campaign-insight.interface';
@Injectable()
export class GoogleAdsService implements IAdPlatformAdapter {
private readonly logger = new Logger(GoogleAdsService.name);
readonly platformName = 'google';
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async fetchInsights(params: any): Promise<NormalizedCampaignInsight[]> {
this.logger.log('Fetching insights from Google Ads API (Reporting Service)...');
return [
{
campaignId: 'gg_search_998',
campaignName: 'Search - SaaS Keywords - MENA',
impressions: 45200,
clicks: 2150,
spend: 645.0,
ctr: 4.75,
cpc: 0.30,
cpm: 14.26,
dateStart: params.dateStart || '2024-03-01',
dateStop: params.dateEnd || '2024-03-10',
source: 'google',
status: 'ACTIVE',
platform: 'google',
},
];
}
async exchangeCodeForToken(code: string): Promise<string> {
const clientId = this.configService.get<string>('google.clientId');
const clientSecret = this.configService.get<string>('google.clientSecret');
const redirectUri = this.configService.get<string>('google.redirectUri');
const url = 'https://oauth2.googleapis.com/token';
try {
const response = await firstValueFrom(
this.httpService.post(url, {
code: code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
})
);
return response.data.access_token;
} catch (error) {
throw new HttpException('Google Token exchange failed', HttpStatus.BAD_GATEWAY);
}
}
async getAdAccounts(accessToken: string): Promise<any[]> {
// In a real Google Ads API integration, we would use the CustomerService or
// GoogleAdsService.search to list accessible customers.
return [{ id: 'gg_act_001', name: 'Google Ads Sample Account' }];
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
/**
* Health Controller
* متحكم الحالة الصحية
*
* Provides an endpoint to verify service availability.
* يوفر نقطة نهاية للتحقق من توفر الخدمة.
*/
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Check API status | التحقق من حالة الـ API' })
@ApiResponse({ status: 200, description: 'Service is healthy | الخدمة سليمة' })
check() {
return {
status: 'ok',
service: 'ads-analytics-backend',
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
/**
* Health Module
* وحدة الحالة الصحية
*
* Why: Isolated module for monitoring purposes.
* لماذا: وحدة معزولة لأغراض المراقبة.
*/
@Module({
controllers: [HealthController],
})
export class HealthModule {}

71
src/main.ts Normal file
View File

@@ -0,0 +1,71 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
/**
* Bootstrap Function
* وظيفة التشغيل الأساسية
*
* This is the entry point of the NestJS application.
* هذه هي نقطة البداية لتطبيق NestJS.
*/
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve Static Assets | تقديم الملفات الثابتة
// This allows us to share ad images for analysis
app.useStaticAssets(join(__dirname, '..', 'public'), {
prefix: '/api',
});
// Get ConfigService for port | الحصول على خدمة الإعدادات للمنفذ
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3001;
// Security | الحماية
// Helmet adds various HTTP headers to protect the app
app.use(helmet());
app.enableCors();
// Global Prefix | البادئة العالمية
// This helps in versioning and clean routing
app.setGlobalPrefix('api');
// Global Guards, Filters, Interceptors | الحماية والمرشحات والمراقبين العالميين
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
// Swagger Documentation | توثيق سواجير
// Essential for any SaaS backend to allow frontend devs to explore endpoints
const config = new DocumentBuilder()
.setTitle('Ads Analytics SaaS API')
.setDescription('The Meta Ads Analytics Platform API description')
.setVersion('1.0')
.addTag('ads')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// Start Server | بدء الخادم
await app.listen(port);
logger.log(`🚀 Application is running on: http://localhost:${port}/api`);
logger.log(`📑 API Documentation available at: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,45 @@
import { Controller, Post, Body, Headers } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MetaAdsService } from '../services/meta-ads.service';
import { FetchMetaInsightsDto } from '../dto/fetch-insights.dto';
import { ConnectAccountDto } from '../dto/connect-account.dto';
import { ConnectedAccount } from '../entities/connected-account.entity';
import { NormalizedCampaignInsight } from '../interfaces/campaign-insight.interface';
/**
* Meta Ads Controller
* متحكم إعلانات ميتا
*
* Exposes endpoints for Meta Ads data retrieval.
* يوفر نقاط نهاية لاسترجاع بيانات إعلانات ميتا.
*/
@ApiTags('meta-ads')
@Controller('meta')
export class MetaAdsController {
constructor(private readonly metaAdsService: MetaAdsService) {}
@Post('connect')
@ApiOperation({ summary: 'Connect an Ad Account | ربط حساب إعلاني' })
@ApiResponse({ status: 201, type: ConnectedAccount })
async connectAccount(@Body() dto: ConnectAccountDto): Promise<ConnectedAccount> {
return this.metaAdsService.connectAccount(dto);
}
@Post('insights')
@ApiOperation({ summary: 'Get normalized insights directly from Meta API | الحصول على رؤى موحدة مباشرة من ميتا' })
@ApiResponse({ status: 200, description: 'Return list of normalized campaign insights' })
async getInsights(
@Body() dto: FetchMetaInsightsDto,
@Headers('x-user-id') userId?: string,
): Promise<NormalizedCampaignInsight[]> {
if (userId) dto.userId = userId;
return this.metaAdsService.fetchInsights(dto);
}
@Post('accounts')
@ApiOperation({ summary: 'Get connected accounts for a user | جلب الحسابات المرتبطة للمستخدم' })
@ApiResponse({ status: 200, type: [ConnectedAccount] })
async getConnectedAccounts(@Body('userId') userId: string): Promise<ConnectedAccount[]> {
return this.metaAdsService.getConnectedAccounts(userId);
}
}

View File

@@ -0,0 +1,32 @@
import { IsEmail, IsNotEmpty, IsString, Matches, MinLength, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* Connect Account DTO
* DTO لربط حساب
*/
export class ConnectAccountDto {
@ApiProperty({ example: 'My Ad Account', description: 'Friendly name for the account | اسم مألوف للحساب' })
@IsString()
@MinLength(3)
name: string;
@ApiProperty({ example: 'meta', enum: ['meta', 'tiktok', 'google'] })
@IsEnum(['meta', 'tiktok', 'google'])
platform: string;
@ApiProperty({ example: 'act_123456789', description: 'Meta Ad Account ID | معرف حساب ميتا' })
@IsOptional()
@IsString()
@Matches(/^act_\d+$/, { message: 'Meta Ad Account ID must start with "act_"' })
adAccountId?: string;
@ApiProperty({ example: 'EAAYf4cd...', description: 'Access Token | توكن الوصول' })
@IsString()
@MinLength(20)
accessToken: string;
@ApiProperty({ example: 'test-user-uuid', description: 'User UUID | معرف المستخدم' })
@IsUUID()
userId: string;
}

View File

@@ -0,0 +1,63 @@
import { IsString, IsOptional, IsEnum, IsDateString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* Fetch Insights DTO
* كائن نقل بيانات جلب الرؤى
*
* Defines the structure and validation for fetching Meta Ads insights.
* يحدد الهيكل والتحقق من جلب رؤى إعلانات ميتا.
*/
export enum MetaLevel {
AD = 'ad',
ADSET = 'adset',
CAMPAIGN = 'campaign',
ACCOUNT = 'account',
}
export class FetchMetaInsightsDto {
@ApiProperty({
description: 'The Meta Ad Account ID (e.g., act_12345)',
example: 'act_123456789',
required: false,
})
@IsString()
@IsOptional()
adAccountId?: string;
@ApiProperty({
description: 'The User ID for token lookup',
example: 'user-123',
required: false,
})
@IsString()
@IsOptional()
userId?: string;
@ApiProperty({
description: 'Start date for insights (YYYY-MM-DD)',
example: '2024-01-01',
required: false,
})
@IsDateString()
@IsOptional()
dateStart?: string;
@ApiProperty({
description: 'End date for insights (YYYY-MM-DD)',
example: '2024-01-31',
required: false,
})
@IsDateString()
@IsOptional()
dateEnd?: string;
@ApiProperty({
enum: MetaLevel,
default: MetaLevel.CAMPAIGN,
description: 'Level of reporting',
})
@IsEnum(MetaLevel)
@IsOptional()
level: MetaLevel = MetaLevel.CAMPAIGN;
}

View File

@@ -0,0 +1,58 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Workspace } from '../../users/entities/workspace.entity';
/**
* Connected Account Entity
* كيان الحساب المرتبط
*
* Stores the connection between a SaaS user and an ad platform (Meta, TikTok, Google).
* يخزن الربط بين مستخدم SaaS ومنصة إعلانية (ميتا، تيك توك، جوجل).
*/
@Entity('connected_accounts')
/**
* ConnectedAccount Entity: كيان الحساب المرتبط
* يخزن بيانات الربط مع أي منصة إعلانية (ميتا، تيك توك، جوجل)
*/
export class ConnectedAccount {
@PrimaryGeneratedColumn('uuid')
id: string; // المعرف الفريد للربط
@Column()
name: string; // User-defined name for this account | اسم معرف من قبل المستخدم لهذا الحساب
@Column({
type: 'enum',
enum: ['meta', 'tiktok', 'google'],
default: 'meta',
})
platform: string;
@Column({ nullable: true })
externalAdAccountId: string; // The platform-specific ID (e.g., act_XXXX for Meta)
@Column({ type: 'jsonb', nullable: true })
credentials: Record<string, any>; // Stores tokens, secrets, etc. | يخزن التوكنات، الأسرار، إلخ
@Column({ nullable: true })
accessToken: string; // Common field for most platforms
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.accounts, { nullable: true })
workspace: Workspace;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,79 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { ConnectedAccount } from './connected-account.entity';
/**
* Meta Campaign Entity
* كيان حملة ميتا
*
* Caches campaign performance data in the database.
* يخزن بيانات أداء الحملة في قاعدة البيانات.
*/
@Entity('meta_campaigns')
@Index(['campaignId', 'userId'], { unique: true })
export class MetaCampaign {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
campaignId: string; // The Meta-specific ID
@Column()
campaignName: string;
@Column()
status: string; // ACTIVE, PAUSED, etc.
@Column({ type: 'bigint', default: 0 })
impressions: number;
@Column({ type: 'int', default: 0 })
clicks: number;
@Column({ type: 'float', default: 0 })
spend: number;
@Column({ type: 'float', default: 0 })
ctr: number;
@Column({ type: 'float', default: 0 })
cpc: number;
@Column({ type: 'float', default: 0 })
cpm: number;
@Column({ nullable: true })
imageUrl: string;
@Column({ type: 'text', nullable: true })
adCopy: string;
@Column({ nullable: true })
platform: string; // facebook, instagram
@Column({ type: 'jsonb', nullable: true })
rawMetrics: any;
@Column({ type: 'timestamp', nullable: true })
lastSyncedAt: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
@ManyToOne(() => ConnectedAccount, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'accountId' })
account: ConnectedAccount;
@Column()
accountId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,37 @@
/**
* Normalized Campaign Insight
* رؤى الحملة الموحدة
*
* This is our internal representation of an ad campaign performance.
* هذا هو تمثيلنا الداخلي لأداء حملة إعلانية.
*
* Using this instead of raw API responses makes our system "Multi-platform ready".
* استخدام هذا بدلاً من استجابات API الخام يجعل نظامنا "جاهزاً لمنصات متعددة".
*/
export interface NormalizedCampaignInsight {
campaignId: string;
campaignName: string;
impressions: number;
clicks: number;
spend: number;
ctr: number;
cpc: number;
cpm: number;
dateStart: string;
dateStop: string;
source: 'meta' | 'google' | 'tiktok';
status: string;
platform?: string; // e.g., facebook, instagram, google, tiktok
imageUrl?: string;
adCopy?: string;
aiRecommendation?: string;
// Extra metrics for AI
conversions?: number;
costPerResult?: number;
objective?: string;
frequency?: number;
reach?: number;
hookRate?: number;
thumbStopRate?: number;
landingPageViews?: number;
}

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MetaAdsService } from './services/meta-ads.service';
import { MetaAdsController } from './controllers/meta-ads.controller';
import { User } from '../users/entities/user.entity';
import { ConnectedAccount } from './entities/connected-account.entity';
import { UsersModule } from '../users/users.module';
import { Workspace } from '../users/entities/workspace.entity';
import { MetaCampaign } from './entities/meta-campaign.entity';
import { AIAnalysis } from '../analytics/entities/ai-analysis.entity';
/**
* Meta Ads Module
* وحدة إعلانات ميتا
*
* Why: Encapsulates all logic related to Meta Ads Marketing API.
* لماذا: تغلف كل المنطق المتعلق بواجهة برمجة تطبيقات ميتـا للتسويق.
*/
@Module({
imports: [
HttpModule,
UsersModule,
TypeOrmModule.forFeature([User, ConnectedAccount, Workspace, MetaCampaign, AIAnalysis]),
],
providers: [MetaAdsService],
controllers: [MetaAdsController],
exports: [MetaAdsService],
})
export class MetaAdsModule {}

View File

@@ -0,0 +1,487 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom, catchError } from 'rxjs';
import { AxiosResponse, AxiosError } from 'axios';
import { FetchMetaInsightsDto } from '../dto/fetch-insights.dto';
import { NormalizedCampaignInsight } from '../interfaces/campaign-insight.interface';
import { MetaNormalizer } from '../utils/normalize.util';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { ConnectedAccount } from '../entities/connected-account.entity';
import { ConnectAccountDto } from '../dto/connect-account.dto';
import { MetaCampaign } from '../entities/meta-campaign.entity';
import { AIAnalysis } from '../../analytics/entities/ai-analysis.entity';
/**
* Meta Ads Service
* خدمة إعلانات ميتا
*
* Handles all direct communication with the Meta Marketing API.
* يتعامل مع جميع الاتصالات المباشرة مع واجهة ميتا للتسويق.
*/
@Injectable()
export class MetaAdsService {
private readonly logger = new Logger(MetaAdsService.name);
private readonly accessToken: string;
private readonly apiVersion: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(ConnectedAccount)
private readonly accountRepository: Repository<ConnectedAccount>,
@InjectRepository(MetaCampaign)
private readonly campaignRepository: Repository<MetaCampaign>,
@InjectRepository(AIAnalysis)
private readonly aiAnalysisRepository: Repository<AIAnalysis>,
) {
this.accessToken = this.configService.get<string>('meta.accessToken')!;
this.apiVersion = this.configService.get<string>('meta.apiVersion') || 'v19.0';
this.logger.log(`MetaAdsService Initialized | API Version: ${this.apiVersion}`);
}
/**
* Fetch campaign insights from Meta API
* جلب رؤى الحملة من واجهة ميتا
*/
async fetchInsights(dto: FetchMetaInsightsDto): Promise<NormalizedCampaignInsight[]> {
const finalAdAccountId = dto.adAccountId || this.configService.get<string>('meta.adAccountId');
const dateStart = dto.dateStart || '2026-01-01'; // Get data for the current year by default
const dateEnd = dto.dateEnd || new Date().toISOString().split('T')[0];
const { level } = dto;
if (!finalAdAccountId) {
throw new HttpException('Ad Account ID is required', HttpStatus.BAD_REQUEST);
}
// Determine access token to use | تحديد توكن الوصول للاستخدام
let accessToken = this.configService.get<string>('meta.accessToken');
// Prioritize user-specific tokens if userId is provided
// إعطاء الأولوية للتوكنات الخاصة بالمستخدم إذا تم توفير معرف المستخدم
if (dto.userId) {
const query: any = { user: { id: dto.userId }, platform: 'meta' };
if (dto.adAccountId) {
query.externalAdAccountId = dto.adAccountId;
}
const savedAccount = await this.accountRepository.findOne({
where: query,
order: { createdAt: 'DESC' }
});
if (savedAccount && savedAccount.accessToken) {
accessToken = savedAccount.accessToken;
this.logger.log(`Using saved ${savedAccount.platform} token for user ${dto.userId}`);
// If the request didn't specify an account, use the one we found
if (!dto.adAccountId) {
dto.adAccountId = savedAccount.externalAdAccountId;
}
}
}
// Construct Meta Insights URL | بناء رابط رؤى ميتا
// Format: /v19.0/act_<ID>/insights
const url = `https://graph.facebook.com/${this.apiVersion}/${finalAdAccountId}/insights`;
const params: any = {
access_token: accessToken,
level: level,
time_range: JSON.stringify({ since: dateStart, until: dateEnd }),
fields: 'campaign_id,campaign_name,impressions,clicks,spend,date_start,date_stop,reach,frequency,objective,conversions,cost_per_result',
breakdowns: 'publisher_platform',
};
try {
this.logger.log(`Fetching Meta insights for ${finalAdAccountId} from ${dateStart} to ${dateEnd}`);
const response = await firstValueFrom(
this.httpService.get(url, { params }),
);
const rawData = response.data.data || [];
this.logger.log(`Received ${rawData.length} raw insight records from Meta`);
// 1. Fetch All Campaigns for Status (Batch) | جلب حالات جميع الحملات (جماعي)
let campaignStatuses: Record<string, string> = {};
try {
const campaignsRes = await firstValueFrom(this.httpService.get(`https://graph.facebook.com/${this.apiVersion}/${finalAdAccountId}/campaigns`, {
params: { access_token: accessToken, fields: 'id,effective_status', limit: 200 }
}));
(campaignsRes.data.data || []).forEach((c: any) => {
campaignStatuses[c.id] = c.effective_status;
});
this.logger.log(`Fetched status for ${Object.keys(campaignStatuses).length} campaigns.`);
} catch (e) {
this.logger.warn(`Failed batch status fetch: ${e.message}`);
}
// 2. Fetch All Ads for Visuals (Batch) | جلب جميع الإعلانات للتصاميم (جماعي)
const campaignCreatives: Record<string, { imageUrl: string; adCopy: string; platform: string }> = {};
try {
const adsRes = await firstValueFrom(
this.httpService.get(`https://graph.facebook.com/${this.apiVersion}/${finalAdAccountId}/ads`, {
params: {
access_token: accessToken,
fields: 'campaign_id,adcreatives{image_url,thumbnail_url,body,object_story_spec,instagram_actor_id}',
limit: 200,
},
}),
);
(adsRes.data.data || []).forEach((ad: any) => {
if (!campaignCreatives[ad.campaign_id] && ad.adcreatives?.data?.length > 0) {
const creative = ad.adcreatives.data[0];
campaignCreatives[ad.campaign_id] = {
imageUrl: creative.image_url || creative.thumbnail_url || '',
adCopy:
creative.body ||
creative.object_story_spec?.link_data?.message ||
creative.object_story_spec?.video_data?.message ||
'',
platform: creative.instagram_actor_id ? 'instagram' : 'facebook',
};
}
});
this.logger.log(`Fetched visuals for ${Object.keys(campaignCreatives).length} campaigns.`);
} catch (e) {
this.logger.warn(`Failed batch visuals fetch: ${e.message}`);
}
// 3. Map everything to insights | ربط جميع البيانات بنتائج الأداء
const enrichedData = await Promise.all(rawData.map(async (insight: any) => {
const creative = campaignCreatives[insight.campaign_id];
// Fetch latest AI recommendation from DB if exists | جلب آخر توصية من الذكاء الاصطناعي إذا وجدت
let aiRecommendation: string | undefined;
try {
const latestAnalysis = await this.aiAnalysisRepository.findOne({
where: { campaignId: insight.campaign_id },
order: { createdAt: 'DESC' }
});
if (latestAnalysis) {
aiRecommendation = latestAnalysis.analysisText;
}
} catch (e) {
this.logger.warn(`Failed to fetch cached analysis for ${insight.campaign_id}`);
}
return {
...insight,
effective_status: campaignStatuses[insight.campaign_id] || 'UNKNOWN',
image_url: creative?.imageUrl || '',
ad_copy: creative?.adCopy || '',
platform: creative?.platform || 'facebook', // Default to facebook if unknown
aiRecommendation,
};
}));
const normalized = MetaNormalizer.normalizeList(enrichedData);
// 4. Persistence: Save/Update in DB | الحفظ/التحديث في قاعدة البيانات
if (dto.userId && normalized.length > 0) {
const connectedAccount = await this.accountRepository.findOne({
where: { externalAdAccountId: finalAdAccountId, user: { id: dto.userId } }
});
if (connectedAccount) {
await this.upsertCampaigns(normalized, dto.userId, connectedAccount.id);
}
}
return normalized;
} catch (error) {
const errorMsg = error.response?.data?.error?.message || error.message;
this.logger.error(`Meta API Error: ${errorMsg}`);
throw new HttpException(
{
message: 'Failed to fetch insights from Meta API',
detail: errorMsg,
},
HttpStatus.BAD_GATEWAY, // 502 Bad Gateway is appropriate for external API failures
);
}
}
// دالة لتنظيف رابط الصورة وجلب النسخة الأصلية عالية الجودة
// قم بإضافتها كـ private method داخل كلاس MetaAdsService
private cleanImageUrl(url: string): string {
if (!url) return '';
try {
const urlObj = new URL(url);
const params = urlObj.searchParams;
// إذا وجدنا متغير stp، نقوم بحذفه ليرجع السيرفر الصورة الأصلية دون قص أو تصغير
if (params.has('stp')) {
params.delete('stp');
this.logger.debug('Image URL cleaned for high-res fetch.');
}
return urlObj.toString();
} catch (e) {
// في حال فشل تحليل الرابط لسبب ما، نرجع الرابط الأصلي
return url;
}
}
/**
* Connect an Ad Account
* ربط حساب إعلاني
*/
async connectAccount(dto: ConnectAccountDto): Promise<ConnectedAccount> {
const user = await this.userRepository.findOne({ where: { id: dto.userId } });
if (!user) {
throw new Error('User not found');
}
let finalToken = dto.accessToken;
// Verify token with Meta if it's a Meta platform and exchange for long-lived
if (dto.platform === 'meta') {
await this.verifyTokenWithMeta(dto.accessToken);
try {
this.logger.log('Exchanging short-lived token for long-lived token...');
const longLivedToken = await this.getLongLivedToken(dto.accessToken);
if (longLivedToken) {
finalToken = longLivedToken;
this.logger.log('Successfully obtained long-lived token.');
}
} catch (error) {
this.logger.warn('Failed to obtain long-lived token, falling back to short-lived.', error.message);
}
}
// Check if account already exists for this user and platform
let account = await this.accountRepository.findOne({
where: {
externalAdAccountId: dto.adAccountId,
platform: dto.platform,
user: { id: dto.userId }
}
});
if (account) {
this.logger.log(`Updating existing ${dto.platform} account: ${dto.adAccountId}`);
account.accessToken = finalToken;
account.name = dto.name;
} else {
this.logger.log(`Creating new ${dto.platform} account: ${dto.adAccountId}`);
account = this.accountRepository.create({
name: dto.name,
platform: dto.platform,
externalAdAccountId: dto.adAccountId,
accessToken: finalToken,
user,
});
}
return this.accountRepository.save(account);
}
/**
* Verify Access Token with Meta Graph API
* التحقق من توكن الوصول عبر واجهة ميتا غراف
*/
private async verifyTokenWithMeta(accessToken: string): Promise<void> {
const url = `https://graph.facebook.com/${this.apiVersion}/me`;
try {
await firstValueFrom(
this.httpService.get(url, { params: { access_token: accessToken } })
);
this.logger.log('Meta token verified successfully');
} catch (error) {
const errorData = error.response?.data?.error;
const message = errorData?.message || 'Invalid Meta Access Token';
this.logger.error(`Token verification failed: ${message}`);
throw new HttpException(
{
message: 'فشل التحقق من التوكن مع ميتـا | Meta Token Verification Failed',
detail: message,
code: errorData?.code || 'INVALID_TOKEN',
},
HttpStatus.UNAUTHORIZED
);
}
}
/**
* Exchange OAuth Code for User Access Token
* استبدال رمز OAuth بتوكن وصول المستخدم
*/
async exchangeCodeForToken(code: string): Promise<string> {
const appId = this.configService.get<string>('meta.appId');
const appSecret = this.configService.get<string>('meta.appSecret');
const redirectUri = this.configService.get<string>('meta.redirectUri');
const url = `https://graph.facebook.com/${this.apiVersion}/oauth/access_token`;
try {
const response = await firstValueFrom(
this.httpService.get(url, {
params: {
client_id: appId,
client_secret: appSecret,
redirect_uri: redirectUri,
code: code,
},
})
);
return response.data.access_token;
} catch (error) {
throw new HttpException('Failed to exchange code for token', HttpStatus.BAD_GATEWAY);
}
}
/**
* Exchange Short-lived Token for Long-lived Token (60 days)
* استبدال التوكن قصير الأمد بآخر طويل الأمد (60 يوماً)
*/
async getLongLivedToken(shortLivedToken: string): Promise<string> {
const appId = this.configService.get<string>('meta.appId');
const appSecret = this.configService.get<string>('meta.appSecret');
const url = `https://graph.facebook.com/${this.apiVersion}/oauth/access_token`;
try {
const response = await firstValueFrom(
this.httpService.get(url, {
params: {
grant_type: 'fb_exchange_token',
client_id: appId,
client_secret: appSecret,
fb_exchange_token: shortLivedToken,
},
})
);
return response.data.access_token;
} catch (error) {
throw new HttpException('Failed to get long-lived token', HttpStatus.BAD_GATEWAY);
}
}
/**
* Get User Ad Accounts
* جلب الحسابات الإعلانية للمستخدم
*/
async getAdAccounts(accessToken: string): Promise<any[]> {
const url = `https://graph.facebook.com/${this.apiVersion}/me/adaccounts`;
try {
const response = await firstValueFrom(
this.httpService.get(url, {
params: {
access_token: accessToken,
fields: 'id,name,account_id',
},
})
);
return response.data.data;
} catch (error) {
throw new HttpException('Failed to fetch ad accounts', HttpStatus.BAD_GATEWAY);
}
}
/**
* Update the status of an Ad object (Campaign, AdSet, or Ad)
* تحديث حالة الكائن الإعلاني (حملة، مجموعة، أو إعلان)
*/
async updateAdStatus(id: string, accessToken: string, status: 'PAUSED' | 'ACTIVE'): Promise<boolean> {
const url = `https://graph.facebook.com/${this.apiVersion}/${id}`;
try {
this.logger.log(`Updating Meta object ${id} status to ${status}`);
await firstValueFrom(
this.httpService.post(url, {
status,
access_token: accessToken,
})
);
return true;
} catch (error) {
const errorMsg = error.response?.data?.error?.message || error.message;
this.logger.error(`Failed to update Meta status for ${id}: ${errorMsg}`);
throw new HttpException(
{
message: 'Failed to update Meta Ad status',
detail: errorMsg,
},
HttpStatus.BAD_GATEWAY
);
}
}
/**
* Upsert campaigns into the database
* حفظ أو تحديث الحملات في قاعدة البيانات
*/
private async upsertCampaigns(items: NormalizedCampaignInsight[], userId: string, accountId: string): Promise<void> {
const today = new Date();
for (const item of items) {
try {
let campaign = await this.campaignRepository.findOne({
where: { campaignId: item.campaignId, userId }
});
if (campaign) {
// Update existing | تحديث الموجود
Object.assign(campaign, {
campaignName: item.campaignName,
status: item.status,
impressions: item.impressions,
clicks: item.clicks,
spend: item.spend,
ctr: item.ctr,
cpc: item.cpc,
cpm: item.cpm,
imageUrl: item.imageUrl,
adCopy: item.adCopy,
platform: item.platform,
lastSyncedAt: today,
accountId,
});
} else {
// Create new | إنشاء جديد
campaign = this.campaignRepository.create({
campaignId: item.campaignId,
campaignName: item.campaignName,
status: item.status,
impressions: item.impressions,
clicks: item.clicks,
spend: item.spend,
ctr: item.ctr,
cpc: item.cpc,
cpm: item.cpm,
imageUrl: item.imageUrl,
adCopy: item.adCopy,
platform: item.platform,
lastSyncedAt: today,
userId,
accountId,
});
}
await this.campaignRepository.save(campaign);
} catch (error) {
this.logger.warn(`Failed to upsert campaign ${item.campaignId}: ${error.message}`);
}
}
}
/**
* Get all connected accounts for a user
* جلب جميع الحسابات المرتبطة للمستخدم
*/
async getConnectedAccounts(userId: string): Promise<ConnectedAccount[]> {
return this.accountRepository.find({
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,55 @@
import { NormalizedCampaignInsight } from '../interfaces/campaign-insight.interface';
/**
* Meta Normalizer Utility
* أداة توحيد بيانات ميتا
*
* Converts raw Meta Marketing API response into our internal format.
* يحول استجابة واجهة ميتا للتسويق الخام إلى تنسيقنا الداخلي.
*/
export class MetaNormalizer {
static normalize(raw: any): NormalizedCampaignInsight {
const impressions = parseInt(raw.impressions || '0', 10);
const clicks = parseInt(raw.clicks || '0', 10);
const spend = parseFloat(raw.spend || '0');
// Calculate metrics if not provided or to ensure accuracy
// حساب المقاييس إذا لم تكن موجودة أو لضمان الدقة
const ctr = impressions > 0 ? (clicks / impressions) * 100 : 0;
const cpc = clicks > 0 ? spend / clicks : 0;
const cpm = impressions > 0 ? (spend / impressions) * 1000 : 0;
const reach = parseInt(raw.reach || '0', 10);
const frequency = parseFloat(raw.frequency || '1');
const conversions = parseInt(raw.conversions || '0', 10);
const costPerResult = parseFloat(raw.cost_per_result || '0');
const objective = raw.objective || 'N/A';
return {
campaignId: raw.campaign_id || 'N/A',
campaignName: raw.campaign_name || 'Unnamed Campaign',
impressions,
clicks,
spend,
ctr: parseFloat(ctr.toFixed(4)),
cpc: parseFloat(cpc.toFixed(4)),
cpm: parseFloat(cpm.toFixed(4)),
dateStart: raw.date_start,
dateStop: raw.date_stop,
source: 'meta',
platform: raw.publisher_platform || 'facebook',
status: raw.effective_status || 'UNKNOWN',
imageUrl: raw.image_url || raw.thumbnail_url || 'https://images.unsplash.com/photo-1611162617474-5b21e879e113?q=80&w=1000&auto=format&fit=crop', // Real URL or Default Meta placeholder
adCopy: raw.ad_copy || undefined,
reach,
frequency,
conversions,
costPerResult,
objective,
aiRecommendation: raw.ai_recommendation || raw.aiRecommendation,
};
}
static normalizeList(rawList: any[]): NormalizedCampaignInsight[] {
return rawList.map((item) => this.normalize(item));
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { NotificationService } from './services/notification.service';
@Global()
@Module({
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule {}

View File

@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as admin from 'firebase-admin';
@Injectable()
/**
* NotificationService: مركز التنبيهات
* المسؤول عن إرسال الرسائل إلى Telegram, Slack, و FCM (تنبيهات الهاتف)
*/
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
/**
* نستخدم الـ constructor هنا لحقن ConfigService
* وأيضاً لتهيئة Firebase Admin بمجرد تشغيل الخدمة
*/
constructor(private readonly configService: ConfigService) {
this.initializeFirebase();
}
private initializeFirebase() {
const serviceAccount = this.configService.get<string>('notifications.firebase.serviceAccount');
if (serviceAccount && admin.apps.length === 0) {
try {
const cert = typeof serviceAccount === 'string' && serviceAccount.trim().startsWith('{')
? JSON.parse(serviceAccount)
: serviceAccount; // Path or JSON string
admin.initializeApp({
credential: admin.credential.cert(cert),
});
this.logger.log('Firebase Admin initialized successfully | تم تهيئة فيربيز بنجاح');
} catch (error) {
this.logger.error(`Failed to initialize Firebase: ${error.message}`);
}
}
}
/**
* Send notification to multiple channels
* إرسال تنبيه عبرة قنوات متعددة
*/
async send(
message: string,
channels: ('telegram' | 'slack' | 'fcm')[],
options?: { fcmToken?: string },
): Promise<void> {
this.logger.log(`Sending notification: ${message} to ${channels.join(', ')}`);
const promises = channels.map((channel) => {
switch (channel) {
case 'telegram':
return this.sendTelegram(message);
case 'slack':
return this.sendSlack(message);
case 'fcm':
return options?.fcmToken
? this.sendFcm(options.fcmToken, 'SaaS Pro Alert | تنبيه برو', message)
: Promise.resolve();
default:
return Promise.resolve();
}
});
await Promise.allSettled(promises);
}
/**
* Send Telegram message
* إرسال رسالة تيليجرام
*/
private async sendTelegram(message: string): Promise<void> {
const botToken = this.configService.get<string>('notifications.telegramToken');
const chatId = this.configService.get<string>('notifications.telegramChatId');
if (!botToken || !chatId) {
this.logger.warn('Telegram notifications not configured.');
return;
}
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
try {
await axios.post(url, {
chat_id: chatId,
text: `🔔 *SaaS Pro Alert*\n\n${message}`,
parse_mode: 'Markdown',
});
} catch (error) {
this.logger.error(`Telegram Error: ${error.message}`);
}
}
/**
* Send Slack message
* إرسال رسالة سلاك
*/
private async sendSlack(message: string): Promise<void> {
const webhookUrl = this.configService.get<string>('notifications.slackWebhookUrl');
if (!webhookUrl) {
this.logger.warn('Slack notifications not configured.');
return;
}
try {
await axios.post(webhookUrl, {
text: `🚀 *SaaS Pro Notification*\n${message}`,
});
} catch (error) {
this.logger.error(`Slack Error: ${error.message}`);
}
}
/**
* Send Firebase Cloud Message (FCM)
* إرسال تنبيه عبر فيربيز
*/
async sendFcm(token: string, title: string, body: string): Promise<void> {
if (admin.apps.length === 0) {
this.logger.warn('FCM not configured (No Firebase app initialized).');
return;
}
try {
await admin.messaging().send({
token,
notification: {
title,
body,
},
data: {
click_action: 'FLUTTER_NOTIFICATION_CLICK',
},
});
this.logger.log(`FCM sent to token ending in: ...${token.substring(token.length - 5)}`);
} catch (error) {
this.logger.error(`FCM Error: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,112 @@
import { Controller, Post, Body, Query, Logger, HttpStatus, Res, HttpException, Headers } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { PaymobService } from '../services/paymob.service.js';
import { BinancePayService } from '../services/binance-pay.service.js';
import { InitiatePaymentDto } from '../dto/initiate-payment.dto.js';
import { UsersService } from '../../users/services/users.service.js';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
private readonly logger = new Logger(PaymentsController.name);
constructor(
private readonly paymobService: PaymobService,
private readonly binancePayService: BinancePayService,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
@Post('initiate')
@ApiOperation({ summary: 'Initiate a payment session | بدء جلسة دفع' })
async initiate(@Body() dto: InitiatePaymentDto) {
if (dto.gateway === 'paymob') {
const token = await this.paymobService.authenticate();
const orderId = await this.paymobService.registerOrder(token, dto.amount * 100, dto.currency);
const cardId = this.configService.get<number>('paymob.cardIntegrationId') || 0;
const walletId = this.configService.get<number>('paymob.walletIntegrationId') || 0;
const integrationId = dto.method === 'wallet' ? walletId : cardId;
const paymentKey = await this.paymobService.generatePaymentKey(
token,
orderId,
dto.amount * 100,
dto.currency,
{
email: dto.email || 'guest@example.com',
first_name: dto.firstName || 'Guest',
last_name: dto.lastName || 'User',
phone_number: dto.phoneNumber || '01000000000',
city: 'Cairo',
country: 'EG',
street: 'N/A',
building: 'N/A',
floor: 'N/A',
apartment: 'N/A',
},
integrationId,
);
return {
gateway: 'paymob',
paymentUrl: `https://accept.paymob.com/api/acceptance/iframes/your_iframe_id?payment_token=${paymentKey}`
};
} else if (dto.gateway === 'binance') {
const result = await this.binancePayService.createOrder({
merchantTradeNo: `ORDER_${Date.now()}`,
orderAmount: dto.amount,
currency: dto.currency,
description: 'SaaS Subscription - Pro Tier',
});
return {
gateway: 'binance',
paymentUrl: result.checkoutUrl,
qrCode: result.qrContent,
};
}
}
@Post('paymob/callback')
@ApiOperation({ summary: 'Paymob Webhook Callback' })
async paymobCallback(@Body() body: any, @Query('hmac') hmac: string) {
this.logger.log('Received Paymob callback');
if (!this.paymobService.verifyHmac(body.obj, hmac)) {
this.logger.warn('Invalid Paymob HMAC signature');
throw new HttpException('Invalid signature', HttpStatus.FORBIDDEN);
}
const transaction = body.obj;
if (transaction.success && transaction.is_auth === false) {
const orderId = transaction.order.id;
this.logger.log(`Payment successful for Order: ${orderId}`);
// In a real app, find user by orderId. Demo:
await this.usersService.updateSubscription('64b134ab-bf6b-486b-8556-1000f2c93942', 'pro', 30);
}
return { status: 'received' };
}
@Post('binance/callback')
@ApiOperation({ summary: 'Binance Pay Webhook Callback' })
async binanceCallback(
@Body() body: any,
@Headers('binancepay-signature') signature: string,
@Headers('binancepay-timestamp') timestamp: string,
@Headers('binancepay-nonce') nonce: string,
) {
this.logger.log('Received Binance Pay callback');
const payload = JSON.stringify(body);
if (!this.binancePayService.verifySignature(payload, timestamp, nonce, signature)) {
this.logger.warn('Invalid Binance Pay signature');
throw new HttpException('Invalid signature', HttpStatus.FORBIDDEN);
}
if (body.bizStatus === 'PAY_SUCCESS') {
this.logger.log(`Binance Payment successful for: ${body.bizId}`);
await this.usersService.updateSubscription('64b134ab-bf6b-486b-8556-1000f2c93942', 'pro', 30);
}
return { returnCode: 'SUCCESS', returnMsg: 'OK' };
}
}

View File

@@ -0,0 +1,39 @@
import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class InitiatePaymentDto {
@IsString()
@IsNotEmpty()
gateway: 'paymob' | 'binance';
@IsString()
@IsOptional()
method?: 'card' | 'wallet';
@IsNumber()
@IsNotEmpty()
amount: number;
@IsString()
@IsNotEmpty()
currency: string;
@IsString()
@IsNotEmpty()
userId: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
firstName?: string;
@IsString()
@IsOptional()
lastName?: string;
@IsString()
@IsOptional()
phoneNumber?: string;
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaymobService } from './services/paymob.service.js';
import { BinancePayService } from './services/binance-pay.service.js';
import { PaymentsController } from './controllers/payments.controller.js';
import { User } from '../users/entities/user.entity.js';
import { UsersModule } from '../users/users.module.js';
@Module({
imports: [
HttpModule,
TypeOrmModule.forFeature([User]),
UsersModule,
],
providers: [PaymobService, BinancePayService],
controllers: [PaymentsController],
exports: [PaymobService, BinancePayService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,63 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'crypto';
@Injectable()
export class BinancePayService {
private readonly logger = new Logger(BinancePayService.name);
private readonly baseUrl = 'https://bpay.binanceapi.com/binancepay/openapi/v2';
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
private generateSignature(payload: string, timestamp: number, nonce: string): string {
const secretKey = this.configService.get<string>('binance.secretKey') || '';
const signaturePayload = `${timestamp}\n${nonce}\n${payload}\n`;
return crypto
.createHmac('sha512', secretKey)
.update(signaturePayload)
.digest('hex')
.toUpperCase();
}
verifySignature(payload: string, timestamp: string, nonce: string, signature: string): boolean {
const calculatedSignature = this.generateSignature(payload, parseInt(timestamp), nonce);
return calculatedSignature === signature;
}
async createOrder(params: {
merchantTradeNo: string;
orderAmount: number;
currency: string;
description: string;
}) {
const apiKey = this.configService.get<string>('binance.apiKey');
const timestamp = Date.now();
const nonce = crypto.randomBytes(16).toString('hex');
const payload = JSON.stringify(params);
const signature = this.generateSignature(payload, timestamp, nonce);
try {
const response = await firstValueFrom(
this.httpService.post(`${this.baseUrl}/order`, payload, {
headers: {
'Content-Type': 'application/json',
'BinancePay-Timestamp': timestamp,
'BinancePay-Nonce': nonce,
'BinancePay-Certificate-SN': apiKey,
'BinancePay-Signature': signature,
},
})
);
return response.data.data;
} catch (error) {
this.logger.error('Binance Pay Order failed', error.response?.data || error.message);
throw new HttpException('Binance Pay order creation failed', HttpStatus.BAD_GATEWAY);
}
}
}

View File

@@ -0,0 +1,117 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'crypto';
@Injectable()
export class PaymobService {
private readonly logger = new Logger(PaymobService.name);
private readonly baseUrl = 'https://accept.paymob.com/api';
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
/**
* HMAC Verification for Paymob Webhooks
*/
verifyHmac(obj: any, hmac: string): boolean {
const hmacSecret = this.configService.get<string>('paymob.hmacSecret') || '';
// Concatenation order as per Paymob docs
const data =
obj.amount_cents +
obj.created_at +
obj.currency +
obj.error_occured +
obj.has_parent_transaction +
obj.id +
obj.integration_id +
obj.is_3d_secure +
obj.is_auth +
obj.is_capture +
obj.is_refunded +
obj.is_standalone_payment +
obj.source_data.pan +
obj.source_data.sub_type +
obj.source_data.type +
obj.success;
const calculatedHmac = crypto
.createHmac('sha512', hmacSecret)
.update(data)
.digest('hex');
return calculatedHmac === hmac;
}
/**
* Step 1: Authentication Request
* Obtaining an authentication token
*/
async authenticate(): Promise<string> {
const apiKey = this.configService.get<string>('paymob.apiKey');
try {
const response = await firstValueFrom(
this.httpService.post(`${this.baseUrl}/auth/tokens`, { api_key: apiKey })
);
return response.data.token;
} catch (error) {
this.logger.error('Paymob Auth failed', error.response?.data || error.message);
throw new HttpException('Paymob authentication failed', HttpStatus.BAD_GATEWAY);
}
}
/**
* Step 2: Order Registration
*/
async registerOrder(token: string, amountCents: number, currency: string = 'EGP'): Promise<number> {
try {
const response = await firstValueFrom(
this.httpService.post(`${this.baseUrl}/ecommerce/orders`, {
auth_token: token,
delivery_needed: false,
amount_cents: amountCents,
currency: currency,
items: [],
})
);
return response.data.id;
} catch (error) {
this.logger.error('Paymob Order registration failed', error.response?.data || error.message);
throw new HttpException('Paymob order registration failed', HttpStatus.BAD_GATEWAY);
}
}
/**
* Step 3: Payment Key Generation
*/
async generatePaymentKey(
token: string,
orderId: number,
amountCents: number,
currency: string = 'EGP',
billingData: any,
integrationId: number,
): Promise<string> {
try {
const response = await firstValueFrom(
this.httpService.post(`${this.baseUrl}/ecommerce/payment_keys`, {
auth_token: token,
amount_cents: amountCents,
expiration: 3600,
order_id: orderId,
billing_data: billingData,
currency: currency,
integration_id: integrationId,
})
);
return response.data.token;
} catch (error) {
this.logger.error('Paymob Payment Key generation failed', error.response?.data || error.message);
throw new HttpException('Paymob payment key generation failed', HttpStatus.BAD_GATEWAY);
}
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { IAdPlatformAdapter } from '../../common/interfaces/platform-adapter.interface';
import { NormalizedCampaignInsight } from '../../meta-ads/interfaces/campaign-insight.interface';
@Injectable()
export class TikTokAdsService implements IAdPlatformAdapter {
private readonly logger = new Logger(TikTokAdsService.name);
readonly platformName = 'tiktok';
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async fetchInsights(params: any): Promise<NormalizedCampaignInsight[]> {
this.logger.log('Fetching insights from TikTok Business API...');
return [
{
campaignId: 'tt_camp_8823',
campaignName: 'Ramadan 2024 - TopView',
impressions: 1250000,
clicks: 85400,
spend: 2450.50,
ctr: 6.83,
cpc: 0.028,
cpm: 1.96,
dateStart: params.dateStart || '2024-03-01',
dateStop: params.dateEnd || '2024-03-10',
source: 'tiktok',
status: 'ACTIVE',
platform: 'tiktok',
},
];
}
async exchangeCodeForToken(code: string): Promise<string> {
const appId = this.configService.get<string>('tiktok.appId');
const secret = this.configService.get<string>('tiktok.secret');
const url = 'https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/';
try {
const response = await firstValueFrom(
this.httpService.post(url, {
app_id: appId,
secret: secret,
auth_code: code,
})
);
return response.data.data.access_token;
} catch (error) {
throw new HttpException('TikTok Token exchange failed', HttpStatus.BAD_GATEWAY);
}
}
async getAdAccounts(accessToken: string): Promise<any[]> {
const url = 'https://business-api.tiktok.com/open_api/v1.3/advertiser/info/';
try {
const response = await firstValueFrom(
this.httpService.get(url, {
headers: { 'Access-Token': accessToken },
})
);
return response.data.data.list || [];
} catch (error) {
return [{ id: 'tt_act_001', name: 'TikTok Sample Account' }];
}
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TikTokAdsService } from './services/tiktok-ads.service';
@Module({
imports: [HttpModule],
providers: [TikTokAdsService],
exports: [TikTokAdsService],
})
export class TikTokAdsModule {}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Post, Body, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UsersService } from '../services/users.service';
import { User } from '../entities/user.entity';
@ApiTags('users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@ApiOperation({ summary: 'Get all users | جلب جميع المستخدمين' })
@ApiResponse({ status: 200, type: [User] })
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
@Post('login')
@ApiOperation({ summary: 'Simple email login | دخول بسيط بالبريد' })
async login(@Body('email') email: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('المستخدم غير موجود. يرجى مراجعة البريد الإلكتروني.');
}
return user;
}
}

View File

@@ -0,0 +1,63 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Workspace } from './workspace.entity';
/**
* User Entity
* كيان المستخدم
*
* Represents a SaaS user who can connect their Meta Ads accounts.
* يمثل مستخدم SaaS الذي يمكنه ربط حسابات إعلانات ميتا الخاصة به.
*/
@Entity('users')
/**
* User Entity: كيان المستخدم
* يمثل الشخص المشترك في الـ SaaS ويحتوي على بياناته المالية واشتراكه
*/
export class User {
@PrimaryGeneratedColumn('uuid')
id: string; // المعرف الفريد للمستخدم
@Column({ unique: true })
email: string;
@Column({ select: false }) // Don't return password by default | لا ترجع كلمة المرور افتراضياً
passwordHash: string;
@Column({ default: true })
isActive: boolean;
// Subscription Fields | حقول الاشتراك
@Column({
type: 'enum',
enum: ['free', 'pro'],
default: 'free',
})
subscriptionTier: string;
@Column({ type: 'timestamp', nullable: true })
subscriptionExpiresAt: Date;
@Column({ type: 'timestamp', nullable: true })
lastPaymentDate: Date;
@Column({ nullable: true })
stripeCustomerId: string;
@Column({ type: 'timestamp', nullable: true })
trialEndsAt: Date;
@Column({ default: 0 })
requestCount: number;
@Column({ nullable: true })
fcmToken: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => Workspace, (workspace) => workspace.owner)
workspaces: Workspace[];
}

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { User } from './user.entity';
import { ConnectedAccount } from '../../meta-ads/entities/connected-account.entity';
/**
* Workspace Entity
* كيان مساحة العمل (الوكالة / الفريق)
*
* Allows users to organize accounts into separate environments (e.g., for different clients).
* يتيح للمستخدمين تنظيم الحسابات في بيئات منفصلة (على سبيل المثال، لمختلف العملاء).
*/
@Entity('workspaces')
export class Workspace {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string; // e.g., "Client A", "Marketing Team"
@Column({ nullable: true })
description: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => User, (user) => user.workspaces)
owner: User;
@OneToMany(() => ConnectedAccount, (account) => account.workspace)
accounts: ConnectedAccount[];
}

View File

@@ -0,0 +1,42 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async findAll(): Promise<User[]> {
return this.userRepository.find();
}
async findById(id: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async updateSubscription(userId: string, tier: 'free' | 'pro', durationDays: number): Promise<User> {
const user = await this.findById(userId);
user.subscriptionTier = tier;
const expiry = new Date();
expiry.setDate(expiry.getDate() + durationDays);
user.subscriptionExpiresAt = expiry;
user.lastPaymentDate = new Date();
return this.userRepository.save(user);
}
}

14
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Workspace } from './entities/workspace.entity';
import { UsersService } from './services/users.service';
import { UsersController } from './controllers/users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User, Workspace])],
controllers: [UsersController],
providers: [UsersService],
exports: [TypeOrmModule, UsersService], // Export UsersService for other modules
})
export class UsersModule {}

27
test/app.e2e-spec.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect((res) => {
expect(res.body.message).toBe('Welcome to Ads Analytics SaaS API');
});
});
});

9
test/jest-e2e.json Normal file
View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

1002
tut.html Normal file

File diff suppressed because it is too large Load Diff