Update: 2026-05-08 04:58:23

This commit is contained in:
Hamza-Ayed
2026-05-08 04:58:23 +03:00
parent 4721ca83da
commit 6db8986fca
48 changed files with 2212 additions and 108 deletions

View File

@@ -0,0 +1,155 @@
<?php
/**
* Gamification Service — Badges & Points
*
* Awards points and badges based on user actions.
* Call GamificationService::award() from relevant endpoints.
*/
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
class GamificationService
{
// Points per action
private const POINTS = [
'invoice_uploaded' => 5,
'invoice_approved' => 10,
'jofotara_submitted' => 15,
'company_created' => 20,
'referral_registered' => 50,
'first_login' => 10,
'streak_7_days' => 30,
'streak_30_days' => 100,
];
// Badge definitions
private const BADGES = [
'starter' => ['name' => 'بداية موفقة', 'icon' => '🌟', 'desc' => 'رفعت أول فاتورة', 'condition' => 'invoices >= 1'],
'active_10' => ['name' => 'نشيط', 'icon' => '🔥', 'desc' => '10 فواتير مرفوعة', 'condition' => 'invoices >= 10'],
'pro_50' => ['name' => 'محترف', 'icon' => '💎', 'desc' => '50 فاتورة مرفوعة', 'condition' => 'invoices >= 50'],
'master_200' => ['name' => 'خبير فوترة', 'icon' => '👑', 'desc' => '200 فاتورة مرفوعة', 'condition' => 'invoices >= 200'],
'jofotara_first' => ['name' => 'رسمي', 'icon' => '🏛️', 'desc' => 'أول إرسال لجوفوترا', 'condition' => 'submitted >= 1'],
'jofotara_50' => ['name' => 'فوترة ذهبية', 'icon' => '🏆', 'desc' => '50 فاتورة مرسلة لجوفوترا', 'condition' => 'submitted >= 50'],
'multi_company' => ['name' => 'مدير شركات', 'icon' => '🏢', 'desc' => 'تدير 3 شركات أو أكثر', 'condition' => 'companies >= 3'],
'referrer' => ['name' => 'سفير مُصادَق', 'icon' => '🤝', 'desc' => 'أحلت مستخدم جديد', 'condition' => 'referrals >= 1'],
'streak_week' => ['name' => 'مثابر', 'icon' => '📅', 'desc' => 'دخلت 7 أيام متتالية', 'condition' => 'streak >= 7'],
];
/**
* Award points for an action
*/
public static function award(string $userId, string $tenantId, string $action): void
{
try {
$points = self::POINTS[$action] ?? 0;
if ($points === 0) return;
$db = Database::getInstance();
// Add points
$db->prepare("
INSERT INTO user_points (id, user_id, tenant_id, action, points, created_at)
VALUES (UUID(), ?, ?, ?, ?, NOW())
")->execute([$userId, $tenantId, $action, $points]);
// Check for new badges
self::checkBadges($userId, $tenantId);
} catch (\Throwable $e) {
error_log("[Gamification] Award failed: " . $e->getMessage());
}
}
/**
* Check and award any earned badges
*/
private static function checkBadges(string $userId, string $tenantId): void
{
$db = Database::getInstance();
// Get user stats
$invoices = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ?")->execute([$tenantId])?->fetchColumn() ?: 0;
$submitted = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'submitted'")->execute([$tenantId])?->fetchColumn() ?: 0;
$companies = (int)$db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL")->execute([$tenantId])?->fetchColumn() ?: 0;
$referrals = (int)$db->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id = ?")->execute([$userId])?->fetchColumn() ?: 0;
// Get existing badges
$existingStmt = $db->prepare("SELECT badge_key FROM user_badges WHERE user_id = ?");
$existingStmt->execute([$userId]);
$existing = $existingStmt->fetchAll(\PDO::FETCH_COLUMN);
$stats = compact('invoices', 'submitted', 'companies', 'referrals');
foreach (self::BADGES as $key => $badge) {
if (in_array($key, $existing)) continue;
if (self::evaluateCondition($badge['condition'], $stats)) {
$db->prepare("
INSERT INTO user_badges (id, user_id, tenant_id, badge_key, badge_name, badge_icon, earned_at)
VALUES (UUID(), ?, ?, ?, ?, ?, NOW())
")->execute([$userId, $tenantId, $key, $badge['name'], $badge['icon']]);
// Notify user
SmartNotifications::send($tenantId, $userId, 'badge_earned',
"{$badge['icon']} شارة جديدة: {$badge['name']}!",
$badge['desc'],
['badge_key' => $key]
);
}
}
}
/**
* Simple condition evaluator
*/
private static function evaluateCondition(string $condition, array $stats): bool
{
if (preg_match('/(\w+)\s*>=\s*(\d+)/', $condition, $m)) {
$field = $m[1];
$value = (int)$m[2];
return ($stats[$field] ?? 0) >= $value;
}
return false;
}
/**
* Get user's gamification profile
*/
public static function getProfile(string $userId, string $tenantId): array
{
$db = Database::getInstance();
// Total points
$pointsStmt = $db->prepare("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?");
$pointsStmt->execute([$userId]);
$totalPoints = (int)$pointsStmt->fetchColumn();
// Badges
$badgesStmt = $db->prepare("SELECT badge_key, badge_name, badge_icon, earned_at FROM user_badges WHERE user_id = ? ORDER BY earned_at DESC");
$badgesStmt->execute([$userId]);
$badges = $badgesStmt->fetchAll();
// Level (every 100 points = 1 level)
$level = max(1, (int)floor($totalPoints / 100) + 1);
$levelNames = ['', 'مبتدئ', 'ناشط', 'متقدم', 'خبير', 'أسطورة', 'سيد الفوترة'];
$levelName = $levelNames[min($level, count($levelNames) - 1)] ?? 'أسطورة';
// Progress to next level
$pointsInLevel = $totalPoints % 100;
$progressPercent = $pointsInLevel;
return [
'total_points' => $totalPoints,
'level' => $level,
'level_name' => $levelName,
'progress_percent' => $progressPercent,
'badges' => $badges,
'badges_count' => count($badges),
'available_badges' => count(self::BADGES),
];
}
}

View File

@@ -158,17 +158,87 @@ class NotificationService
}
/**
* Get OAuth2 Access Token for Firebase (Cache this in production!)
* Note: This requires a JWT library or manual signing.
* For simplicity, we assume the user might use a Google Auth library.
* But since we avoid extra deps, I will provide a minimal implementation or suggestion.
* Get OAuth2 Access Token for Firebase using Service Account JWT
* Self-contained: no external libraries needed.
*/
private function getAccessToken(): ?string
{
// This is a complex part that usually requires 'google/auth' library.
// For now, I will return null and tell the user they need to install google/auth via composer
// OR I can write a minimal JWT signer for Google Auth if they don't want composer.
error_log("[NotificationService] OAuth2 Token generation needs google/auth library.");
return null;
// Check cache first (token is valid for 1 hour, we cache for 50 min)
$cacheFile = STORAGE_PATH . '/cache/fcm_token.json';
if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached && ($cached['expires_at'] ?? 0) > time()) {
return $cached['access_token'];
}
}
if (!file_exists($this->serviceAccountPath)) {
error_log("[NotificationService] Firebase service account file missing");
return null;
}
$sa = json_decode(file_get_contents($this->serviceAccountPath), true);
if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) {
error_log("[NotificationService] Invalid service account JSON");
return null;
}
// Build JWT
$now = time();
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
$payload = json_encode([
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
]);
$b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '=');
$b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
$signingInput = $b64Header . '.' . $b64Payload;
$privateKey = openssl_pkey_get_private($sa['private_key']);
if (!$privateKey) {
error_log("[NotificationService] Failed to parse private key");
return null;
}
openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
$jwt = $signingInput . '.' . $b64Signature;
// Exchange JWT for access token
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[NotificationService] Token exchange failed ($httpCode): $response");
return null;
}
$tokenData = json_decode($response, true);
$accessToken = $tokenData['access_token'] ?? null;
if ($accessToken) {
// Cache for 50 minutes
@file_put_contents($cacheFile, json_encode([
'access_token' => $accessToken,
'expires_at' => $now + 3000,
]));
}
return $accessToken;
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* Smart Notification Triggers
*
* Centralized service for sending intelligent, context-aware notifications.
* Call these methods from relevant endpoints (e.g., after invoice upload, approval, etc.)
*/
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
class SmartNotifications
{
/**
* Notify admin when quota usage reaches 80%
*/
public static function checkQuotaWarning(string $tenantId): void
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) return;
$usage = ($sub['max_invoices_per_month'] > 0)
? ($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100
: 0;
if ($usage >= 80 && $usage < 100) {
// Find admin user
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
$adminStmt->execute([$tenantId]);
$adminId = $adminStmt->fetchColumn();
if ($adminId) {
self::send($tenantId, $adminId, 'quota_warning',
'⚠️ اقتربت من حد الباقة',
'استخدمت ' . round($usage) . '% من حصة الفواتير الشهرية. فكّر بالترقية لتجنب التوقف.',
['usage_percent' => round($usage)]
);
}
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Quota warning failed: " . $e->getMessage());
}
}
/**
* Notify user when invoice is approved
*/
public static function invoiceApproved(string $tenantId, string $uploaderId, string $invoiceId, string $invoiceNumber): void
{
self::send($tenantId, $uploaderId, 'invoice_approved',
'✅ تم اعتماد الفاتورة',
"الفاتورة رقم {$invoiceNumber} تم اعتمادها وهي جاهزة للإرسال لجوفوترا.",
['invoice_id' => $invoiceId]
);
}
/**
* Notify when JoFotara submission succeeds
*/
public static function jofotaraSuccess(string $tenantId, string $userId, string $invoiceId, string $uuid): void
{
self::send($tenantId, $userId, 'jofotara_success',
'🎉 تم إرسال الفاتورة لجوفوترا',
"الفاتورة أُرسلت بنجاح. UUID: {$uuid}",
['invoice_id' => $invoiceId, 'jofotara_uuid' => $uuid]
);
}
/**
* Notify when JoFotara submission fails
*/
public static function jofotaraRejected(string $tenantId, string $userId, string $invoiceId, string $error): void
{
self::send($tenantId, $userId, 'jofotara_rejected',
'❌ رُفضت الفاتورة من جوفوترا',
"الفاتورة لم تُقبل: {$error}",
['invoice_id' => $invoiceId]
);
}
/**
* Notify admin about pending invoices (daily digest)
*/
public static function pendingInvoicesDigest(string $tenantId): void
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'extracted'");
$stmt->execute([$tenantId]);
$count = (int)$stmt->fetchColumn();
if ($count === 0) return;
$adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
$adminStmt->execute([$tenantId]);
$adminId = $adminStmt->fetchColumn();
if ($adminId) {
self::send($tenantId, $adminId, 'pending_digest',
"📋 لديك {$count} فاتورة بانتظار المراجعة",
"هناك {$count} فاتورة مستخرجة لم تُراجع بعد. راجعها واعتمدها لإرسالها لجوفوترا.",
['pending_count' => $count]
);
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Pending digest failed: " . $e->getMessage());
}
}
/**
* Welcome notification for new users
*/
public static function welcomeUser(string $tenantId, string $userId, string $name): void
{
self::send($tenantId, $userId, 'welcome',
"مرحباً بك في مُصادَق، {$name}! 🎉",
'ابدأ برفع أول فاتورة — صوّرها أو ارفع PDF والذكاء الاصطناعي يكمل الباقي.',
[]
);
}
/**
* Core send method — writes to DB (push handled by NotificationService)
*/
private static function send(string $tenantId, string $userId, string $type, string $title, string $body, array $data): void
{
try {
$db = Database::getInstance();
// Deduplicate: don't send same type within 1 hour
$dedup = $db->prepare("
SELECT id FROM notifications
WHERE user_id = ? AND type = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
LIMIT 1
");
$dedup->execute([$userId, $type]);
if ($dedup->fetch()) return;
$db->prepare("
INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, NOW())
")->execute([$tenantId, $userId, $type, $title, $body, json_encode($data, JSON_UNESCAPED_UNICODE)]);
// Try push notification (non-blocking)
try {
$notifService = new NotificationService();
$notifService->sendNotification($userId, $title, $body, $data);
} catch (\Throwable $e) {
// Push failure is non-critical
}
} catch (\Throwable $e) {
error_log("[SmartNotifications] Send failed: " . $e->getMessage());
}
}
}