Files
SkinbaseNova/app/Models/World.php

624 lines
19 KiB
PHP

<?php
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';
public const TYPE_TRIBUTE = 'tribute';
public const PARTICIPATION_MODE_MANUAL_APPROVAL = 'manual_approval';
public const PARTICIPATION_MODE_AUTO_ADD = 'auto_add';
public const PARTICIPATION_MODE_CLOSED = 'closed';
protected $fillable = [
'title',
'slug',
'tagline',
'summary',
'teaser_title',
'teaser_summary',
'description',
'cover_path',
'teaser_image_path',
'theme_key',
'accent_color',
'accent_color_secondary',
'background_motif',
'icon_name',
'status',
'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',
'community_section_enabled',
'allow_readd_after_removal',
'is_recurring',
'recurrence_key',
'recurrence_rule',
'edition_year',
'cta_label',
'cta_url',
'badge_label',
'campaign_label',
'badge_description',
'submission_guidelines',
'badge_url',
'seo_title',
'seo_description',
'og_image_path',
'related_tags_json',
'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');
}
public function parentWorld(): BelongsTo
{
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');
}
public function worldRelations(): HasMany
{
return $this->hasMany(WorldRelation::class)->orderBy('section_key')->orderBy('sort_order')->orderBy('id');
}
public function worldSubmissions(): HasMany
{
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
->where('status', self::STATUS_PUBLISHED)
->where(function (Builder $builder): void {
$builder->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
public function scopePubliclyVisible(Builder $query): Builder
{
return $query
->whereIn('status', [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED])
->where(function (Builder $builder): void {
$builder->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
public function scopeCurrent(Builder $query): Builder
{
return $query
->published()
->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 scopeUpcoming(Builder $query): Builder
{
return $query
->published()
->whereNotNull('starts_at')
->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
->publiclyVisible()
->where(function (Builder $builder): void {
$builder->where('status', self::STATUS_ARCHIVED)
->orWhere(function (Builder $expired): void {
$expired->whereNotNull('ends_at')
->where('ends_at', '<', now());
});
});
}
public function isPubliclyVisible(): bool
{
if (! in_array($this->status, [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED], true)) {
return false;
}
if ($this->published_at && $this->published_at->isFuture()) {
return false;
}
return true;
}
public function isCurrent(): bool
{
if (! $this->isPubliclyVisible()) {
return false;
}
if ($this->starts_at && $this->starts_at->isFuture()) {
return false;
}
if ($this->ends_at && $this->ends_at->isPast()) {
return false;
}
return true;
}
public function isAcceptingSubmissions(): bool
{
if (! $this->isPubliclyVisible() || ! $this->accepts_submissions || ! $this->allowsCreatorParticipation()) {
return false;
}
if ($this->submission_starts_at && $this->submission_starts_at->isFuture()) {
return false;
}
if ($this->submission_ends_at && $this->submission_ends_at->isPast()) {
return false;
}
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, [
self::PARTICIPATION_MODE_MANUAL_APPROVAL,
self::PARTICIPATION_MODE_AUTO_ADD,
], true);
}
public function submissionStartsAsLive(): bool
{
return (string) $this->participation_mode === self::PARTICIPATION_MODE_AUTO_ADD;
}
public function coverUrl(): ?string
{
$path = trim((string) $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 ogImageUrl(): ?string
{
$path = trim((string) ($this->og_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 publicUrl(): string
{
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 static function primeCanonicalEditionIds(iterable $recurrenceKeys): void
{
$keys = collect($recurrenceKeys)
->map(static fn ($key): string => trim((string) $key))
->filter()
->unique()
->reject(static fn (string $key): bool => array_key_exists($key, static::$canonicalRecurrenceEditionIds))
->values();
if ($keys->isEmpty()) {
return;
}
$editionsByRecurrence = static::query()
->publiclyVisible()
->whereIn('recurrence_key', $keys->all())
->get()
->groupBy('recurrence_key');
foreach ($keys as $key) {
$canonical = static::selectCanonicalEdition(new EloquentCollection($editionsByRecurrence->get($key, collect())->all()));
static::$canonicalRecurrenceEditionIds[$key] = $canonical ? (int) $canonical->id : null;
}
}
public function sectionOrder(): array
{
$defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string'));
$custom = array_values(array_filter($this->section_order_json ?? [], 'is_string'));
return array_values(array_unique(array_merge($custom, $defaults)));
}
public function sectionVisibility(): array
{
$defaults = collect(array_keys((array) config('worlds.sections', [])))
->mapWithKeys(fn (string $key): array => [$key => true])
->all();
$custom = collect((array) $this->section_visibility_json)
->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value])
->all();
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();
}
}