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

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class AcademyBillingEvent extends Model
{
use HasFactory;
/**
* @var list<string>
*/
protected $fillable = [
'user_id',
'stripe_event_id',
'stripe_customer_id',
'stripe_subscription_id',
'event_type',
'academy_tier',
'academy_plan',
'payload_summary',
'processed_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'payload_summary' => 'array',
'processed_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AcademyContentMetricDaily extends Model
{
protected $table = 'academy_content_metrics_daily';
protected $fillable = [
'date',
'content_type',
'content_id',
'views',
'unique_visitors',
'guest_views',
'user_views',
'subscriber_views',
'engaged_views',
'scroll_50',
'scroll_75',
'scroll_100',
'likes',
'saves',
'prompt_copies',
'negative_prompt_copies',
'starts',
'completions',
'upgrade_clicks',
'premium_preview_views',
'search_impressions',
'search_clicks',
'bounce_count',
'avg_engaged_seconds',
'popularity_score',
'conversion_score',
];
protected $casts = [
'date' => 'date',
'popularity_score' => 'decimal:2',
'conversion_score' => 'decimal:2',
];
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AcademyEvent extends Model
{
protected $fillable = [
'event_type',
'content_type',
'content_id',
'user_id',
'visitor_id',
'session_id',
'url',
'route_name',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'device_type',
'browser',
'platform',
'country_code',
'is_logged_in',
'is_subscriber',
'is_admin',
'is_bot',
'is_crawler',
'is_suspicious',
'metadata',
'occurred_at',
];
protected $casts = [
'metadata' => 'array',
'occurred_at' => 'datetime',
'is_logged_in' => 'boolean',
'is_subscriber' => 'boolean',
'is_admin' => 'boolean',
'is_bot' => 'boolean',
'is_crawler' => 'boolean',
'is_suspicious' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AcademyLike extends Model
{
protected $fillable = [
'user_id',
'content_type',
'content_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -24,6 +24,10 @@ class AcademyPromptTemplate extends Model
'negative_prompt',
'usage_notes',
'workflow_notes',
'documentation',
'placeholders',
'helper_prompts',
'prompt_variants',
'difficulty',
'access_level',
'aspect_ratio',
@@ -41,6 +45,10 @@ class AcademyPromptTemplate extends Model
protected $casts = [
'tags' => 'array',
'tool_notes' => 'array',
'documentation' => 'array',
'placeholders' => 'array',
'helper_prompts' => 'array',
'prompt_variants' => 'array',
'featured' => 'boolean',
'prompt_of_week' => 'boolean',
'active' => 'boolean',

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AcademySave extends Model
{
protected $fillable = [
'user_id',
'content_type',
'content_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AcademySearchLog extends Model
{
protected $fillable = [
'user_id',
'visitor_id',
'query',
'normalized_query',
'results_count',
'clicked_content_type',
'clicked_content_id',
'filters',
'is_logged_in',
'is_subscriber',
'is_bot',
];
protected $casts = [
'filters' => 'array',
'is_logged_in' => 'boolean',
'is_subscriber' => 'boolean',
'is_bot' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AcademyUserProgress extends Model
{
protected $table = 'academy_user_progress';
protected $fillable = [
'user_id',
'course_id',
'lesson_id',
'status',
'progress_percent',
'started_at',
'completed_at',
'last_seen_at',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'last_seen_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function course(): BelongsTo
{
return $this->belongsTo(AcademyCourse::class, 'course_id');
}
public function lesson(): BelongsTo
{
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
}
}

View File

@@ -18,8 +18,13 @@ use App\Models\ConversationParticipant;
use App\Models\AcademyBadge;
use App\Models\AcademyCourseEnrollment;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademyLessonProgress;
use App\Models\AcademySave;
use App\Models\AcademySavedPrompt;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Models\Message;
use App\Models\Notification;
use App\Models\Achievement;
@@ -30,6 +35,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Billable;
use Laravel\Scout\Searchable;
class User extends Authenticatable
@@ -40,7 +46,7 @@ class User extends Authenticatable
];
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
use Billable, HasFactory, Notifiable, SoftDeletes;
use Searchable {
Searchable::bootSearchable as private bootScoutSearchable;
}
@@ -218,6 +224,31 @@ class User extends Authenticatable
return $this->hasMany(AcademySavedPrompt::class, 'user_id');
}
public function academyEvents(): HasMany
{
return $this->hasMany(AcademyEvent::class, 'user_id');
}
public function academyLikes(): HasMany
{
return $this->hasMany(AcademyLike::class, 'user_id');
}
public function academySaves(): HasMany
{
return $this->hasMany(AcademySave::class, 'user_id');
}
public function academyUserProgress(): HasMany
{
return $this->hasMany(AcademyUserProgress::class, 'user_id');
}
public function academySearchLogs(): HasMany
{
return $this->hasMany(AcademySearchLog::class, 'user_id');
}
public function academyChallengeSubmissions(): HasMany
{
return $this->hasMany(AcademyChallengeSubmission::class, 'user_id');
@@ -448,12 +479,12 @@ class User extends Authenticatable
public function hasAcademyCreatorAccess(): bool
{
return $this->hasAcademyProAccess() || strtolower(trim((string) ($this->role ?? ''))) === 'academy_creator';
return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['creator', 'pro', 'admin'], true);
}
public function hasAcademyProAccess(): bool
{
return strtolower(trim((string) ($this->role ?? ''))) === 'academy_pro';
return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['pro', 'admin'], true);
}
public function canAccessAcademyContent(object|array $content): bool

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
@@ -197,6 +198,16 @@ class World extends Model
return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
}
public function webStories(): HasMany
{
return $this->hasMany(WorldWebStory::class)->orderByDesc('published_at')->orderByDesc('id');
}
public function publishedWebStory(): HasOne
{
return $this->hasOne(WorldWebStory::class)->visible()->latest('published_at')->latest('id');
}
public function scopePublished(Builder $query): Builder
{
return $query

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Support\Facades\Cache;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class WorldWebStory extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'world_id',
'slug',
'title',
'subtitle',
'excerpt',
'description',
'seo_title',
'seo_description',
'poster_portrait_path',
'poster_square_path',
'publisher_logo_path',
'status',
'featured',
'active',
'noindex',
'published_at',
'starts_at',
'ends_at',
'created_by',
'updated_by',
];
protected $casts = [
'featured' => 'boolean',
'active' => 'boolean',
'noindex' => 'boolean',
'published_at' => 'datetime',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
protected static function booted(): void
{
$flushCache = static function (self $story): void {
Cache::forget('web_story_index');
Cache::forget('web_story:' . $story->slug);
if ($story->world?->slug) {
Cache::forget('world:' . $story->world->slug . ':web_story');
} elseif ($story->world_id) {
$worldSlug = World::query()->whereKey($story->world_id)->value('slug');
if (is_string($worldSlug) && $worldSlug !== '') {
Cache::forget('world:' . $worldSlug . ':web_story');
}
}
};
static::saved($flushCache);
static::deleted($flushCache);
static::restored($flushCache);
}
public function world(): BelongsTo
{
return $this->belongsTo(World::class);
}
public function pages(): HasMany
{
return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages();
}
public function orderedPages(): HasMany
{
return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages();
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', self::STATUS_PUBLISHED);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeFeatured(Builder $query): Builder
{
return $query->where('featured', true);
}
public function scopeVisible(Builder $query): Builder
{
return $query
->active()
->published()
->where('noindex', false)
->where(function (Builder $builder): void {
$builder->whereNull('published_at')
->orWhere('published_at', '<=', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('starts_at')
->orWhere('starts_at', '<=', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('ends_at')
->orWhere('ends_at', '>=', now());
});
}
public function publicUrl(): string
{
return route('web-stories.show', ['slug' => $this->slug]);
}
public function posterPortraitUrl(): ?string
{
return $this->assetUrl($this->poster_portrait_path);
}
public function posterSquareUrl(): ?string
{
return $this->assetUrl($this->poster_square_path);
}
public function publisherLogoUrl(): ?string
{
return $this->assetUrl($this->publisher_logo_path);
}
public function seoTitle(): string
{
return trim((string) ($this->seo_title ?: $this->title));
}
public function seoDescription(): string
{
return trim((string) ($this->seo_description ?: $this->excerpt ?: $this->description ?: ''));
}
private function assetUrl(?string $path): ?string
{
$resolved = trim((string) $path);
if ($resolved === '') {
return null;
}
if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) {
return $resolved;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/');
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Support\Facades\Cache;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class WorldWebStoryPage extends Model
{
use HasFactory;
use SoftDeletes;
public const LAYOUT_COVER = 'cover';
public const LAYOUT_ARTWORK = 'artwork';
public const LAYOUT_CREATOR = 'creator';
public const LAYOUT_MOOD = 'mood';
public const LAYOUT_COLLECTION = 'collection';
public const LAYOUT_CTA = 'cta';
public const BACKGROUND_IMAGE = 'image';
public const BACKGROUND_VIDEO = 'video';
public const BACKGROUND_GRADIENT = 'gradient';
protected $fillable = [
'story_id',
'artwork_id',
'position',
'layout',
'background_type',
'background_path',
'background_mobile_path',
'headline',
'body',
'cta_label',
'cta_url',
'alt_text',
'caption',
'credit_text',
'text_position',
'overlay_strength',
'animation',
'active',
];
protected $casts = [
'position' => 'integer',
'overlay_strength' => 'integer',
'active' => 'boolean',
];
protected static function booted(): void
{
$flushCache = static function (self $page): void {
$story = $page->relationLoaded('story') ? $page->story : $page->story()->with('world')->first();
if (! ($story instanceof WorldWebStory)) {
return;
}
Cache::forget('web_story:' . $story->slug);
Cache::forget('web_story_index');
if ($story->world?->slug) {
Cache::forget('world:' . $story->world->slug . ':web_story');
}
};
static::saved($flushCache);
static::deleted($flushCache);
static::restored($flushCache);
}
public function story(): BelongsTo
{
return $this->belongsTo(WorldWebStory::class, 'story_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function scopeOrderedPages(Builder $query): Builder
{
return $query->orderBy('position')->orderBy('id');
}
public function backgroundUrl(): ?string
{
return $this->assetUrl($this->background_mobile_path ?: $this->background_path);
}
public function desktopBackgroundUrl(): ?string
{
return $this->assetUrl($this->background_path ?: $this->background_mobile_path);
}
private function assetUrl(?string $path): ?string
{
$resolved = trim((string) $path);
if ($resolved === '') {
return null;
}
if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) {
return $resolved;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/');
}
}