media->storeUploadedEntityImage($group, $attributes['cover_file'], 'projects'); } $project = GroupProject::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'] ?? GroupProject::STATUS_PLANNED), 'visibility' => (string) ($attributes['visibility'] ?? GroupProject::VISIBILITY_PUBLIC), 'start_date' => $attributes['start_date'] ?? null, 'target_date' => $attributes['target_date'] ?? null, 'released_at' => null, 'created_by_user_id' => (int) $actor->id, 'lead_user_id' => $this->normalizeLeadUserId($group, $attributes['lead_user_id'] ?? null), 'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null), 'linked_featured_artwork_id' => $this->normalizeArtworkId($group, $attributes['linked_featured_artwork_id'] ?? null), 'pinned_post_id' => $this->normalizePostId($group, $attributes['pinned_post_id'] ?? null), ]); $this->syncMembers($project, $group, $attributes['member_user_ids'] ?? []); return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile']); }); } catch (\Throwable $exception) { $this->media->deleteIfManaged($coverPath); throw $exception; } $this->history->record( $group, $actor, 'project_created', sprintf('Created project "%s".', $project->title), 'group_project', (int) $project->id, null, $project->only(['title', 'status', 'visibility']) ); $this->activity->record( $group, $actor, 'project_created', 'group_project', (int) $project->id, sprintf('%s created a new project: %s', $actor->name ?: $actor->username ?: 'A member', $project->title), $project->summary, $project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal', ); return $project; } public function update(GroupProject $project, User $actor, array $attributes): GroupProject { $coverPath = null; $oldCoverPath = $project->cover_path; $before = $project->only(['title', 'summary', 'description', 'status', 'visibility', 'lead_user_id', 'linked_collection_id', 'linked_featured_artwork_id', 'pinned_post_id']); try { DB::transaction(function () use ($project, $actor, $attributes, &$coverPath): void { if (($attributes['cover_file'] ?? null) instanceof UploadedFile) { $coverPath = $this->media->storeUploadedEntityImage($project->group, $attributes['cover_file'], 'projects'); } $title = trim((string) ($attributes['title'] ?? $project->title)); $project->fill([ 'title' => $title, 'slug' => $title !== $project->title ? $this->makeUniqueSlug($title, (int) $project->id) : $project->slug, 'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $project->summary, 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $project->description, 'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $project->cover_path), 'visibility' => (string) ($attributes['visibility'] ?? $project->visibility), 'start_date' => $attributes['start_date'] ?? $project->start_date, 'target_date' => $attributes['target_date'] ?? $project->target_date, 'lead_user_id' => array_key_exists('lead_user_id', $attributes) ? $this->normalizeLeadUserId($project->group, $attributes['lead_user_id']) : $project->lead_user_id, 'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($project->group, $attributes['linked_collection_id']) : $project->linked_collection_id, 'linked_featured_artwork_id' => array_key_exists('linked_featured_artwork_id', $attributes) ? $this->normalizeArtworkId($project->group, $attributes['linked_featured_artwork_id']) : $project->linked_featured_artwork_id, 'pinned_post_id' => array_key_exists('pinned_post_id', $attributes) ? $this->normalizePostId($project->group, $attributes['pinned_post_id']) : $project->pinned_post_id, ])->save(); if (array_key_exists('member_user_ids', $attributes)) { $this->syncMembers($project, $project->group, $attributes['member_user_ids']); } }); } catch (\Throwable $exception) { $this->media->deleteIfManaged($coverPath); throw $exception; } if ($coverPath !== null && $oldCoverPath !== $project->cover_path) { $this->media->deleteIfManaged($oldCoverPath); } $project->refresh(); $this->history->record( $project->group, $actor, 'project_updated', sprintf('Updated project "%s".', $project->title), 'group_project', (int) $project->id, $before, $project->only(['title', 'summary', 'description', 'status', 'visibility', 'lead_user_id', 'linked_collection_id', 'linked_featured_artwork_id', 'pinned_post_id']) ); $this->activity->record( $project->group, $actor, 'project_updated', 'group_project', (int) $project->id, sprintf('%s updated project %s', $actor->name ?: $actor->username ?: 'A member', $project->title), $project->summary, $project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal', ); return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile', 'artworks.primaryAuthor.profile', 'assets.uploader.profile']); } public function updateStatus(GroupProject $project, User $actor, string $status): GroupProject { $before = $project->only(['status', 'released_at']); $previousStatus = (string) $project->status; $project->forceFill([ 'status' => $status, 'released_at' => $status === GroupProject::STATUS_RELEASED ? now() : $project->released_at, ])->save(); $this->history->record( $project->group, $actor, 'project_status_updated', sprintf('Marked project "%s" as %s.', $project->title, $status), 'group_project', (int) $project->id, $before, ['status' => $project->status, 'released_at' => $project->released_at?->toISOString()] ); $activityType = $status === GroupProject::STATUS_RELEASED ? 'project_released' : 'project_updated'; $this->activity->record( $project->group, $actor, $activityType, 'group_project', (int) $project->id, $status === GroupProject::STATUS_RELEASED ? sprintf('%s released project %s', $project->group->name, $project->title) : sprintf('%s updated project status for %s', $actor->name ?: $actor->username ?: 'A member', $project->title), $project->summary, $project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal', ); if ($status === GroupProject::STATUS_RELEASED && $project->visibility === GroupProject::VISIBILITY_PUBLIC) { foreach ($project->group->follows()->with('user.profile')->get() as $follow) { if ($follow->user) { $this->notifications->notifyGroupProjectReleased($follow->user, $actor, $project->group, $project); } } } elseif ($previousStatus !== $status && $project->visibility === GroupProject::VISIBILITY_PUBLIC) { foreach ($project->group->follows()->with('user.profile')->get() as $follow) { if ($follow->user) { $this->notifications->notifyGroupProjectStatusChanged($follow->user, $actor, $project->group, $project); } } } return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile', 'artworks.primaryAuthor.profile', 'assets.uploader.profile']); } public function attachArtwork(GroupProject $project, Artwork $artwork, User $actor): GroupProject { if ((int) $artwork->group_id !== (int) $project->group_id) { throw ValidationException::withMessages([ 'artwork' => 'Only artworks published under this group can be attached to a group project.', ]); } GroupProjectArtwork::query()->updateOrCreate( [ 'group_project_id' => (int) $project->id, 'artwork_id' => (int) $artwork->id, ], [ 'sort_order' => (int) $project->artworkLinks()->count(), ] ); $this->history->record( $project->group, $actor, 'project_artwork_attached', sprintf('Attached artwork "%s" to project "%s".', $artwork->title, $project->title), 'group_project', (int) $project->id, null, ['artwork_id' => (int) $artwork->id] ); return $project->fresh(['group', 'artworks.primaryAuthor.profile', 'creator.profile', 'lead.profile', 'memberLinks.user.profile', 'assets.uploader.profile']); } public function attachAsset(GroupProject $project, GroupAsset $asset, User $actor): GroupAsset { if ((int) $asset->group_id !== (int) $project->group_id) { throw ValidationException::withMessages([ 'asset' => 'Only assets belonging to this group can be attached to the project.', ]); } $asset->forceFill([ 'linked_project_id' => (int) $project->id, ])->save(); $this->history->record( $project->group, $actor, 'project_asset_attached', sprintf('Attached asset "%s" to project "%s".', $asset->title, $project->title), 'group_project', (int) $project->id, null, ['asset_id' => (int) $asset->id] ); return $asset->fresh(['uploader.profile', 'approver.profile']); } public function createMilestone(GroupProject $project, User $actor, array $attributes): GroupProjectMilestone { $milestone = $project->milestones()->create([ 'title' => trim((string) $attributes['title']), 'summary' => $this->nullableString($attributes['summary'] ?? null), 'status' => (string) ($attributes['status'] ?? GroupProjectMilestone::STATUS_PENDING), 'due_date' => $attributes['due_date'] ?? null, 'owner_user_id' => $this->normalizeLeadUserId($project->group, $attributes['owner_user_id'] ?? null), 'sort_order' => (int) $project->milestones()->count(), 'notes' => $this->nullableString($attributes['notes'] ?? null), ]); $this->history->record( $project->group, $actor, 'project_milestone_created', sprintf('Created milestone "%s" for project "%s".', $milestone->title, $project->title), 'group_project', (int) $project->id, null, ['milestone_id' => (int) $milestone->id, 'status' => $milestone->status] ); if ($milestone->owner) { $this->notifications->notifyGroupMilestoneAssigned( $milestone->owner, $actor, $project->group, 'project', $project->title, $milestone->title, route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project]) ); $this->notifyMilestoneDueSoonIfNeeded( $milestone->owner, $actor, $project->group, 'project', $project->title, $milestone->title, $milestone->due_date?->toDateString(), route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project]) ); } return $milestone->fresh('owner.profile'); } public function updateMilestone(GroupProjectMilestone $milestone, User $actor, array $attributes): GroupProjectMilestone { $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->project->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->project->group, $actor, 'project_milestone_updated', sprintf('Updated milestone "%s" for project "%s".', $milestone->title, $milestone->project->title), 'group_project', (int) $milestone->project_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->project->group, 'project', $milestone->project->title, $milestone->title, route('studio.groups.projects.edit', ['group' => $milestone->project->group, 'project' => $milestone->project]) ); } 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->project->group, 'project', $milestone->project->title, $milestone->title, $milestone->due_date?->toDateString(), route('studio.groups.projects.edit', ['group' => $milestone->project->group, 'project' => $milestone->project]) ); } return $milestone->fresh(['owner.profile', 'project']); } public function featuredProject(Group $group, ?User $viewer = null): ?array { $project = $this->visibleQuery($group, $viewer) ->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost']) ->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_RELEASED, GroupProject::STATUS_REVIEW]) ->orderByRaw("CASE status WHEN 'released' THEN 0 WHEN 'active' THEN 1 ELSE 2 END") ->latest('updated_at') ->first(); return $project ? $this->mapPublicProject($project, $viewer) : null; } public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array { return $this->visibleQuery($group, $viewer) ->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost']) ->latest('updated_at') ->limit($limit) ->get() ->map(fn (GroupProject $project): array => $this->mapPublicProject($project, $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 = GroupProject::query() ->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost']) ->where('group_id', $group->id); if ($bucket !== 'all') { $query->where('status', $bucket); } $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); return [ 'items' => collect($paginator->items())->map(fn (GroupProject $project): array => $this->mapStudioProject($project))->values()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'filters' => ['bucket' => $bucket], 'bucket_options' => [ ['value' => 'all', 'label' => 'All'], ['value' => GroupProject::STATUS_PLANNED, 'label' => 'Planned'], ['value' => GroupProject::STATUS_ACTIVE, 'label' => 'Active'], ['value' => GroupProject::STATUS_REVIEW, 'label' => 'Review'], ['value' => GroupProject::STATUS_RELEASED, 'label' => 'Released'], ['value' => GroupProject::STATUS_ARCHIVED, 'label' => 'Archived'], ], ]; } public function detailPayload(GroupProject $project, ?User $viewer = null): array { $project->loadMissing([ 'group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork.primaryAuthor.profile', 'pinnedPost.author.profile', 'artworks.primaryAuthor.profile', 'releases', 'assets.uploader.profile', 'milestones.owner.profile', 'memberLinks.user.profile', ]); $payload = $this->mapPublicProject($project, $viewer); $payload['description'] = $project->description; $payload['artworks'] = $project->artworks->take(12)->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['assets'] = $project->assets ->filter(fn (GroupAsset $asset): bool => $asset->canBeViewedBy($viewer)) ->take(12) ->map(fn (GroupAsset $asset): array => [ 'id' => (int) $asset->id, 'title' => (string) $asset->title, 'category' => (string) $asset->category, 'visibility' => (string) $asset->visibility, 'download_url' => route('groups.assets.download', ['group' => $project->group, 'asset' => $asset]), ])->values()->all(); $payload['milestones'] = $project->milestones->map(fn (GroupProjectMilestone $milestone): array => [ '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, ])->values()->all(); $payload['release_count'] = (int) $project->releases()->count(); $payload['team'] = $project->memberLinks->map(fn (GroupProjectMember $member): array => [ 'id' => (int) $member->user_id, 'name' => $member->user?->name, 'username' => $member->user?->username, 'avatar_url' => $member->user?->profile?->avatar_url ?? null, 'role_label' => $member->role_label, 'is_lead' => (bool) $member->is_lead, ])->values()->all(); return $payload; } public function mapPublicProject(GroupProject $project, ?User $viewer = null): array { return [ 'id' => (int) $project->id, 'title' => (string) $project->title, 'slug' => (string) $project->slug, 'summary' => $project->summary, 'status' => (string) $project->status, 'visibility' => (string) $project->visibility, 'cover_url' => $project->coverUrl(), 'start_date' => $project->start_date?->toDateString(), 'target_date' => $project->target_date?->toDateString(), 'released_at' => $project->released_at?->toISOString(), 'lead' => $project->lead ? [ 'id' => (int) $project->lead->id, 'name' => $project->lead->name, 'username' => $project->lead->username, ] : null, 'linked_collection' => $project->linkedCollection ? [ 'id' => (int) $project->linkedCollection->id, 'title' => $project->linkedCollection->title, 'url' => route('profile.collections.show', ['username' => strtolower((string) $project->linkedCollection->user?->username), 'slug' => $project->linkedCollection->slug]), ] : null, 'pinned_post' => $project->pinnedPost ? [ 'id' => (int) $project->pinnedPost->id, 'title' => $project->pinnedPost->title, 'url' => route('groups.posts.show', ['group' => $project->group, 'post' => $project->pinnedPost]), ] : null, 'counts' => [ 'artworks' => (int) $project->artworks()->count(), 'assets' => (int) $project->assets()->count(), 'team' => (int) $project->memberLinks()->count(), 'milestones' => (int) $project->milestones()->count(), 'releases' => (int) $project->releases()->count(), ], 'url' => route('groups.projects.show', ['group' => $project->group, 'project' => $project]), ]; } public function mapStudioProject(GroupProject $project): array { return array_merge($this->mapPublicProject($project), [ 'description' => $project->description, 'urls' => [ 'public' => $project->visibility !== GroupProject::VISIBILITY_PRIVATE ? route('groups.projects.show', ['group' => $project->group, 'project' => $project]) : null, 'edit' => route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project]), 'status' => route('studio.groups.projects.status', ['group' => $project->group, 'project' => $project]), 'attach_artwork' => route('studio.groups.projects.attach-artwork', ['group' => $project->group, 'project' => $project]), 'attach_asset' => route('studio.groups.projects.attach-asset', ['group' => $project->group, 'project' => $project]), ], ]); } 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(); } private function visibleQuery(Group $group, ?User $viewer = null) { return GroupProject::query() ->where('group_id', $group->id) ->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void { $query->where('visibility', GroupProject::VISIBILITY_PUBLIC) ->where('status', '!=', GroupProject::STATUS_ARCHIVED); }); } private function syncMembers(GroupProject $project, Group $group, array $memberUserIds): void { $allowedIds = $group->members() ->where('status', Group::STATUS_ACTIVE) ->pluck('user_id') ->push((int) $group->owner_user_id) ->map(fn ($id): int => (int) $id) ->unique() ->values(); $targetIds = collect($memberUserIds) ->map(fn ($id): int => (int) $id) ->filter(fn (int $id): bool => $id > 0 && $allowedIds->contains($id)) ->unique() ->values(); GroupProjectMember::query()->where('group_project_id', $project->id)->whereNotIn('user_id', $targetIds->all())->delete(); foreach ($targetIds as $userId) { GroupProjectMember::query()->updateOrCreate( [ 'group_project_id' => (int) $project->id, 'user_id' => $userId, ], [ 'role_label' => null, 'is_lead' => (int) ($project->lead_user_id ?? 0) === $userId, ] ); } } private function makeUniqueSlug(string $source, ?int $ignoreProjectId = null): string { $base = Str::slug(Str::limit($source, 150, '')) ?: 'project'; $slug = $base; $suffix = 2; while (GroupProject::query()->where('slug', $slug)->when($ignoreProjectId !== null, fn ($query) => $query->where('id', '!=', $ignoreProjectId))->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; } $exists = $group->members()->where('user_id', $id)->where('status', Group::STATUS_ACTIVE)->exists(); return $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 normalizePostId(Group $group, mixed $postId): ?int { $id = (int) $postId; return $id > 0 && $group->posts()->where('id', $id)->exists() ? $id : null; } private function nullableString(mixed $value): ?string { $trimmed = trim((string) $value); return $trimmed !== '' ? $trimmed : null; } 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, [GroupProjectMilestone::STATUS_PENDING, GroupProjectMilestone::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; } }