156 lines
6.5 KiB
PHP
156 lines
6.5 KiB
PHP
<?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),
|
|
];
|
|
}
|
|
}
|