Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -15,11 +15,39 @@ use App\Models\AcademyPromptTemplate;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Cashier\Subscription;
final class AcademyAccessService
{
/**
* @var array<string, bool>
*/
private array $assetExistsCache = [];
/**
* @var array<int, string|null>
*/
private array $paidTierCache = [];
/**
* @var array<int, Subscription|null>
*/
private array $subscriptionCache = [];
/**
* @var array<string, string>|null
*/
private ?array $priceTierMap = null;
public function canAccess(?User $user, string $requiredLevel): bool
{
return $this->canAccessContent($user, $requiredLevel);
}
public function canAccessContent(?User $user, string $accessLevel): bool
{
$accessLevel = $this->normalizeAccessLevel($accessLevel);
if ($accessLevel === 'free') {
return true;
}
@@ -28,11 +56,40 @@ final class AcademyAccessService
return false;
}
if ($user->isAdmin()) {
return true;
return $this->rankForLevel($this->currentTier($user)) >= $this->rankForLevel($accessLevel);
}
public function currentTier(?User $user): string
{
if (! $user instanceof User) {
return 'free';
}
return $this->rankForUser($user) >= $this->rankForLevel($accessLevel);
if ($this->isAcademyAdmin($user)) {
return 'admin';
}
return $this->paidTier($user) ?? 'free';
}
public function paidTier(?User $user): ?string
{
if (! $user instanceof User) {
return null;
}
$cacheKey = (int) $user->getKey();
if (array_key_exists($cacheKey, $this->paidTierCache)) {
return $this->paidTierCache[$cacheKey];
}
return $this->paidTierCache[$cacheKey] = $this->resolveSubscriptionTier($user) ?? $this->resolveLegacyPaidTier($user);
}
public function hasActiveAcademySubscription(User $user): bool
{
return $this->activeAcademySubscription($user) instanceof Subscription;
}
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
@@ -59,11 +116,7 @@ final class AcademyAccessService
{
$accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free'));
if ($accessLevel === 'premium') {
return $user?->isAdmin() ?? false;
}
return $this->canAccessContent($user, $accessLevel === 'mixed' ? 'free' : $accessLevel);
return $this->canAccessContent($user, $accessLevel);
}
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array
@@ -172,6 +225,19 @@ final class AcademyAccessService
public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array
{
$authorized = $this->canAccessPrompt($viewer, $prompt);
$publicExamples = $this->promptPublicExamplesPayload($prompt, (array) ($prompt->tool_notes ?? []));
$previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? ''));
$documentation = $this->promptDocumentationPayload($prompt->documentation);
$placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? []));
$hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders);
$hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== [];
$hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== [];
$helperPrompts = $authorized && $includeFull
? $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? []))
: [];
$promptVariants = $authorized && $includeFull
? $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? []))
: [];
return [
'id' => (int) $prompt->id,
@@ -183,12 +249,25 @@ final class AcademyAccessService
'usage_notes' => ($authorized && $includeFull) ? (string) ($prompt->usage_notes ?? '') : null,
'workflow_notes' => ($authorized && $includeFull) ? (string) ($prompt->workflow_notes ?? '') : null,
'prompt_preview' => $authorized ? null : $this->previewText((string) $prompt->prompt, 220),
'documentation' => $documentation,
'placeholders' => $placeholders,
'has_placeholder_inputs' => $hasPlaceholderInputs,
'has_helper_prompts' => $hasHelperPrompts,
'has_prompt_variants' => $hasPromptVariants,
'helper_prompts' => $helperPrompts,
'prompt_variants' => $promptVariants,
'difficulty' => (string) $prompt->difficulty,
'access_level' => (string) $prompt->access_level,
'access_requirement' => $this->promptAccessRequirement((string) $prompt->access_level),
'unlock_heading' => $this->promptUnlockHeading((string) $prompt->access_level),
'unlock_description' => $this->promptUnlockDescription((string) $prompt->access_level),
'aspect_ratio' => $prompt->aspect_ratio,
'tags' => array_values((array) ($prompt->tags ?? [])),
'public_examples' => $publicExamples,
'tool_notes' => $authorized ? $this->promptToolNotesPayload((array) ($prompt->tool_notes ?? [])) : [],
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
'preview_image' => $previewImage['url'],
'preview_image_thumb' => $previewImage['thumb_url'],
'preview_image_srcset' => $previewImage['srcset'],
'featured' => (bool) $prompt->featured,
'prompt_of_week' => (bool) $prompt->prompt_of_week,
'published_at' => $prompt->published_at?->toISOString(),
@@ -202,6 +281,235 @@ final class AcademyAccessService
];
}
/**
* @param mixed $documentation
* @return array<string, mixed>
*/
private function promptDocumentationPayload(mixed $documentation): array
{
$normalized = is_array($documentation) ? $documentation : [];
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
$payload = [
'summary' => $this->nullableTrimmedString($normalized['summary'] ?? null),
'display_notes' => $this->nullableTrimmedString($normalized['display_notes'] ?? null),
];
foreach ($listFields as $field) {
$payload[$field] = $this->normalizeStringList($normalized[$field] ?? []);
}
return $payload;
}
/**
* @param array<int, mixed> $placeholders
* @return array<int, array<string, mixed>>
*/
private function promptPlaceholdersPayload(array $placeholders): array
{
return collect($placeholders)
->filter(static fn ($placeholder): bool => is_array($placeholder))
->map(function (array $placeholder): array {
return [
'key' => trim((string) ($placeholder['key'] ?? '')),
'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'example' => $placeholder['example'] ?? null,
'default' => $placeholder['default'] ?? null,
'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
];
})
->filter(function (array $placeholder): bool {
return collect([
$placeholder['key'],
$placeholder['label'],
$placeholder['description'],
$placeholder['example'],
$placeholder['default'],
$placeholder['type'],
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->values()
->all();
}
/**
* @param array<int, array<string, mixed>> $placeholders
*/
private function promptHasPlaceholderInputs(string $prompt, array $placeholders): bool
{
if ($prompt === '' || $placeholders === []) {
return false;
}
foreach ($placeholders as $placeholder) {
$key = trim((string) ($placeholder['key'] ?? ''));
if ($key === '') {
continue;
}
if (mb_stripos($prompt, '['.$key.']') !== false) {
return true;
}
}
return false;
}
/**
* @param array<int, mixed> $helperPrompts
* @return array<int, array<string, mixed>>
*/
private function promptHelperPromptsPayload(array $helperPrompts): array
{
return collect($helperPrompts)
->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
->map(function (array $helperPrompt): array {
return [
'title' => trim((string) ($helperPrompt['title'] ?? '')),
'type' => trim((string) ($helperPrompt['type'] ?? 'other')) ?: 'other',
'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
'prompt' => trim((string) ($helperPrompt['prompt'] ?? '')),
'expected_output' => trim((string) ($helperPrompt['expected_output'] ?? 'text')) ?: 'text',
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $helperPrompt): bool {
return $helperPrompt['active'] !== false
&& collect([
$helperPrompt['title'],
$helperPrompt['description'],
$helperPrompt['prompt'],
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @param array<int, mixed> $variants
* @return array<int, array<string, mixed>>
*/
private function promptVariantsPayload(array $variants): array
{
return collect($variants)
->filter(static fn ($variant): bool => is_array($variant))
->map(function (array $variant): array {
return [
'title' => trim((string) ($variant['title'] ?? '')),
'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
'description' => $this->nullableTrimmedString($variant['description'] ?? null),
'prompt' => trim((string) ($variant['prompt'] ?? '')),
'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []),
'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []),
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->filter(function (array $variant): bool {
return $variant['active'] !== false
&& collect([
$variant['title'],
$variant['description'],
$variant['prompt'],
$variant['negative_prompt'],
])->contains(fn ($item): bool => $item !== null && $item !== '');
})
->values()
->all();
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, mixed>>
*/
private function promptPublicExamplesPayload(AcademyPromptTemplate $prompt, array $notes): array
{
$promptTitle = trim((string) $prompt->title);
return collect($notes)
->values()
->filter(static fn ($note): bool => is_array($note))
->filter(function (array $note): bool {
return (filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true) !== false;
})
->map(function (array $note, int $index) use ($promptTitle): ?array {
$imagePayload = $this->responsiveLessonImagePayload(
(string) ($note['image_path'] ?? ''),
(string) ($note['thumb_path'] ?? ''),
);
$imagePath = $imagePayload['image_path'];
$thumbPath = $imagePayload['thumb_path'];
$imageUrl = $imagePayload['image_url'];
$thumbUrl = $imagePayload['thumb_url'];
if ($imageUrl === null && $thumbUrl === null) {
return null;
}
$displayType = trim((string) ($note['display_type'] ?? ''));
$provider = trim((string) ($note['provider'] ?? ''));
$modelName = trim((string) ($note['model_name'] ?? ''));
$typeLabel = $displayType !== ''
? (string) Str::of($displayType)->replace(['_', '-'], ' ')->headline()
: 'Prompt variation';
$title = $displayType !== ''
? $typeLabel
: ($modelName !== '' ? $modelName : ($provider !== '' ? $provider : sprintf('Prompt Example %02d', $index + 1)));
$caption = $displayType !== ''
? sprintf('%s preview for %s.', $typeLabel, $promptTitle !== '' ? $promptTitle : 'this prompt')
: sprintf('Example result preview for %s.', $promptTitle !== '' ? $promptTitle : 'this prompt');
return [
'type_label' => $typeLabel,
'title' => $title,
'caption' => $caption,
'alt' => sprintf('%s preview image for %s', $title, $promptTitle !== '' ? $promptTitle : 'Skinbase Academy prompt'),
'provider' => $provider,
'model_name' => $modelName,
'image_path' => $imagePath,
'image_url' => $imageUrl,
'thumb_path' => $thumbPath,
'thumb_url' => $thumbUrl,
'image_srcset' => $imagePayload['srcset'],
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
];
})
->filter()
->values()
->all();
}
private function promptAccessRequirement(string $accessLevel): ?string
{
return match (trim(strtolower($accessLevel))) {
'pro' => 'Requires Pro access.',
'creator' => 'Requires Creator or Pro access.',
default => null,
};
}
private function promptUnlockHeading(string $accessLevel): ?string
{
return match (trim(strtolower($accessLevel))) {
'pro' => 'Unlock the full Pro prompt.',
'creator' => 'Unlock the full Creator prompt.',
default => null,
};
}
private function promptUnlockDescription(string $accessLevel): ?string
{
return match (trim(strtolower($accessLevel))) {
'pro' => 'Get the complete reusable prompt, negative prompt, workflow notes, model settings, and variation strategy.',
'creator' => 'Get the complete reusable prompt, negative prompt, workflow notes, and creative workflow.',
default => null,
};
}
/**
* @param array<int, mixed> $notes
* @return array<int, array<string, mixed>>
@@ -211,17 +519,24 @@ final class AcademyAccessService
return collect($notes)
->filter(static fn ($note): bool => is_array($note))
->map(function (array $note): array {
$imagePayload = $this->responsiveLessonImagePayload(
(string) ($note['image_path'] ?? ''),
(string) ($note['thumb_path'] ?? ''),
);
return [
'display_type' => trim((string) ($note['display_type'] ?? '')),
'provider' => trim((string) ($note['provider'] ?? '')),
'model_name' => trim((string) ($note['model_name'] ?? '')),
'notes' => trim((string) ($note['notes'] ?? '')),
'strengths' => trim((string) ($note['strengths'] ?? '')),
'weaknesses' => trim((string) ($note['weaknesses'] ?? '')),
'best_for' => trim((string) ($note['best_for'] ?? '')),
'image_path' => trim((string) ($note['image_path'] ?? '')),
'image_url' => $this->resolveLessonMediaUrl((string) ($note['image_path'] ?? '')),
'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
'thumb_url' => $this->resolveLessonMediaUrl((string) ($note['thumb_path'] ?? '')),
'image_path' => $imagePayload['image_path'],
'image_url' => $imagePayload['image_url'],
'thumb_path' => $imagePayload['thumb_path'],
'thumb_url' => $imagePayload['thumb_url'],
'image_srcset' => $imagePayload['srcset'],
'settings' => trim((string) ($note['settings'] ?? '')),
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
@@ -229,6 +544,7 @@ final class AcademyAccessService
})
->filter(function (array $note): bool {
return collect([
$note['display_type'],
$note['provider'],
$note['model_name'],
$note['notes'],
@@ -296,30 +612,127 @@ final class AcademyAccessService
];
}
private function rankForUser(User $user): int
{
if (method_exists($user, 'hasAcademyProAccess') && $user->hasAcademyProAccess()) {
return $this->rankForLevel('pro');
}
if (method_exists($user, 'hasAcademyCreatorAccess') && $user->hasAcademyCreatorAccess()) {
return $this->rankForLevel('creator');
}
return $this->rankForLevel('free');
}
private function rankForLevel(string $accessLevel): int
{
return match (Str::lower(trim($accessLevel))) {
return match ($this->normalizeAccessLevel($accessLevel)) {
'admin' => 99,
'premium' => 40,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function normalizeAccessLevel(string $accessLevel): string
{
return match (Str::lower(trim($accessLevel))) {
'admin' => 'admin',
'pro' => 'pro',
'creator', 'premium' => 'creator',
'mixed' => 'free',
default => 'free',
};
}
private function isAcademyAdmin(User $user): bool
{
return $user->hasStaffAccess() || $user->isModerator();
}
private function resolveSubscriptionTier(User $user): ?string
{
$subscription = $this->activeAcademySubscription($user);
if (! $subscription instanceof Subscription) {
return null;
}
$matchedTier = null;
foreach ($subscription->items as $item) {
$priceId = trim((string) $item->stripe_price);
if ($priceId === '') {
continue;
}
$tier = $this->priceTierMap()[$priceId] ?? null;
if ($tier === null) {
continue;
}
if ($matchedTier === null || $this->rankForLevel($tier) > $this->rankForLevel($matchedTier)) {
$matchedTier = $tier;
}
}
return $matchedTier;
}
private function resolveLegacyPaidTier(User $user): ?string
{
return match (Str::lower(trim((string) ($user->role ?? '')))) {
'academy_pro' => 'pro',
'academy_creator' => 'creator',
default => null,
};
}
private function activeAcademySubscription(User $user): ?Subscription
{
$cacheKey = (int) $user->getKey();
if (array_key_exists($cacheKey, $this->subscriptionCache)) {
return $this->subscriptionCache[$cacheKey];
}
$subscription = $user->subscription($this->subscriptionName());
if (! $subscription instanceof Subscription) {
return $this->subscriptionCache[$cacheKey] = null;
}
if (! $subscription->active() && ! $subscription->onGracePeriod()) {
return $this->subscriptionCache[$cacheKey] = null;
}
return $this->subscriptionCache[$cacheKey] = $subscription->loadMissing('items');
}
/**
* @return array<string, string>
*/
private function priceTierMap(): array
{
if (is_array($this->priceTierMap)) {
return $this->priceTierMap;
}
$map = [];
foreach ((array) config('academy_billing.plans', []) as $plan) {
if (! is_array($plan)) {
continue;
}
$priceId = trim((string) ($plan['stripe_price_id'] ?? ''));
$tier = $this->normalizeAccessLevel((string) ($plan['tier'] ?? 'free'));
if ($priceId === '' || $tier === 'free') {
continue;
}
$map[$priceId] = $tier;
}
return $this->priceTierMap = $map;
}
private function subscriptionName(): string
{
return (string) config('academy_billing.subscription_name', 'academy');
}
private function previewText(string $value, int $limit): string
{
$plain = trim(strip_tags($value));
@@ -338,6 +751,33 @@ final class AcademyAccessService
return rtrim(mb_substr($plain, 0, $previewLength)).'...';
}
private function nullableTrimmedString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
/**
* @return array<int, string>
*/
private function normalizeStringList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null ? [] : [$value];
}
return collect($value)
->map(fn ($item): string => trim((string) $item))
->filter(static fn (string $item): bool => $item !== '')
->values()
->all();
}
private function resolvePreviewImageUrl(string $previewImage): ?string
{
$previewImage = trim($previewImage);
@@ -353,6 +793,25 @@ final class AcademyAccessService
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage);
}
/**
* @return array{url:?string,thumb_url:?string,srcset:?string}
*/
private function promptPreviewImagePayload(string $previewImage): array
{
$url = $this->resolvePreviewImageUrl($previewImage);
$thumbPath = $this->existingResponsiveVariantPath($previewImage, 'thumb');
$mediumPath = $this->existingResponsiveVariantPath($previewImage, 'md');
return [
'url' => $url,
'thumb_url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : $url,
'srcset' => $this->buildResponsiveSrcset([
['url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : null, 'width' => 480],
['url' => $mediumPath !== null ? $this->resolvePreviewImageUrl($mediumPath) : null, 'width' => 960],
]),
];
}
private function resolveLessonCoverImageUrl(string $coverImage): ?string
{
$coverImage = trim($coverImage);
@@ -383,6 +842,95 @@ final class AcademyAccessService
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path);
}
/**
* @return array{image_path:string,image_url:?string,thumb_path:string,thumb_url:?string,srcset:?string}
*/
private function responsiveLessonImagePayload(string $imagePath, string $thumbPath = ''): array
{
$resolvedImagePath = trim($imagePath);
$resolvedThumbPath = trim($thumbPath);
$imageUrl = $this->resolveLessonMediaUrl($resolvedImagePath);
$thumbUrl = $resolvedThumbPath !== '' ? $this->resolveLessonMediaUrl($resolvedThumbPath) : $imageUrl;
$mediumPath = $resolvedThumbPath !== '' ? $this->existingResponsiveVariantPath($resolvedImagePath, 'md') : null;
return [
'image_path' => $resolvedImagePath,
'image_url' => $imageUrl,
'thumb_path' => $resolvedThumbPath,
'thumb_url' => $thumbUrl,
'srcset' => $this->buildResponsiveSrcset([
['url' => $thumbUrl, 'width' => $resolvedThumbPath !== '' ? 480 : null],
['url' => $mediumPath !== null ? $this->resolveLessonMediaUrl($mediumPath) : null, 'width' => $mediumPath !== null ? 960 : null],
]),
];
}
private function responsiveVariantPath(string $path, string $variant): ?string
{
$path = trim($path);
if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return null;
}
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function existingResponsiveVariantPath(string $path, string $variant): ?string
{
$variantPath = $this->responsiveVariantPath($path, $variant);
if ($variantPath === null || ! $this->storagePathExists($variantPath)) {
return null;
}
return $variantPath;
}
private function storagePathExists(string $path): bool
{
$normalizedPath = trim($path);
if ($normalizedPath === '' || str_starts_with($normalizedPath, 'http://') || str_starts_with($normalizedPath, 'https://') || str_starts_with($normalizedPath, '/')) {
return false;
}
$cacheKey = (string) config('uploads.object_storage.disk', 's3') . ':' . $normalizedPath;
if (array_key_exists($cacheKey, $this->assetExistsCache)) {
return $this->assetExistsCache[$cacheKey];
}
try {
$exists = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->exists($normalizedPath);
} catch (\Throwable) {
$exists = false;
}
$this->assetExistsCache[$cacheKey] = $exists;
return $exists;
}
/**
* @param array<int, array{url:?string,width:int|null}> $variants
*/
private function buildResponsiveSrcset(array $variants): ?string
{
$entries = collect($variants)
->filter(static fn (array $variant): bool => filled($variant['url'] ?? null) && (int) ($variant['width'] ?? 0) > 0)
->unique(fn (array $variant): string => (string) $variant['url'])
->map(fn (array $variant): string => sprintf('%s %dw', (string) $variant['url'], (int) $variant['width']))
->values()
->all();
return $entries !== [] ? implode(', ', $entries) : null;
}
/**
* @return array<string, mixed>|null
*/
@@ -399,22 +947,30 @@ final class AcademyAccessService
->values()
->all();
$results = $block->activeComparisonResults
->map(fn (AcademyAiComparisonResult $result): array => [
'id' => (int) $result->id,
'provider' => (string) ($result->provider ?? ''),
'model_name' => (string) ($result->model_name ?? ''),
'image_path' => (string) $result->image_path,
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
'thumb_path' => (string) ($result->thumb_path ?? ''),
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
'settings' => (string) ($result->settings ?? ''),
'strengths' => (string) ($result->strengths ?? ''),
'weaknesses' => (string) ($result->weaknesses ?? ''),
'best_for' => (string) ($result->best_for ?? ''),
'score' => $result->score,
'sort_order' => (int) $result->sort_order,
'active' => (bool) $result->active,
])
->map(function (AcademyAiComparisonResult $result): array {
$imagePayload = $this->responsiveLessonImagePayload(
(string) $result->image_path,
(string) ($result->thumb_path ?? ''),
);
return [
'id' => (int) $result->id,
'provider' => (string) ($result->provider ?? ''),
'model_name' => (string) ($result->model_name ?? ''),
'image_path' => $imagePayload['image_path'],
'image_url' => $imagePayload['image_url'],
'thumb_path' => $imagePayload['thumb_path'],
'thumb_url' => $imagePayload['thumb_url'],
'image_srcset' => $imagePayload['srcset'],
'settings' => (string) ($result->settings ?? ''),
'strengths' => (string) ($result->strengths ?? ''),
'weaknesses' => (string) ($result->weaknesses ?? ''),
'best_for' => (string) ($result->best_for ?? ''),
'score' => $result->score,
'sort_order' => (int) $result->sort_order,
'active' => (bool) $result->active,
];
})
->values()
->all();

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyBillingEvent;
use Illuminate\Support\Collection;
use Laravel\Cashier\Subscription;
final class AcademyAdminBillingOverviewService
{
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {}
/**
* @return array<string, mixed>
*/
public function summary(): array
{
$subscriptions = Subscription::query()
->where('type', $this->plans->subscriptionName())
->with('items')
->get();
$activeSubscriptions = $subscriptions->filter(
fn (Subscription $subscription): bool => $subscription->active() || $subscription->onGracePeriod()
);
$subscriberTiers = [];
$planBreakdown = [];
$gracePeriodSubscribers = [];
foreach ($activeSubscriptions as $subscription) {
$userId = (int) $subscription->user_id;
$tier = $this->tierForSubscription($subscription);
if ($subscription->onGracePeriod()) {
$gracePeriodSubscribers[$userId] = true;
}
if ($tier !== null) {
$existingTier = $subscriberTiers[$userId] ?? null;
if ($existingTier === null || $this->rankForTier($tier) > $this->rankForTier($existingTier)) {
$subscriberTiers[$userId] = $tier;
}
}
foreach ($this->planKeysForSubscription($subscription) as $planKey) {
$planBreakdown[$planKey] = (int) ($planBreakdown[$planKey] ?? 0) + 1;
}
}
$recentEvents = AcademyBillingEvent::query()->count();
$lastWebhookAt = AcademyBillingEvent::query()->latest('processed_at')->value('processed_at');
return [
'enabled' => $this->plans->enabled(),
'active_subscribers' => count($subscriberTiers),
'creator_subscribers' => count(array_filter($subscriberTiers, static fn (string $tier): bool => $tier === 'creator')),
'pro_subscribers' => count(array_filter($subscriberTiers, static fn (string $tier): bool => $tier === 'pro')),
'grace_period_subscribers' => count($gracePeriodSubscribers),
'ended_subscriptions' => $subscriptions->filter(
fn (Subscription $subscription): bool => ! $subscription->active() && ! $subscription->onGracePeriod()
)->count(),
'configured_plan_count' => count(array_keys($this->plans->plans())),
'missing_plan_keys' => $this->plans->missingPriceIds(),
'plan_breakdown' => $this->formatPlanBreakdown($planBreakdown),
'recent_webhook_count' => $recentEvents,
'last_webhook_at' => $lastWebhookAt?->toISOString(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function recentEvents(int $limit = 15): array
{
return AcademyBillingEvent::query()
->latest('processed_at')
->latest('id')
->limit($limit)
->get()
->map(fn (AcademyBillingEvent $event): array => [
'id' => (int) $event->id,
'event_type' => (string) $event->event_type,
'academy_tier' => $event->academy_tier ? (string) $event->academy_tier : null,
'academy_plan' => $event->academy_plan ? (string) $event->academy_plan : null,
'user_id' => $event->user_id ? (int) $event->user_id : null,
'stripe_customer_id' => $event->stripe_customer_id ? (string) $event->stripe_customer_id : null,
'stripe_subscription_id' => $event->stripe_subscription_id ? (string) $event->stripe_subscription_id : null,
'processed_at' => $event->processed_at?->toISOString(),
'created_at' => $event->created_at?->toISOString(),
'payload_summary' => is_array($event->payload_summary) ? $event->payload_summary : [],
])
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function formatPlanBreakdown(array $planBreakdown): array
{
return collect(array_keys($this->plans->plans()))
->map(function (string $planKey) use ($planBreakdown): array {
$plan = $this->plans->plan($planKey);
return [
'key' => $planKey,
'label' => (string) ($plan['label'] ?? $planKey),
'tier' => (string) ($plan['tier'] ?? 'free'),
'interval' => (string) ($plan['interval'] ?? 'monthly'),
'configured' => (bool) ($plan['configured'] ?? false),
'subscribers' => (int) ($planBreakdown[$planKey] ?? 0),
];
})
->values()
->all();
}
/**
* @return list<string>
*/
private function planKeysForSubscription(Subscription $subscription): array
{
$keys = [];
foreach ($this->priceIdsForSubscription($subscription) as $priceId) {
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
$keys[] = (string) ($plan['key'] ?? '');
}
return array_values(array_unique(array_filter($keys, static fn (string $key): bool => $key !== '')));
}
private function tierForSubscription(Subscription $subscription): ?string
{
$matchedTier = null;
foreach ($this->priceIdsForSubscription($subscription) as $priceId) {
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
$tier = (string) ($plan['tier'] ?? 'free');
if ($matchedTier === null || $this->rankForTier($tier) > $this->rankForTier($matchedTier)) {
$matchedTier = $tier;
}
}
return $matchedTier;
}
/**
* @return Collection<int, string>
*/
private function priceIdsForSubscription(Subscription $subscription): Collection
{
$priceIds = $subscription->items
->pluck('stripe_price')
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value));
if ($priceIds->isNotEmpty()) {
return $priceIds->values();
}
$fallbackPrice = trim((string) $subscription->stripe_price);
return $fallbackPrice === ''
? collect()
: collect([$fallbackPrice]);
}
private function rankForTier(string $tier): int
{
return match ($this->plans->normalizeTier($tier)) {
'pro' => 2,
'creator' => 1,
default => 0,
};
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model;
final class AcademyAnalyticsContentResolver
{
public function resolve(string $contentType, int $contentId): ?Model
{
$modelClass = match ($contentType) {
AcademyAnalyticsContentType::PROMPT => AcademyPromptTemplate::class,
AcademyAnalyticsContentType::LESSON => AcademyLesson::class,
AcademyAnalyticsContentType::COURSE => AcademyCourse::class,
AcademyAnalyticsContentType::PROMPT_PACK => AcademyPromptPack::class,
AcademyAnalyticsContentType::CHALLENGE => AcademyChallenge::class,
default => null,
};
if ($modelClass === null) {
return null;
}
return $modelClass::query()->find($contentId);
}
public function exists(string $contentType, int $contentId): bool
{
return $this->resolve($contentType, $contentId) instanceof Model;
}
public function title(string $contentType, ?int $contentId): string
{
if (! $contentId) {
return match ($contentType) {
AcademyAnalyticsContentType::HOME => 'Academy Home',
AcademyAnalyticsContentType::SEARCH => 'Academy Search',
AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade',
default => 'Unknown Academy Content',
};
}
$content = $this->resolve($contentType, $contentId);
if (! $content instanceof Model) {
return 'Unknown Academy Content';
}
return (string) ($content->title ?? $content->name ?? sprintf('%s #%d', $contentType, $contentId));
}
public function accessLevel(string $contentType, ?int $contentId): ?string
{
if (! $contentId) {
return null;
}
$content = $this->resolve($contentType, $contentId);
return $content instanceof Model ? (string) ($content->access_level ?? '') : null;
}
}

View File

@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Models\User;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
final class AcademyAnalyticsService
{
/**
* @var list<string>
*/
private const KNOWN_BOT_PATTERNS = [
'googlebot',
'bingbot',
'ahrefsbot',
'semrushbot',
'dotbot',
'barkrowler',
'claudebot',
'gptbot',
'amazonbot',
'mj12bot',
'petalbot',
'yandexbot',
'bytespider',
'crawler',
'spider',
'headless',
'preview',
];
public function __construct(private readonly AcademyAnalyticsContentResolver $contentResolver)
{
}
/**
* @param array<string, mixed> $payload
*/
public function track(array $payload, ?User $user = null, ?Request $request = null): AcademyEvent
{
$request ??= request();
$user ??= $request?->user();
$eventType = trim((string) ($payload['event_type'] ?? ''));
$contentType = $this->normalizeNullableString($payload['content_type'] ?? null);
$contentId = filled($payload['content_id'] ?? null) ? (int) $payload['content_id'] : null;
$metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$rawOccurredAt = $payload['occurred_at'] ?? null;
$occurredAt = $rawOccurredAt instanceof Carbon
? $rawOccurredAt
: Carbon::parse((string) ($rawOccurredAt ?? now()->toISOString()));
$userAgent = strtolower(trim((string) ($request?->userAgent() ?? '')));
$isBot = $this->looksLikeBot($userAgent);
$isCrawler = $isBot || str_contains($userAgent, 'crawl');
$isAdmin = $user ? ($user->hasStaffAccess() || $user->isModerator()) : false;
$isSubscriber = $user ? ($user->hasAcademyProAccess() || $user->hasAcademyCreatorAccess()) : false;
$visitorId = $this->resolveVisitorId($payload, $request, $user);
$event = AcademyEvent::query()->create([
'event_type' => $eventType,
'content_type' => $contentType,
'content_id' => $contentId,
'user_id' => $user?->id,
'visitor_id' => $visitorId,
'session_id' => $this->normalizeNullableString($payload['session_id'] ?? ($request?->hasSession() ? $request->session()->getId() : null)),
'url' => $this->normalizeNullableString($payload['url'] ?? $request?->fullUrl()),
'route_name' => $this->normalizeNullableString($payload['route_name'] ?? $request?->route()?->getName()),
'referrer' => $this->normalizeNullableString($payload['referrer'] ?? $request?->headers->get('referer')),
'utm_source' => $this->normalizeNullableString($payload['utm_source'] ?? $request?->query('utm_source')),
'utm_medium' => $this->normalizeNullableString($payload['utm_medium'] ?? $request?->query('utm_medium')),
'utm_campaign' => $this->normalizeNullableString($payload['utm_campaign'] ?? $request?->query('utm_campaign')),
'device_type' => $this->deviceTypeFromUserAgent($userAgent),
'browser' => $this->browserFromUserAgent($userAgent),
'platform' => $this->platformFromUserAgent($userAgent),
'country_code' => $this->countryCodeFromRequest($request),
'is_logged_in' => $user !== null,
'is_subscriber' => $isSubscriber,
'is_admin' => $isAdmin,
'is_bot' => $isBot,
'is_crawler' => $isCrawler,
'is_suspicious' => $isBot || $this->looksSuspicious($request, $userAgent),
'metadata' => $metadata === [] ? null : $metadata,
'occurred_at' => $occurredAt,
]);
if ($eventType === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) {
$this->syncSearchResultClickAttribution($event, $metadata, $request, $user);
}
return $event;
}
public function trackContentView(string $contentType, ?int $contentId, Request $request): void
{
$this->track([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => $contentType,
'content_id' => $contentId,
'metadata' => ['source' => 'academy_page'],
], $request->user(), $request);
$specificEvent = match ($contentType) {
AcademyAnalyticsContentType::PROMPT => AcademyAnalyticsEventType::CONTENT_VIEW,
AcademyAnalyticsContentType::LESSON => AcademyAnalyticsEventType::LESSON_VIEW,
AcademyAnalyticsContentType::COURSE => AcademyAnalyticsEventType::COURSE_VIEW,
AcademyAnalyticsContentType::PROMPT_PACK => AcademyAnalyticsEventType::PROMPT_PACK_VIEW,
AcademyAnalyticsContentType::CHALLENGE => AcademyAnalyticsEventType::CHALLENGE_VIEW,
default => AcademyAnalyticsEventType::CONTENT_VIEW,
};
$this->track([
'event_type' => $specificEvent,
'content_type' => $contentType,
'content_id' => $contentId,
], $request->user(), $request);
}
public function trackPromptCopy(int $promptId, string $copyType, Request $request): void
{
$eventType = trim(strtolower($copyType)) === 'negative'
? AcademyAnalyticsEventType::PROMPT_NEGATIVE_COPY
: AcademyAnalyticsEventType::PROMPT_COPY;
$this->track([
'event_type' => $eventType,
'content_type' => AcademyAnalyticsContentType::PROMPT,
'content_id' => $promptId,
'metadata' => [
'copy_type' => $copyType,
'source' => 'prompt_detail',
],
], $request->user(), $request);
}
public function trackUpgradeClick(?string $source, ?string $contentType, ?int $contentId, Request $request): void
{
$this->track([
'event_type' => AcademyAnalyticsEventType::UPGRADE_CLICK,
'content_type' => $contentType ?: AcademyAnalyticsContentType::UPGRADE,
'content_id' => $contentId,
'metadata' => array_filter([
'source' => $this->normalizeNullableString($source),
]),
], $request->user(), $request);
}
/**
* @param array<string, mixed> $filters
*/
public function trackSearch(string $query, int $resultsCount, array $filters = [], ?Request $request = null): AcademySearchLog
{
$request ??= request();
$user = $request?->user();
$normalizedQuery = $this->normalizeSearchQuery($query);
$isBot = $this->looksLikeBot(strtolower(trim((string) ($request?->userAgent() ?? ''))));
$log = AcademySearchLog::query()->create([
'user_id' => $user?->id,
'visitor_id' => $this->resolveVisitorId([], $request, $user),
'query' => trim($query),
'normalized_query' => $normalizedQuery,
'results_count' => max(0, $resultsCount),
'filters' => $filters === [] ? null : $filters,
'is_logged_in' => $user !== null,
'is_subscriber' => $user ? ($user->hasAcademyCreatorAccess() || $user->hasAcademyProAccess()) : false,
'is_bot' => $isBot,
]);
$this->track([
'event_type' => AcademyAnalyticsEventType::SEARCH,
'content_type' => AcademyAnalyticsContentType::SEARCH,
'metadata' => [
'query' => $normalizedQuery,
'results_count' => $resultsCount,
'filters' => $filters,
],
], $user, $request);
if ($resultsCount === 0) {
$this->track([
'event_type' => AcademyAnalyticsEventType::ZERO_SEARCH_RESULTS,
'content_type' => AcademyAnalyticsContentType::SEARCH,
'metadata' => [
'query' => $normalizedQuery,
'filters' => $filters,
],
], $user, $request);
}
return $log;
}
public function normalizeSearchQuery(string $query): string
{
$value = strtolower(trim($query));
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
$value = preg_replace('/[^a-z0-9\s\-_]+/', '', $value) ?? $value;
return trim($value);
}
/**
* @param array<string, mixed> $metadata
*/
private function syncSearchResultClickAttribution(AcademyEvent $event, array $metadata, ?Request $request, ?User $user): AcademySearchLog
{
$query = trim((string) ($metadata['query'] ?? ''));
$normalizedQuery = $this->normalizeSearchQuery((string) ($metadata['normalized_query'] ?? $query));
$resultsCount = max(0, (int) ($metadata['results_count'] ?? 0));
$filters = is_array($metadata['filters'] ?? null) ? $metadata['filters'] : [];
$visitorId = $this->normalizeNullableString($event->visitor_id) ?? $this->resolveVisitorId([], $request, $user);
$recentThreshold = ($event->occurred_at ?? now())->copy()->subMinutes(30);
$searchLog = AcademySearchLog::query()
->where('normalized_query', $normalizedQuery)
->where('created_at', '>=', $recentThreshold)
->whereNull('clicked_content_id')
->where(function ($builder) use ($user, $visitorId): void {
if ($user?->id !== null) {
$builder->orWhere('user_id', $user->id);
}
if ($visitorId !== null) {
$builder->orWhere('visitor_id', $visitorId);
}
})
->latest('id')
->first();
if ($searchLog instanceof AcademySearchLog) {
$searchLog->forceFill([
'clicked_content_type' => $event->content_type,
'clicked_content_id' => $event->content_id,
])->save();
return $searchLog;
}
return AcademySearchLog::query()->create([
'user_id' => $user?->id,
'visitor_id' => $visitorId,
'query' => $query,
'normalized_query' => $normalizedQuery,
'results_count' => $resultsCount,
'clicked_content_type' => $event->content_type,
'clicked_content_id' => $event->content_id,
'filters' => $filters === [] ? null : $filters,
'is_logged_in' => $user !== null,
'is_subscriber' => (bool) $event->is_subscriber,
'is_bot' => (bool) $event->is_bot,
]);
}
private function resolveVisitorId(array $payload, ?Request $request, ?User $user): ?string
{
$payloadVisitorId = $this->normalizeNullableString($payload['visitor_id'] ?? null);
if ($payloadVisitorId !== null) {
return $payloadVisitorId;
}
$cookieVisitorId = $this->normalizeNullableString($request?->cookie('academy_visitor_id'));
if ($cookieVisitorId !== null) {
return $cookieVisitorId;
}
if ($user) {
return sprintf('user:%d', $user->id);
}
return (string) Str::uuid();
}
private function looksLikeBot(string $userAgent): bool
{
if ($userAgent === '') {
return false;
}
foreach (self::KNOWN_BOT_PATTERNS as $pattern) {
if (str_contains($userAgent, $pattern)) {
return true;
}
}
return false;
}
private function looksSuspicious(?Request $request, string $userAgent): bool
{
if ($request === null) {
return false;
}
return $this->looksLikeBot($userAgent)
|| str_contains(strtolower((string) $request->headers->get('accept', '')), '*/*')
|| $request->headers->get('sec-fetch-site') === null;
}
private function deviceTypeFromUserAgent(string $userAgent): string
{
if ($userAgent === '') {
return 'unknown';
}
if (str_contains($userAgent, 'tablet') || str_contains($userAgent, 'ipad')) {
return 'tablet';
}
if (str_contains($userAgent, 'mobile') || str_contains($userAgent, 'android')) {
return 'mobile';
}
return 'desktop';
}
private function browserFromUserAgent(string $userAgent): ?string
{
if ($userAgent === '') {
return null;
}
return match (true) {
str_contains($userAgent, 'edg/') => 'Edge',
str_contains($userAgent, 'chrome/') => 'Chrome',
str_contains($userAgent, 'firefox/') => 'Firefox',
str_contains($userAgent, 'safari/') && ! str_contains($userAgent, 'chrome/') => 'Safari',
default => 'Other',
};
}
private function platformFromUserAgent(string $userAgent): ?string
{
if ($userAgent === '') {
return null;
}
return match (true) {
str_contains($userAgent, 'windows') => 'Windows',
str_contains($userAgent, 'mac os') || str_contains($userAgent, 'macintosh') => 'macOS',
str_contains($userAgent, 'android') => 'Android',
str_contains($userAgent, 'iphone') || str_contains($userAgent, 'ipad') || str_contains($userAgent, 'ios') => 'iOS',
str_contains($userAgent, 'linux') => 'Linux',
default => 'Other',
};
}
private function countryCodeFromRequest(?Request $request): ?string
{
$country = strtoupper(trim((string) ($request?->headers->get('cf-ipcountry') ?? $request?->headers->get('x-country-code') ?? '')));
return $country !== '' && strlen($country) <= 8 ? $country : null;
}
private function normalizeNullableString(mixed $value): ?string
{
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use RuntimeException;
final class AcademyBillingPlanService
{
public function enabled(): bool
{
return (bool) config('academy_billing.enabled', false);
}
public function subscriptionName(): string
{
return (string) config('academy_billing.subscription_name', 'academy');
}
/**
* @return array<string, array<string, mixed>>
*/
public function plans(): array
{
$plans = config('academy_billing.plans', []);
return is_array($plans) ? $plans : [];
}
public function normalizePlanKey(?string $planKey): string
{
return Str::of((string) $planKey)
->trim()
->lower()
->replace('-', '_')
->value();
}
/**
* @return array<string, mixed>|null
*/
public function plan(?string $planKey): ?array
{
$normalized = $this->normalizePlanKey($planKey);
if ($normalized === '') {
return null;
}
$plan = Arr::get($this->plans(), $normalized);
if (! is_array($plan)) {
return null;
}
$plan['key'] = $normalized;
$plan['tier'] = $this->normalizeTier((string) ($plan['tier'] ?? 'free'));
$plan['interval'] = Str::lower(trim((string) ($plan['interval'] ?? 'monthly')));
$plan['amount'] = trim((string) ($plan['amount'] ?? ''));
$plan['currency'] = Str::upper(trim((string) ($plan['currency'] ?? config('cashier.currency', 'EUR'))));
$plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? ''));
$plan['configured'] = $plan['stripe_price_id'] !== '';
$plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']);
$plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null;
return $plan;
}
/**
* @return array<string, mixed>|null
*/
public function planForPriceId(?string $priceId): ?array
{
$priceId = trim((string) $priceId);
if ($priceId === '') {
return null;
}
foreach (array_keys($this->plans()) as $planKey) {
$plan = $this->plan((string) $planKey);
if ($plan !== null && ($plan['stripe_price_id'] ?? null) === $priceId) {
return $plan;
}
}
return null;
}
/**
* @return array<int, string>
*/
public function missingPriceIds(?string $planKey = null): array
{
if ($planKey !== null) {
$plan = $this->plan($planKey);
return $plan !== null && ! ($plan['configured'] ?? false)
? [$this->normalizePlanKey($planKey)]
: [];
}
return collect(array_keys($this->plans()))
->filter(fn (string $key): bool => ! ((bool) ($this->plan($key)['configured'] ?? false)))
->values()
->all();
}
public function assertConfigured(?string $planKey = null): void
{
if (app()->environment(['local', 'testing'])) {
return;
}
$missingPlans = $this->missingPriceIds($planKey);
if ($missingPlans === []) {
return;
}
throw new RuntimeException('Academy billing price IDs are missing for: '.implode(', ', $missingPlans));
}
public function normalizeTier(string $tier): string
{
return match (Str::lower(trim($tier))) {
'admin' => 'admin',
'pro' => 'pro',
'creator', 'premium' => 'creator',
default => 'free',
};
}
public function isValidPriceId(?string $priceId): bool
{
$priceId = trim((string) $priceId);
if ($priceId === '') {
return false;
}
return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1;
}
}

View File

@@ -0,0 +1,784 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
final class AcademyContentIntelligenceService
{
public function __construct(private readonly AcademyAnalyticsContentResolver $resolver) {}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getContentOpportunities(array $filters = []): array
{
return $this->remember('content-opportunities', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]);
$promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]);
$lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]);
$courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]);
$premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]);
$recommendations = $this->getEditorialRecommendations(['from' => $from, 'to' => $to, 'limit' => $limit]);
$cards = [
[
'label' => 'Content opportunities',
'value' => count($recommendations['rows']),
'description' => 'Actionable content, conversion, and editorial recommendations generated from Academy analytics.',
],
[
'label' => 'Search gaps',
'value' => (int) $searchGaps['summary']['gap_count'],
'description' => 'Queries with zero results, weak CTR, or no result clicks.',
],
[
'label' => 'Prompt insights',
'value' => (int) $promptInsights['summary']['signal_count'],
'description' => 'Prompts that should be improved, promoted, or expanded.',
],
[
'label' => 'Lesson drop-offs',
'value' => (int) $lessonDropoffs['summary']['signal_count'],
'description' => 'Lessons losing users before they meaningfully start or finish.',
],
[
'label' => 'Course health',
'value' => (int) $courseHealth['summary']['signal_count'],
'description' => 'Courses that need restructuring or are ready for expansion.',
],
[
'label' => 'Premium interest',
'value' => (int) $premiumInterest['summary']['signal_count'],
'description' => 'Content that shows premium teaser strength or weakness.',
],
[
'label' => 'Editorial recommendations',
'value' => (int) $recommendations['summary']['total'],
'description' => 'Prioritized actions for what to create, improve, promote, or premiumize next.',
],
];
return [
'cards' => $cards,
'highlights' => collect($recommendations['rows'])
->take(6)
->map(fn (array $row): array => [
'title' => (string) $row['title'],
'priority' => (string) $row['priority'],
'reason' => (string) $row['reason'],
'suggested_action' => (string) $row['suggested_action'],
])
->values()
->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getSearchGaps(array $filters = []): array
{
return $this->remember('search-gaps', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$rows = $this->searchQuery($from, $to)->get()->map(function ($row): array {
$searches = max(0, (int) $row->searches);
$clicks = max(0, (int) ($row->clicks ?? 0));
$resultsCount = round((float) ($row->avg_results_count ?? 0), 1);
$ctr = $searches > 0 ? round(($clicks / $searches) * 100, 1) : 0.0;
$signal = $this->classifySearchGap(
searches: $searches,
resultsCount: $resultsCount,
clicks: $clicks,
ctr: $ctr,
loggedInSearches: max(0, (int) ($row->logged_in_searches ?? 0)),
subscriberSearches: max(0, (int) ($row->subscriber_searches ?? 0)),
);
return [
'query' => (string) ($row->query ?: $row->normalized_query),
'normalized_query' => (string) $row->normalized_query,
'searches' => $searches,
'results_count' => $resultsCount,
'clicks' => $clicks,
'ctr' => $ctr,
'last_searched_at' => $row->last_searched_at ? Carbon::parse((string) $row->last_searched_at)->toDateTimeString() : null,
'logged_in_searches' => max(0, (int) ($row->logged_in_searches ?? 0)),
'subscriber_searches' => max(0, (int) ($row->subscriber_searches ?? 0)),
'issue' => $signal['issue'],
'priority' => $signal['priority'],
'priority_score' => $signal['priority_score'],
'suggested_action' => $signal['suggested_action'],
];
})->filter(fn (array $row): bool => $row['issue'] !== null)->values();
$zeroResultSearches = $rows
->filter(fn (array $row): bool => $row['issue'] === 'Zero-result demand')
->sortByDesc('searches')
->take($limit)
->values();
$searchesWithResultsNoClicks = $rows
->filter(fn (array $row): bool => $row['issue'] === 'Results with no clicks')
->sortByDesc('searches')
->take($limit)
->values();
$lowCtrSearches = $rows
->filter(fn (array $row): bool => $row['issue'] === 'Low click-through rate')
->sortBy('ctr')
->take($limit)
->values();
$highCtrSearches = $rows
->filter(fn (array $row): bool => $row['issue'] === 'High click-through topic')
->sortByDesc('ctr')
->take($limit)
->values();
$repeatedQueries = $rows
->filter(fn (array $row): bool => $row['logged_in_searches'] >= 2 || $row['subscriber_searches'] >= 2)
->sortByDesc('searches')
->take($limit)
->values();
$dedupedRows = $this->dedupeByKey(
collect([$zeroResultSearches, $searchesWithResultsNoClicks, $lowCtrSearches, $highCtrSearches])
->flatten(1)
->sortByDesc('priority_score')
->sortByDesc('searches')
->values(),
'normalized_query',
)->take($limit)->values();
return [
'summary' => [
'gap_count' => $dedupedRows->count(),
'zero_result_count' => $zeroResultSearches->count(),
'no_click_count' => $searchesWithResultsNoClicks->count(),
'low_ctr_count' => $lowCtrSearches->count(),
'high_ctr_count' => $highCtrSearches->count(),
'repeated_member_count' => $repeatedQueries->count(),
],
'rows' => $dedupedRows->all(),
'zero_result_searches' => $zeroResultSearches->all(),
'searches_with_results_no_clicks' => $searchesWithResultsNoClicks->all(),
'low_ctr_searches' => $lowCtrSearches->all(),
'high_ctr_searches' => $highCtrSearches->all(),
'repeated_queries' => $repeatedQueries->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getPromptInsights(array $filters = []): array
{
return $this->remember('prompt-insights', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->map(function (array $row): ?array {
$signal = $this->classifyPromptInsight($row);
if ($signal === null) {
return null;
}
return array_merge($row, $signal);
})->filter()->sortByDesc('priority_score')->take($limit)->values();
return [
'summary' => [
'signal_count' => $rows->count(),
'high_view_low_copy' => $rows->where('issue', 'High views, low copies')->count(),
'low_view_high_copy_rate' => $rows->where('issue', 'Low views, high copy rate')->count(),
'high_save_low_copy' => $rows->where('issue', 'High saves, low copies')->count(),
'high_copy_low_like' => $rows->where('issue', 'High copies, low likes')->count(),
'high_upgrade_interest' => $rows->where('issue', 'High upgrade interest')->count(),
],
'rows' => $rows->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getLessonDropoffs(array $filters = []): array
{
return $this->remember('lesson-dropoffs', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->map(function (array $row): ?array {
$signal = $this->classifyLessonDropoff($row);
if ($signal === null) {
return null;
}
return array_merge($row, $signal);
})->filter()->sortByDesc('priority_score')->take($limit)->values();
return [
'summary' => [
'signal_count' => $rows->count(),
'low_start_rate' => $rows->where('issue', 'High views, low starts')->count(),
'low_completion_rate' => $rows->where('issue', 'High starts, low completions')->count(),
'underpromoted_winners' => $rows->where('issue', 'High completions, low views')->count(),
'upgrade_interest' => $rows->where('issue', 'Upgrade interest')->count(),
],
'rows' => $rows->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getCourseHealth(array $filters = []): array
{
return $this->remember('course-health', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$progress = AcademyUserProgress::query()
->selectRaw('course_id, avg(progress_percent) as avg_progress_percent, count(*) as learners')
->whereNotNull('course_id')
->whereNull('lesson_id')
->whereBetween('updated_at', [$from, $to])
->groupBy('course_id')
->get()
->keyBy(fn ($row): int => (int) $row->course_id);
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->map(function (array $row) use ($progress): ?array {
$courseProgress = $progress->get((int) $row['content_id']);
$row['avg_progress'] = $courseProgress ? round((float) ($courseProgress->avg_progress_percent ?? 0), 1) : 0.0;
$row['learners'] = $courseProgress ? (int) ($courseProgress->learners ?? 0) : 0;
$signal = $this->classifyCourseHealth($row);
if ($signal === null) {
return null;
}
return array_merge($row, $signal);
})->filter()->sortByDesc('priority_score')->take($limit)->values();
return [
'summary' => [
'signal_count' => $rows->count(),
'low_start_rate' => $rows->where('issue', 'Low course start rate')->count(),
'low_completion_rate' => $rows->where('issue', 'Low course completion rate')->count(),
'expandable_courses' => $rows->where('issue', 'Expansion candidate')->count(),
'upgrade_interest' => $rows->where('issue', 'Premium follow-up opportunity')->count(),
],
'rows' => $rows->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getPremiumInterest(array $filters = []): array
{
return $this->remember('premium-interest', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$rows = collect([
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->all(),
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->all(),
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->all(),
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT_PACK)->all(),
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::CHALLENGE)->all(),
])->map(function (array $row): ?array {
$signal = $this->classifyPremiumInterest($row);
if ($signal === null) {
return null;
}
return array_merge($row, $signal, [
'premium_interest_score' => round(((float) $row['premium_preview_views'] * 2) + ((float) $row['upgrade_clicks'] * 10), 1),
]);
})->filter()->sortByDesc('priority_score')->sortByDesc('premium_interest_score')->take($limit)->values();
return [
'summary' => [
'signal_count' => $rows->count(),
'strong_candidates' => $rows->where('issue', 'Strong premium candidate')->count(),
'weak_teasers' => $rows->where('issue', 'Weak premium teaser')->count(),
],
'rows' => $rows->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function getEditorialRecommendations(array $filters = []): array
{
return $this->remember('editorial-recommendations', $filters, function (Carbon $from, Carbon $to, int $limit): array {
$searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]);
$promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]);
$lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]);
$courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]);
$premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]);
$recommendations = collect();
foreach (array_slice($searchGaps['zero_result_searches'], 0, 5) as $row) {
$recommendations->push([
'title' => sprintf('Create content for "%s"', $row['query']),
'description' => sprintf('Users searched for "%s" %d times and saw %.1f results.', $row['query'], $row['searches'], $row['results_count']),
'reason' => 'Repeated zero-result searches indicate missing Academy content coverage.',
'priority' => $row['searches'] >= 3 ? 'high' : 'medium',
'priority_score' => $row['searches'] >= 3 ? 300 + $row['searches'] : 200 + $row['searches'],
'content_type' => null,
'content_id' => null,
'metric_snapshot' => [
'searches' => $row['searches'],
'results_count' => $row['results_count'],
'clicks' => $row['clicks'],
],
'suggested_action' => 'Create content for this topic',
]);
}
foreach (array_slice($promptInsights['rows'], 0, 4) as $row) {
$recommendations->push([
'title' => sprintf('Review prompt "%s"', $row['title']),
'description' => sprintf('%s with %d views, %d copies, and a %.1f%% copy rate.', $row['issue'], $row['views'], $row['prompt_copies'], $row['copy_rate']),
'reason' => 'Prompt performance suggests either discoverability or quality improvements are needed.',
'priority' => $row['priority'],
'priority_score' => 180 + (int) $row['priority_score'],
'content_type' => $row['content_type'],
'content_id' => $row['content_id'],
'metric_snapshot' => [
'views' => $row['views'],
'copies' => $row['prompt_copies'],
'copy_rate' => $row['copy_rate'],
'upgrade_clicks' => $row['upgrade_clicks'],
],
'suggested_action' => $row['suggested_action'],
]);
}
foreach (array_slice($lessonDropoffs['rows'], 0, 4) as $row) {
$recommendations->push([
'title' => sprintf('Improve lesson "%s"', $row['title']),
'description' => sprintf('%s with %d starts and a %.1f%% completion rate.', $row['issue'], $row['starts'], $row['completion_rate']),
'reason' => 'Lesson funnel data shows where learners hesitate or drop off.',
'priority' => $row['priority'],
'priority_score' => 170 + (int) $row['priority_score'],
'content_type' => $row['content_type'],
'content_id' => $row['content_id'],
'metric_snapshot' => [
'views' => $row['views'],
'starts' => $row['starts'],
'completions' => $row['completions'],
'completion_rate' => $row['completion_rate'],
],
'suggested_action' => $row['suggested_action'],
]);
}
foreach (array_slice($courseHealth['rows'], 0, 4) as $row) {
$recommendations->push([
'title' => sprintf('Review course "%s"', $row['title']),
'description' => sprintf('%s with a %.1f%% completion rate and %.1f%% average progress.', $row['issue'], $row['completion_rate'], $row['avg_progress']),
'reason' => 'Course progression data highlights where sequencing or positioning may be blocking learners.',
'priority' => $row['priority'],
'priority_score' => 160 + (int) $row['priority_score'],
'content_type' => $row['content_type'],
'content_id' => $row['content_id'],
'metric_snapshot' => [
'views' => $row['views'],
'starts' => $row['starts'],
'completions' => $row['completions'],
'avg_progress' => $row['avg_progress'],
],
'suggested_action' => $row['suggested_action'],
]);
}
foreach (array_slice($premiumInterest['rows'], 0, 4) as $row) {
$recommendations->push([
'title' => sprintf('Use "%s" as a premium signal', $row['title']),
'description' => sprintf('%s with %d preview views and %d upgrade clicks.', $row['issue'], $row['premium_preview_views'], $row['upgrade_clicks']),
'reason' => 'Premium preview behavior shows which topics can sell subscriptions or need better teaser copy.',
'priority' => $row['priority'],
'priority_score' => 150 + (int) $row['priority_score'],
'content_type' => $row['content_type'],
'content_id' => $row['content_id'],
'metric_snapshot' => [
'premium_preview_views' => $row['premium_preview_views'],
'upgrade_clicks' => $row['upgrade_clicks'],
'upgrade_rate' => $row['upgrade_rate'],
],
'suggested_action' => $row['suggested_action'],
]);
}
$rows = $recommendations
->sortByDesc('priority_score')
->take($limit)
->values()
->map(function (array $row): array {
unset($row['priority_score']);
return $row;
});
return [
'summary' => [
'total' => $rows->count(),
'high_priority' => $rows->where('priority', 'high')->count(),
'medium_priority' => $rows->where('priority', 'medium')->count(),
'low_priority' => $rows->where('priority', 'low')->count(),
],
'rows' => $rows->all(),
];
});
}
/**
* @param array<string, mixed> $filters
* @return array{0: Carbon, 1: Carbon, 2: int}
*/
private function resolveFilters(array $filters): array
{
$from = ($filters['from'] ?? null) instanceof Carbon
? $filters['from']->copy()->startOfDay()
: Carbon::parse((string) ($filters['from'] ?? now()->subDays(29)->toDateString()))->startOfDay();
$to = ($filters['to'] ?? null) instanceof Carbon
? $filters['to']->copy()->endOfDay()
: Carbon::parse((string) ($filters['to'] ?? now()->toDateString()))->endOfDay();
$limit = max(1, min(50, (int) ($filters['limit'] ?? 25)));
return [$from, $to, $limit];
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private function remember(string $suffix, array $filters, callable $callback): array
{
[$from, $to, $limit] = $this->resolveFilters($filters);
return Cache::remember(
sprintf('academy_analytics_%s:%s:%s:%d', $suffix, $from->toDateString(), $to->toDateString(), $limit),
now()->addMinutes(10),
fn (): array => $callback($from, $to, $limit),
);
}
private function searchQuery(Carbon $from, Carbon $to): Builder
{
return AcademySearchLog::query()
->whereBetween('created_at', [$from, $to])
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results_count, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, max(created_at) as last_searched_at, sum(case when is_logged_in = 1 then 1 else 0 end) as logged_in_searches, sum(case when is_subscriber = 1 then 1 else 0 end) as subscriber_searches')
->whereNotNull('normalized_query')
->groupBy('normalized_query');
}
/**
* @return Collection<int, array<string, int|float|string|null>>
*/
private function contentMetrics(Carbon $from, Carbon $to, string $contentType): Collection
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
->where('content_type', $contentType)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(negative_prompt_copies) as negative_prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(premium_preview_views) as premium_preview_views, sum(search_clicks) as search_clicks, sum(popularity_score) as popularity_score')
->groupBy('content_type', 'content_id')
->get()
->map(function ($row) use ($contentType): array {
$contentId = (int) $row->content_id;
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
$likes = max(0, (int) ($row->likes ?? 0));
$saves = max(0, (int) ($row->saves ?? 0));
$starts = max(0, (int) ($row->starts ?? 0));
$completions = max(0, (int) ($row->completions ?? 0));
$searchClicks = max(0, (int) ($row->search_clicks ?? 0));
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
return [
'content_type' => $contentType,
'content_id' => $contentId,
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
'title' => $this->resolver->title($contentType, $contentId),
'views' => max(0, (int) ($row->views ?? 0)),
'unique_visitors' => $uniqueVisitors,
'engaged_views' => max(0, (int) ($row->engaged_views ?? 0)),
'likes' => $likes,
'saves' => $saves,
'prompt_copies' => $promptCopies,
'negative_prompt_copies' => max(0, (int) ($row->negative_prompt_copies ?? 0)),
'starts' => $starts,
'completions' => $completions,
'search_clicks' => $searchClicks,
'premium_preview_views' => $premiumPreviewViews,
'upgrade_clicks' => $upgradeClicks,
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0.0,
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0.0,
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0.0,
'search_click_rate' => $uniqueVisitors > 0 ? round(($searchClicks / $uniqueVisitors) * 100, 1) : 0.0,
'start_rate' => $uniqueVisitors > 0 ? round(($starts / $uniqueVisitors) * 100, 1) : 0.0,
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0.0,
'engagement_rate' => $uniqueVisitors > 0 ? round((((int) ($row->engaged_views ?? 0)) / $uniqueVisitors) * 100, 1) : 0.0,
'upgrade_rate' => $premiumPreviewViews > 0 ? round(($upgradeClicks / $premiumPreviewViews) * 100, 1) : 0.0,
];
});
}
/**
* @return array{issue: string|null, priority: string, priority_score: int, suggested_action: string}
*/
private function classifySearchGap(int $searches, float $resultsCount, int $clicks, float $ctr, int $loggedInSearches, int $subscriberSearches): array
{
if ($resultsCount <= 0.4) {
return [
'issue' => 'Zero-result demand',
'priority' => $searches >= 3 || $subscriberSearches >= 2 ? 'high' : 'medium',
'priority_score' => 300 + $searches,
'suggested_action' => 'Create content for this topic',
];
}
if ($resultsCount > 0 && $clicks === 0) {
return [
'issue' => 'Results with no clicks',
'priority' => $searches >= 3 || $loggedInSearches >= 2 ? 'high' : 'medium',
'priority_score' => 240 + $searches,
'suggested_action' => 'Improve titles, excerpts, thumbnails, or relevance',
];
}
if ($searches >= 2 && $ctr < 10) {
return [
'issue' => 'Low click-through rate',
'priority' => 'medium',
'priority_score' => 180 + $searches,
'suggested_action' => 'Improve matching content or create better content',
];
}
if ($searches >= 2 && $ctr >= 40) {
return [
'issue' => 'High click-through topic',
'priority' => 'medium',
'priority_score' => 140 + $searches,
'suggested_action' => 'Consider expanding this topic',
];
}
return [
'issue' => null,
'priority' => 'low',
'priority_score' => 0,
'suggested_action' => 'Monitor search intent',
];
}
/**
* @param array<string, int|float|string|null> $row
* @return array<string, int|string>|null
*/
private function classifyPromptInsight(array $row): ?array
{
if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 15 && (int) $row['premium_preview_views'] >= 5)) {
return [
'issue' => 'High upgrade interest',
'priority' => 'high',
'priority_score' => 300 + (int) $row['upgrade_clicks'],
'suggested_action' => 'Create premium pack or advanced lesson around this topic',
];
}
if ((int) $row['views'] >= 120 && (float) $row['copy_rate'] < 8) {
return [
'issue' => 'High views, low copies',
'priority' => 'medium',
'priority_score' => 230 + (int) $row['views'],
'suggested_action' => 'Improve prompt quality, preview image, title, or negative prompt',
];
}
if ((int) $row['views'] <= 30 && (int) $row['prompt_copies'] >= 3 && (float) $row['copy_rate'] >= 35) {
return [
'issue' => 'Low views, high copy rate',
'priority' => 'medium',
'priority_score' => 210 + (int) $row['prompt_copies'],
'suggested_action' => 'Feature this prompt, improve SEO, add to related content',
];
}
if ((int) $row['saves'] >= 5 && (int) $row['prompt_copies'] < (int) $row['saves']) {
return [
'issue' => 'High saves, low copies',
'priority' => 'medium',
'priority_score' => 190 + (int) $row['saves'],
'suggested_action' => 'Add examples, variations, or usage notes',
];
}
if ((int) $row['prompt_copies'] >= 8 && (float) $row['like_rate'] < 5) {
return [
'issue' => 'High copies, low likes',
'priority' => 'low',
'priority_score' => 160 + (int) $row['prompt_copies'],
'suggested_action' => 'Improve like/save UI visibility or ask for feedback',
];
}
return null;
}
/**
* @param array<string, int|float|string|null> $row
* @return array<string, int|string>|null
*/
private function classifyLessonDropoff(array $row): ?array
{
if ((int) $row['starts'] >= 12 && (float) $row['completion_rate'] < 35) {
return [
'issue' => 'High starts, low completions',
'priority' => 'high',
'priority_score' => 300 + (int) $row['starts'],
'suggested_action' => 'Lesson may be too long, confusing, or missing examples',
];
}
if ((int) $row['views'] >= 80 && (float) $row['start_rate'] < 18) {
return [
'issue' => 'High views, low starts',
'priority' => 'medium',
'priority_score' => 230 + (int) $row['views'],
'suggested_action' => 'Improve lesson intro, title, excerpt, or call-to-action',
];
}
if ((int) $row['completions'] >= 8 && (int) $row['views'] <= 35) {
return [
'issue' => 'High completions, low views',
'priority' => 'medium',
'priority_score' => 200 + (int) $row['completions'],
'suggested_action' => 'Promote this lesson more',
];
}
if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 12 && (int) $row['premium_preview_views'] >= 5)) {
return [
'issue' => 'Upgrade interest',
'priority' => 'medium',
'priority_score' => 180 + (int) $row['upgrade_clicks'],
'suggested_action' => 'This lesson may be useful as a subscription conversion entry point',
];
}
return null;
}
/**
* @param array<string, int|float|string|null> $row
* @return array<string, int|string>|null
*/
private function classifyCourseHealth(array $row): ?array
{
if ((int) $row['starts'] >= 10 && (float) $row['completion_rate'] < 35) {
return [
'issue' => 'Low course completion rate',
'priority' => 'high',
'priority_score' => 300 + (int) $row['starts'],
'suggested_action' => 'Add shorter lessons, move the strongest lesson earlier, or improve examples',
];
}
if ((int) $row['views'] >= 60 && (float) $row['start_rate'] < 18) {
return [
'issue' => 'Low course start rate',
'priority' => 'medium',
'priority_score' => 220 + (int) $row['views'],
'suggested_action' => 'Improve course landing page, cover image, or course positioning',
];
}
if ((int) $row['upgrade_clicks'] >= 3) {
return [
'issue' => 'Premium follow-up opportunity',
'priority' => 'medium',
'priority_score' => 190 + (int) $row['upgrade_clicks'],
'suggested_action' => 'Add a premium follow-up course around this topic',
];
}
if ((int) $row['completions'] >= 8 && (float) $row['completion_rate'] >= 65) {
return [
'issue' => 'Expansion candidate',
'priority' => 'medium',
'priority_score' => 170 + (int) $row['completions'],
'suggested_action' => 'Expand this course with advanced follow-up material',
];
}
return null;
}
/**
* @param array<string, int|float|string|null> $row
* @return array<string, int|string>|null
*/
private function classifyPremiumInterest(array $row): ?array
{
if ((int) $row['upgrade_clicks'] >= 3) {
return [
'issue' => 'Strong premium candidate',
'priority' => 'high',
'priority_score' => 300 + (int) $row['upgrade_clicks'],
'suggested_action' => 'Create advanced premium content around this topic',
];
}
if ((int) $row['premium_preview_views'] >= 15 && (int) $row['upgrade_clicks'] <= 1) {
return [
'issue' => 'Weak premium teaser',
'priority' => 'medium',
'priority_score' => 190 + (int) $row['premium_preview_views'],
'suggested_action' => 'Improve teaser copy, preview images, or value proposition',
];
}
return null;
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @return Collection<int, array<string, mixed>>
*/
private function dedupeByKey(Collection $rows, string $key): Collection
{
$seen = [];
return $rows->filter(function (array $row) use (&$seen, $key): bool {
$value = (string) ($row[$key] ?? '');
if ($value === '' || isset($seen[$value])) {
return false;
}
$seen[$value] = true;
return true;
});
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyLike;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySave;
use App\Models\User;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use InvalidArgumentException;
final class AcademyInteractionService
{
public function __construct(
private readonly AcademyAnalyticsContentResolver $contentResolver,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyProgressService $progress,
) {
}
/**
* @return array<string, int|bool>
*/
public function toggleLike(User $user, string $contentType, int $contentId, ?Request $request = null): array
{
$this->assertSupportedContent($contentType, $contentId);
$existing = AcademyLike::query()
->where('user_id', $user->id)
->where('content_type', $contentType)
->where('content_id', $contentId)
->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
AcademyLike::query()->create([
'user_id' => $user->id,
'content_type' => $contentType,
'content_id' => $contentId,
]);
$liked = true;
if ($contentType === AcademyAnalyticsContentType::PROMPT) {
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::PROMPT_LIKE,
'content_type' => $contentType,
'content_id' => $contentId,
], $user, $request);
}
}
return [
'liked' => $liked,
'likes_count' => $this->likesCount($contentType, $contentId),
];
}
/**
* @return array<string, int|bool>
*/
public function toggleSave(User $user, string $contentType, int $contentId, ?Request $request = null): array
{
$content = $this->assertSupportedContent($contentType, $contentId);
if ($contentType === AcademyAnalyticsContentType::PROMPT && $content instanceof AcademyPromptTemplate) {
$existing = AcademySave::query()
->where('user_id', $user->id)
->where('content_type', $contentType)
->where('content_id', $contentId)
->exists();
if ($existing) {
$this->progress->unsavePrompt($user, $content);
$saved = false;
} else {
$this->progress->savePrompt($user, $content);
$saved = true;
}
return [
'saved' => $saved,
'saves_count' => $this->savesCount($contentType, $contentId),
];
}
$existing = AcademySave::query()
->where('user_id', $user->id)
->where('content_type', $contentType)
->where('content_id', $contentId)
->first();
if ($existing) {
$existing->delete();
$saved = false;
} else {
AcademySave::query()->create([
'user_id' => $user->id,
'content_type' => $contentType,
'content_id' => $contentId,
]);
$saved = true;
}
return [
'saved' => $saved,
'saves_count' => $this->savesCount($contentType, $contentId),
];
}
/**
* @return array<string, int|bool>
*/
public function getInteractionState(?User $user, string $contentType, int $contentId): array
{
$this->assertSupportedContent($contentType, $contentId);
return [
'liked' => $user
? AcademyLike::query()->where('user_id', $user->id)->where('content_type', $contentType)->where('content_id', $contentId)->exists()
: false,
'saved' => $user
? AcademySave::query()->where('user_id', $user->id)->where('content_type', $contentType)->where('content_id', $contentId)->exists()
: false,
'likes_count' => $this->likesCount($contentType, $contentId),
'saves_count' => $this->savesCount($contentType, $contentId),
];
}
public function likesCount(string $contentType, int $contentId): int
{
return AcademyLike::query()
->where('content_type', $contentType)
->where('content_id', $contentId)
->count();
}
public function savesCount(string $contentType, int $contentId): int
{
return AcademySave::query()
->where('content_type', $contentType)
->where('content_id', $contentId)
->count();
}
private function assertSupportedContent(string $contentType, int $contentId): mixed
{
if (! in_array($contentType, [
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
], true)) {
throw new InvalidArgumentException('Unsupported Academy interaction content type.');
}
$content = $this->contentResolver->resolve($contentType, $contentId);
if ($content === null) {
throw new InvalidArgumentException('Unknown Academy interaction content target.');
}
return $content;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyContentMetricDaily;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
final class AcademyPopularityService
{
/**
* @param array<string, int|float|null> $metrics
*/
public function calculatePopularityScore(array $metrics): float
{
return round(
((float) ($metrics['unique_visitors'] ?? 0) * 1)
+ ((float) ($metrics['engaged_views'] ?? 0) * 3)
+ ((float) ($metrics['likes'] ?? 0) * 5)
+ ((float) ($metrics['saves'] ?? 0) * 7)
+ ((float) ($metrics['prompt_copies'] ?? 0) * 8)
+ ((float) ($metrics['negative_prompt_copies'] ?? 0) * 4)
+ ((float) ($metrics['starts'] ?? 0) * 4)
+ ((float) ($metrics['completions'] ?? 0) * 10)
+ ((float) ($metrics['upgrade_clicks'] ?? 0) * 15)
+ ((float) ($metrics['premium_preview_views'] ?? 0) * 3)
- ((float) ($metrics['bounce_count'] ?? 0) * 2),
2,
);
}
/**
* @param array<string, int|float|null> $metrics
*/
public function calculateConversionScore(array $metrics): float
{
$uniqueVisitors = max(1, (int) ($metrics['unique_visitors'] ?? 0));
return round((((float) ($metrics['upgrade_clicks'] ?? 0) * 100) / $uniqueVisitors), 2);
}
public function queryBetween(Carbon $from, Carbon $to): Builder
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
}
public function topContent(Carbon $from, Carbon $to, int $limit = 10): Collection
{
return $this->queryBetween($from, $to)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score')
->groupBy('content_type', 'content_id')
->orderByDesc('popularity_score')
->limit($limit)
->get();
}
}

View File

@@ -9,17 +9,120 @@ use App\Models\AcademyLesson;
use App\Models\AcademyLessonProgress;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySavedPrompt;
use App\Models\AcademySave;
use App\Models\AcademyUserProgress;
use App\Models\User;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use App\Support\AcademyAnalytics\AcademyAnalyticsProgressStatus;
use Illuminate\Http\Request;
final class AcademyProgressService
{
public function __construct(
private readonly AcademyBadgeService $badges,
private readonly AcademyCourseProgressService $courses,
private readonly AcademyAnalyticsService $analytics,
) {
}
public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null): AcademyLessonProgress
public function startLesson(User $user, int $lessonId, ?int $courseId = null, ?Request $request = null): AcademyUserProgress
{
$progress = $this->updateUserProgressRecord($user, $courseId, $lessonId, [
'status' => AcademyAnalyticsProgressStatus::STARTED,
'progress_percent' => 0,
'started_at' => now(),
'completed_at' => null,
'last_seen_at' => now(),
]);
if ($courseId) {
$course = AcademyCourse::query()->find($courseId);
if ($course instanceof AcademyCourse) {
$this->courses->markEnrollmentStarted($user, $course);
$lesson = AcademyLesson::query()->find($lessonId);
if ($lesson instanceof AcademyLesson) {
$this->courses->updateLastLesson($user, $course, $lesson);
}
$this->syncCourseProgressRecord($user, $course);
}
}
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::LESSON_STARTED,
'content_type' => AcademyAnalyticsContentType::LESSON,
'content_id' => $lessonId,
'metadata' => array_filter([
'course_id' => $courseId,
], static fn (mixed $value): bool => $value !== null),
], $user, $request);
return $progress;
}
public function completeLesson(User $user, int $lessonId, ?int $courseId = null, ?Request $request = null): AcademyUserProgress
{
$progress = $this->updateUserProgressRecord($user, $courseId, $lessonId, [
'status' => AcademyAnalyticsProgressStatus::COMPLETED,
'progress_percent' => 100,
'started_at' => now(),
'completed_at' => now(),
'last_seen_at' => now(),
]);
if ($courseId) {
$course = AcademyCourse::query()->find($courseId);
if ($course instanceof AcademyCourse) {
$this->syncCourseProgressRecord($user, $course);
}
}
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::LESSON_COMPLETED,
'content_type' => AcademyAnalyticsContentType::LESSON,
'content_id' => $lessonId,
'metadata' => array_filter([
'course_id' => $courseId,
], static fn (mixed $value): bool => $value !== null),
], $user, $request);
return $progress;
}
public function startCourse(User $user, int $courseId, ?Request $request = null): AcademyUserProgress
{
$course = AcademyCourse::query()->findOrFail($courseId);
$this->courses->markEnrollmentStarted($user, $course);
$progress = $this->syncCourseProgressRecord($user, $course, true);
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::COURSE_STARTED,
'content_type' => AcademyAnalyticsContentType::COURSE,
'content_id' => $courseId,
], $user, $request);
return $progress;
}
public function completeCourse(User $user, int $courseId, ?Request $request = null): AcademyUserProgress
{
$course = AcademyCourse::query()->findOrFail($courseId);
$this->courses->markCourseCompletedIfFinished($user, $course);
$progress = $this->syncCourseProgressRecord($user, $course);
if ($progress->status === AcademyAnalyticsProgressStatus::COMPLETED) {
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::COURSE_COMPLETED,
'content_type' => AcademyAnalyticsContentType::COURSE,
'content_id' => $courseId,
], $user, $request);
}
return $progress;
}
public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null, ?Request $request = null): AcademyLessonProgress
{
$progress = AcademyLessonProgress::query()->updateOrCreate(
[
@@ -36,6 +139,8 @@ final class AcademyProgressService
$this->courses->markCourseCompletedIfFinished($user, $course);
}
$this->completeLesson($user, (int) $lesson->id, $course?->id, $request);
$this->badges->syncForUser($user);
return $progress;
@@ -48,6 +153,18 @@ final class AcademyProgressService
'user_id' => $user->id,
]);
AcademySave::query()->firstOrCreate([
'user_id' => $user->id,
'content_type' => AcademyAnalyticsContentType::PROMPT,
'content_id' => $prompt->id,
]);
$this->analytics->track([
'event_type' => AcademyAnalyticsEventType::PROMPT_SAVE,
'content_type' => AcademyAnalyticsContentType::PROMPT,
'content_id' => (int) $prompt->id,
], $user);
$this->badges->syncForUser($user);
return $saved;
@@ -60,6 +177,50 @@ final class AcademyProgressService
->where('user_id', $user->id)
->delete();
AcademySave::query()
->where('user_id', $user->id)
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->where('content_id', $prompt->id)
->delete();
$this->badges->syncForUser($user);
}
/**
* @param array<string, mixed> $attributes
*/
private function updateUserProgressRecord(User $user, ?int $courseId, ?int $lessonId, array $attributes): AcademyUserProgress
{
return AcademyUserProgress::query()->updateOrCreate(
[
'user_id' => $user->id,
'course_id' => $courseId,
'lesson_id' => $lessonId,
],
$attributes,
);
}
private function syncCourseProgressRecord(User $user, AcademyCourse $course, bool $forceStarted = false): AcademyUserProgress
{
$progressPercent = $this->courses->getProgressPercent($user, $course);
$isComplete = $this->courses->getTotalRequiredLessonsCount($course) > 0 && $progressPercent >= 100;
$status = $isComplete
? AcademyAnalyticsProgressStatus::COMPLETED
: ($progressPercent > 0 || $forceStarted
? AcademyAnalyticsProgressStatus::IN_PROGRESS
: AcademyAnalyticsProgressStatus::STARTED);
return $this->updateUserProgressRecord($user, (int) $course->id, null, [
'status' => $status,
'progress_percent' => $progressPercent,
'started_at' => now(),
'completed_at' => $isComplete ? now() : null,
'last_seen_at' => now(),
'metadata' => [
'completed_required' => $this->courses->getCompletedRequiredLessonsCount($user, $course),
'total_required' => $this->courses->getTotalRequiredLessonsCount($course),
],
]);
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyBillingEvent;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
final class AcademyStripeWebhookAuditService
{
private const TRACKED_EVENT_TYPES = [
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_succeeded',
'invoice.payment_failed',
'invoice.payment_action_required',
];
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {}
/**
* @param array<string, mixed> $payload
*/
public function recordReceived(array $payload): void
{
$context = $this->buildContext($payload);
$tracked = in_array($context['event_type'], self::TRACKED_EVENT_TYPES, true);
$cacheKeys = [];
if ($tracked && $context['user'] instanceof User) {
$cacheKeys = [
'academy.billing.account.'.$context['user']->id,
'academy.billing.pricing.'.$context['user']->id,
];
foreach ($cacheKeys as $cacheKey) {
Cache::forget($cacheKey);
}
}
$event = $this->persistEvent($context, [
'received' => true,
'received_at' => now()->toISOString(),
'tracked' => $tracked,
'action' => $tracked ? 'received_for_cashier_processing' : 'ignored_untracked_event',
'user_resolved' => $context['user'] instanceof User,
'cache_cleared' => $cacheKeys !== [],
'cache_keys' => $cacheKeys,
'status' => $context['object']['status'] ?? null,
'mode' => $context['object']['mode'] ?? null,
'amount_total' => $context['object']['amount_total'] ?? null,
'currency' => $context['object']['currency'] ?? null,
'price_ids' => $this->extractPriceIds($context['object']),
]);
Log::info('academy.stripe.webhook.received', [
'stripe_event_id' => $context['event_id'],
'event_type' => $context['event_type'],
'tracked' => $tracked,
'user_id' => $context['user']?->id,
'academy_plan' => $context['plan']['key'] ?? null,
'academy_tier' => $context['plan']['tier'] ?? null,
'audit_event_id' => $event->id,
]);
}
/**
* @param array<string, mixed> $payload
*/
public function recordHandled(array $payload): void
{
$context = $this->buildContext($payload);
$localSubscription = $this->resolveLocalSubscription($context['subscription_id'], $context['user']);
$outcome = $localSubscription instanceof Subscription
? 'local_subscription_synced'
: 'handled_without_local_subscription_change';
$event = $this->persistEvent($context, [
'handled' => true,
'handled_at' => now()->toISOString(),
'outcome' => $outcome,
'local_subscription_found' => $localSubscription instanceof Subscription,
'local_subscription_status' => $localSubscription?->stripe_status,
'local_subscription_active' => $localSubscription?->active(),
'local_subscription_on_grace_period' => $localSubscription?->onGracePeriod(),
'local_price_ids' => $localSubscription instanceof Subscription
? $localSubscription->items->pluck('stripe_price')->filter()->values()->all()
: [],
]);
Log::info('academy.stripe.webhook.handled', [
'stripe_event_id' => $context['event_id'],
'event_type' => $context['event_type'],
'user_id' => $context['user']?->id,
'academy_plan' => $context['plan']['key'] ?? null,
'academy_tier' => $context['plan']['tier'] ?? null,
'outcome' => $outcome,
'audit_event_id' => $event->id,
]);
}
/**
* @param array<string, mixed> $payload
* @return array{event_id:string,event_type:string,object:array<string,mixed>,customer_id:?string,subscription_id:?string,plan:?array<string,mixed>,user:?User}
*/
private function buildContext(array $payload): array
{
$eventType = trim((string) ($payload['type'] ?? ''));
$object = is_array($payload['data']['object'] ?? null)
? $payload['data']['object']
: [];
$customerId = $this->extractCustomerId($object);
$subscriptionId = $this->extractSubscriptionId($object);
$plan = $this->resolvePlan($object);
$user = $this->resolveUser($customerId, $subscriptionId, $object);
return [
'event_id' => trim((string) ($payload['id'] ?? '')),
'event_type' => $eventType,
'object' => $object,
'customer_id' => $customerId,
'subscription_id' => $subscriptionId,
'plan' => $plan,
'user' => $user,
];
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $summary
*/
private function persistEvent(array $context, array $summary): AcademyBillingEvent
{
$eventId = $context['event_id'];
$event = $eventId !== ''
? AcademyBillingEvent::query()->firstOrNew(['stripe_event_id' => $eventId])
: new AcademyBillingEvent();
$existingSummary = is_array($event->payload_summary) ? $event->payload_summary : [];
$event->fill([
'user_id' => $context['user']?->id,
'stripe_event_id' => $eventId !== '' ? $eventId : null,
'stripe_customer_id' => $context['customer_id'],
'stripe_subscription_id' => $context['subscription_id'],
'event_type' => $context['event_type'] !== '' ? $context['event_type'] : 'unknown',
'academy_tier' => $context['plan']['tier'] ?? null,
'academy_plan' => $context['plan']['key'] ?? null,
'payload_summary' => array_merge($existingSummary, $summary),
'processed_at' => now(),
]);
$event->save();
return $event;
}
/**
* @param array<string, mixed> $object
* @return array<string, mixed>|null
*/
private function resolvePlan(array $object): ?array
{
$metadataPlan = trim((string) Arr::get($object, 'metadata.academy_plan', ''));
if ($metadataPlan !== '') {
return $this->plans->plan($metadataPlan);
}
foreach ($this->extractPriceIds($object) as $priceId) {
$plan = $this->plans->planForPriceId($priceId);
if ($plan !== null) {
return $plan;
}
}
return null;
}
/**
* @param array<string, mixed> $object
* @return list<string>
*/
private function extractPriceIds(array $object): array
{
$priceIds = [];
foreach ((array) Arr::get($object, 'items.data', []) as $item) {
if (! is_array($item)) {
continue;
}
$priceId = trim((string) Arr::get($item, 'price.id', ''));
if ($priceId !== '') {
$priceIds[] = $priceId;
}
}
$lineItemPriceId = trim((string) Arr::get($object, 'display_items.0.price.id', ''));
if ($lineItemPriceId !== '') {
$priceIds[] = $lineItemPriceId;
}
return array_values(array_unique($priceIds));
}
/**
* @param array<string, mixed> $object
*/
private function extractCustomerId(array $object): ?string
{
$value = trim((string) ($object['customer'] ?? ''));
return $value !== '' ? $value : null;
}
/**
* @param array<string, mixed> $object
*/
private function extractSubscriptionId(array $object): ?string
{
$subscriptionId = trim((string) ($object['id'] ?? ''));
if (str_starts_with($subscriptionId, 'sub_')) {
return $subscriptionId;
}
$nested = trim((string) ($object['subscription'] ?? ''));
return $nested !== '' ? $nested : null;
}
/**
* @param array<string, mixed> $object
*/
private function resolveUser(?string $customerId, ?string $subscriptionId, array $object): ?User
{
$metadataUserId = (int) Arr::get($object, 'metadata.user_id', 0);
if ($metadataUserId > 0) {
return User::query()->find($metadataUserId);
}
if ($customerId !== null) {
$user = User::query()->where('stripe_id', $customerId)->first();
if ($user instanceof User) {
return $user;
}
}
if ($subscriptionId !== null) {
$subscription = Subscription::query()->where('stripe_id', $subscriptionId)->first();
if ($subscription !== null && $subscription->user instanceof User) {
return $subscription->user;
}
}
return null;
}
private function resolveLocalSubscription(?string $subscriptionId, ?User $user): ?Subscription
{
if ($subscriptionId !== null) {
$subscription = Subscription::query()->where('stripe_id', $subscriptionId)->with('items')->first();
if ($subscription instanceof Subscription) {
return $subscription;
}
}
if (! $user instanceof User) {
return null;
}
$subscription = $user->subscription($this->plans->subscriptionName());
return $subscription instanceof Subscription
? $subscription->loadMissing('items')
: null;
}
}