Build world campaigns rewards and recaps
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user