Commit workspace changes
This commit is contained in:
696
app/Services/GroupProjectService.php
Normal file
696
app/Services/GroupProjectService.php
Normal file
@@ -0,0 +1,696 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupAsset;
|
||||
use App\Models\GroupPost;
|
||||
use App\Models\GroupProject;
|
||||
use App\Models\GroupProjectArtwork;
|
||||
use App\Models\GroupProjectMilestone;
|
||||
use App\Models\GroupProjectMember;
|
||||
use App\Models\User;
|
||||
use App\Support\ThumbnailPresenter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GroupProjectService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly GroupActivityService $activity,
|
||||
private readonly GroupMediaService $media,
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(Group $group, User $actor, array $attributes): GroupProject
|
||||
{
|
||||
$coverPath = null;
|
||||
|
||||
try {
|
||||
$project = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupProject {
|
||||
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
|
||||
$coverPath = $this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user