feat(academy): prepare AI Academy v1 for production enablement
This commit is contained in:
209
app/Services/Academy/AcademyAccessService.php
Normal file
209
app/Services/Academy/AcademyAccessService.php
Normal 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)) . '...';
|
||||
}
|
||||
}
|
||||
38
app/Services/Academy/AcademyBadgeService.php
Normal file
38
app/Services/Academy/AcademyBadgeService.php
Normal 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()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
94
app/Services/Academy/AcademyCacheService.php
Normal file
94
app/Services/Academy/AcademyCacheService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
71
app/Services/Academy/AcademyChallengeService.php
Normal file
71
app/Services/Academy/AcademyChallengeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
app/Services/Academy/AcademyProgressService.php
Normal file
57
app/Services/Academy/AcademyProgressService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user