Commit workspace changes
This commit is contained in:
817
app/Services/GroupReleaseService.php
Normal file
817
app/Services/GroupReleaseService.php
Normal file
@@ -0,0 +1,817 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\GroupReleaseArtwork;
|
||||
use App\Models\GroupReleaseContributor;
|
||||
use App\Models\GroupReleaseMilestone;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GroupReleaseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly GroupActivityService $activity,
|
||||
private readonly GroupMediaService $media,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly GroupReputationService $reputation,
|
||||
private readonly GroupDiscoveryService $discovery,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Group $group, User $actor, array $attributes): GroupRelease
|
||||
{
|
||||
$coverPath = null;
|
||||
|
||||
try {
|
||||
$release = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupRelease {
|
||||
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
|
||||
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'releases');
|
||||
}
|
||||
|
||||
return GroupRelease::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),
|
||||
'status' => (string) ($attributes['status'] ?? GroupRelease::STATUS_PLANNED),
|
||||
'current_stage' => (string) ($attributes['current_stage'] ?? GroupRelease::STAGE_CONCEPT),
|
||||
'visibility' => (string) ($attributes['visibility'] ?? GroupRelease::VISIBILITY_PUBLIC),
|
||||
'planned_release_at' => $attributes['planned_release_at'] ?? null,
|
||||
'released_at' => null,
|
||||
'lead_user_id' => $this->normalizeLeadUserId($group, $attributes['lead_user_id'] ?? null),
|
||||
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
|
||||
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
|
||||
'featured_artwork_id' => $this->normalizeArtworkId($group, $attributes['featured_artwork_id'] ?? null),
|
||||
'release_notes' => $this->nullableString($attributes['release_notes'] ?? null),
|
||||
'created_by_user_id' => (int) $actor->id,
|
||||
'published_at' => null,
|
||||
'is_featured' => (bool) ($attributes['is_featured'] ?? false),
|
||||
]);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
$this->media->deleteIfManaged($coverPath);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->history->record(
|
||||
$group,
|
||||
$actor,
|
||||
'release_created',
|
||||
sprintf('Created release "%s".', $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
null,
|
||||
$release->only(['title', 'status', 'current_stage', 'visibility'])
|
||||
);
|
||||
|
||||
$this->activity->record(
|
||||
$group,
|
||||
$actor,
|
||||
'release_created',
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
sprintf('%s opened a new release pipeline: %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
|
||||
$release->summary,
|
||||
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
$this->notifyReleaseScheduledIfNeeded($release, $actor, null, null);
|
||||
|
||||
$this->reputation->refreshGroup($group);
|
||||
$this->discovery->refresh($group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function update(GroupRelease $release, User $actor, array $attributes): GroupRelease
|
||||
{
|
||||
$coverPath = null;
|
||||
$oldCoverPath = $release->cover_path;
|
||||
$before = $release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($release, $attributes, &$coverPath): void {
|
||||
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
|
||||
$coverPath = $this->media->storeUploadedEntityImage($release->group, $attributes['cover_file'], 'releases');
|
||||
}
|
||||
|
||||
$title = trim((string) ($attributes['title'] ?? $release->title));
|
||||
|
||||
$release->fill([
|
||||
'title' => $title,
|
||||
'slug' => $title !== $release->title ? $this->makeUniqueSlug($title, (int) $release->id) : $release->slug,
|
||||
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $release->summary,
|
||||
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $release->description,
|
||||
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $release->cover_path),
|
||||
'status' => (string) ($attributes['status'] ?? $release->status),
|
||||
'current_stage' => (string) ($attributes['current_stage'] ?? $release->current_stage),
|
||||
'visibility' => (string) ($attributes['visibility'] ?? $release->visibility),
|
||||
'planned_release_at' => $attributes['planned_release_at'] ?? $release->planned_release_at,
|
||||
'lead_user_id' => array_key_exists('lead_user_id', $attributes) ? $this->normalizeLeadUserId($release->group, $attributes['lead_user_id']) : $release->lead_user_id,
|
||||
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($release->group, $attributes['linked_project_id']) : $release->linked_project_id,
|
||||
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($release->group, $attributes['linked_collection_id']) : $release->linked_collection_id,
|
||||
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($release->group, $attributes['featured_artwork_id']) : $release->featured_artwork_id,
|
||||
'release_notes' => array_key_exists('release_notes', $attributes) ? $this->nullableString($attributes['release_notes']) : $release->release_notes,
|
||||
'is_featured' => (bool) ($attributes['is_featured'] ?? $release->is_featured),
|
||||
])->save();
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
$this->media->deleteIfManaged($coverPath);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($coverPath !== null && $oldCoverPath !== $release->cover_path) {
|
||||
$this->media->deleteIfManaged($oldCoverPath);
|
||||
}
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_updated',
|
||||
sprintf('Updated release "%s".', $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
$before,
|
||||
$release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured'])
|
||||
);
|
||||
|
||||
$this->activity->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_updated',
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
sprintf('%s updated release %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
|
||||
$release->summary,
|
||||
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
if (! (bool) ($before['is_featured'] ?? false) && $release->is_featured && $release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
|
||||
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
|
||||
if ($follow->user) {
|
||||
$this->notifications->notifyFeaturedReleasePromoted($follow->user, $actor, $release->group, $release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->notifyReleaseScheduledIfNeeded(
|
||||
$release,
|
||||
$actor,
|
||||
(string) ($before['status'] ?? null),
|
||||
$before['planned_release_at'] ? (string) $before['planned_release_at'] : null,
|
||||
);
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
$this->discovery->refresh($release->group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function updateStage(GroupRelease $release, User $actor, string $stage): GroupRelease
|
||||
{
|
||||
$before = $release->only(['current_stage', 'status']);
|
||||
$status = $release->status;
|
||||
|
||||
if ($stage === GroupRelease::STAGE_RELEASED) {
|
||||
$status = GroupRelease::STATUS_RELEASED;
|
||||
} elseif ($stage === GroupRelease::STAGE_APPROVAL && $release->status === GroupRelease::STATUS_PLANNED) {
|
||||
$status = GroupRelease::STATUS_INTERNAL_REVIEW;
|
||||
} elseif ($release->status === GroupRelease::STATUS_PLANNED) {
|
||||
$status = GroupRelease::STATUS_IN_PROGRESS;
|
||||
}
|
||||
|
||||
$release->forceFill([
|
||||
'current_stage' => $stage,
|
||||
'status' => $status,
|
||||
])->save();
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_stage_updated',
|
||||
sprintf('Moved release "%s" to %s.', $release->title, $stage),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
$before,
|
||||
['current_stage' => $release->current_stage, 'status' => $release->status]
|
||||
);
|
||||
|
||||
$this->activity->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_stage_updated',
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
sprintf('%s moved release %s to %s', $actor->name ?: $actor->username ?: 'A member', $release->title, $stage),
|
||||
$release->summary,
|
||||
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
|
||||
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
|
||||
if ($follow->user) {
|
||||
$this->notifications->notifyGroupReleaseStageChanged($follow->user, $actor, $release->group, $release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
$this->discovery->refresh($release->group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function publish(GroupRelease $release, User $actor): GroupRelease
|
||||
{
|
||||
$this->guardPublishable($release);
|
||||
|
||||
$before = $release->only(['status', 'current_stage', 'released_at', 'published_at']);
|
||||
|
||||
$release->forceFill([
|
||||
'status' => GroupRelease::STATUS_RELEASED,
|
||||
'current_stage' => GroupRelease::STAGE_RELEASED,
|
||||
'released_at' => now(),
|
||||
'published_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_published',
|
||||
sprintf('Published release "%s".', $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
$before,
|
||||
[
|
||||
'status' => $release->status,
|
||||
'current_stage' => $release->current_stage,
|
||||
'released_at' => $release->released_at?->toISOString(),
|
||||
'published_at' => $release->published_at?->toISOString(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->activity->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_published',
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
sprintf('%s released %s', $release->group->name, $release->title),
|
||||
$release->summary,
|
||||
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
|
||||
);
|
||||
|
||||
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
|
||||
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
|
||||
if ($follow->user) {
|
||||
$this->notifications->notifyGroupReleasePublished($follow->user, $actor, $release->group, $release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
$this->discovery->refresh($release->group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function attachArtwork(GroupRelease $release, Artwork $artwork, User $actor): GroupRelease
|
||||
{
|
||||
if ((int) $artwork->group_id !== (int) $release->group_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'artwork' => 'Only artworks published under this group can be attached to a group release.',
|
||||
]);
|
||||
}
|
||||
|
||||
GroupReleaseArtwork::query()->updateOrCreate(
|
||||
[
|
||||
'group_release_id' => (int) $release->id,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
],
|
||||
[
|
||||
'sort_order' => (int) $release->artworkLinks()->count(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_artwork_attached',
|
||||
sprintf('Attached artwork "%s" to release "%s".', $artwork->title, $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
null,
|
||||
['artwork_id' => (int) $artwork->id]
|
||||
);
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function attachContributor(GroupRelease $release, User $contributor, User $actor, ?string $roleLabel = null): GroupRelease
|
||||
{
|
||||
if (! $release->group->hasActiveMember($contributor) && ! $release->group->isOwnedBy($contributor)) {
|
||||
throw ValidationException::withMessages([
|
||||
'user' => 'Only active group members can be attached as release contributors.',
|
||||
]);
|
||||
}
|
||||
|
||||
GroupReleaseContributor::query()->updateOrCreate(
|
||||
[
|
||||
'group_release_id' => (int) $release->id,
|
||||
'user_id' => (int) $contributor->id,
|
||||
],
|
||||
[
|
||||
'role_label' => $this->nullableString($roleLabel),
|
||||
'sort_order' => (int) $release->contributorLinks()->count(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_contributor_attached',
|
||||
sprintf('Attached %s as a contributor to release "%s".', $contributor->name ?: $contributor->username ?: 'a member', $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
null,
|
||||
['user_id' => (int) $contributor->id, 'role_label' => $this->nullableString($roleLabel)]
|
||||
);
|
||||
|
||||
$this->notifications->notifyGroupReleaseContributorAdded($contributor, $actor, $release->group, $release, $this->nullableString($roleLabel));
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
|
||||
return $release->fresh($this->detailRelations());
|
||||
}
|
||||
|
||||
public function createMilestone(GroupRelease $release, User $actor, array $attributes): GroupReleaseMilestone
|
||||
{
|
||||
$milestone = $release->milestones()->create([
|
||||
'title' => trim((string) $attributes['title']),
|
||||
'summary' => $this->nullableString($attributes['summary'] ?? null),
|
||||
'status' => (string) ($attributes['status'] ?? GroupReleaseMilestone::STATUS_PENDING),
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'owner_user_id' => $this->normalizeLeadUserId($release->group, $attributes['owner_user_id'] ?? null),
|
||||
'sort_order' => (int) $release->milestones()->count(),
|
||||
'notes' => $this->nullableString($attributes['notes'] ?? null),
|
||||
]);
|
||||
|
||||
$this->history->record(
|
||||
$release->group,
|
||||
$actor,
|
||||
'release_milestone_created',
|
||||
sprintf('Created milestone "%s" for release "%s".', $milestone->title, $release->title),
|
||||
'group_release',
|
||||
(int) $release->id,
|
||||
null,
|
||||
['milestone_id' => (int) $milestone->id, 'status' => $milestone->status]
|
||||
);
|
||||
|
||||
if ($milestone->owner) {
|
||||
$this->notifications->notifyGroupMilestoneAssigned(
|
||||
$milestone->owner,
|
||||
$actor,
|
||||
$release->group,
|
||||
'release',
|
||||
$release->title,
|
||||
$milestone->title,
|
||||
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
|
||||
);
|
||||
|
||||
$this->notifyMilestoneDueSoonIfNeeded(
|
||||
$milestone->owner,
|
||||
$actor,
|
||||
$release->group,
|
||||
'release',
|
||||
$release->title,
|
||||
$milestone->title,
|
||||
$milestone->due_date?->toDateString(),
|
||||
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
|
||||
);
|
||||
}
|
||||
|
||||
$this->reputation->refreshGroup($release->group);
|
||||
|
||||
return $milestone->fresh('owner.profile');
|
||||
}
|
||||
|
||||
public function updateMilestone(GroupReleaseMilestone $milestone, User $actor, array $attributes): GroupReleaseMilestone
|
||||
{
|
||||
$before = $milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes']);
|
||||
$previousOwnerId = (int) ($milestone->owner_user_id ?? 0);
|
||||
|
||||
$milestone->fill([
|
||||
'title' => trim((string) ($attributes['title'] ?? $milestone->title)),
|
||||
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $milestone->summary,
|
||||
'status' => (string) ($attributes['status'] ?? $milestone->status),
|
||||
'due_date' => $attributes['due_date'] ?? $milestone->due_date,
|
||||
'owner_user_id' => array_key_exists('owner_user_id', $attributes) ? $this->normalizeLeadUserId($milestone->release->group, $attributes['owner_user_id']) : $milestone->owner_user_id,
|
||||
'notes' => array_key_exists('notes', $attributes) ? $this->nullableString($attributes['notes']) : $milestone->notes,
|
||||
])->save();
|
||||
|
||||
$this->history->record(
|
||||
$milestone->release->group,
|
||||
$actor,
|
||||
'release_milestone_updated',
|
||||
sprintf('Updated milestone "%s" for release "%s".', $milestone->title, $milestone->release->title),
|
||||
'group_release',
|
||||
(int) $milestone->release_id,
|
||||
$before,
|
||||
$milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes'])
|
||||
);
|
||||
|
||||
if ((int) ($milestone->owner_user_id ?? 0) > 0 && (int) $milestone->owner_user_id !== $previousOwnerId && $milestone->owner) {
|
||||
$this->notifications->notifyGroupMilestoneAssigned(
|
||||
$milestone->owner,
|
||||
$actor,
|
||||
$milestone->release->group,
|
||||
'release',
|
||||
$milestone->release->title,
|
||||
$milestone->title,
|
||||
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
|
||||
);
|
||||
}
|
||||
|
||||
if ($milestone->owner && $this->shouldNotifyDueSoon($before['due_date'] ?? null, $milestone->due_date, $before['status'] ?? null, $milestone->status, $previousOwnerId, (int) ($milestone->owner_user_id ?? 0))) {
|
||||
$this->notifyMilestoneDueSoonIfNeeded(
|
||||
$milestone->owner,
|
||||
$actor,
|
||||
$milestone->release->group,
|
||||
'release',
|
||||
$milestone->release->title,
|
||||
$milestone->title,
|
||||
$milestone->due_date?->toDateString(),
|
||||
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
|
||||
);
|
||||
}
|
||||
|
||||
$this->reputation->refreshGroup($milestone->release->group);
|
||||
|
||||
return $milestone->fresh(['owner.profile', 'release']);
|
||||
}
|
||||
|
||||
public function featuredRelease(Group $group, ?User $viewer = null): ?array
|
||||
{
|
||||
$release = $this->visibleQuery($group, $viewer)
|
||||
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork', 'contributorLinks.user.profile'])
|
||||
->where(function ($query): void {
|
||||
$query->where('is_featured', true)
|
||||
->orWhere('status', GroupRelease::STATUS_RELEASED);
|
||||
})
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('released_at')
|
||||
->latest('updated_at')
|
||||
->first();
|
||||
|
||||
return $release ? $this->mapPublicRelease($release, $viewer) : null;
|
||||
}
|
||||
|
||||
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
|
||||
{
|
||||
return $this->visibleQuery($group, $viewer)
|
||||
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
|
||||
->orderByDesc('released_at')
|
||||
->latest('updated_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (GroupRelease $release): array => $this->mapPublicRelease($release, $viewer))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
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 = GroupRelease::query()
|
||||
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
|
||||
->where('group_id', $group->id);
|
||||
|
||||
if ($bucket !== 'all') {
|
||||
$query->where('status', $bucket);
|
||||
}
|
||||
|
||||
$paginator = $query->orderByDesc('released_at')->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'items' => collect($paginator->items())->map(fn (GroupRelease $release): array => $this->mapStudioRelease($release))->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
'filters' => ['bucket' => $bucket],
|
||||
'bucket_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
], collect((array) config('groups.releases.statuses', []))
|
||||
->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', Str::headline($value))])
|
||||
->values()
|
||||
->all()),
|
||||
];
|
||||
}
|
||||
|
||||
public function detailPayload(GroupRelease $release, ?User $viewer = null): array
|
||||
{
|
||||
$release->loadMissing($this->detailRelations());
|
||||
|
||||
$payload = $this->mapPublicRelease($release, $viewer);
|
||||
$payload['description'] = $release->description;
|
||||
$payload['release_notes'] = $release->release_notes;
|
||||
$payload['artworks'] = $release->artworks->take(18)->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]),
|
||||
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username,
|
||||
])->values()->all();
|
||||
$payload['contributors'] = $release->contributorLinks->map(fn (GroupReleaseContributor $contributor): array => [
|
||||
'id' => (int) $contributor->user_id,
|
||||
'name' => $contributor->user?->name,
|
||||
'username' => $contributor->user?->username,
|
||||
'avatar_url' => $contributor->user ? AvatarUrl::forUser((int) $contributor->user->id, $contributor->user->profile?->avatar_hash, 72) : null,
|
||||
'role_label' => $contributor->role_label,
|
||||
])->values()->all();
|
||||
$payload['milestones'] = $release->milestones->map(fn (GroupReleaseMilestone $milestone): array => $this->mapMilestone($milestone))->values()->all();
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function memberOptions(Group $group): array
|
||||
{
|
||||
return $group->members()
|
||||
->with('user.profile')
|
||||
->where('status', Group::STATUS_ACTIVE)
|
||||
->get()
|
||||
->map(fn ($member): array => [
|
||||
'id' => (int) $member->user_id,
|
||||
'name' => $member->user?->name,
|
||||
'username' => $member->user?->username,
|
||||
])
|
||||
->prepend([
|
||||
'id' => (int) $group->owner_user_id,
|
||||
'name' => $group->owner?->name,
|
||||
'username' => $group->owner?->username,
|
||||
])
|
||||
->unique('id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function mapPublicRelease(GroupRelease $release, ?User $viewer = null): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $release->id,
|
||||
'title' => (string) $release->title,
|
||||
'slug' => (string) $release->slug,
|
||||
'summary' => $release->summary,
|
||||
'status' => (string) $release->status,
|
||||
'current_stage' => (string) $release->current_stage,
|
||||
'visibility' => (string) $release->visibility,
|
||||
'cover_url' => $release->coverUrl(),
|
||||
'planned_release_at' => $release->planned_release_at?->toISOString(),
|
||||
'released_at' => $release->released_at?->toISOString(),
|
||||
'published_at' => $release->published_at?->toISOString(),
|
||||
'is_featured' => (bool) $release->is_featured,
|
||||
'lead' => $release->lead ? [
|
||||
'id' => (int) $release->lead->id,
|
||||
'name' => $release->lead->name,
|
||||
'username' => $release->lead->username,
|
||||
] : null,
|
||||
'linked_project' => $release->linkedProject ? [
|
||||
'id' => (int) $release->linkedProject->id,
|
||||
'title' => $release->linkedProject->title,
|
||||
'url' => route('groups.projects.show', ['group' => $release->group, 'project' => $release->linkedProject]),
|
||||
] : null,
|
||||
'linked_collection' => $release->linkedCollection ? [
|
||||
'id' => (int) $release->linkedCollection->id,
|
||||
'title' => $release->linkedCollection->title,
|
||||
'url' => route('profile.collections.show', ['username' => strtolower((string) $release->linkedCollection->user?->username), 'slug' => $release->linkedCollection->slug]),
|
||||
] : null,
|
||||
'featured_artwork' => $release->featuredArtwork ? [
|
||||
'id' => (int) $release->featuredArtwork->id,
|
||||
'title' => $release->featuredArtwork->title,
|
||||
'thumb' => ThumbnailPresenter::present($release->featuredArtwork, 'md')['url'] ?? $release->featuredArtwork->thumbUrl('md'),
|
||||
] : null,
|
||||
'counts' => [
|
||||
'artworks' => (int) $release->artworks()->count(),
|
||||
'contributors' => (int) $release->contributorLinks()->count(),
|
||||
'milestones' => (int) $release->milestones()->count(),
|
||||
],
|
||||
'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]),
|
||||
];
|
||||
}
|
||||
|
||||
public function mapStudioRelease(GroupRelease $release): array
|
||||
{
|
||||
return array_merge($this->mapPublicRelease($release), [
|
||||
'description' => $release->description,
|
||||
'release_notes' => $release->release_notes,
|
||||
'urls' => [
|
||||
'public' => $release->visibility !== GroupRelease::VISIBILITY_PRIVATE ? route('groups.releases.show', ['group' => $release->group, 'release' => $release]) : null,
|
||||
'edit' => route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release]),
|
||||
'stage' => route('studio.groups.releases.stage', ['group' => $release->group, 'release' => $release]),
|
||||
'publish' => route('studio.groups.releases.publish', ['group' => $release->group, 'release' => $release]),
|
||||
'attach_artwork' => route('studio.groups.releases.attach-artwork', ['group' => $release->group, 'release' => $release]),
|
||||
'attach_contributor' => route('studio.groups.releases.attach-contributor', ['group' => $release->group, 'release' => $release]),
|
||||
'store_milestone' => route('studio.groups.releases.milestones.store', ['group' => $release->group, 'release' => $release]),
|
||||
'update_milestone_pattern' => route('studio.groups.releases.milestones.update', ['group' => $release->group, 'release' => $release, 'milestone' => '__MILESTONE__']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapMilestone(GroupReleaseMilestone $milestone): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $milestone->id,
|
||||
'title' => (string) $milestone->title,
|
||||
'summary' => $milestone->summary,
|
||||
'status' => (string) $milestone->status,
|
||||
'due_date' => $milestone->due_date?->toDateString(),
|
||||
'notes' => $milestone->notes,
|
||||
'owner' => $milestone->owner ? [
|
||||
'id' => (int) $milestone->owner->id,
|
||||
'name' => $milestone->owner->name,
|
||||
'username' => $milestone->owner->username,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function visibleQuery(Group $group, ?User $viewer = null)
|
||||
{
|
||||
return GroupRelease::query()
|
||||
->where('group_id', $group->id)
|
||||
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
|
||||
$query->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
|
||||
->whereNotIn('status', [GroupRelease::STATUS_ARCHIVED, GroupRelease::STATUS_CANCELLED]);
|
||||
});
|
||||
}
|
||||
|
||||
private function guardPublishable(GroupRelease $release): void
|
||||
{
|
||||
if ($release->group->status !== Group::LIFECYCLE_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'group' => 'Archived or suspended groups cannot publish new releases.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($release->visibility === GroupRelease::VISIBILITY_PRIVATE) {
|
||||
throw ValidationException::withMessages([
|
||||
'visibility' => 'Private releases cannot be published publicly.',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($release->artworks as $artwork) {
|
||||
if ((int) $artwork->group_id !== (int) $release->group_id || ! (bool) $artwork->is_public || ! (bool) $artwork->is_approved) {
|
||||
throw ValidationException::withMessages([
|
||||
'artworks' => 'All release artworks must belong to the group and be approved for public visibility.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($release->linkedProject && (int) $release->linkedProject->group_id !== (int) $release->group_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'linked_project_id' => 'Linked project must belong to the same group.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($release->linkedCollection && (int) ($release->linkedCollection->group_id ?? 0) !== (int) $release->group_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'linked_collection_id' => 'Linked collection must belong to the same group.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function detailRelations(): array
|
||||
{
|
||||
return [
|
||||
'group',
|
||||
'creator.profile',
|
||||
'lead.profile',
|
||||
'linkedProject',
|
||||
'linkedCollection.user.profile',
|
||||
'featuredArtwork.primaryAuthor.profile',
|
||||
'artworks.primaryAuthor.profile',
|
||||
'contributorLinks.user.profile',
|
||||
'milestones.owner.profile',
|
||||
];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $source, ?int $ignoreReleaseId = null): string
|
||||
{
|
||||
$base = Str::slug(Str::limit($source, 150, '')) ?: 'release';
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
|
||||
while (GroupRelease::query()->where('slug', $slug)->when($ignoreReleaseId !== null, fn ($query) => $query->where('id', '!=', $ignoreReleaseId))->exists()) {
|
||||
$slug = Str::limit($base, 180, '') . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function normalizeLeadUserId(Group $group, mixed $leadUserId): ?int
|
||||
{
|
||||
$id = (int) $leadUserId;
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $group->owner_user_id === $id) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
return $group->members()->where('user_id', $id)->where('status', Group::STATUS_ACTIVE)->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 normalizeCollectionId(Group $group, mixed $collectionId): ?int
|
||||
{
|
||||
$id = (int) $collectionId;
|
||||
|
||||
return $id > 0 && $group->collections()->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;
|
||||
}
|
||||
|
||||
private function notifyReleaseScheduledIfNeeded(GroupRelease $release, User $actor, ?string $previousStatus, ?string $previousPlannedReleaseAt): void
|
||||
{
|
||||
if ($release->visibility !== GroupRelease::VISIBILITY_PUBLIC || $release->status !== GroupRelease::STATUS_SCHEDULED || $release->planned_release_at === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plannedReleaseAt = $release->planned_release_at->toISOString();
|
||||
|
||||
if ($previousStatus === GroupRelease::STATUS_SCHEDULED && $previousPlannedReleaseAt === $plannedReleaseAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
|
||||
if ($follow->user) {
|
||||
$this->notifications->notifyGroupReleaseScheduled($follow->user, $actor, $release->group, $release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyMilestoneDueSoonIfNeeded(User $recipient, User $actor, Group $group, string $contextType, string $contextTitle, string $milestoneTitle, ?string $dueDate, string $url): void
|
||||
{
|
||||
if ($dueDate === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = now()->parse($dueDate);
|
||||
if (! $date->betweenIncluded(now()->startOfDay(), now()->copy()->addDays(3)->endOfDay())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifications->notifyGroupMilestoneDueSoon($recipient, $actor, $group, $contextType, $contextTitle, $milestoneTitle, $date->toDateString(), $url);
|
||||
}
|
||||
|
||||
private function shouldNotifyDueSoon(mixed $beforeDueDate, mixed $afterDueDate, mixed $beforeStatus, string $afterStatus, int $previousOwnerId, int $currentOwnerId): bool
|
||||
{
|
||||
if ($currentOwnerId <= 0 || ! in_array($afterStatus, [GroupReleaseMilestone::STATUS_PENDING, GroupReleaseMilestone::STATUS_ACTIVE], true) || $afterDueDate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$beforeNormalized = $beforeDueDate ? now()->parse((string) $beforeDueDate)->toDateString() : null;
|
||||
$afterNormalized = now()->parse((string) $afterDueDate)->toDateString();
|
||||
|
||||
return $previousOwnerId !== $currentOwnerId || $beforeNormalized !== $afterNormalized || (string) $beforeStatus !== $afterStatus;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user