File: /home/xedaptot/ai.naniguide.com/app/Services/Customer/SendingDomainService.php
<?php
namespace App\Services\Customer;
use App\Dto\Refactor\SendingDomain\SendingDomainSidebarStatsDto;
use App\Model\Customer;
use App\Model\SendingIdentity;
use App\SendingServers\Identities\IdentityKind;
use App\SendingServers\Identities\IdentityStatus;
use App\SendingServers\Identities\ManagementMode;
use App\Services\Plans\Quotas\QuotaKey;
use App\Services\Plans\Quotas\QuotasService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* Read aggregator for the customer-side rich-redesigned Sending Domains page.
*
* Post-refactor this service queries `sending_identities` directly (filter
* `management_mode=LOCAL_DKIM`, `kind=domain`, `customer_id=local`).
* The page is the only entry point for LOCAL_DKIM rows, so the scope is
* unambiguous: customer-owned, server-less, Acelle-DKIM-signed domains.
*/
class SendingDomainService
{
public function __construct(private QuotasService $quotas)
{
}
/**
* @return array{total:int,verified:int,unverified:int,dkim_signed:int}
*/
public function dashboardStats(Customer $customer): array
{
$localId = $customer->local()->id;
$base = $this->baseQuery($localId);
$total = (clone $base)->count();
$verified = (clone $base)->where('status', IdentityStatus::VERIFIED->value)->count();
$unverified = max(0, $total - $verified);
// "DKIM signed" = domain has DKIM signing happening from somewhere:
// - LOCAL_DKIM with signing_enabled=true (Acelle signs locally), OR
// - VENDOR_SYNC verified (vendor signs on its infrastructure)
// MANUAL is excluded — relay's DKIM behavior is opaque to Acelle.
$dkimSigned = (clone $base)
->where(function ($q) {
$q->where(function ($qq) {
$qq->where('management_mode', ManagementMode::LOCAL_DKIM->value)
->where('signing_enabled', 1);
})->orWhere(function ($qq) {
$qq->where('management_mode', ManagementMode::VENDOR_SYNC->value)
->where('status', IdentityStatus::VERIFIED->value);
});
})
->count();
return [
'total' => $total,
'verified' => $verified,
'unverified' => $unverified,
'dkim_signed' => $dkimSigned,
];
}
public function list(
Customer $customer,
?string $keyword = null,
?string $status = null,
string $sortBy = 'created_at',
string $sortDir = 'desc',
int $perPage = 25,
): LengthAwarePaginator {
$query = $this->baseQuery($customer->local()->id);
if ($keyword !== null && $keyword !== '') {
$query->where('value', 'like', '%' . $keyword . '%');
}
if ($status === 'verified') {
$query->where('status', IdentityStatus::VERIFIED->value);
} elseif ($status === 'unverified') {
$query->where('status', '!=', IdentityStatus::VERIFIED->value);
}
$allowedSorts = ['created_at', 'value', 'status'];
// Backward compat: legacy callers passed 'name' for the value column
$sortBy = $sortBy === 'name' ? 'value' : $sortBy;
if (!in_array($sortBy, $allowedSorts, true)) {
$sortBy = 'created_at';
}
$sortDir = strtolower($sortDir) === 'asc' ? 'asc' : 'desc';
return $query->orderBy($sortBy, $sortDir)->paginate($perPage);
}
public function sidebarStats(Customer $customer): SendingDomainSidebarStatsDto
{
$stats = $this->dashboardStats($customer);
$verifiedPct = $stats['total'] > 0
? (int) round(($stats['verified'] / $stats['total']) * 100)
: 0;
return new SendingDomainSidebarStatsDto(
verificationDonut: [
'verified' => $stats['verified'],
'unverified' => $stats['unverified'],
'verified_pct' => $verifiedPct,
],
needsAttention: $this->needsAttention($customer),
addActivity: $this->addActivity($customer),
quota: $this->quotaUsage($customer, $stats['total']),
);
}
public function customerCanVerify(Customer $customer): bool
{
return $customer->allowVerifyingOwnDomains();
}
/** @return array{count:int,oldest_days:int,first_uid:?string} */
private function needsAttention(Customer $customer): array
{
$base = $this->baseQuery($customer->local()->id)
->where('status', '!=', IdentityStatus::VERIFIED->value);
$count = (clone $base)->count();
if ($count === 0) {
return ['count' => 0, 'oldest_days' => 0, 'first_uid' => null];
}
$oldest = (clone $base)->orderBy('created_at', 'asc')->first();
$days = ($oldest && $oldest->created_at)
? max(0, (int) $oldest->created_at->diffInDays(now()))
: 0;
return [
'count' => $count,
'oldest_days' => $days,
'first_uid' => $oldest?->uid,
];
}
/**
* @return array{added_30d:int,added_prev_30d:int,delta_pct:?float,daily:list<array{date:string,count:int}>}
*/
private function addActivity(Customer $customer): array
{
$localId = $customer->local()->id;
$now = now();
$start30 = $now->copy()->subDays(30);
$startPrev = $now->copy()->subDays(60);
$added30 = $this->baseQuery($localId)
->where('created_at', '>=', $start30)
->count();
$addedPrev = $this->baseQuery($localId)
->whereBetween('created_at', [$startPrev, $start30])
->count();
$deltaPct = null;
if ($addedPrev > 0) {
$deltaPct = round((($added30 - $addedPrev) / $addedPrev) * 100, 1);
} elseif ($added30 > 0) {
$deltaPct = 100.0;
}
$tablePrefix = DB::getTablePrefix();
$rows = DB::table('sending_identities')
->where('customer_id', $localId)
->where('management_mode', ManagementMode::LOCAL_DKIM->value)
->where('kind', IdentityKind::DOMAIN->value)
->where('created_at', '>=', $start30)
->select([
DB::raw('DATE(' . $tablePrefix . 'sending_identities.created_at) as day'),
DB::raw('COUNT(*) as c'),
])
->groupBy('day')
->get()
->keyBy('day');
$daily = [];
for ($i = 0; $i < 30; $i++) {
$date = $now->copy()->subDays(29 - $i)->format('Y-m-d');
$daily[] = [
'date' => $date,
'count' => isset($rows[$date]) ? (int) $rows[$date]->c : 0,
];
}
return [
'added_30d' => $added30,
'added_prev_30d' => $addedPrev,
'delta_pct' => $deltaPct,
'daily' => $daily,
];
}
/** @return array{used:int,max:int|null,pct:?int,unlimited:bool,not_on_plan:bool} */
private function quotaUsage(Customer $customer, int $used): array
{
// limit() returns int|null|false (false = plan doesn't grant, null = unlimited, int = cap)
$limit = $this->quotas->limit($customer, QuotaKey::MAX_SENDING_DOMAINS);
if ($limit === false) {
// Plan does not grant MAX_SENDING_DOMAINS at all → widget renders "not on plan" state.
return ['used' => $used, 'max' => null, 'pct' => null, 'unlimited' => false, 'not_on_plan' => true];
}
if ($limit === null) {
return ['used' => $used, 'max' => null, 'pct' => null, 'unlimited' => true, 'not_on_plan' => false];
}
if ($limit > 0) {
$pct = min(100, (int) round(($used / $limit) * 100));
} else {
$pct = $used > 0 ? 100 : 0;
}
return [
'used' => $used,
'max' => $limit,
'pct' => $pct,
'unlimited' => false,
'not_on_plan' => false,
];
}
/**
* All customer-owned domain identities, across every management mode.
* The page renders a mode badge per row so customers can tell apart:
* - LOCAL_DKIM : server-less, Acelle DKIM-signed
* - VENDOR_SYNC : pushed to a vendor (SES / SendGrid / etc.)
* - MANUAL : declared on a server with no vendor sync API
*/
private function baseQuery(int $customerId): \Illuminate\Database\Eloquent\Builder
{
return SendingIdentity::query()
->where('customer_id', $customerId)
->where('kind', IdentityKind::DOMAIN->value);
}
}