media->storeUploadedEntityImage($group, $attributes['cover_file'], 'challenges'); } return GroupChallenge::query()->create([ 'group_id' => (int) $group->id, 'title' => trim((string) $attributes['title']), 'slug' => $this->makeUniqueSlug((string) $attributes['title']), 'summary' => $this->nullableString($attributes['summary'] ?? null), 'description' => $this->nullableString($attributes['description'] ?? null), 'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null), 'visibility' => (string) ($attributes['visibility'] ?? GroupChallenge::VISIBILITY_PUBLIC), 'participation_scope' => (string) ($attributes['participation_scope'] ?? GroupChallenge::PARTICIPATION_GROUP_ONLY), 'status' => (string) ($attributes['status'] ?? GroupChallenge::STATUS_DRAFT), 'start_at' => $attributes['start_at'] ?? null, 'end_at' => $attributes['end_at'] ?? null, 'rules_text' => $this->nullableString($attributes['rules_text'] ?? null), 'submission_instructions' => $this->nullableString($attributes['submission_instructions'] ?? null), 'judging_mode' => $this->nullableString($attributes['judging_mode'] ?? null), 'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null), 'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null), 'created_by_user_id' => (int) $actor->id, 'featured_artwork_id' => null, ]); }); } catch (\Throwable $exception) { $this->media->deleteIfManaged($coverPath); throw $exception; } $this->history->record( $group, $actor, 'challenge_created', sprintf('Created challenge "%s".', $challenge->title), 'group_challenge', (int) $challenge->id, null, $challenge->only(['title', 'status', 'visibility', 'participation_scope']) ); $this->activity->record( $group, $actor, 'challenge_created', 'group_challenge', (int) $challenge->id, sprintf('%s launched a new challenge draft: %s', $actor->name ?: $actor->username ?: 'A member', $challenge->title), $challenge->summary, $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal', ); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'outcomes.artwork.user.profile']); } public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge { $coverPath = null; $oldCoverPath = $challenge->cover_path; $before = [ ...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']), 'outcomes_count' => $challenge->outcomes()->count(), ]; try { DB::transaction(function () use ($challenge, $actor, $attributes, &$coverPath): void { if (($attributes['cover_file'] ?? null) instanceof UploadedFile) { $coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges'); } $title = trim((string) ($attributes['title'] ?? $challenge->title)); $featuredArtworkId = array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id; $challenge->fill([ 'title' => $title, 'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug, 'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $challenge->summary, 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $challenge->description, 'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $challenge->cover_path), 'visibility' => (string) ($attributes['visibility'] ?? $challenge->visibility), 'participation_scope' => (string) ($attributes['participation_scope'] ?? $challenge->participation_scope), 'status' => (string) ($attributes['status'] ?? $challenge->status), 'start_at' => $attributes['start_at'] ?? $challenge->start_at, 'end_at' => $attributes['end_at'] ?? $challenge->end_at, 'rules_text' => array_key_exists('rules_text', $attributes) ? $this->nullableString($attributes['rules_text']) : $challenge->rules_text, 'submission_instructions' => array_key_exists('submission_instructions', $attributes) ? $this->nullableString($attributes['submission_instructions']) : $challenge->submission_instructions, 'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode, 'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id, 'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id, 'featured_artwork_id' => $featuredArtworkId, ])->save(); if (array_key_exists('outcomes', $attributes)) { $canonicalWinnerArtworkId = $this->syncOutcomes($challenge, $actor, (array) ($attributes['outcomes'] ?? []), $featuredArtworkId); if ((int) ($challenge->featured_artwork_id ?? 0) !== (int) ($canonicalWinnerArtworkId ?? 0)) { $challenge->forceFill([ 'featured_artwork_id' => $canonicalWinnerArtworkId, ])->save(); } } }); } catch (\Throwable $exception) { $this->media->deleteIfManaged($coverPath); throw $exception; } if ($coverPath !== null && $oldCoverPath !== $challenge->cover_path) { $this->media->deleteIfManaged($oldCoverPath); } $challenge->refresh(); $this->history->record( $challenge->group, $actor, 'challenge_updated', sprintf('Updated challenge "%s".', $challenge->title), 'group_challenge', (int) $challenge->id, $before, [ ...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']), 'outcomes_count' => $challenge->outcomes()->count(), ] ); $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']); } public function publish(GroupChallenge $challenge, User $actor): GroupChallenge { if ($challenge->group->status !== Group::LIFECYCLE_ACTIVE) { throw ValidationException::withMessages([ 'group' => 'Archived or suspended groups cannot publish challenges.', ]); } if (! $challenge->start_at || ! $challenge->end_at || $challenge->end_at->lt($challenge->start_at)) { throw ValidationException::withMessages([ 'timeline' => 'Challenges need a valid start and end date before they can be published.', ]); } $challenge->forceFill([ 'status' => $challenge->start_at->lte(now()) ? GroupChallenge::STATUS_ACTIVE : GroupChallenge::STATUS_PUBLISHED, ])->save(); $this->history->record( $challenge->group, $actor, 'challenge_published', sprintf('Published challenge "%s".', $challenge->title), 'group_challenge', (int) $challenge->id, ['status' => GroupChallenge::STATUS_DRAFT], ['status' => $challenge->status] ); $this->activity->record( $challenge->group, $actor, 'challenge_published', 'group_challenge', (int) $challenge->id, sprintf('%s launched the challenge %s', $challenge->group->name, $challenge->title), $challenge->summary, $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal', ); if ($challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC) { foreach ($challenge->group->follows()->with('user.profile')->get() as $follow) { if ($follow->user) { $this->notifications->notifyGroupChallengePublished($follow->user, $actor, $challenge->group, $challenge); } } } $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']); } public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge { if (! $this->canAttachArtwork($challenge, $artwork, $actor)) { throw ValidationException::withMessages([ 'artwork' => 'This artwork is not eligible for this challenge.', ]); } GroupChallengeArtwork::query()->updateOrCreate( [ 'group_challenge_id' => (int) $challenge->id, 'artwork_id' => (int) $artwork->id, ], [ 'submitted_by_user_id' => (int) $actor->id, 'sort_order' => (int) $challenge->artworkLinks()->count(), ] ); $this->history->record( $challenge->group, $actor, 'challenge_artwork_attached', sprintf('Attached artwork "%s" to challenge "%s".', $artwork->title, $challenge->title), 'group_challenge', (int) $challenge->id, null, ['artwork_id' => (int) $artwork->id] ); $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']); } public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array { return $this->visibleQuery($group, $viewer) ->with(['creator.profile', 'linkedCollection', 'linkedProject']) ->latest('start_at') ->limit($limit) ->get() ->map(fn (GroupChallenge $challenge): array => $this->mapPublicChallenge($challenge)) ->values() ->all(); } public function activeChallenge(Group $group, ?User $viewer = null): ?array { $challenge = $this->visibleQuery($group, $viewer) ->with(['creator.profile', 'linkedCollection', 'linkedProject']) ->whereIn('status', [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED]) ->orderByRaw("CASE status WHEN 'active' THEN 0 ELSE 1 END") ->orderBy('start_at') ->first(); return $challenge ? $this->mapPublicChallenge($challenge) : null; } public function studioListing(Group $group, array $filters = []): array { $bucket = (string) ($filters['bucket'] ?? 'all'); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); $query = GroupChallenge::query() ->with(['creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']) ->where('group_id', $group->id); if ($bucket !== 'all') { $query->where('status', $bucket); } $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); return [ 'items' => collect($paginator->items())->map(fn (GroupChallenge $challenge): array => $this->mapStudioChallenge($challenge))->values()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'filters' => ['bucket' => $bucket], 'bucket_options' => [ ['value' => 'all', 'label' => 'All'], ['value' => GroupChallenge::STATUS_DRAFT, 'label' => 'Drafts'], ['value' => GroupChallenge::STATUS_PUBLISHED, 'label' => 'Published'], ['value' => GroupChallenge::STATUS_ACTIVE, 'label' => 'Active'], ['value' => GroupChallenge::STATUS_ENDED, 'label' => 'Ended'], ['value' => GroupChallenge::STATUS_ARCHIVED, 'label' => 'Archived'], ], ]; } public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array { $challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']); $primaryWinnerArtwork = $this->primaryWinnerArtwork($challenge) ?? $challenge->featuredArtwork; return array_merge($this->mapPublicChallenge($challenge), [ 'description' => $challenge->description, 'rules_text' => $challenge->rules_text, 'submission_instructions' => $challenge->submission_instructions, 'featured_artwork' => $primaryWinnerArtwork ? [ 'id' => (int) $primaryWinnerArtwork->id, 'title' => $primaryWinnerArtwork->title, 'url' => route('art.show', ['id' => $primaryWinnerArtwork->id, 'slug' => $primaryWinnerArtwork->slug ?: $primaryWinnerArtwork->id]), ] : null, 'artworks' => $challenge->artworks->map(fn (Artwork $artwork): array => [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]), ])->values()->all(), 'outcomes' => $challenge->outcomes->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeForEditor($outcome))->values()->all(), 'outcome_sections' => $this->outcomeSectionsPayload($challenge), 'outcome_counts' => $this->outcomeCounts($challenge), ]); } public function mapPublicChallenge(GroupChallenge $challenge): array { $challenge->loadMissing(['group', 'outcomes']); return [ 'id' => (int) $challenge->id, 'title' => (string) $challenge->title, 'slug' => (string) $challenge->slug, 'summary' => $challenge->summary, 'status' => (string) $challenge->status, 'visibility' => (string) $challenge->visibility, 'participation_scope' => (string) $challenge->participation_scope, 'cover_url' => $challenge->coverUrl(), 'start_at' => $challenge->start_at?->toISOString(), 'end_at' => $challenge->end_at?->toISOString(), 'rules_text' => $challenge->rules_text, 'entry_count' => (int) $challenge->artworkLinks()->count(), 'outcome_counts' => $this->outcomeCounts($challenge), 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]), ]; } public function mapStudioChallenge(GroupChallenge $challenge): array { return array_merge($this->mapPublicChallenge($challenge), [ 'description' => $challenge->description, 'urls' => [ 'public' => $challenge->visibility !== GroupChallenge::VISIBILITY_PRIVATE ? route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]) : null, 'edit' => route('studio.groups.challenges.edit', ['group' => $challenge->group, 'challenge' => $challenge]), 'publish' => route('studio.groups.challenges.publish', ['group' => $challenge->group, 'challenge' => $challenge]), 'attach_artwork' => route('studio.groups.challenges.attach-artwork', ['group' => $challenge->group, 'challenge' => $challenge]), ], ]); } private function visibleQuery(Group $group, ?User $viewer = null) { return GroupChallenge::query() ->where('group_id', $group->id) ->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void { $query->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) ->where('status', '!=', GroupChallenge::STATUS_DRAFT); }); } private function canAttachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): bool { if ($challenge->participation_scope === GroupChallenge::PARTICIPATION_PUBLIC) { return (int) $artwork->user_id === (int) $actor->id || (int) ($artwork->uploaded_by_user_id ?? 0) === (int) $actor->id || (int) ($artwork->primary_author_user_id ?? 0) === (int) $actor->id || ((int) $artwork->group_id === (int) $challenge->group_id && $challenge->group->hasActiveMember($actor)); } return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id; } private function syncOutcomes(GroupChallenge $challenge, User $actor, array $rows, ?int $fallbackFeaturedArtworkId = null): ?int { $normalized = collect($rows) ->values() ->map(function (mixed $row, int $index): ?array { if (! is_array($row)) { return null; } $artworkId = (int) ($row['artwork_id'] ?? 0); $outcomeType = trim((string) ($row['outcome_type'] ?? '')); if ($artworkId < 1 || $outcomeType === '') { return null; } return [ 'artwork_id' => $artworkId, 'outcome_type' => $outcomeType, 'position' => isset($row['position']) && (int) $row['position'] > 0 ? (int) $row['position'] : null, 'sort_order' => max(0, (int) ($row['sort_order'] ?? $index)), 'title_override' => $this->nullableString($row['title_override'] ?? null), 'note' => $this->nullableString($row['note'] ?? null), ]; }) ->filter() ->values(); $pairs = $normalized ->map(fn (array $row): string => $row['artwork_id'] . '|' . $row['outcome_type']); if ($pairs->count() !== $pairs->unique()->count()) { throw ValidationException::withMessages([ 'outcomes' => 'Each artwork can only receive a given outcome type once per challenge.', ]); } $artworkIds = $normalized->pluck('artwork_id')->unique()->values(); $validArtworkIds = $artworkIds->isEmpty() ? collect() : $challenge->artworkLinks() ->whereIn('artwork_id', $artworkIds->all()) ->pluck('artwork_id') ->map(fn ($id): int => (int) $id) ->values(); if ($artworkIds->diff($validArtworkIds)->isNotEmpty()) { throw ValidationException::withMessages([ 'outcomes' => 'Challenge outcomes can only reference artworks already attached as challenge entries.', ]); } $artworksById = $artworkIds->isEmpty() ? collect() : Artwork::query() ->whereIn('id', $artworkIds->all()) ->get(['id', 'user_id']) ->keyBy('id'); GroupChallengeOutcome::query() ->where('group_challenge_id', (int) $challenge->id) ->delete(); if ($normalized->isEmpty()) { return $fallbackFeaturedArtworkId; } $challenge->outcomes()->createMany($normalized->map(function (array $row) use ($actor, $artworksById): array { /** @var Artwork|null $artwork */ $artwork = $artworksById->get($row['artwork_id']); return [ 'artwork_id' => $row['artwork_id'], 'user_id' => (int) ($artwork?->user_id ?? 0) > 0 ? (int) $artwork->user_id : null, 'outcome_type' => $row['outcome_type'], 'position' => $row['position'], 'sort_order' => $row['sort_order'], 'title_override' => $row['title_override'], 'note' => $row['note'], 'awarded_by_user_id' => (int) $actor->id, 'awarded_at' => now(), ]; })->all()); $winner = $normalized ->sortBy([ fn (array $row): int => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER ? 0 : 1, fn (array $row): int => (int) $row['sort_order'], fn (array $row): int => (int) ($row['position'] ?? PHP_INT_MAX), ]) ->first(fn (array $row): bool => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER); return $winner['artwork_id'] ?? $fallbackFeaturedArtworkId; } private function primaryWinnerArtwork(GroupChallenge $challenge): ?Artwork { /** @var GroupChallengeOutcome|null $winner */ $winner = $challenge->outcomes ->first(fn (GroupChallengeOutcome $outcome): bool => $outcome->outcome_type === GroupChallengeOutcome::TYPE_WINNER && $outcome->artwork !== null); return $winner?->artwork; } private function outcomeCounts(GroupChallenge $challenge): array { $challenge->loadMissing('outcomes'); return collect(GroupChallengeOutcome::supportedTypes()) ->mapWithKeys(fn (string $type): array => [$type => $challenge->outcomes->where('outcome_type', $type)->count()]) ->all(); } private function outcomeSectionsPayload(GroupChallenge $challenge): array { $challenge->loadMissing(['outcomes.artwork.user.profile']); $sections = []; foreach (GroupChallengeOutcome::supportedTypes() as $type) { $items = $challenge->outcomes ->where('outcome_type', $type) ->values(); if ($items->isEmpty()) { continue; } $sections[$type] = [ 'type' => $type, 'label' => $this->outcomeSectionLabel($type, $items->count()), 'items' => $items->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeItem($outcome))->all(), ]; } return $sections; } private function outcomeSectionLabel(string $type, int $count): string { return match ($type) { GroupChallengeOutcome::TYPE_WINNER => $count === 1 ? 'Winner' : 'Winners', GroupChallengeOutcome::TYPE_FINALIST => 'Finalists', GroupChallengeOutcome::TYPE_RUNNER_UP => $count === 1 ? 'Runner-up' : 'Runner-up', GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'Honorable Mentions', GroupChallengeOutcome::TYPE_FEATURED => 'Featured Entries', default => GroupChallengeOutcome::labelForType($type), }; } private function mapOutcomeForEditor(GroupChallengeOutcome $outcome): array { return [ 'id' => (int) $outcome->id, 'artwork_id' => (int) $outcome->artwork_id, 'outcome_type' => (string) $outcome->outcome_type, 'position' => $outcome->position, 'sort_order' => (int) $outcome->sort_order, 'title_override' => (string) ($outcome->title_override ?? ''), 'note' => (string) ($outcome->note ?? ''), 'artwork_title' => (string) ($outcome->artwork?->title ?? ''), ]; } private function mapOutcomeItem(GroupChallengeOutcome $outcome): array { $artwork = $outcome->artwork; $creator = $artwork?->user; $statusLabel = $outcome->title_override ?: GroupChallengeOutcome::labelForType((string) $outcome->outcome_type); return [ 'id' => (int) $outcome->id, 'artwork_id' => (int) ($artwork?->id ?? 0), 'outcome_type' => (string) $outcome->outcome_type, 'position' => $outcome->position, 'title' => (string) ($artwork?->title ?: 'Untitled artwork'), 'subtitle' => (string) ($creator?->name ?: $creator?->username ?: ''), 'description' => (string) ($outcome->note ?: Str::limit(trim(strip_tags((string) ($artwork?->description ?? ''))), 140)), 'url' => $artwork ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]) : null, 'image' => $artwork ? (ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md')) : null, 'status' => (string) $outcome->outcome_type, 'status_label' => $statusLabel, 'context_label' => 'Challenge outcome', 'meta' => array_values(array_filter([ $outcome->position ? 'Place ' . $outcome->position : null, $outcome->awarded_at?->format('M j, Y'), ])), ]; } private function makeUniqueSlug(string $source, ?int $ignoreId = null): string { $base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge'; $slug = $base; $suffix = 2; while (GroupChallenge::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) { $slug = Str::limit($base, 180, '') . '-' . $suffix; $suffix++; } return $slug; } private function normalizeCollectionId(Group $group, mixed $collectionId): ?int { $id = (int) $collectionId; return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null; } private function normalizeProjectId(Group $group, mixed $projectId): ?int { $id = (int) $projectId; return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null; } private function normalizeArtworkId(Group $group, mixed $artworkId): ?int { $id = (int) $artworkId; return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null; } private function nullableString(mixed $value): ?string { $trimmed = trim((string) $value); return $trimmed !== '' ? $trimmed : null; } }