php-uptelligence/Services/UptelligenceDigestService.php

272 lines
8.8 KiB
PHP
Raw Normal View History

2026-01-26 23:56:46 +00:00
<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Services;
2026-01-26 23:56:46 +00:00
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Core\Mod\Uptelligence\Models\UpstreamTodo;
use Core\Mod\Uptelligence\Models\UptelligenceDigest;
use Core\Mod\Uptelligence\Models\Vendor;
use Core\Mod\Uptelligence\Models\VersionRelease;
use Core\Mod\Uptelligence\Notifications\SendUptelligenceDigest;
2026-01-26 23:56:46 +00:00
/**
* UptelligenceDigestService - generates and sends digest email notifications.
*
* Collects new releases, pending todos, and security updates since the last
* digest and sends summarised email notifications to subscribed users.
*/
class UptelligenceDigestService
{
/**
* Generate digest content for a specific user's preferences.
*
* @return array{releases: Collection, todos: array, security_count: int, has_content: bool}
*/
public function generateDigestContent(UptelligenceDigest $digest): array
{
$sinceDate = $digest->last_sent_at ?? now()->subMonth();
$vendorIds = $digest->getVendorIds();
$minPriority = $digest->getMinPriority();
// Build base vendor query
$vendorQuery = Vendor::active();
if ($vendorIds !== null) {
$vendorQuery->whereIn('id', $vendorIds);
}
$trackedVendorIds = $vendorQuery->pluck('id');
// Gather new releases
$releases = collect();
if ($digest->includesReleases()) {
$releases = $this->getNewReleases($trackedVendorIds, $sinceDate);
}
// Gather pending todos grouped by priority
$todosByPriority = [];
if ($digest->includesTodos()) {
$todosByPriority = $this->getTodosByPriority($trackedVendorIds, $minPriority);
}
// Count security-related updates
$securityCount = 0;
if ($digest->includesSecurity()) {
$securityCount = $this->getSecurityUpdatesCount($trackedVendorIds, $sinceDate);
}
$hasContent = $releases->isNotEmpty()
|| ! empty(array_filter($todosByPriority))
|| $securityCount > 0;
return [
'releases' => $releases,
'todos' => $todosByPriority,
'security_count' => $securityCount,
'has_content' => $hasContent,
];
}
/**
* Get new releases since the given date.
*/
protected function getNewReleases(Collection $vendorIds, \DateTimeInterface $since): Collection
{
return VersionRelease::whereIn('vendor_id', $vendorIds)
->where('created_at', '>=', $since)
->analyzed()
->with('vendor:id,name,slug')
->orderByDesc('created_at')
->take(20)
->get()
->map(fn (VersionRelease $release) => [
'vendor_name' => $release->vendor->name,
'vendor_slug' => $release->vendor->slug,
'version' => $release->version,
'previous_version' => $release->previous_version,
'files_changed' => $release->getTotalChanges(),
'impact_level' => $release->getImpactLevel(),
'todos_created' => $release->todos_created ?? 0,
'analyzed_at' => $release->analyzed_at,
]);
}
/**
* Get pending todos grouped by priority level.
*
* @return array{critical: int, high: int, medium: int, low: int, total: int}
*/
protected function getTodosByPriority(Collection $vendorIds, ?int $minPriority): array
{
$query = UpstreamTodo::whereIn('vendor_id', $vendorIds)
->pending();
if ($minPriority !== null) {
$query->where('priority', '>=', $minPriority);
}
$todos = $query->get(['priority']);
return [
'critical' => $todos->where('priority', '>=', 8)->count(),
'high' => $todos->whereBetween('priority', [6, 7])->count(),
'medium' => $todos->whereBetween('priority', [4, 5])->count(),
'low' => $todos->where('priority', '<', 4)->count(),
'total' => $todos->count(),
];
}
/**
* Get count of security-related updates since the given date.
*/
protected function getSecurityUpdatesCount(Collection $vendorIds, \DateTimeInterface $since): int
{
return UpstreamTodo::whereIn('vendor_id', $vendorIds)
->securityRelated()
->pending()
->where('created_at', '>=', $since)
->count();
}
/**
* Send a digest notification to a user.
*/
public function sendDigest(UptelligenceDigest $digest): bool
{
$content = $this->generateDigestContent($digest);
// Skip if there's nothing to report
if (! $content['has_content']) {
Log::debug('Uptelligence: Skipping empty digest', [
'user_id' => $digest->user_id,
'workspace_id' => $digest->workspace_id,
]);
// Still mark as sent to prevent re-checking
$digest->markAsSent();
return false;
}
try {
$digest->user->notify(new SendUptelligenceDigest(
digest: $digest,
releases: $content['releases'],
todosByPriority: $content['todos'],
securityCount: $content['security_count'],
));
$digest->markAsSent();
Log::info('Uptelligence: Digest sent successfully', [
'user_id' => $digest->user_id,
'workspace_id' => $digest->workspace_id,
'releases_count' => $content['releases']->count(),
'todos_count' => $content['todos']['total'] ?? 0,
]);
return true;
} catch (\Exception $e) {
Log::error('Uptelligence: Failed to send digest', [
'user_id' => $digest->user_id,
'workspace_id' => $digest->workspace_id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Process all digests due for a specific frequency.
*
* @return array{sent: int, skipped: int, failed: int}
*/
public function processDigests(string $frequency): array
{
$stats = ['sent' => 0, 'skipped' => 0, 'failed' => 0];
$digests = UptelligenceDigest::dueForDigest($frequency)
->with(['user', 'workspace'])
->get();
foreach ($digests as $digest) {
// Skip if user or workspace no longer exists
if (! $digest->user || ! $digest->workspace) {
$digest->delete();
$stats['skipped']++;
continue;
}
$result = $this->sendDigest($digest);
if ($result) {
$stats['sent']++;
} else {
// Check if it was skipped (empty) or failed
if (! $this->generateDigestContent($digest)['has_content']) {
$stats['skipped']++;
} else {
$stats['failed']++;
}
}
}
return $stats;
}
/**
* Get a preview of what would be included in a digest.
*
* Useful for showing users what they'll receive before enabling.
*/
public function getDigestPreview(UptelligenceDigest $digest): array
{
$content = $this->generateDigestContent($digest);
// Get top vendors with pending work
$vendorIds = $digest->getVendorIds();
$vendorQuery = Vendor::active()
->withCount(['todos as pending_count' => fn ($q) => $q->pending()]);
if ($vendorIds !== null) {
$vendorQuery->whereIn('id', $vendorIds);
}
$topVendors = $vendorQuery
->having('pending_count', '>', 0)
->orderByDesc('pending_count')
->take(5)
->get(['id', 'name', 'slug', 'current_version']);
return [
'releases' => $content['releases']->take(5),
'todos' => $content['todos'],
'security_count' => $content['security_count'],
'top_vendors' => $topVendors,
'has_content' => $content['has_content'],
'frequency_label' => $digest->getFrequencyLabel(),
'next_send' => $digest->getNextSendDate()?->format('j F Y'),
];
}
/**
* Get or create a digest preference for a user in a workspace.
*/
public function getOrCreateDigest(int $userId, int $workspaceId): UptelligenceDigest
{
return UptelligenceDigest::firstOrCreate(
[
'user_id' => $userId,
'workspace_id' => $workspaceId,
],
[
'frequency' => UptelligenceDigest::FREQUENCY_WEEKLY,
'is_enabled' => false, // Start disabled, user must opt-in
]
);
}
}