Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -8,9 +8,12 @@ use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\GroupChallengeArtwork;
|
||||
use App\Models\GroupChallengeOutcome;
|
||||
use App\Models\User;
|
||||
use App\Support\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\Worlds\WorldRewardService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -22,6 +25,7 @@ class GroupChallengeService
|
||||
private readonly GroupActivityService $activity,
|
||||
private readonly GroupMediaService $media,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly WorldRewardService $worldRewards,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,22 +87,29 @@ class GroupChallengeService
|
||||
$challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']);
|
||||
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']);
|
||||
$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, $attributes, &$coverPath): void {
|
||||
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,
|
||||
@@ -115,8 +126,18 @@ class GroupChallengeService
|
||||
'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,
|
||||
'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);
|
||||
@@ -137,10 +158,15 @@ class GroupChallengeService
|
||||
'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'])
|
||||
[
|
||||
...$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(),
|
||||
]
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$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
|
||||
@@ -191,7 +217,9 @@ class GroupChallengeService
|
||||
}
|
||||
}
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$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
|
||||
@@ -224,7 +252,9 @@ class GroupChallengeService
|
||||
['artwork_id' => (int) $artwork->id]
|
||||
);
|
||||
|
||||
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$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
|
||||
@@ -289,16 +319,18 @@ class GroupChallengeService
|
||||
|
||||
public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array
|
||||
{
|
||||
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
|
||||
$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' => $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]),
|
||||
'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,
|
||||
@@ -306,11 +338,16 @@ class GroupChallengeService
|
||||
'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,
|
||||
@@ -324,6 +361,7 @@ class GroupChallengeService
|
||||
'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]),
|
||||
];
|
||||
}
|
||||
@@ -363,6 +401,196 @@ class GroupChallengeService
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user