624 lines
19 KiB
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();
|
|
}
|
|
} |