Files
2026-04-18 17:02:56 +02:00

817 lines
36 KiB
PHP

<?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;
}
}