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) ?: '?';
}
}