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']); } 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']); try { DB::transaction(function () use ($challenge, $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)); $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' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id, ])->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']) ); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.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); } } } return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.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] ); return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.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']); return array_merge($this->mapPublicChallenge($challenge), [ 'description' => $challenge->description, 'rules_text' => $challenge->rules_text, 'submission_instructions' => $challenge->submission_instructions, 'featured_artwork' => $challenge->featuredArtwork ? [ 'id' => (int) $challenge->featuredArtwork->id, 'title' => $challenge->featuredArtwork->title, 'url' => route('art.show', ['id' => $challenge->featuredArtwork->id, 'slug' => $challenge->featuredArtwork->slug ?: $challenge->featuredArtwork->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(), ]); } public function mapPublicChallenge(GroupChallenge $challenge): array { 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(), '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 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; } }