HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Services/Customer/AdminCustomerDashboardService.php
<?php

namespace App\Services\Customer;

use App\Library\Cache\AppCache;
use App\Model\ActivityLog;
use App\Model\Customer;
use App\Model\Invoice;
use App\Model\SendingServer;
use App\Model\Subscription;
use App\Model\TrackingLog;
use App\Services\Plans\Quotas\QuotaKey;
use App\Services\Plans\Quotas\QuotasService;
use App\Services\Plans\RateLimits\RateLimitKey;
use App\Model\PlanRateLimit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

/**
 * Aggregates everything an admin needs to see about ONE customer in a single
 * 360° dashboard. Read-only — no mutations. Mirrors the customer-facing
 * DashboardService but with admin-context queries (no auth tied to current user).
 *
 * Each public method returns a primitive array / Collection that the view can
 * render directly — no nested objects, no surprise nulls. Empty states are
 * represented by zeros, empty arrays, or nulls in well-known keys.
 */
class AdminCustomerDashboardService
{
    public function __construct(protected Customer $customer)
    {
    }

    public function identity(): array
    {
        $owner = $this->customer->users()->first();
        $sub = $this->customer->getCurrentActiveSubscription();
        $planName = $sub && $sub->plan ? $sub->plan->name : null;
        $planStatus = $sub ? $sub->status : null;
        $contact = $this->customer->contact()->first();

        $initials = $this->initials();

        return [
            'uid' => $this->customer->uid,
            'name' => $this->customer->displayName(),
            'email' => $owner ? $owner->email : null,
            'company' => $contact ? $contact->company : null,
            'avatar_url' => $this->uploadedAvatarUrl($owner),
            'initials' => $initials,
            'avatar_color' => $this->avatarColor($initials),
            'status' => $this->customer->status,
            'plan_name' => $planName,
            'plan_status' => $planStatus,
            'since' => $this->customer->created_at,
            'since_human' => $this->customer->created_at ? $this->customer->created_at->diffForHumans() : null,
        ];
    }

    public function ribbon(): array
    {
        $lc = $this->customer->local();

        return [
            'subscribers' => (int) AppCache::for($lc)->read('SubscriberCount', 0),
            'campaigns' => $lc->campaignsCount(),
            'lists' => $lc->listsCount(),
            'lifetime_paid' => $this->lifetimePaid(),
            'currency' => $this->currencyCode(),
        ];
    }

    public function users(int $limit = 5): array
    {
        $allUsers = $this->customer->users()->get();
        $total = $allUsers->count();

        $first = $allUsers->take($limit)->map(function ($u) {
            $initials = $this->userInitials($u);
            return [
                'uid' => $u->uid,
                'name' => $u->displayName(false),
                'email' => $u->email,
                'avatar_url' => $this->uploadedAvatarUrl($u),
                'initials' => $initials,
                'avatar_color' => $this->avatarColor($initials),
            ];
        })->values()->all();

        return [
            'total' => $total,
            'first' => $first,
            'overflow' => max(0, $total - $limit),
        ];
    }

    public function subscription(): array
    {
        $sub = $this->customer->getCurrentActiveSubscription();

        if (!$sub) {
            return [
                'has_subscription' => false,
                'plan_name' => null,
                'status' => null,
                'is_recurring' => false,
                'next_renewal' => null,
                'next_renewal_human' => null,
                'days_until_renewal' => null,
                'price' => null,
                'currency' => null,
                'started_at' => null,
            ];
        }

        $renewal = $sub->current_period_ends_at;

        return [
            'has_subscription' => true,
            'plan_name' => $sub->plan ? $sub->plan->name : '—',
            'status' => $sub->status,
            'is_recurring' => method_exists($sub, 'isRecurring') ? $sub->isRecurring() : (bool) ($sub->is_recurring ?? false),
            'next_renewal' => $renewal,
            'next_renewal_human' => $renewal ? $renewal->diffForHumans() : null,
            'days_until_renewal' => $renewal ? max(0, (int) Carbon::now()->diffInDays($renewal, false)) : null,
            'price' => $sub->plan ? $sub->plan->getPrice() : null,
            'currency' => $sub->plan && $sub->plan->currency ? $sub->plan->currency->code : null,
            'started_at' => $sub->started_at ?? $sub->created_at,
        ];
    }

    public function billing(): array
    {
        $invoices = $this->customer->invoices()->orderBy('created_at', 'desc')->get();
        $paid = $invoices->where('status', Invoice::STATUS_PAID);
        $unpaid = $invoices->where('status', '!=', Invoice::STATUS_PAID);

        $lifetimePaid = 0.0;
        foreach ($paid as $invoice) {
            $lifetimePaid += (float) $invoice->total();
        }

        $last = $invoices->first();
        $lastTotal = null;
        if ($last) {
            try {
                $lastTotal = (float) $last->total();
            } catch (\Throwable $e) {
                $lastTotal = null;
            }
        }

        return [
            'lifetime_paid' => $lifetimePaid,
            'currency' => $this->currencyCode(),
            'paid_count' => $paid->count(),
            'pending_count' => $unpaid->count(),
            'total_count' => $invoices->count(),
            'last_invoice_amount' => $lastTotal,
            'last_invoice_date' => $last ? $last->created_at : null,
            'last_invoice_status' => $last ? $last->status : null,
        ];
    }

    public function sendingCredits(): array
    {
        $balance = 0;
        try {
            $balance = (int) $this->customer->getSendingCredits();
        } catch (\Throwable $e) {
            $balance = 0;
        }

        $lastItem = null;
        try {
            $lastItem = $this->customer->getLastPaidSendingCreditsOrderItem();
        } catch (\Throwable $e) {
            $lastItem = null;
        }

        return [
            'balance' => $balance,
            'last_purchase_date' => $lastItem && $lastItem->order ? $lastItem->order->created_at : null,
            'last_purchase_amount' => $lastItem && $lastItem->order ? $this->safeOrderTotal($lastItem->order) : null,
            'has_purchases' => $balance > 0 || $lastItem !== null,
        ];
    }

    public function evsCredits(): array
    {
        $lastOrder = method_exists($this->customer, 'getLastPaidEmailVerificationCreditsOrder')
            ? $this->customer->getLastPaidEmailVerificationCreditsOrder()
            : null;

        $balance = 0;
        try {
            $balance = (int) $this->customer->emailVerificationCreditsOrders()
                ->paid()
                ->withSum('orderItems', 'verification_credits')
                ->get()
                ->sum('order_items_sum_verification_credits');
        } catch (\Throwable $e) {
            $balance = 0;
        }

        return [
            'balance' => $balance,
            'last_purchase_date' => $lastOrder ? $lastOrder->created_at : null,
            'last_purchase_amount' => $lastOrder ? $this->safeOrderTotal($lastOrder) : null,
            'has_purchases' => $lastOrder !== null,
        ];
    }

    public function quotaGrid(): array
    {
        $lc = $this->customer->local();

        $listsCount = $lc->listsCount();
        $campaignsCount = $lc->campaignsCount();
        $subscribersCount = (int) AppCache::for($lc)->read('SubscriberCount', 0);

        // limit() returns int|null|false — buildQuotaCard handles the 4-state union directly.
        $quotas = app(QuotasService::class);
        $maxLists       = $quotas->limit($this->customer, QuotaKey::MAX_LISTS);
        $maxCampaigns   = $quotas->limit($this->customer, QuotaKey::MAX_CAMPAIGNS);
        $maxSubscribers = $quotas->limit($this->customer, QuotaKey::MAX_SUBSCRIBERS);

        $sub = $this->customer->getCurrentActiveSubscription();
        $rateRow = $sub
            ? app(\App\Services\Plans\RateLimits\RateLimitsService::class)
                ->configForPlan($sub->plan, RateLimitKey::SEND_EMAIL_RATE)
            : null;
        // RateLimit: row exists → int limit_value; no row → not configured (use false).
        $maxSending = $rateRow ? (int) $rateRow->limit_value : false;

        return [
            'lists'       => $this->buildQuotaCard('lists', $listsCount, $maxLists),
            'campaigns'   => $this->buildQuotaCard('campaigns', $campaignsCount, $maxCampaigns),
            'subscribers' => $this->buildQuotaCard('subscribers', $subscribersCount, $maxSubscribers),
            'sending'     => $this->buildQuotaCard('sending', 0, $maxSending),
        ];
    }

    public function metrics30d(): array
    {
        $since = Carbon::now()->subDays(30);
        $customerId = $this->customer->id;

        $totalSent = TrackingLog::where('customer_id', $customerId)
            ->where('status', TrackingLog::STATUS_SENT)
            ->where('created_at', '>=', $since)
            ->count();

        $openRate = 0.0;
        $clickRate = 0.0;
        $bounceRate = 0.0;

        if ($totalSent > 0) {
            $sentMessages = TrackingLog::where('customer_id', $customerId)
                ->where('status', TrackingLog::STATUS_SENT)
                ->where('created_at', '>=', $since)
                ->select('message_id');

            try {
                $uniqueOpens = \DB::table('open_logs')
                    ->whereIn('message_id', $sentMessages)
                    ->distinct()
                    ->count('message_id');

                $uniqueClicks = \DB::table('click_logs')
                    ->whereIn('message_id', $sentMessages)
                    ->distinct()
                    ->count('message_id');

                $totalBounces = \DB::table('bounce_logs')
                    ->whereIn('message_id', $sentMessages)
                    ->count();

                $openRate = round(($uniqueOpens / $totalSent) * 100, 1);
                $clickRate = round(($uniqueClicks / $totalSent) * 100, 1);
                $bounceRate = round(($totalBounces / $totalSent) * 100, 1);
            } catch (\Throwable $e) {
                // Tables may not exist on minimal demo data — fall back gracefully.
            }
        }

        $sparkline = $this->dailySendingCounts(14);

        return [
            'sent' => [
                'value' => $totalSent,
                'sparkline' => $sparkline,
                'label' => 'sent',
            ],
            'open_rate' => [
                'value' => $openRate,
                'sparkline' => $sparkline,
                'label' => 'open_rate',
            ],
            'click_rate' => [
                'value' => $clickRate,
                'sparkline' => $sparkline,
                'label' => 'click_rate',
            ],
            'bounce_rate' => [
                'value' => $bounceRate,
                'sparkline' => $sparkline,
                'label' => 'bounce_rate',
            ],
        ];
    }

    public function sendingTrend(int $days = 30): array
    {
        $labels = [];
        $data = [];

        for ($i = $days - 1; $i >= 0; $i--) {
            $date = Carbon::now()->subDays($i);
            $labels[] = $date->format('M d');
            $data[] = TrackingLog::where('customer_id', $this->customer->id)
                ->where('status', TrackingLog::STATUS_SENT)
                ->whereDate('created_at', $date->toDateString())
                ->count();
        }

        return [
            'labels' => $labels,
            'data' => $data,
            'days' => $days,
            'total' => array_sum($data),
        ];
    }

    public function subscriberGrowth(int $months = 6): array
    {
        $labels = [];
        $data = [];

        $listIds = $this->customer->mailLists()->pluck('id');

        for ($i = $months - 1; $i >= 0; $i--) {
            $endOfMonth = Carbon::now()->subMonths($i)->endOfMonth();
            $labels[] = $endOfMonth->format('M Y');

            if ($listIds->isEmpty()) {
                $data[] = 0;
                continue;
            }

            try {
                $count = \DB::table('subscribers')
                    ->whereIn('mail_list_id', $listIds)
                    ->where('created_at', '<=', $endOfMonth)
                    ->count();
                $data[] = $count;
            } catch (\Throwable $e) {
                $data[] = 0;
            }
        }

        return [
            'labels' => $labels,
            'data' => $data,
            'months' => $months,
        ];
    }

    public function sendingServers(): array
    {
        $own = $this->customer->sendingServers()->count();

        $systemCount = 0;
        try {
            $systemCount = SendingServer::whereNull('customer_id')->count();
        } catch (\Throwable $e) {
            $systemCount = 0;
        }

        $typesInUse = $this->customer->sendingServers()
            ->select('type')
            ->distinct()
            ->pluck('type')
            ->filter()
            ->values()
            ->all();

        return [
            'own_count' => $own,
            'system_count' => $systemCount,
            'types_in_use' => $typesInUse,
            'total_available' => $own + $systemCount,
        ];
    }

    public function recentCampaigns(int $limit = 5): Collection
    {
        return $this->customer->local()
            ->campaigns()
            ->orderBy('updated_at', 'desc')
            ->limit($limit)
            ->get()
            ->map(function ($campaign) {
                return (object) [
                    'uid' => $campaign->uid,
                    'name' => $campaign->name,
                    'status' => $campaign->status,
                    'updated_at' => $campaign->updated_at,
                    'delivered' => (int) AppCache::for($campaign)->read('DeliveredCount', 0),
                    'open_rate' => round((float) AppCache::for($campaign)->read('UniqOpenRate', 0) * 100, 1),
                    'click_rate' => round((float) AppCache::for($campaign)->read('ClickedRate', 0) * 100, 1),
                ];
            });
    }

    public function activityFeed(int $limit = 8): Collection
    {
        return $this->customer->activityLogs()
            ->orderBy('created_at', 'desc')
            ->limit($limit)
            ->get();
    }

    // ----------------------------------------------------------- helpers


    /**
     * @param int|null|false $max   4-state union (false=not-granted, null=unlimited, int=cap incl 0)
     */
    protected function buildQuotaCard(string $key, int $count, int|null|false $max): array
    {
        $unlimited  = $max === null;
        $notGranted = $max === false;

        $percent = match (true) {
            $unlimited  => 0,
            $notGranted => 100, // visually blocked (locked)
            $max === 0  => 100,
            default     => min(100, (int) round(($count / $max) * 100)),
        };
        $label = QuotasService::displayValue($max); // 'Not included' / '∞' / number

        return [
            'key'        => $key,
            'count'      => $count,
            'max'        => $max,
            'max_label'  => $label,
            'percent'    => $percent,
            'unlimited'  => $unlimited,
            'not_on_plan'=> $notGranted,
        ];
    }

    protected function dailySendingCounts(int $days): array
    {
        $data = [];
        for ($i = $days - 1; $i >= 0; $i--) {
            $date = Carbon::now()->subDays($i);
            $data[] = TrackingLog::where('customer_id', $this->customer->id)
                ->where('status', TrackingLog::STATUS_SENT)
                ->whereDate('created_at', $date->toDateString())
                ->count();
        }
        return $data;
    }

    protected function lifetimePaid(): float
    {
        $invoices = $this->customer->invoices()->where('status', Invoice::STATUS_PAID)->get();
        $total = 0.0;
        foreach ($invoices as $invoice) {
            try {
                $total += (float) $invoice->total();
            } catch (\Throwable $e) {
                // ignore
            }
        }
        return $total;
    }

    protected function currencyCode(): ?string
    {
        $currency = $this->currencyModel();
        return $currency ? $currency->code : null;
    }

    public function currencyFormat(): string
    {
        $currency = $this->currencyModel();
        if ($currency && !empty($currency->format)) {
            return $currency->format;
        }
        return '${PRICE}';
    }

    protected function currencyModel(): ?\App\Model\Currency
    {
        $sub = $this->customer->getCurrentActiveSubscription();
        if ($sub && $sub->plan && $sub->plan->currency) {
            return $sub->plan->currency;
        }
        /** @var \App\Model\Invoice|null $invoice */
        $invoice = $this->customer->invoices()->orderBy('created_at', 'desc')->first();
        if ($invoice instanceof \App\Model\Invoice) {
            $currency = $invoice->currency()->first();
            if ($currency instanceof \App\Model\Currency) {
                return $currency;
            }
        }
        return null;
    }

    protected function safeOrderTotal($order): ?float
    {
        try {
            return (float) $order->total();
        } catch (\Throwable $e) {
            return null;
        }
    }

    protected function initials(): string
    {
        $name = $this->customer->displayName() ?? $this->customer->user->email ?? '?';
        return $this->initialsFrom($name);
    }

    protected function userInitials($user): string
    {
        $name = trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? ''));
        if ($name === '') {
            $name = $user->email ?? '?';
        }
        return $this->initialsFrom($name);
    }

    /**
     * Return a real uploaded avatar URL or null. `getProfileImageUrl()` falls
     * back to `images/user-placeholder.svg`, which would defeat the colored
     * initials fallback the rest of the rui system relies on.
     */
    protected function uploadedAvatarUrl($user): ?string
    {
        if (!$user) {
            return null;
        }
        return file_exists($user->getProfileImagePath()) ? $user->getProfileImageUrl() : null;
    }

    protected function avatarColor(string $initials): int
    {
        $first = mb_substr($initials !== '' ? $initials : '?', 0, 1);
        return (ord(strtoupper($first)) % 8) + 1;
    }

    protected function initialsFrom(string $name): string
    {
        $parts = preg_split('/\s+/', trim($name));
        if (!$parts || count($parts) === 0) {
            return '?';
        }
        $first = mb_substr($parts[0] ?? '', 0, 1);
        $second = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : '';
        return mb_strtoupper($first . $second) ?: '?';
    }
}