407 lines
19 KiB
PHP
407 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Group;
|
|
use App\Models\GroupChallenge;
|
|
use App\Models\GroupChallengeArtwork;
|
|
use App\Models\User;
|
|
use App\Support\ThumbnailPresenter;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class GroupChallengeService
|
|
{
|
|
public function __construct(
|
|
private readonly GroupHistoryService $history,
|
|
private readonly GroupActivityService $activity,
|
|
private readonly GroupMediaService $media,
|
|
private readonly NotificationService $notifications,
|
|
) {
|
|
}
|
|
|
|
public function create(Group $group, User $actor, array $attributes): GroupChallenge
|
|
{
|
|
$coverPath = null;
|
|
|
|
try {
|
|
$challenge = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupChallenge {
|
|
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
|
|
$coverPath = $this->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;
|
|
}
|
|
} |