Build world campaigns rewards and recaps

This commit is contained in:
2026-05-01 11:44:41 +02:00
parent 28e7e46e13
commit 257b0dbef6
100 changed files with 11300 additions and 367 deletions

View File

@@ -91,6 +91,14 @@ class GroupChallenge extends Model
return $this->hasMany(GroupChallengeArtwork::class);
}
public function outcomes(): HasMany
{
return $this->hasMany(GroupChallengeOutcome::class)
->orderBy('sort_order')
->orderBy('position')
->orderBy('id');
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_challenge_artworks')

View File

@@ -0,0 +1,89 @@
<?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;
class GroupChallengeOutcome extends Model
{
use HasFactory;
public const TYPE_WINNER = 'winner';
public const TYPE_FINALIST = 'finalist';
public const TYPE_RUNNER_UP = 'runner_up';
public const TYPE_HONORABLE_MENTION = 'honorable_mention';
public const TYPE_FEATURED = 'featured';
protected $fillable = [
'group_challenge_id',
'artwork_id',
'user_id',
'outcome_type',
'position',
'sort_order',
'title_override',
'note',
'awarded_by_user_id',
'awarded_at',
];
protected function casts(): array
{
return [
'group_challenge_id' => 'integer',
'artwork_id' => 'integer',
'user_id' => 'integer',
'position' => 'integer',
'sort_order' => 'integer',
'awarded_by_user_id' => 'integer',
'awarded_at' => 'datetime',
];
}
public static function supportedTypes(): array
{
return [
self::TYPE_WINNER,
self::TYPE_FINALIST,
self::TYPE_RUNNER_UP,
self::TYPE_HONORABLE_MENTION,
self::TYPE_FEATURED,
];
}
public static function labelForType(string $type): string
{
return match ($type) {
self::TYPE_WINNER => 'Winner',
self::TYPE_FINALIST => 'Finalist',
self::TYPE_RUNNER_UP => 'Runner-up',
self::TYPE_HONORABLE_MENTION => 'Honorable Mention',
self::TYPE_FEATURED => 'Featured',
default => ucwords(str_replace('_', ' ', $type)),
};
}
public function challenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'group_challenge_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function awardedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'awarded_by_user_id');
}
}

View File

@@ -4,22 +4,30 @@ declare(strict_types=1);
namespace App\Models;
use cPad\Plugins\News\Models\NewsArticle;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
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;
use Illuminate\Support\Carbon;
class World extends Model
{
use HasFactory;
use SoftDeletes;
protected static array $canonicalRecurrenceEditionIds = [];
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
public const RECAP_STATUS_DRAFT = 'draft';
public const RECAP_STATUS_PUBLISHED = 'published';
public const TYPE_SEASONAL = 'seasonal';
public const TYPE_EVENT = 'event';
public const TYPE_CAMPAIGN = 'campaign';
@@ -34,8 +42,11 @@ class World extends Model
'slug',
'tagline',
'summary',
'teaser_title',
'teaser_summary',
'description',
'cover_path',
'teaser_image_path',
'theme_key',
'accent_color',
'accent_color_secondary',
@@ -45,9 +56,14 @@ class World extends Model
'type',
'starts_at',
'ends_at',
'promotion_starts_at',
'promotion_ends_at',
'submission_starts_at',
'submission_ends_at',
'is_featured',
'is_active_campaign',
'is_homepage_featured',
'campaign_priority',
'accepts_submissions',
'participation_mode',
'submission_note_enabled',
@@ -60,6 +76,7 @@ class World extends Model
'cta_label',
'cta_url',
'badge_label',
'campaign_label',
'badge_description',
'submission_guidelines',
'badge_url',
@@ -70,28 +87,71 @@ class World extends Model
'section_order_json',
'section_visibility_json',
'parent_world_id',
'linked_challenge_id',
'show_linked_challenge_section',
'show_linked_challenge_entries',
'show_linked_challenge_winners',
'show_linked_challenge_finalists',
'auto_grant_challenge_world_rewards',
'challenge_teaser_override',
'hidden_linked_challenge_artwork_ids_json',
'created_by_user_id',
'published_at',
'recap_status',
'recap_title',
'recap_summary',
'recap_intro',
'recap_editor_note',
'recap_cover_path',
'recap_article_id',
'recap_stats_snapshot_json',
'recap_published_at',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'promotion_starts_at' => 'datetime',
'promotion_ends_at' => 'datetime',
'submission_starts_at' => 'datetime',
'submission_ends_at' => 'datetime',
'published_at' => 'datetime',
'is_featured' => 'boolean',
'is_active_campaign' => 'boolean',
'is_homepage_featured' => 'boolean',
'campaign_priority' => 'integer',
'accepts_submissions' => 'boolean',
'allow_readd_after_removal' => 'boolean',
'submission_note_enabled' => 'boolean',
'community_section_enabled' => 'boolean',
'is_recurring' => 'boolean',
'edition_year' => 'integer',
'linked_challenge_id' => 'integer',
'show_linked_challenge_section' => 'boolean',
'show_linked_challenge_entries' => 'boolean',
'show_linked_challenge_winners' => 'boolean',
'show_linked_challenge_finalists' => 'boolean',
'auto_grant_challenge_world_rewards' => 'boolean',
'hidden_linked_challenge_artwork_ids_json' => 'array',
'related_tags_json' => 'array',
'section_order_json' => 'array',
'section_visibility_json' => 'array',
'recap_article_id' => 'integer',
'recap_stats_snapshot_json' => 'array',
'recap_published_at' => 'datetime',
];
protected static function booted(): void
{
$flushRecurrenceCache = static function (): void {
static::$canonicalRecurrenceEditionIds = [];
};
static::saved($flushRecurrenceCache);
static::deleted($flushRecurrenceCache);
static::restored($flushRecurrenceCache);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
@@ -102,6 +162,16 @@ class World extends Model
return $this->belongsTo(self::class, 'parent_world_id');
}
public function linkedChallenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id');
}
public function recapArticle(): BelongsTo
{
return $this->belongsTo(NewsArticle::class, 'recap_article_id');
}
public function archiveEditions(): HasMany
{
return $this->hasMany(self::class, 'parent_world_id')->orderByDesc('edition_year')->orderByDesc('starts_at');
@@ -117,6 +187,16 @@ class World extends Model
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
}
public function editorialSuggestionStates(): HasMany
{
return $this->hasMany(WorldEditorialSuggestionState::class)->orderByDesc('updated_at')->orderByDesc('id');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
}
public function scopePublished(Builder $query): Builder
{
return $query
@@ -159,6 +239,36 @@ class World extends Model
->where('starts_at', '>', now());
}
public function scopeCampaignActive(Builder $query): Builder
{
$now = now()->toDateTimeString();
return $query
->published()
->where('is_active_campaign', true)
->where(function (Builder $builder) use ($now): void {
$builder->whereRaw('COALESCE(promotion_starts_at, starts_at) IS NULL')
->orWhereRaw('COALESCE(promotion_starts_at, starts_at) <= ?', [$now]);
})
->where(function (Builder $builder) use ($now): void {
$builder->whereRaw('COALESCE(promotion_ends_at, ends_at) IS NULL')
->orWhereRaw('COALESCE(promotion_ends_at, ends_at) >= ?', [$now]);
});
}
public function scopeCampaignUpcoming(Builder $query): Builder
{
return $query
->published()
->where('is_active_campaign', true)
->whereRaw('COALESCE(promotion_starts_at, starts_at) > ?', [now()->toDateTimeString()]);
}
public function scopeHomepageFeatured(Builder $query): Builder
{
return $query->where('is_homepage_featured', true);
}
public function scopeArchive(Builder $query): Builder
{
return $query
@@ -219,6 +329,135 @@ class World extends Model
return true;
}
public function effectivePromotionStartsAt(): ?Carbon
{
return $this->promotion_starts_at ?? $this->starts_at;
}
public function effectivePromotionEndsAt(): ?Carbon
{
return $this->promotion_ends_at ?? $this->ends_at;
}
public function isActiveCampaign(): bool
{
if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) {
return false;
}
$startsAt = $this->effectivePromotionStartsAt();
$endsAt = $this->effectivePromotionEndsAt();
if ($startsAt && $startsAt->isFuture()) {
return false;
}
if ($endsAt && $endsAt->isPast()) {
return false;
}
return true;
}
public function isUpcomingCampaign(): bool
{
if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) {
return false;
}
$startsAt = $this->effectivePromotionStartsAt();
return $startsAt ? $startsAt->isFuture() : false;
}
public function isEndingSoon(?int $days = null): bool
{
if (! $this->isActiveCampaign()) {
return false;
}
$endsAt = $this->effectivePromotionEndsAt();
if (! $endsAt) {
return false;
}
$threshold = now()->addDays($days ?? (int) config('worlds.campaign_ending_soon_days', 5));
return $endsAt->greaterThanOrEqualTo(now()) && $endsAt->lessThanOrEqualTo($threshold);
}
public function isEndedEdition(): bool
{
return (string) $this->status === self::STATUS_ARCHIVED
|| ($this->ends_at && $this->ends_at->isPast());
}
public function hasPublishedRecap(): bool
{
return (string) $this->recap_status === self::RECAP_STATUS_PUBLISHED
&& $this->recap_published_at !== null;
}
public function hasRecapDraftContent(): bool
{
return trim((string) ($this->recap_title ?? '')) !== ''
|| trim((string) ($this->recap_summary ?? '')) !== ''
|| trim((string) ($this->recap_intro ?? '')) !== ''
|| trim((string) ($this->recap_cover_path ?? '')) !== ''
|| (int) ($this->recap_article_id ?? 0) > 0
|| ! empty($this->recap_stats_snapshot_json);
}
public function recapCoverUrl(): ?string
{
$path = trim((string) ($this->recap_cover_path ?: $this->cover_path ?: ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
public function teaserImageUrl(): ?string
{
$path = trim((string) ($this->teaser_image_path ?: $this->cover_path ?: ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
public function teaserTitle(): string
{
$title = trim((string) ($this->teaser_title ?? ''));
return $title !== '' ? $title : (string) $this->title;
}
public function teaserSummary(): ?string
{
$summary = trim((string) ($this->teaser_summary ?? ''));
if ($summary !== '') {
return $summary;
}
$fallback = trim((string) ($this->summary ?? ''));
return $fallback !== '' ? $fallback : null;
}
public function allowsCreatorParticipation(): bool
{
return in_array((string) $this->participation_mode, [
@@ -264,7 +503,47 @@ class World extends Model
public function publicUrl(): string
{
return route('worlds.show', ['world' => $this->slug]);
if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') {
return route('worlds.show', ['world' => $this->slug]);
}
if ($this->isCanonicalEdition()) {
return $this->familyUrl();
}
if ($this->edition_year !== null) {
return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]);
}
return $this->familyUrl();
}
public function familySlug(): string
{
return trim((string) ($this->recurrence_key ?: $this->slug));
}
public function familyUrl(): string
{
return route('worlds.show', ['world' => $this->familySlug()]);
}
public function editionUrl(): ?string
{
if (! $this->is_recurring || trim((string) $this->recurrence_key) === '' || $this->edition_year === null) {
return null;
}
return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]);
}
public function isCanonicalEdition(): bool
{
if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') {
return true;
}
return static::canonicalEditionIdForRecurrence((string) $this->recurrence_key) === (int) $this->id;
}
public function sectionOrder(): array
@@ -287,4 +566,33 @@ class World extends Model
return array_merge($defaults, $custom);
}
private static function canonicalEditionIdForRecurrence(string $recurrenceKey): ?int
{
if (array_key_exists($recurrenceKey, static::$canonicalRecurrenceEditionIds)) {
return static::$canonicalRecurrenceEditionIds[$recurrenceKey];
}
$canonical = static::selectCanonicalEdition(
static::query()
->publiclyVisible()
->where('recurrence_key', $recurrenceKey)
->get()
);
return static::$canonicalRecurrenceEditionIds[$recurrenceKey] = $canonical ? (int) $canonical->id : null;
}
private static function selectCanonicalEdition(EloquentCollection $editions): ?self
{
return $editions
->sortBy([
fn (self $world): int => (string) $world->status === self::STATUS_PUBLISHED ? 0 : 1,
fn (self $world): int => $world->isCurrent() ? 0 : 1,
fn (self $world): int => -1 * (int) ($world->edition_year ?? 0),
fn (self $world): int => -1 * ($world->starts_at?->getTimestamp() ?? $world->published_at?->getTimestamp() ?? 0),
fn (self $world): int => -1 * (int) $world->id,
])
->first();
}
}

View File

@@ -0,0 +1,64 @@
<?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;
class WorldAnalyticsEvent extends Model
{
use HasFactory;
public $timestamps = false;
const CREATED_AT = 'occurred_at';
const UPDATED_AT = null;
protected $fillable = [
'world_id',
'event_type',
'world_slug',
'world_type',
'recurrence_key',
'edition_year',
'section_key',
'cta_key',
'entity_type',
'entity_id',
'entity_title',
'challenge_id',
'source_surface',
'source_detail',
'viewer_type',
'user_id',
'visitor_key',
'meta',
'occurred_at',
];
protected function casts(): array
{
return [
'world_id' => 'integer',
'edition_year' => 'integer',
'entity_id' => 'integer',
'challenge_id' => 'integer',
'user_id' => 'integer',
'meta' => 'array',
'occurred_at' => 'datetime',
];
}
public function world(): BelongsTo
{
return $this->belongsTo(World::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

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;
class WorldEditorialSuggestionState extends Model
{
use HasFactory;
public const STATUS_PINNED = 'pinned';
public const STATUS_DISMISSED = 'dismissed';
public const STATUS_NOT_RELEVANT = 'not_relevant';
protected $fillable = [
'world_id',
'related_type',
'related_id',
'status',
'section_key',
'acted_by_user_id',
];
protected $casts = [
'related_id' => 'integer',
'world_id' => 'integer',
'acted_by_user_id' => 'integer',
];
public function world(): BelongsTo
{
return $this->belongsTo(World::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'acted_by_user_id');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\WorldRewardType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorldRewardGrant extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'world_id',
'artwork_id',
'world_submission_id',
'granted_by_user_id',
'reward_type',
'grant_source',
'note',
'granted_at',
];
protected function casts(): array
{
return [
'user_id' => 'integer',
'world_id' => 'integer',
'artwork_id' => 'integer',
'world_submission_id' => 'integer',
'granted_by_user_id' => 'integer',
'reward_type' => WorldRewardType::class,
'granted_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function world(): BelongsTo
{
return $this->belongsTo(World::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function worldSubmission(): BelongsTo
{
return $this->belongsTo(WorldSubmission::class, 'world_submission_id');
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WorldSubmission extends Model
{
@@ -71,4 +72,9 @@ class WorldSubmission extends Model
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class, 'world_submission_id')->orderByDesc('granted_at')->orderByDesc('id');
}
}