feat(academy): prepare AI Academy v1 for production enablement

This commit is contained in:
2026-05-03 19:59:27 +02:00
parent 90e93f0d42
commit a3cfc6c17f
131 changed files with 60702 additions and 135850 deletions

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyChallenge;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use Illuminate\Support\Str;
final class AcademyAccessService
{
public function canAccessContent(?User $user, string $accessLevel): bool
{
if ($accessLevel === 'free') {
return true;
}
if ($user === null) {
return false;
}
if ($user->isAdmin()) {
return true;
}
return $this->rankForUser($user) >= $this->rankForLevel($accessLevel);
}
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
{
return $this->canAccessContent($user, (string) $lesson->access_level);
}
public function canAccessPrompt(?User $user, AcademyPromptTemplate $prompt): bool
{
return $this->canAccessContent($user, (string) $prompt->access_level);
}
public function canAccessPack(?User $user, AcademyPromptPack $pack): bool
{
return $this->canAccessContent($user, (string) $pack->access_level);
}
public function canAccessChallenge(?User $user, AcademyChallenge $challenge): bool
{
return $this->canAccessContent($user, (string) $challenge->access_level);
}
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false): array
{
$authorized = $this->canAccessLesson($viewer, $lesson);
$fullContent = (string) ($lesson->content ?? '');
return [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'excerpt' => (string) ($lesson->excerpt ?? ''),
'content' => ($authorized && $includeFull) ? $fullContent : null,
'content_preview' => $authorized ? null : $this->previewText($fullContent, 360),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'lesson_type' => (string) $lesson->lesson_type,
'cover_image' => $lesson->cover_image,
'video_url' => $authorized ? $lesson->video_url : null,
'reading_minutes' => (int) $lesson->reading_minutes,
'featured' => (bool) $lesson->featured,
'active' => (bool) $lesson->active,
'published_at' => $lesson->published_at?->toISOString(),
'category' => $lesson->category ? [
'id' => (int) $lesson->category->id,
'name' => (string) $lesson->category->name,
'slug' => (string) $lesson->category->slug,
] : null,
'locked' => ! $authorized,
'can_access' => $authorized,
];
}
public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array
{
$authorized = $this->canAccessPrompt($viewer, $prompt);
return [
'id' => (int) $prompt->id,
'title' => (string) $prompt->title,
'slug' => (string) $prompt->slug,
'excerpt' => (string) ($prompt->excerpt ?? ''),
'prompt' => ($authorized && $includeFull) ? (string) $prompt->prompt : null,
'negative_prompt' => ($authorized && $includeFull) ? (string) ($prompt->negative_prompt ?? '') : null,
'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),
'difficulty' => (string) $prompt->difficulty,
'access_level' => (string) $prompt->access_level,
'aspect_ratio' => $prompt->aspect_ratio,
'tags' => array_values((array) ($prompt->tags ?? [])),
'tool_notes' => $authorized ? (array) ($prompt->tool_notes ?? []) : [],
'preview_image' => $prompt->preview_image,
'featured' => (bool) $prompt->featured,
'prompt_of_week' => (bool) $prompt->prompt_of_week,
'published_at' => $prompt->published_at?->toISOString(),
'category' => $prompt->category ? [
'id' => (int) $prompt->category->id,
'name' => (string) $prompt->category->name,
'slug' => (string) $prompt->category->slug,
] : null,
'locked' => ! $authorized,
'can_access' => $authorized,
];
}
public function packPayload(AcademyPromptPack $pack, ?User $viewer, bool $includePrompts = false): array
{
$authorized = $this->canAccessPack($viewer, $pack);
return [
'id' => (int) $pack->id,
'title' => (string) $pack->title,
'slug' => (string) $pack->slug,
'excerpt' => (string) ($pack->excerpt ?? ''),
'description' => (string) ($pack->description ?? ''),
'access_level' => (string) $pack->access_level,
'cover_image' => $pack->cover_image,
'tags' => array_values((array) ($pack->tags ?? [])),
'featured' => (bool) $pack->featured,
'published_at' => $pack->published_at?->toISOString(),
'locked' => ! $authorized,
'can_access' => $authorized,
'prompts' => $includePrompts
? $pack->prompts->map(fn (AcademyPromptTemplate $prompt): array => $this->promptPayload($prompt, $viewer, $authorized))->values()->all()
: [],
];
}
public function challengePayload(AcademyChallenge $challenge, ?User $viewer, bool $includeSubmissions = false): array
{
$authorized = $this->canAccessChallenge($viewer, $challenge);
return [
'id' => (int) $challenge->id,
'title' => (string) $challenge->title,
'slug' => (string) $challenge->slug,
'excerpt' => (string) ($challenge->excerpt ?? ''),
'description' => (string) ($challenge->description ?? ''),
'brief' => (string) ($challenge->brief ?? ''),
'rules' => (string) ($challenge->rules ?? ''),
'access_level' => (string) $challenge->access_level,
'status' => (string) $challenge->status,
'starts_at' => $challenge->starts_at?->toISOString(),
'ends_at' => $challenge->ends_at?->toISOString(),
'voting_starts_at' => $challenge->voting_starts_at?->toISOString(),
'voting_ends_at' => $challenge->voting_ends_at?->toISOString(),
'cover_image' => $challenge->cover_image,
'prize_text' => $challenge->prize_text,
'required_tags' => array_values((array) ($challenge->required_tags ?? [])),
'allowed_categories' => array_values((array) ($challenge->allowed_categories ?? [])),
'featured' => (bool) $challenge->featured,
'locked' => ! $authorized,
'can_access' => $authorized,
'submission_count' => $includeSubmissions ? (int) $challenge->submissions()->approved()->count() : null,
];
}
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))) {
'admin' => 99,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function previewText(string $value, int $limit): string
{
$plain = trim(strip_tags($value));
if ($plain === '') {
return '';
}
$length = mb_strlen($plain);
$previewLength = min($limit, max(12, (int) ceil($length * 0.55)));
if ($previewLength >= $length) {
$previewLength = max(1, $length - 1);
}
return rtrim(mb_substr($plain, 0, $previewLength)) . '...';
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyBadge;
use App\Models\AcademySavedPrompt;
use App\Models\User;
final class AcademyBadgeService
{
public function syncForUser(User $user): void
{
if (! (bool) config('academy.badges_enabled', true)) {
return;
}
$savedPromptCount = AcademySavedPrompt::query()->where('user_id', $user->id)->count();
$completedLessons = $user->academyLessonProgress()->whereNotNull('completed_at')->count();
foreach (AcademyBadge::query()->active()->get() as $badge) {
$rules = (array) ($badge->rules ?? []);
if (isset($rules['complete_lessons']) && $completedLessons < (int) $rules['complete_lessons']) {
continue;
}
if (isset($rules['save_prompts']) && $savedPromptCount < (int) $rules['save_prompts']) {
continue;
}
$user->academyBadges()->syncWithoutDetaching([
$badge->id => ['awarded_at' => now()],
]);
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use Illuminate\Support\Facades\Cache;
final class AcademyCacheService
{
private const HOME_KEY = 'academy.home';
private const FEATURED_LESSONS_KEY = 'academy.featured_lessons';
private const FEATURED_PROMPTS_KEY = 'academy.featured_prompts';
private const FEATURED_CHALLENGES_KEY = 'academy.featured_challenges';
private const CATEGORIES_KEY = 'academy.categories';
public function homePayload(callable $resolver): array
{
return Cache::remember(self::HOME_KEY, $this->ttl(), $resolver);
}
public function featuredLessons(): array
{
return Cache::remember(self::FEATURED_LESSONS_KEY, $this->ttl(), static fn (): array => AcademyLesson::query()
->with('category')
->active()
->published()
->where('featured', true)
->latest('published_at')
->limit(6)
->get()
->all());
}
public function featuredPrompts(): array
{
return Cache::remember(self::FEATURED_PROMPTS_KEY, $this->ttl(), static fn (): array => AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->where(function ($query): void {
$query->where('featured', true)->orWhere('prompt_of_week', true);
})
->latest('published_at')
->limit(6)
->get()
->all());
}
public function featuredChallenges(): array
{
return Cache::remember(self::FEATURED_CHALLENGES_KEY, $this->ttl(), static fn (): array => AcademyChallenge::query()
->publiclyVisible()
->where('featured', true)
->latest('starts_at')
->limit(6)
->get()
->all());
}
public function categoriesByType(string $type): array
{
$cacheKey = self::CATEGORIES_KEY . '.' . $type;
return Cache::remember($cacheKey, $this->ttl(), static fn (): array => AcademyCategory::query()
->active()
->where('type', $type)
->orderBy('order_num')
->orderBy('name')
->get()
->all());
}
public function clearAll(): void
{
Cache::forget(self::HOME_KEY);
Cache::forget(self::FEATURED_LESSONS_KEY);
Cache::forget(self::FEATURED_PROMPTS_KEY);
Cache::forget(self::FEATURED_CHALLENGES_KEY);
foreach (['lesson', 'prompt', 'challenge', 'pack'] as $type) {
Cache::forget(self::CATEGORIES_KEY . '.' . $type);
}
}
private function ttl(): int
{
return max(60, (int) config('academy.cache.ttl', 1800));
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Collection;
final class AcademyChallengeService
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyBadgeService $badges,
) {
}
public function eligibleArtworkOptions(User $user): Collection
{
return Artwork::query()
->where('user_id', $user->id)
->public()
->latest('published_at')
->limit(50)
->get(['id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at']);
}
/**
* @param array<string, mixed> $attributes
*/
public function submit(User $user, AcademyChallenge $challenge, Artwork $artwork, array $attributes): AcademyChallengeSubmission
{
abort_unless((bool) config('academy.challenges_enabled', true), 404);
abort_unless($this->access->canAccessChallenge($user, $challenge), 403);
abort_unless($artwork->user_id === $user->id, 403);
abort_unless($challenge->active && $challenge->status === AcademyChallenge::STATUS_ACTIVE, 422, 'Challenge is not accepting submissions.');
$submission = AcademyChallengeSubmission::query()->updateOrCreate(
[
'challenge_id' => $challenge->id,
'user_id' => $user->id,
'artwork_id' => $artwork->id,
],
[
'prompt_used' => $attributes['prompt_used'] ?? null,
'workflow_notes' => $attributes['workflow_notes'] ?? null,
'ai_tool_used' => $attributes['ai_tool_used'] ?? null,
'is_ai_generated' => (bool) ($attributes['is_ai_generated'] ?? false),
'is_ai_assisted' => (bool) ($attributes['is_ai_assisted'] ?? true),
'moderation_status' => 'pending',
'submitted_at' => now(),
],
);
$this->badges->syncForUser($user);
return $submission;
}
public function setModerationStatus(AcademyChallengeSubmission $submission, string $status): AcademyChallengeSubmission
{
$submission->forceFill([
'moderation_status' => $status,
])->save();
return $submission;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonProgress;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySavedPrompt;
use App\Models\User;
final class AcademyProgressService
{
public function __construct(private readonly AcademyBadgeService $badges)
{
}
public function markLessonComplete(User $user, AcademyLesson $lesson): AcademyLessonProgress
{
$progress = AcademyLessonProgress::query()->updateOrCreate(
[
'lesson_id' => $lesson->id,
'user_id' => $user->id,
],
[
'completed_at' => now(),
],
);
$this->badges->syncForUser($user);
return $progress;
}
public function savePrompt(User $user, AcademyPromptTemplate $prompt): AcademySavedPrompt
{
$saved = AcademySavedPrompt::query()->firstOrCreate([
'prompt_template_id' => $prompt->id,
'user_id' => $user->id,
]);
$this->badges->syncForUser($user);
return $saved;
}
public function unsavePrompt(User $user, AcademyPromptTemplate $prompt): void
{
AcademySavedPrompt::query()
->where('prompt_template_id', $prompt->id)
->where('user_id', $user->id)
->delete();
$this->badges->syncForUser($user);
}
}