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