php-uptelligence/Services/UptelligenceDigestService.php
Snider e0d2325a20 refactor: move namespace from Core\Uptelligence to Core\Mod\Uptelligence
Aligns module namespace with Core PHP Framework conventions where
modules live under the Core\Mod\ namespace hierarchy. This follows
the monorepo separation work started in 40d893a.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:32:55 +00:00

271 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Services;
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;
/**
* 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
]
);
}
}