diff --git a/app/Console/Commands/PublishScheduledNewsCommand.php b/app/Console/Commands/PublishScheduledNewsCommand.php new file mode 100644 index 00000000..27109a3c --- /dev/null +++ b/app/Console/Commands/PublishScheduledNewsCommand.php @@ -0,0 +1,88 @@ +option('dry-run'); + $limit = (int) $this->option('limit'); + $now = now()->utc(); + + $candidates = NewsArticle::query() + ->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED) + ->whereNotNull('published_at') + ->where('published_at', '<=', $now) + ->orderBy('published_at') + ->limit($limit) + ->get(['id', 'title', 'published_at']); + + if ($candidates->isEmpty()) { + $this->line('No scheduled News articles due for publishing.'); + + return self::SUCCESS; + } + + $published = 0; + $errors = 0; + + foreach ($candidates as $candidate) { + if ($dryRun) { + $this->line(sprintf('[dry-run] Would publish News article #%d: "%s"', $candidate->id, $candidate->title)); + continue; + } + + try { + DB::transaction(function () use ($candidate, $now, &$published): void { + $article = NewsArticle::query() + ->lockForUpdate() + ->where('id', $candidate->id) + ->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED) + ->whereNotNull('published_at') + ->where('published_at', '<=', $now) + ->first(); + + if (! $article) { + return; + } + + $article->forceFill([ + 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, + 'status' => 'published', + 'published_at' => $article->published_at ?? $now, + ])->save(); + + $published++; + $this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title)); + }); + } catch (\Throwable $exception) { + $errors++; + Log::error('PublishScheduledNewsCommand failed', [ + 'article_id' => $candidate->id, + 'message' => $exception->getMessage(), + ]); + $this->error(sprintf('Failed to publish News article #%d: %s', $candidate->id, $exception->getMessage())); + } + } + + if (! $dryRun) { + $this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors)); + } + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8e8b1723..cdba7202 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -31,6 +31,7 @@ use App\Jobs\RankBuildListsJob; use App\Uploads\Commands\CleanupUploadsCommand; use App\Console\Commands\NormalizeArtworkSlugsCommand; use App\Console\Commands\PublishScheduledArtworksCommand; +use App\Console\Commands\PublishScheduledNewsCommand; use App\Console\Commands\PublishScheduledNovaCardsCommand; use App\Console\Commands\BuildSitemapsCommand; use App\Console\Commands\ListSitemapReleasesCommand; @@ -62,6 +63,7 @@ class Kernel extends ConsoleKernel RollbackSitemapReleaseCommand::class, NormalizeArtworkSlugsCommand::class, PublishScheduledArtworksCommand::class, + PublishScheduledNewsCommand::class, PublishScheduledNovaCardsCommand::class, SyncCollectionLifecycleCommand::class, ValidateSitemapsCommand::class, @@ -115,6 +117,11 @@ class Kernel extends ConsoleKernel ->name('publish-scheduled-artworks') ->withoutOverlapping(2) // prevent overlap up to 2 minutes ->runInBackground(); + $schedule->command('news:publish-scheduled') + ->everyMinute() + ->name('publish-scheduled-news') + ->withoutOverlapping(2) + ->runInBackground(); $schedule->command('nova-cards:publish-scheduled') ->everyMinute() ->name('publish-scheduled-nova-cards') diff --git a/app/Http/Controllers/Api/ArtworkController.php b/app/Http/Controllers/Api/ArtworkController.php index 33f5e1ff..37b8a461 100644 --- a/app/Http/Controllers/Api/ArtworkController.php +++ b/app/Http/Controllers/Api/ArtworkController.php @@ -34,11 +34,12 @@ class ArtworkController extends Controller : null; $result = $drafts->createDraft( - (int) $user->id, + $user, (string) $data['title'], isset($data['description']) ? (string) $data['description'] : null, $categoryId, - (bool) ($data['is_mature'] ?? false) + (bool) ($data['is_mature'] ?? false), + $data['group'] ?? null, ); return response()->json([ diff --git a/app/Http/Controllers/Api/LeaderboardController.php b/app/Http/Controllers/Api/LeaderboardController.php index 31287e74..ffc52fe8 100644 --- a/app/Http/Controllers/Api/LeaderboardController.php +++ b/app/Http/Controllers/Api/LeaderboardController.php @@ -26,6 +26,13 @@ final class LeaderboardController extends Controller ); } + public function groups(Request $request, LeaderboardService $leaderboards): JsonResponse + { + return response()->json( + $leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, (string) $request->query('period', 'weekly')) + ); + } + public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse { return response()->json( diff --git a/app/Http/Controllers/Api/Search/GroupSearchController.php b/app/Http/Controllers/Api/Search/GroupSearchController.php new file mode 100644 index 00000000..091161f7 --- /dev/null +++ b/app/Http/Controllers/Api/Search/GroupSearchController.php @@ -0,0 +1,35 @@ +query('q', '')); + if (mb_strlen($q) < 2) { + return response()->json(['data' => []]); + } + + $perPage = min(max((int) $request->query('per_page', 6), 1), 12); + $items = array_map(function (array $group): array { + $group['group_type'] = $group['type'] ?? null; + $group['type'] = 'group'; + + return $group; + }, $this->groups->searchCards($q, $request->user(), $perPage)); + + return response()->json([ + 'data' => $items, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 6f686bc7..2969631d 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -32,6 +32,7 @@ use Carbon\Carbon; use App\Uploads\Jobs\VirusScanJob; use App\Uploads\Services\PublishService; use App\Services\Activity\UserActivityService; +use App\Services\ArtworkAttributionService; use App\Uploads\Exceptions\UploadNotFoundException; use App\Uploads\Exceptions\UploadOwnershipException; use App\Uploads\Exceptions\UploadPublishValidationException; @@ -39,6 +40,8 @@ use App\Uploads\Services\ArchiveInspectorService; use App\Uploads\Services\DraftQuotaService; use App\Uploads\Exceptions\DraftQuotaException; use App\Models\Artwork; +use App\Models\Group; +use App\Services\GroupArtworkReviewService; use Illuminate\Support\Str; final class UploadController extends Controller @@ -555,7 +558,7 @@ final class UploadController extends Controller ], Response::HTTP_OK); } - public function publish(string $id, Request $request, PublishService $publishService) + public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution) { $user = $request->user(); @@ -572,6 +575,14 @@ final class UploadController extends Controller 'publish_at' => ['nullable', 'string', 'date'], 'timezone' => ['nullable', 'string', 'max:64'], 'visibility' => ['nullable', 'string', 'in:public,unlisted,private'], + 'group' => ['nullable', 'string', 'max:90'], + 'primary_author_user_id' => ['nullable', 'integer', 'min:1'], + 'contributor_user_ids' => ['nullable', 'array', 'max:20'], + 'contributor_user_ids.*' => ['integer', 'min:1'], + 'contributor_credits' => ['nullable', 'array', 'max:20'], + 'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'], + 'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'], + 'contributor_credits.*.is_primary' => ['nullable', 'boolean'], ]); $mode = $validated['mode'] ?? 'now'; @@ -623,6 +634,8 @@ final class UploadController extends Controller } $artwork->slug = Str::limit($slugBase, 160, ''); $artwork->artwork_timezone = $validated['timezone'] ?? null; + $artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id; + $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id; // Sync category if provided $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; @@ -643,6 +656,9 @@ final class UploadController extends Controller $artwork->tags()->sync($tagIds); } + $artwork->save(); + $artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated); + if ($mode === 'schedule' && $publishAt) { // Scheduled: store publish_at but don't make public yet $artwork->visibility = $visibility; @@ -735,4 +751,56 @@ final class UploadController extends Controller return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); } } + + public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews) + { + $user = $request->user(); + + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:150'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'integer', 'exists:categories,id'], + 'tags' => ['nullable', 'array', 'max:15'], + 'tags.*' => ['string', 'max:64'], + 'is_mature' => ['nullable', 'boolean'], + 'nsfw' => ['nullable', 'boolean'], + 'timezone' => ['nullable', 'string', 'max:64'], + 'visibility' => ['nullable', 'string', 'in:public,unlisted,private'], + 'group' => ['required', 'string', 'max:90'], + 'primary_author_user_id' => ['nullable', 'integer', 'min:1'], + 'contributor_user_ids' => ['nullable', 'array', 'max:20'], + 'contributor_user_ids.*' => ['integer', 'min:1'], + 'contributor_credits' => ['nullable', 'array', 'max:20'], + 'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'], + 'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'], + 'contributor_credits.*.is_primary' => ['nullable', 'boolean'], + ]); + + if (! ctype_digit($id)) { + return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $artwork = Artwork::query()->find((int) $id); + if (! $artwork) { + return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND); + } + + if ((int) $artwork->user_id !== (int) $user->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $user->id) { + return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); + } + + $group = Group::query()->with('members')->where('slug', (string) $validated['group'])->first(); + if (! $group) { + return response()->json(['message' => 'Group not found.'], Response::HTTP_NOT_FOUND); + } + + $artwork = $reviews->submit($group, $artwork, $user, $validated); + + return response()->json([ + 'success' => true, + 'artwork_id' => (int) $artwork->id, + 'status' => 'submitted_for_review', + 'group_review_status' => (string) $artwork->group_review_status, + ], Response::HTTP_OK); + } } diff --git a/app/Http/Controllers/GroupAssetController.php b/app/Http/Controllers/GroupAssetController.php new file mode 100644 index 00000000..37739491 --- /dev/null +++ b/app/Http/Controllers/GroupAssetController.php @@ -0,0 +1,27 @@ +authorize('view', $group); + abort_unless((int) $asset->group_id === (int) $group->id, 404); + abort_unless($asset->canBeViewedBy($request->user()), 403); + + return $this->assets->downloadResponse($asset); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupChallengeController.php b/app/Http/Controllers/GroupChallengeController.php new file mode 100644 index 00000000..78f64a34 --- /dev/null +++ b/app/Http/Controllers/GroupChallengeController.php @@ -0,0 +1,74 @@ +authorize('view', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + abort_unless($challenge->canBeViewedBy($request->user()), 403); + + $groupPayload = $this->groups->mapGroupDetail($group, $request->user()); + $challengePayload = $this->challenges->detailPayload($challenge, $request->user()); + $canonical = route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]); + $description = Str::limit(trim(strip_tags((string) ($challengePayload['summary'] ?? $challengePayload['description'] ?? $groupPayload['headline'] ?? 'Group challenge on Skinbase.'))), 160, '…'); + $seo = app(SeoFactory::class)->collectionPage( + sprintf('%s — %s — Skinbase', $challenge->title, $group->name), + $description, + $canonical, + $challengePayload['cover_url'] ?? null, + )->toArray(); + $seo['og_type'] = 'article'; + $seo['json_ld'] = [[ + '@context' => 'https://schema.org', + '@type' => 'CreativeWork', + 'name' => (string) $challenge->title, + 'description' => $description, + 'url' => $canonical, + 'image' => $challengePayload['cover_url'] ?? null, + 'dateCreated' => $challenge->created_at?->toAtomString(), + 'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name], + ]]; + + return Inertia::render('Group/GroupChallengeShow', [ + 'group' => $groupPayload, + 'challenge' => $challengePayload, + 'seo' => $seo, + ])->rootView('collections'); + } + + public function attachArtwork(AttachArtworkToGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse + { + $this->authorize('view', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + abort_unless($challenge->canBeViewedBy($request->user()), 403); + + $artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id')); + $this->challenges->attachArtwork($challenge, $artwork, $request->user()); + + return back()->with('success', 'Artwork attached to challenge.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php new file mode 100644 index 00000000..92c58608 --- /dev/null +++ b/app/Http/Controllers/GroupController.php @@ -0,0 +1,143 @@ +user(); + $surface = (string) $request->query('surface', 'featured'); + $groups = $this->discovery->publicListing( + $viewer, + $surface, + (int) $request->query('page', 1), + 24, + ) + ->through(fn (Group $group): array => $this->groups->mapGroupCard($group, $viewer)); + + return Inertia::render('Group/GroupIndex', [ + 'title' => 'Groups', + 'description' => 'Collective publishing identities for collaborative artwork, collections, and shared presence on Skinbase Nova.', + 'surfaces' => $this->discovery->availableSurfaces(), + 'currentSurface' => $surface, + 'spotlightGroup' => $this->discovery->spotlightCard($viewer, $surface), + 'highlightSections' => [ + [ + 'key' => 'featured', + 'title' => 'Featured groups', + 'description' => 'Established collectives publishing together across artworks, releases, and community initiatives.', + 'items' => $this->discovery->surfaceCards($viewer, 'featured', 4), + ], + [ + 'key' => 'recruiting', + 'title' => 'Open for collaborators', + 'description' => 'Groups currently recruiting artists, curators, moderators, and production contributors.', + 'items' => $this->discovery->surfaceCards($viewer, 'recruiting', 4), + ], + [ + 'key' => 'new_rising', + 'title' => 'New and rising', + 'description' => 'Emerging groups building momentum through fresh releases, shared projects, and fast growth.', + 'items' => $this->discovery->surfaceCards($viewer, 'new_rising', 4), + ], + ], + 'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5), + 'groups' => [ + 'data' => $groups->items(), + 'meta' => [ + 'current_page' => $groups->currentPage(), + 'last_page' => $groups->lastPage(), + 'per_page' => $groups->perPage(), + 'total' => $groups->total(), + ], + ], + ])->rootView('collections'); + } + + public function show(Request $request, Group $group, string $section = 'overview'): Response + { + $this->authorize('view', $group); + + $viewer = $request->user(); + $group->loadMissing('owner.profile'); + $members = collect($this->memberships->mapMembers($group, $viewer)) + ->where('status', Group::STATUS_ACTIVE) + ->values() + ->all(); + $groupPayload = $this->groups->mapGroupDetail($group, $viewer); + + return Inertia::render('Group/GroupShow', [ + 'group' => $groupPayload, + 'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview', + 'featuredArtworks' => $this->groups->featuredArtworkCards($group), + 'artworks' => $this->groups->publicArtworkCards($group), + 'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer), + 'collections' => $this->groups->publicCollectionCards($group, $viewer), + 'members' => $members, + 'leadership' => $this->groups->mapLeadershipPreview($members, $groupPayload['owner'] ?? []), + 'posts' => $this->groups->publicPostListing($group), + 'projects' => $this->groups->publicProjectListing($group, $viewer), + 'releases' => $this->groups->publicReleaseListing($group, $viewer), + 'challenges' => $this->groups->publicChallengeListing($group, $viewer), + 'events' => $this->groups->publicEventListing($group, $viewer), + 'assets' => $this->groups->publicAssetListing($group), + 'activity' => $this->groups->publicActivityFeed($group), + 'topContributors' => $groupPayload['top_contributors'] ?? [], + 'trustSignals' => $groupPayload['trust_signals'] ?? [], + 'badgeShowcase' => $groupPayload['badge_showcase'] ?? [], + 'recruitment' => $this->groups->recruitmentPayload($group), + 'reportEndpoint' => $viewer ? route('api.reports.store') : null, + ])->rootView('collections'); + } + + public function posts(Request $request, Group $group): Response + { + return $this->show($request, $group, 'posts'); + } + + public function projects(Request $request, Group $group): Response + { + return $this->show($request, $group, 'projects'); + } + + public function releases(Request $request, Group $group): Response + { + return $this->show($request, $group, 'releases'); + } + + public function challenges(Request $request, Group $group): Response + { + return $this->show($request, $group, 'challenges'); + } + + public function events(Request $request, Group $group): Response + { + return $this->show($request, $group, 'events'); + } + + public function activity(Request $request, Group $group): Response + { + return $this->show($request, $group, 'activity'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupEngagementController.php b/app/Http/Controllers/GroupEngagementController.php new file mode 100644 index 00000000..944424b2 --- /dev/null +++ b/app/Http/Controllers/GroupEngagementController.php @@ -0,0 +1,44 @@ +authorize('view', $group); + + $this->follows->follow($group, $request->user()); + + return response()->json([ + 'ok' => true, + 'following' => true, + 'followers_count' => (int) $group->fresh()->followers_count, + ]); + } + + public function unfollow(Request $request, Group $group): JsonResponse + { + $this->authorize('view', $group); + + $this->follows->unfollow($group, $request->user()); + + return response()->json([ + 'ok' => true, + 'following' => false, + 'followers_count' => (int) $group->fresh()->followers_count, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupEventController.php b/app/Http/Controllers/GroupEventController.php new file mode 100644 index 00000000..3af2d130 --- /dev/null +++ b/app/Http/Controllers/GroupEventController.php @@ -0,0 +1,62 @@ +authorize('view', $group); + abort_unless((int) $event->group_id === (int) $group->id, 404); + abort_unless($event->canBeViewedBy($request->user()), 403); + + $groupPayload = $this->groups->mapGroupDetail($group, $request->user()); + $eventPayload = $this->events->detailPayload($event); + $canonical = route('groups.events.show', ['group' => $group, 'event' => $event]); + $description = Str::limit(trim(strip_tags((string) ($eventPayload['summary'] ?? $eventPayload['description'] ?? $groupPayload['headline'] ?? 'Group event on Skinbase.'))), 160, '…'); + $seo = app(SeoFactory::class)->collectionPage( + sprintf('%s — %s — Skinbase', $event->title, $group->name), + $description, + $canonical, + $eventPayload['cover_url'] ?? null, + )->toArray(); + $seo['og_type'] = 'article'; + $seo['json_ld'] = [[ + '@context' => 'https://schema.org', + '@type' => 'Event', + 'name' => (string) $event->title, + 'description' => $description, + 'url' => $canonical, + 'image' => $eventPayload['cover_url'] ?? null, + 'startDate' => $event->start_at?->toAtomString(), + 'endDate' => $event->end_at?->toAtomString(), + 'eventAttendanceMode' => 'https://schema.org/OnlineEventAttendanceMode', + 'eventStatus' => 'https://schema.org/EventScheduled', + 'organizer' => ['@type' => 'Organization', 'name' => (string) $group->name], + ]]; + + return Inertia::render('Group/GroupEventShow', [ + 'group' => $groupPayload, + 'event' => $eventPayload, + 'seo' => $seo, + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupJoinRequestController.php b/app/Http/Controllers/GroupJoinRequestController.php new file mode 100644 index 00000000..b891432a --- /dev/null +++ b/app/Http/Controllers/GroupJoinRequestController.php @@ -0,0 +1,53 @@ +authorize('requestJoin', $group); + + $joinRequest = $this->joinRequests->submit($group, $request->user(), $request->validated()); + + if ($request->expectsJson()) { + return response()->json([ + 'ok' => true, + 'join_request' => $this->joinRequests->mapRequest($joinRequest, $group, $request->user()), + ]); + } + + return back()->with('success', 'Your join request has been sent.'); + } + + public function destroy(Request $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse|JsonResponse + { + abort_unless((int) $joinRequest->group_id === (int) $group->id, 404); + + $updated = $this->joinRequests->withdraw($joinRequest, $request->user()); + + if ($request->expectsJson()) { + return response()->json([ + 'ok' => true, + 'join_request' => $this->joinRequests->mapRequest($updated, $group, $request->user()), + ]); + } + + return back()->with('success', 'Your join request has been withdrawn.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupMemberController.php b/app/Http/Controllers/GroupMemberController.php new file mode 100644 index 00000000..ed5a53e2 --- /dev/null +++ b/app/Http/Controllers/GroupMemberController.php @@ -0,0 +1,154 @@ +authorize('manageMembers', $group); + + $invitee = User::query() + ->whereRaw('LOWER(username) = ?', [strtolower((string) $request->validated('username'))]) + ->firstOrFail(); + + $invitation = $this->memberships->inviteMember( + $group, + $request->user(), + $invitee, + (string) $request->validated('role'), + $request->validated('note'), + $request->integer('expires_in_days') ?: null, + ); + + return response()->json([ + 'ok' => true, + 'member' => $invitation, + 'invitation' => $invitation, + 'members' => $this->memberships->mapMembers($group, $request->user()), + 'invitations' => $this->memberships->mapInvitations($group, $request->user()), + ]); + } + + public function update(UpdateGroupMemberRequest $request, Group $group, GroupMember $member): JsonResponse + { + $this->authorize('manageMembers', $group); + + abort_unless((int) $member->group_id === (int) $group->id, 404); + + $updated = $this->memberships->updateMemberRole($member, $request->user(), (string) $request->validated('role')); + + return response()->json([ + 'ok' => true, + 'member' => $updated, + 'members' => $this->memberships->mapMembers($group, $request->user()), + ]); + } + + public function transfer(Request $request, Group $group, GroupMember $member): JsonResponse + { + $this->authorize('update', $group); + abort_unless((int) $member->group_id === (int) $group->id, 404); + + $group = $this->memberships->transferOwnership($group, $member, $request->user()); + + return response()->json([ + 'ok' => true, + 'group_id' => (int) $group->id, + 'members' => $this->memberships->mapMembers($group, $request->user()), + ]); + } + + public function updatePermissions(UpdateGroupMemberPermissionsRequest $request, Group $group, GroupMember $member): JsonResponse + { + $this->authorize('manageMemberPermissions', $group); + abort_unless((int) $member->group_id === (int) $group->id, 404); + + $updated = $this->memberships->updatePermissionOverrides( + $member, + $request->user(), + $request->validated('permission_overrides') ?? [], + ); + + $members = $this->memberships->mapMembers($group, $request->user()); + + return response()->json([ + 'ok' => true, + 'member' => collect($members)->firstWhere('id', (int) $updated->id), + 'members' => $members, + ]); + } + + public function destroy(Request $request, Group $group, GroupMember $member): JsonResponse + { + $this->authorize('manageMembers', $group); + abort_unless((int) $member->group_id === (int) $group->id, 404); + + $this->memberships->revokeMember($member, $request->user()); + + return response()->json([ + 'ok' => true, + 'members' => $this->memberships->mapMembers($group, $request->user()), + ]); + } + + public function acceptInvitation(Request $request, GroupInvitation $invitation): RedirectResponse + { + $member = $this->memberships->acceptInvitation($invitation, $request->user()); + + return redirect()->route('studio.groups.members', ['group' => $member->group]); + } + + public function declineInvitation(Request $request, GroupInvitation $invitation): RedirectResponse + { + $this->memberships->declineInvitation($invitation, $request->user()); + + return redirect()->route('studio.groups.index'); + } + + public function destroyInvitation(Request $request, Group $group, GroupInvitation $invitation): JsonResponse + { + $this->authorize('manageMembers', $group); + abort_unless((int) $invitation->group_id === (int) $group->id, 404); + + $this->memberships->revokeInvitation($invitation, $request->user()); + + return response()->json([ + 'ok' => true, + 'invitations' => $this->memberships->mapInvitations($group, $request->user()), + ]); + } + + public function accept(Request $request, GroupMember $member): RedirectResponse + { + $this->memberships->acceptLegacyInvite($member, $request->user()); + + return redirect()->route('studio.groups.members', ['group' => $member->group]); + } + + public function decline(Request $request, GroupMember $member): RedirectResponse + { + $this->memberships->declineLegacyInvite($member, $request->user()); + + return redirect()->route('studio.groups.index'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupPostController.php b/app/Http/Controllers/GroupPostController.php new file mode 100644 index 00000000..6c5f6603 --- /dev/null +++ b/app/Http/Controllers/GroupPostController.php @@ -0,0 +1,86 @@ +authorize('view', $group); + abort_unless((int) $post->group_id === (int) $group->id && $post->status === GroupPost::STATUS_PUBLISHED, 404); + + $canonical = route('groups.posts.show', ['group' => $group, 'post' => $post]); + $description = $post->excerpt ?: Str::limit(trim(strip_tags((string) $post->content)), 160, '...'); + $coverImage = $post->cover_path ?: ($group->bannerUrl() ?: $group->avatarUrl()); + + return Inertia::render('Group/GroupPostShow', [ + 'group' => $this->groups->mapGroupDetail($group, $request->user()), + 'post' => [ + 'id' => (int) $post->id, + 'type' => (string) $post->type, + 'title' => (string) $post->title, + 'excerpt' => $post->excerpt, + 'content' => $post->content, + 'is_pinned' => (bool) $post->is_pinned, + 'published_at' => $post->published_at?->toISOString(), + 'author' => $post->author ? [ + 'id' => (int) $post->author->id, + 'name' => $post->author->name, + 'username' => $post->author->username, + ] : null, + ], + 'recentPosts' => $this->groups->recentPostCards($group, 4), + 'reportEndpoint' => $request->user() ? route('api.reports.store') : null, + 'seo' => [ + 'title' => $post->title . ' - ' . $group->name . ' - Skinbase', + 'description' => $description, + 'canonical' => $canonical, + 'og_title' => $post->title . ' - ' . $group->name, + 'og_description' => $description, + 'og_url' => $canonical, + 'og_type' => 'article', + 'og_image' => $coverImage, + 'twitter_card' => $coverImage ? 'summary_large_image' : 'summary', + 'twitter_image' => $coverImage, + 'json_ld' => [ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => $post->title, + 'description' => $description, + 'datePublished' => $post->published_at?->toIso8601String(), + 'dateModified' => $post->updated_at?->toIso8601String(), + 'mainEntityOfPage' => $canonical, + 'author' => $post->author ? [ + '@type' => 'Person', + 'name' => $post->author->name ?: $post->author->username, + ] : null, + 'publisher' => [ + '@type' => 'Organization', + 'name' => $group->name, + 'url' => $group->publicUrl(), + 'logo' => $group->avatarUrl() ? [ + '@type' => 'ImageObject', + 'url' => $group->avatarUrl(), + ] : null, + ], + 'image' => $coverImage ? [$coverImage] : null, + ], + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupProjectController.php b/app/Http/Controllers/GroupProjectController.php new file mode 100644 index 00000000..952f041d --- /dev/null +++ b/app/Http/Controllers/GroupProjectController.php @@ -0,0 +1,59 @@ +authorize('view', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + abort_unless($project->canBeViewedBy($request->user()), 403); + + $groupPayload = $this->groups->mapGroupDetail($group, $request->user()); + $projectPayload = $this->projects->detailPayload($project, $request->user()); + $canonical = route('groups.projects.show', ['group' => $group, 'project' => $project]); + $description = Str::limit(trim(strip_tags((string) ($projectPayload['summary'] ?? $projectPayload['description'] ?? $groupPayload['headline'] ?? 'Group project on Skinbase.'))), 160, '…'); + $seo = app(SeoFactory::class)->collectionPage( + sprintf('%s — %s — Skinbase', $project->title, $group->name), + $description, + $canonical, + $projectPayload['cover_url'] ?? null, + )->toArray(); + $seo['og_type'] = 'article'; + $seo['json_ld'] = [[ + '@context' => 'https://schema.org', + '@type' => 'CreativeWork', + 'name' => (string) $project->title, + 'description' => $description, + 'url' => $canonical, + 'image' => $projectPayload['cover_url'] ?? null, + 'dateCreated' => $project->created_at?->toAtomString(), + 'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name], + ]]; + + return Inertia::render('Group/GroupProjectShow', [ + 'group' => $groupPayload, + 'project' => $projectPayload, + 'seo' => $seo, + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GroupReleaseController.php b/app/Http/Controllers/GroupReleaseController.php new file mode 100644 index 00000000..56a0eb7a --- /dev/null +++ b/app/Http/Controllers/GroupReleaseController.php @@ -0,0 +1,60 @@ +authorize('view', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + abort_unless($release->canBeViewedBy($request->user()), 403); + + $groupPayload = $this->groups->mapGroupDetail($group, $request->user()); + $releasePayload = $this->releases->detailPayload($release, $request->user()); + $canonical = route('groups.releases.show', ['group' => $group, 'release' => $release]); + $description = Str::limit(trim(strip_tags((string) ($releasePayload['summary'] ?? $releasePayload['description'] ?? $groupPayload['headline'] ?? 'Group release on Skinbase.'))), 160, '…'); + $seo = app(SeoFactory::class)->collectionPage( + sprintf('%s — %s — Skinbase', $release->title, $group->name), + $description, + $canonical, + $releasePayload['cover_url'] ?? null, + )->toArray(); + $seo['og_type'] = 'article'; + $seo['json_ld'] = [[ + '@context' => 'https://schema.org', + '@type' => 'CreativeWork', + 'name' => (string) $release->title, + 'description' => $description, + 'url' => $canonical, + 'image' => $releasePayload['cover_url'] ?? null, + 'datePublished' => $release->published_at?->toAtomString(), + 'dateCreated' => $release->created_at?->toAtomString(), + 'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name], + ]]; + + return Inertia::render('Group/GroupReleaseShow', [ + 'group' => $groupPayload, + 'release' => $releasePayload, + 'seo' => $seo, + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/News/NewsController.php b/app/Http/Controllers/News/NewsController.php index fa00917f..fd592a99 100644 --- a/app/Http/Controllers/News/NewsController.php +++ b/app/Http/Controllers/News/NewsController.php @@ -3,8 +3,11 @@ namespace App\Http\Controllers\News; use App\Http\Controllers\Controller; +use App\Models\User; +use App\Services\News\NewsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\View\View; use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsCategory; use cPad\Plugins\News\Models\NewsTag; @@ -12,32 +15,44 @@ use cPad\Plugins\News\Models\NewsView; class NewsController extends Controller { + public function __construct(private readonly NewsService $news) + { + } + // ----------------------------------------------------------------------- // Homepage — /news // ----------------------------------------------------------------------- - public function index(Request $request) + public function index(Request $request): View { $perPage = config('news.articles_per_page', 12); $featured = NewsArticle::with('author', 'category') ->published() - ->featured() - ->orderByDesc('published_at') + ->editorialOrder() ->first(); - $query = NewsArticle::with('author', 'category') + $highlightQuery = NewsArticle::with('author', 'category') ->published() - ->orderByDesc('published_at'); + ->editorialOrder(); if ($featured) { - $query->where('id', '!=', $featured->id); + $highlightQuery->where('id', '!=', $featured->id); } - $articles = $query->paginate($perPage); + $highlights = $highlightQuery->limit(3)->get(); + $excludedIds = collect([$featured?->id])->merge($highlights->pluck('id'))->filter()->all(); + + $articles = NewsArticle::with('author', 'category') + ->published() + ->when($excludedIds !== [], fn ($query) => $query->whereNotIn('id', $excludedIds)) + ->editorialOrder() + ->paginate($perPage); + return view('news.index', [ - 'featured' => $featured, - 'articles' => $articles, + 'featured' => $featured, + 'highlights' => $highlights, + 'articles' => $articles, ] + $this->sidebarData()); } @@ -45,7 +60,7 @@ class NewsController extends Controller // Category page — /news/category/{slug} // ----------------------------------------------------------------------- - public function category(Request $request, string $slug) + public function category(Request $request, string $slug): View { $category = NewsCategory::where('slug', $slug)->where('is_active', true)->firstOrFail(); $perPage = config('news.articles_per_page', 12); @@ -53,12 +68,12 @@ class NewsController extends Controller $articles = NewsArticle::with('author', 'category') ->published() ->byCategory($category->id) - ->orderByDesc('published_at') + ->editorialOrder() ->paginate($perPage); return view('news.category', [ - 'category' => $category, - 'articles' => $articles, + 'category' => $category, + 'articles' => $articles, ] + $this->sidebarData()); } @@ -66,7 +81,7 @@ class NewsController extends Controller // Tag page — /news/tag/{slug} // ----------------------------------------------------------------------- - public function tag(Request $request, string $slug) + public function tag(Request $request, string $slug): View { $tag = NewsTag::where('slug', $slug)->firstOrFail(); $perPage = config('news.articles_per_page', 12); @@ -74,12 +89,46 @@ class NewsController extends Controller $articles = NewsArticle::with('author', 'category') ->published() ->whereHas('tags', fn ($q) => $q->where('news_tags.slug', $slug)) - ->orderByDesc('published_at') + ->editorialOrder() ->paginate($perPage); return view('news.tag', [ - 'tag' => $tag, - 'articles' => $articles, + 'tag' => $tag, + 'articles' => $articles, + ] + $this->sidebarData()); + } + + public function archive(Request $request, int $year, int $month): View + { + abort_unless($month >= 1 && $month <= 12, 404); + + $perPage = config('news.articles_per_page', 12); + $articles = NewsArticle::with('author', 'category') + ->published() + ->whereYear('published_at', $year) + ->whereMonth('published_at', $month) + ->editorialOrder() + ->paginate($perPage); + + return view('news.archive', [ + 'archiveDate' => now()->setDate($year, $month, 1), + 'articles' => $articles, + ] + $this->sidebarData()); + } + + public function author(Request $request, string $username): View + { + $author = User::query()->with('profile')->where('username', $username)->firstOrFail(); + $perPage = config('news.articles_per_page', 12); + $articles = NewsArticle::with('author', 'category') + ->published() + ->where('author_id', $author->id) + ->editorialOrder() + ->paginate($perPage); + + return view('news.author', [ + 'author' => $author, + 'articles' => $articles, ] + $this->sidebarData()); } @@ -87,9 +136,9 @@ class NewsController extends Controller // Article page — /news/{slug} // ----------------------------------------------------------------------- - public function show(Request $request, string $slug) + public function show(Request $request, string $slug): View { - $article = NewsArticle::with('author', 'category', 'tags') + $article = NewsArticle::with('author.profile', 'category', 'tags', 'relatedEntities') ->published() ->where('slug', $slug) ->firstOrFail(); @@ -98,17 +147,18 @@ class NewsController extends Controller $this->trackView($request, $article); // Related articles (same category, excluding current) - $related = NewsArticle::with('author') + $related = NewsArticle::with('author', 'category') ->published() ->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id)) ->where('id', '!=', $article->id) - ->orderByDesc('published_at') + ->editorialOrder() ->limit(config('news.related_limit', 4)) ->get(); return view('news.show', [ 'article' => $article, 'related' => $related, + 'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), ] + $this->sidebarData()); } @@ -140,13 +190,6 @@ class NewsController extends Controller private function sidebarData(): array { - return [ - 'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(), - 'trending' => NewsArticle::published() - ->orderByDesc('views') - ->limit(config('news.trending_limit', 5)) - ->get(['id', 'title', 'slug', 'views', 'published_at']), - 'tags' => NewsTag::has('articles')->orderBy('name')->get(), - ]; + return $this->news->sidebarData(); } } diff --git a/app/Http/Controllers/Settings/CollectionManageController.php b/app/Http/Controllers/Settings/CollectionManageController.php index ffb9e576..903efa52 100644 --- a/app/Http/Controllers/Settings/CollectionManageController.php +++ b/app/Http/Controllers/Settings/CollectionManageController.php @@ -19,6 +19,7 @@ use App\Http\Requests\Collections\UpdateCollectionPresentationRequest; use App\Http\Requests\Collections\UpdateCollectionSeriesRequest; use App\Models\Artwork; use App\Models\Collection; +use App\Models\Group; use App\Services\CollectionCollaborationService; use App\Services\CollectionCampaignService; use App\Services\CollectionCommentService; @@ -56,6 +57,12 @@ class CollectionManageController extends Controller $initialMode = $request->query('mode') === Collection::MODE_SMART ? Collection::MODE_SMART : Collection::MODE_MANUAL; + $group = null; + + if ($request->filled('group')) { + $group = Group::query()->with(['owner.profile', 'members'])->where('slug', (string) $request->query('group'))->first(); + abort_if($group && ! $group->canManageCollections($request->user()), 403); + } return Inertia::render('Collection/CollectionManage', [ 'mode' => 'create', @@ -67,7 +74,7 @@ class CollectionManageController extends Controller 'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()), 'initialMode' => $initialMode, 'featuredLimit' => (int) config('collections.featured_limit', 3), - 'owner' => $this->ownerPayload($request), + 'owner' => $this->ownerPayload($request, $group), 'members' => [], 'submissions' => [], 'comments' => [], @@ -75,7 +82,7 @@ class CollectionManageController extends Controller 'canonicalTarget' => null, 'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7), 'endpoints' => [ - 'store' => route('settings.collections.store'), + 'store' => route('settings.collections.store', $group ? ['group' => $group->slug] : []), 'smartPreview' => route('settings.collections.smart.preview'), 'profileCollections' => route('profile.tab', [ 'username' => strtolower((string) $request->user()->username), @@ -337,7 +344,11 @@ class CollectionManageController extends Controller { $artwork = $this->resolveArtworkFromRequest($request, 4); - abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404); + if ((int) ($artwork->group_id ?? 0) > 0) { + abort_unless($artwork->group?->canManageCollections($request->user()) ?? false, 404); + } else { + abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404); + } return response()->json([ 'data' => $this->collections->getCollectionOptionsForArtwork($request->user(), $artwork), @@ -464,6 +475,21 @@ class CollectionManageController extends Controller { $user = $request->user(); + if ($request->filled('group')) { + $group = Group::query()->with('owner.profile')->where('slug', (string) $request->query('group'))->first(); + + if ($group && $group->canManageCollections($user)) { + return [ + 'id' => $group->id, + 'username' => null, + 'name' => $group->name, + 'avatar_url' => $group->avatarUrl(), + 'group_slug' => $group->slug, + 'profile_url' => $group->publicUrl(), + ]; + } + } + return [ 'id' => $user->id, 'username' => $user->username, diff --git a/app/Http/Controllers/Studio/GroupActivityStudioController.php b/app/Http/Controllers/Studio/GroupActivityStudioController.php new file mode 100644 index 00000000..2fe1da55 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupActivityStudioController.php @@ -0,0 +1,48 @@ +authorize('viewStudio', $group); + + return Inertia::render('Studio/StudioGroupActivity', [ + 'title' => $group->name . ' Activity', + 'description' => 'Track public and internal group events from one activity timeline.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'activity' => $this->activity->studioFeed($group, $request->user(), 30), + 'pinPattern' => $group->canPinActivity($request->user()) ? route('studio.groups.activity.pin', ['group' => $group, 'item' => '__ITEM__']) : null, + ]); + } + + public function pin(PinGroupActivityItemRequest $request, Group $group, GroupActivityItem $item): RedirectResponse + { + $this->authorize('pinActivity', $group); + abort_unless((int) $item->group_id === (int) $group->id, 404); + + $this->activity->pin($item, $request->user(), (bool) $request->boolean('is_pinned', ! $item->is_pinned)); + + return back()->with('success', 'Activity updated.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupAssetStudioController.php b/app/Http/Controllers/Studio/GroupAssetStudioController.php new file mode 100644 index 00000000..d5263fbc --- /dev/null +++ b/app/Http/Controllers/Studio/GroupAssetStudioController.php @@ -0,0 +1,63 @@ +authorize('viewStudio', $group); + + return Inertia::render('Studio/StudioGroupAssets', [ + 'title' => $group->name . ' Assets', + 'description' => 'Manage reusable group files, templates, brand assets, and reference packs.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->assets->studioListing($group, $request->user(), $request->only(['bucket', 'category', 'q', 'page', 'per_page'])), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'categoryOptions' => collect((array) config('groups.assets.categories', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.assets.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'statusOptions' => collect((array) config('groups.assets.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'storeUrl' => $group->canManageAssets($request->user()) ? route('studio.groups.assets.store', ['group' => $group]) : null, + 'updatePattern' => $group->canManageAssets($request->user()) ? route('studio.groups.assets.update', ['group' => $group, 'asset' => '__ASSET__']) : null, + ]); + } + + public function store(StoreGroupAssetRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageAssets', $group); + + $this->assets->store($group, $request->user(), $request->validated()); + + return back()->with('success', 'Asset uploaded.'); + } + + public function update(UpdateGroupAssetRequest $request, Group $group, GroupAsset $asset): RedirectResponse + { + $this->authorize('manageAssets', $group); + abort_unless((int) $asset->group_id === (int) $group->id, 404); + + $this->assets->update($asset, $request->user(), $request->validated()); + + return back()->with('success', 'Asset updated.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupChallengeStudioController.php b/app/Http/Controllers/Studio/GroupChallengeStudioController.php new file mode 100644 index 00000000..91e732b9 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupChallengeStudioController.php @@ -0,0 +1,126 @@ +authorize('manageChallenges', $group); + + return Inertia::render('Studio/StudioGroupChallenges', [ + 'title' => $group->name . ' Challenges', + 'description' => 'Run creative prompts, themed sprints, and public or internal participation campaigns.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->challenges->studioListing($group, $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + 'createUrl' => route('studio.groups.challenges.create', ['group' => $group]), + ]); + } + + public function create(Request $request, Group $group): Response + { + $this->authorize('manageChallenges', $group); + + return Inertia::render('Studio/StudioGroupChallengeEditor', [ + 'title' => 'Create challenge', + 'description' => 'Set the timeline, rules, and participation model for a new group challenge.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'challenge' => null, + 'statusOptions' => collect((array) config('groups.challenges.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'storeUrl' => route('studio.groups.challenges.store', ['group' => $group]), + ]); + } + + public function store(StoreGroupChallengeRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageChallenges', $group); + + $challenge = $this->challenges->create($group, $request->user(), $request->validated()); + + return redirect()->route('studio.groups.challenges.edit', ['group' => $group, 'challenge' => $challenge]) + ->with('success', 'Challenge created.'); + } + + public function edit(Request $request, Group $group, GroupChallenge $challenge): Response + { + $this->authorize('manageChallenges', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + + return Inertia::render('Studio/StudioGroupChallengeEditor', [ + 'title' => 'Edit challenge', + 'description' => 'Publish and curate challenge entries from one editing view.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'challenge' => $this->challenges->detailPayload($challenge, $request->user()), + 'statusOptions' => collect((array) config('groups.challenges.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'updateUrl' => route('studio.groups.challenges.update', ['group' => $group, 'challenge' => $challenge]), + 'publishUrl' => route('studio.groups.challenges.publish', ['group' => $group, 'challenge' => $challenge]), + 'attachArtworkUrl' => route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), + ]); + } + + public function update(UpdateGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse + { + $this->authorize('manageChallenges', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + + $this->challenges->update($challenge, $request->user(), $request->validated()); + + return back()->with('success', 'Challenge updated.'); + } + + public function publish(Request $request, Group $group, GroupChallenge $challenge): RedirectResponse + { + $this->authorize('manageChallenges', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + + $this->challenges->publish($challenge, $request->user()); + + return back()->with('success', 'Challenge published.'); + } + + public function attachArtwork(AttachArtworkToGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse + { + $this->authorize('manageChallenges', $group); + abort_unless((int) $challenge->group_id === (int) $group->id, 404); + + $artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id')); + $this->challenges->attachArtwork($challenge, $artwork, $request->user()); + + return back()->with('success', 'Artwork attached to challenge.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupEventStudioController.php b/app/Http/Controllers/Studio/GroupEventStudioController.php new file mode 100644 index 00000000..bdba1329 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupEventStudioController.php @@ -0,0 +1,110 @@ +authorize('manageEvents', $group); + + return Inertia::render('Studio/StudioGroupEvents', [ + 'title' => $group->name . ' Events', + 'description' => 'Manage launches, milestones, streams, and timeline-aware group moments.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->events->studioListing($group, $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + 'createUrl' => route('studio.groups.events.create', ['group' => $group]), + ]); + } + + public function create(Request $request, Group $group): Response + { + $this->authorize('manageEvents', $group); + + return Inertia::render('Studio/StudioGroupEventEditor', [ + 'title' => 'Create event', + 'description' => 'Schedule a release, internal session, livestream, or other group event.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'event' => null, + 'typeOptions' => collect((array) config('groups.events.types', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'statusOptions' => collect((array) config('groups.events.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.events.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'challengeOptions' => $group->challenges()->orderBy('title')->get(['id', 'title'])->map(fn ($challenge): array => ['id' => (int) $challenge->id, 'title' => $challenge->title])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'storeUrl' => route('studio.groups.events.store', ['group' => $group]), + ]); + } + + public function store(StoreGroupEventRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageEvents', $group); + + $event = $this->events->create($group, $request->user(), $request->validated()); + + return redirect()->route('studio.groups.events.edit', ['group' => $group, 'event' => $event]) + ->with('success', 'Event created.'); + } + + public function edit(Request $request, Group $group, GroupEvent $event): Response + { + abort_unless($group->canManageEvents($request->user()) || $group->canPublishEventUpdates($request->user()), 403); + abort_unless((int) $event->group_id === (int) $group->id, 404); + + return Inertia::render('Studio/StudioGroupEventEditor', [ + 'title' => 'Edit event', + 'description' => 'Refine public details and publish event updates safely.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'event' => $this->events->detailPayload($event), + 'typeOptions' => collect((array) config('groups.events.types', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'statusOptions' => collect((array) config('groups.events.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.events.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'challengeOptions' => $group->challenges()->orderBy('title')->get(['id', 'title'])->map(fn ($challenge): array => ['id' => (int) $challenge->id, 'title' => $challenge->title])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'updateUrl' => route('studio.groups.events.update', ['group' => $group, 'event' => $event]), + 'publishUrl' => route('studio.groups.events.publish', ['group' => $group, 'event' => $event]), + ]); + } + + public function update(UpdateGroupEventRequest $request, Group $group, GroupEvent $event): RedirectResponse + { + abort_unless($group->canManageEvents($request->user()) || $group->canPublishEventUpdates($request->user()), 403); + abort_unless((int) $event->group_id === (int) $group->id, 404); + + $this->events->update($event, $request->user(), $request->validated()); + + return back()->with('success', 'Event updated.'); + } + + public function publish(Request $request, Group $group, GroupEvent $event): RedirectResponse + { + $this->authorize('manageEvents', $group); + abort_unless((int) $event->group_id === (int) $group->id, 404); + + $this->events->publish($event, $request->user()); + + return back()->with('success', 'Event published.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupJoinRequestStudioController.php b/app/Http/Controllers/Studio/GroupJoinRequestStudioController.php new file mode 100644 index 00000000..8e4a2fdc --- /dev/null +++ b/app/Http/Controllers/Studio/GroupJoinRequestStudioController.php @@ -0,0 +1,72 @@ +authorize('reviewJoinRequests', $group); + + return Inertia::render('Studio/StudioGroupJoinRequests', [ + 'title' => $group->name . ' Join requests', + 'description' => 'Review incoming applications, compare requested roles, and approve or reject requests with audit history.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->joinRequests->mapRequests($group, $request->user(), $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + 'roleOptions' => [ + ['value' => Group::ROLE_MEMBER, 'label' => 'Contributor'], + ['value' => Group::ROLE_EDITOR, 'label' => 'Editor'], + ['value' => Group::ROLE_ADMIN, 'label' => 'Admin'], + ], + ]); + } + + public function approve(ReviewGroupJoinRequestRequest $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse + { + $this->authorize('reviewJoinRequests', $group); + abort_unless((int) $joinRequest->group_id === (int) $group->id, 404); + + $this->joinRequests->approve( + $joinRequest, + $request->user(), + $request->validated('role'), + $request->validated('review_notes'), + ); + + return back()->with('success', 'Join request approved.'); + } + + public function reject(ReviewGroupJoinRequestRequest $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse + { + $this->authorize('reviewJoinRequests', $group); + abort_unless((int) $joinRequest->group_id === (int) $group->id, 404); + + $this->joinRequests->reject( + $joinRequest, + $request->user(), + $request->validated('review_notes'), + ); + + return back()->with('success', 'Join request rejected.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupPostStudioController.php b/app/Http/Controllers/Studio/GroupPostStudioController.php new file mode 100644 index 00000000..2e0d4b2d --- /dev/null +++ b/app/Http/Controllers/Studio/GroupPostStudioController.php @@ -0,0 +1,133 @@ +authorize('managePosts', $group); + + return Inertia::render('Studio/StudioGroupPosts', [ + 'title' => $group->name . ' Posts', + 'description' => 'Publish announcements, releases, recruitment calls, and pinned updates from the group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->posts->studioListing($group, $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + 'createUrl' => route('studio.groups.posts.create', ['group' => $group]), + ]); + } + + public function create(Request $request, Group $group): Response + { + $this->authorize('managePosts', $group); + + return Inertia::render('Studio/StudioGroupPostEditor', [ + 'title' => 'Create post', + 'description' => 'Draft a new public announcement for the group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'post' => null, + 'typeOptions' => $this->typeOptions(), + 'storeUrl' => route('studio.groups.posts.store', ['group' => $group]), + ]); + } + + public function store(StoreGroupPostRequest $request, Group $group): RedirectResponse + { + $this->authorize('managePosts', $group); + + $post = $this->posts->create($group, $request->user(), $request->validated()); + + return redirect()->route('studio.groups.posts.edit', ['group' => $group, 'post' => $post]) + ->with('success', 'Draft created.'); + } + + public function edit(Request $request, Group $group, GroupPost $post): Response + { + $this->authorize('managePosts', $group); + abort_unless((int) $post->group_id === (int) $group->id, 404); + + return Inertia::render('Studio/StudioGroupPostEditor', [ + 'title' => 'Edit post', + 'description' => 'Update copy, publish state, and pinned status for this group post.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'post' => $this->posts->mapStudioPost($group, $post), + 'typeOptions' => $this->typeOptions(), + 'storeUrl' => null, + 'updateUrl' => route('studio.groups.posts.update', ['group' => $group, 'post' => $post]), + 'publishUrl' => route('studio.groups.posts.publish', ['group' => $group, 'post' => $post]), + 'pinUrl' => route('studio.groups.posts.pin', ['group' => $group, 'post' => $post]), + 'archiveUrl' => route('studio.groups.posts.archive', ['group' => $group, 'post' => $post]), + ]); + } + + public function update(UpdateGroupPostRequest $request, Group $group, GroupPost $post): RedirectResponse + { + $this->authorize('managePosts', $group); + abort_unless((int) $post->group_id === (int) $group->id, 404); + + $this->posts->update($post, $request->user(), $request->validated()); + + return back()->with('success', 'Post updated.'); + } + + public function publish(Request $request, Group $group, GroupPost $post): RedirectResponse + { + $this->authorize('publishPosts', $group); + abort_unless((int) $post->group_id === (int) $group->id, 404); + + $this->posts->publish($post, $request->user()); + + return back()->with('success', 'Post published.'); + } + + public function pin(Request $request, Group $group, GroupPost $post): RedirectResponse + { + $this->authorize('pinPosts', $group); + abort_unless((int) $post->group_id === (int) $group->id, 404); + + $this->posts->pin($post, $request->user(), ! $post->is_pinned); + + return back()->with('success', $post->is_pinned ? 'Post unpinned.' : 'Post pinned.'); + } + + public function archive(Request $request, Group $group, GroupPost $post): RedirectResponse + { + $this->authorize('managePosts', $group); + abort_unless((int) $post->group_id === (int) $group->id, 404); + + $this->posts->archive($post, $request->user()); + + return back()->with('success', 'Post archived.'); + } + + private function typeOptions(): array + { + return [ + ['value' => GroupPost::TYPE_ANNOUNCEMENT, 'label' => 'Announcement'], + ['value' => GroupPost::TYPE_RELEASE, 'label' => 'Release'], + ['value' => GroupPost::TYPE_RECRUITMENT, 'label' => 'Recruitment'], + ['value' => GroupPost::TYPE_UPDATE, 'label' => 'Update'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupProjectStudioController.php b/app/Http/Controllers/Studio/GroupProjectStudioController.php new file mode 100644 index 00000000..13486994 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupProjectStudioController.php @@ -0,0 +1,167 @@ +authorize('manageProjects', $group); + + return Inertia::render('Studio/StudioGroupProjects', [ + 'title' => $group->name . ' Projects', + 'description' => 'Manage structured group releases, collaboration hubs, and showcase pages.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->projects->studioListing($group, $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + 'createUrl' => route('studio.groups.projects.create', ['group' => $group]), + ]); + } + + public function create(Request $request, Group $group): Response + { + $this->authorize('manageProjects', $group); + + return Inertia::render('Studio/StudioGroupProjectEditor', [ + 'title' => 'Create project', + 'description' => 'Set up a project page that can collect artworks, assets, notes, and release state.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'project' => null, + 'statusOptions' => collect((array) config('groups.projects.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.projects.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'memberOptions' => $this->projects->memberOptions($group->loadMissing('owner.profile')), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'postOptions' => $group->posts()->latest('updated_at')->limit(20)->get(['id', 'title'])->map(fn (GroupPost $post): array => ['id' => (int) $post->id, 'title' => $post->title])->values()->all(), + 'storeUrl' => route('studio.groups.projects.store', ['group' => $group]), + ]); + } + + public function store(StoreGroupProjectRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageProjects', $group); + + $project = $this->projects->create($group, $request->user(), $request->validated()); + + return redirect()->route('studio.groups.projects.edit', ['group' => $group, 'project' => $project]) + ->with('success', 'Project created.'); + } + + public function edit(Request $request, Group $group, GroupProject $project): Response + { + $this->authorize('manageProjects', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + return Inertia::render('Studio/StudioGroupProjectEditor', [ + 'title' => 'Edit project', + 'description' => 'Update status, attachments, and project presentation.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'project' => $this->projects->detailPayload($project, $request->user()), + 'statusOptions' => collect((array) config('groups.projects.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.projects.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'memberOptions' => $this->projects->memberOptions($group->loadMissing('owner.profile')), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'assetOptions' => $group->assets()->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn (GroupAsset $asset): array => ['id' => (int) $asset->id, 'title' => $asset->title])->values()->all(), + 'postOptions' => $group->posts()->latest('updated_at')->limit(20)->get(['id', 'title'])->map(fn (GroupPost $post): array => ['id' => (int) $post->id, 'title' => $post->title])->values()->all(), + 'updateUrl' => route('studio.groups.projects.update', ['group' => $group, 'project' => $project]), + 'statusUrl' => route('studio.groups.projects.status', ['group' => $group, 'project' => $project]), + 'attachArtworkUrl' => route('studio.groups.projects.attach-artwork', ['group' => $group, 'project' => $project]), + 'attachAssetUrl' => route('studio.groups.projects.attach-asset', ['group' => $group, 'project' => $project]), + 'storeMilestoneUrl' => route('studio.groups.projects.milestones.store', ['group' => $group, 'project' => $project]), + 'updateMilestonePattern' => route('studio.groups.projects.milestones.update', ['group' => $group, 'project' => $project, 'milestone' => '__MILESTONE__']), + ]); + } + + public function update(UpdateGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse + { + $this->authorize('manageProjects', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + $this->projects->update($project, $request->user(), $request->validated()); + + return back()->with('success', 'Project updated.'); + } + + public function attachArtwork(AttachArtworkToGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse + { + $this->authorize('manageProjects', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + $artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id')); + $this->projects->attachArtwork($project, $artwork, $request->user()); + + return back()->with('success', 'Artwork attached to project.'); + } + + public function attachAsset(AttachAssetToGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse + { + $this->authorize('attachAssetsToProjects', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + $asset = GroupAsset::query()->findOrFail((int) $request->validated('asset_id')); + $this->projects->attachAsset($project, $asset, $request->user()); + + return back()->with('success', 'Asset attached to project.'); + } + + public function status(UpdateGroupProjectStatusRequest $request, Group $group, GroupProject $project): RedirectResponse + { + $this->authorize('manageProjects', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + $this->projects->updateStatus($project, $request->user(), (string) $request->validated('status')); + + return back()->with('success', 'Project status updated.'); + } + + public function storeMilestone(StoreGroupMilestoneRequest $request, Group $group, GroupProject $project): RedirectResponse + { + $this->authorize('manageMilestones', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + + $this->projects->createMilestone($project, $request->user(), $request->validated()); + + return back()->with('success', 'Project milestone created.'); + } + + public function updateMilestone(UpdateGroupMilestoneRequest $request, Group $group, GroupProject $project, GroupProjectMilestone $milestone): RedirectResponse + { + $this->authorize('manageMilestones', $group); + abort_unless((int) $project->group_id === (int) $group->id, 404); + abort_unless((int) $milestone->group_project_id === (int) $project->id, 404); + + $this->projects->updateMilestone($milestone, $request->user(), $request->validated()); + + return back()->with('success', 'Project milestone updated.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupRecruitmentStudioController.php b/app/Http/Controllers/Studio/GroupRecruitmentStudioController.php new file mode 100644 index 00000000..cffe4a27 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupRecruitmentStudioController.php @@ -0,0 +1,64 @@ +authorize('manageRecruitment', $group); + + return Inertia::render('Studio/StudioGroupRecruitment', [ + 'title' => $group->name . ' Recruitment', + 'description' => 'Show open roles publicly, describe your workflow, and control how applicants should get in touch.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'recruitment' => $this->groups->recruitmentPayload($group), + 'contactModes' => [ + ['value' => 'join_request', 'label' => 'Join request'], + ['value' => 'direct_message', 'label' => 'Direct message'], + ['value' => 'external_link', 'label' => 'External link'], + ], + 'visibilityOptions' => [ + ['value' => 'public', 'label' => 'Public'], + ['value' => 'members_only', 'label' => 'Members only'], + ['value' => 'private', 'label' => 'Private'], + ], + 'roleOptions' => collect(config('groups.recruitment.roles', [])) + ->map(fn (string $role): array => ['value' => $role, 'label' => $role]) + ->values() + ->all(), + 'skillOptions' => collect(config('groups.recruitment.skills', [])) + ->map(fn (string $skill): array => ['value' => $skill, 'label' => $skill]) + ->values() + ->all(), + 'updateUrl' => route('studio.groups.recruitment.update', ['group' => $group]), + ]); + } + + public function update(UpdateGroupRecruitmentRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageRecruitment', $group); + + $this->recruitment->upsert($group, $request->validated(), $request->user()); + + return back()->with('success', 'Recruitment profile updated.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupReleaseStudioController.php b/app/Http/Controllers/Studio/GroupReleaseStudioController.php new file mode 100644 index 00000000..713229b4 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupReleaseStudioController.php @@ -0,0 +1,177 @@ +authorize('manageReleases', $group); + + return Inertia::render('Studio/StudioGroupReleases', [ + 'title' => $group->name . ' Releases', + 'description' => 'Manage release pipelines, contributors, and publication stages for major group drops.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->releases->studioListing($group, $request->only(['bucket', 'page', 'per_page'])), + 'createUrl' => route('studio.groups.releases.create', ['group' => $group]), + ]); + } + + public function create(Request $request, Group $group): Response + { + $this->authorize('manageReleases', $group); + + return Inertia::render('Studio/StudioGroupReleaseEditor', [ + 'title' => 'Create release', + 'description' => 'Build a release page and move it from concept through publishing.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'release' => null, + 'statusOptions' => collect((array) config('groups.releases.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', ucfirst($value))])->values()->all(), + 'stageOptions' => collect((array) config('groups.releases.stages', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.releases.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'memberOptions' => $this->releases->memberOptions($group->loadMissing('owner.profile')), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'storeUrl' => route('studio.groups.releases.store', ['group' => $group]), + ]); + } + + public function store(StoreGroupReleaseRequest $request, Group $group): RedirectResponse + { + $this->authorize('manageReleases', $group); + + $release = $this->releases->create($group, $request->user(), $request->validated()); + + return redirect()->route('studio.groups.releases.show', ['group' => $group, 'release' => $release]) + ->with('success', 'Release created.'); + } + + public function show(Request $request, Group $group, GroupRelease $release): Response + { + $this->authorize('manageReleases', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + return Inertia::render('Studio/StudioGroupReleaseEditor', [ + 'title' => 'Edit release', + 'description' => 'Update the release story, stage, contributors, and publish plan.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'release' => $this->releases->detailPayload($release, $request->user()), + 'statusOptions' => collect((array) config('groups.releases.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', ucfirst($value))])->values()->all(), + 'stageOptions' => collect((array) config('groups.releases.stages', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'visibilityOptions' => collect((array) config('groups.releases.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(), + 'memberOptions' => $this->releases->memberOptions($group->loadMissing('owner.profile')), + 'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(), + 'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(), + 'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(), + 'updateUrl' => route('studio.groups.releases.update', ['group' => $group, 'release' => $release]), + 'stageUrl' => route('studio.groups.releases.stage', ['group' => $group, 'release' => $release]), + 'publishUrl' => route('studio.groups.releases.publish', ['group' => $group, 'release' => $release]), + 'attachArtworkUrl' => route('studio.groups.releases.attach-artwork', ['group' => $group, 'release' => $release]), + 'attachContributorUrl' => route('studio.groups.releases.attach-contributor', ['group' => $group, 'release' => $release]), + 'storeMilestoneUrl' => route('studio.groups.releases.milestones.store', ['group' => $group, 'release' => $release]), + 'updateMilestonePattern' => route('studio.groups.releases.milestones.update', ['group' => $group, 'release' => $release, 'milestone' => '__MILESTONE__']), + ]); + } + + public function update(UpdateGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('manageReleases', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $this->releases->update($release, $request->user(), $request->validated()); + + return back()->with('success', 'Release updated.'); + } + + public function stage(UpdateGroupReleaseStageRequest $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('moveReleaseStage', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $this->releases->updateStage($release, $request->user(), (string) $request->validated('current_stage')); + + return back()->with('success', 'Release stage updated.'); + } + + public function publish(Request $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('publishReleases', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $this->releases->publish($release, $request->user()); + + return back()->with('success', 'Release published.'); + } + + public function attachArtwork(AttachArtworkToGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('manageReleases', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id')); + $this->releases->attachArtwork($release, $artwork, $request->user()); + + return back()->with('success', 'Artwork attached to release.'); + } + + public function attachContributor(AttachContributorToGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('manageReleases', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $contributor = User::query()->findOrFail((int) $request->validated('user_id')); + $this->releases->attachContributor($release, $contributor, $request->user(), $request->validated('role_label')); + + return back()->with('success', 'Contributor attached to release.'); + } + + public function storeMilestone(StoreGroupMilestoneRequest $request, Group $group, GroupRelease $release): RedirectResponse + { + $this->authorize('manageMilestones', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + + $this->releases->createMilestone($release, $request->user(), $request->validated()); + + return back()->with('success', 'Release milestone created.'); + } + + public function updateMilestone(UpdateGroupMilestoneRequest $request, Group $group, GroupRelease $release, GroupReleaseMilestone $milestone): RedirectResponse + { + $this->authorize('manageMilestones', $group); + abort_unless((int) $release->group_id === (int) $group->id, 404); + abort_unless((int) $milestone->group_release_id === (int) $release->id, 404); + + $this->releases->updateMilestone($milestone, $request->user(), $request->validated()); + + return back()->with('success', 'Release milestone updated.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupReputationStudioController.php b/app/Http/Controllers/Studio/GroupReputationStudioController.php new file mode 100644 index 00000000..a7f21878 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupReputationStudioController.php @@ -0,0 +1,48 @@ +authorize('viewReputationDashboard', $group); + + $this->reputation->refreshGroup($group); + $metrics = $this->discovery->refresh($group); + + return Inertia::render('Studio/StudioGroupReputation', [ + 'title' => $group->name . ' Reputation', + 'description' => 'Review contributor reliability, badge unlocks, and internal trust metrics.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'reputation' => $this->reputation->summary($group), + 'trustSignals' => $this->reputation->trustSignals($group), + 'metrics' => [ + 'freshness_score' => (float) $metrics->freshness_score, + 'activity_score' => (float) $metrics->activity_score, + 'release_score' => (float) $metrics->release_score, + 'trust_score' => (float) $metrics->trust_score, + 'collaboration_score' => (float) $metrics->collaboration_score, + 'last_calculated_at' => $metrics->last_calculated_at?->toISOString(), + ], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupReviewStudioController.php b/app/Http/Controllers/Studio/GroupReviewStudioController.php new file mode 100644 index 00000000..1349c2f8 --- /dev/null +++ b/app/Http/Controllers/Studio/GroupReviewStudioController.php @@ -0,0 +1,62 @@ +authorize('viewStudio', $group); + + return Inertia::render('Studio/StudioGroupReviewQueue', [ + 'title' => $group->name . ' Review queue', + 'description' => 'Approve, reject, or request changes for artwork submitted under this group identity.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->reviews->listing($group, $request->user(), $request->only(['bucket', 'page', 'per_page'])), + 'recentHistory' => $this->groups->recentHistory($group), + ]); + } + + public function approve(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse + { + $this->authorize('reviewSubmissions', $group); + $this->reviews->approve($group, $artwork, $request->user(), $request->validated('review_notes')); + + return back()->with('success', 'Artwork approved and published.'); + } + + public function needsChanges(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse + { + $this->authorize('reviewSubmissions', $group); + $this->reviews->requestChanges($group, $artwork, $request->user(), $request->validated('review_notes')); + + return back()->with('success', 'Changes requested from the uploader.'); + } + + public function reject(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse + { + $this->authorize('reviewSubmissions', $group); + $this->reviews->reject($group, $artwork, $request->user(), $request->validated('review_notes')); + + return back()->with('success', 'Artwork rejected.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/GroupStudioController.php b/app/Http/Controllers/Studio/GroupStudioController.php new file mode 100644 index 00000000..612e3b2c --- /dev/null +++ b/app/Http/Controllers/Studio/GroupStudioController.php @@ -0,0 +1,235 @@ +user(); + + $groups = Group::query() + ->with(['owner.profile', 'members']) + ->where(function ($query) use ($user): void { + $query->where('owner_user_id', $user->id) + ->orWhereHas('members', function ($memberQuery) use ($user): void { + $memberQuery->where('user_id', $user->id) + ->where('status', Group::STATUS_ACTIVE); + }); + }) + ->orderBy('name') + ->get() + ->map(fn (Group $group): array => $this->groups->mapGroupCard($group, $user)) + ->values() + ->all(); + + return Inertia::render('Studio/StudioGroupsIndex', [ + 'title' => 'Groups', + 'description' => 'Create collective publishing identities, manage memberships, and switch into shared artwork and collection workflows.', + 'groups' => $groups, + 'pendingInvites' => $this->memberships->pendingInvitationsForUser($user), + 'endpoints' => [ + 'create' => route('studio.groups.create'), + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Group::class); + + return Inertia::render('Studio/StudioGroupCreate', [ + 'title' => 'Create Group', + 'description' => 'Set up a shared publishing identity for collaborative uploads and collections.', + 'visibilityOptions' => [ + ['value' => Group::VISIBILITY_PUBLIC, 'label' => 'Public'], + ['value' => Group::VISIBILITY_UNLISTED, 'label' => 'Unlisted'], + ['value' => Group::VISIBILITY_PRIVATE, 'label' => 'Private'], + ], + 'membershipPolicyOptions' => [ + ['value' => Group::MEMBERSHIP_INVITE_ONLY, 'label' => 'Invite only'], + ], + 'endpoints' => [ + 'store' => route('studio.groups.store'), + ], + ]); + } + + public function store(StoreGroupRequest $request): RedirectResponse + { + $this->authorize('create', Group::class); + + $group = $this->groups->createGroup($request->user(), $request->validated()); + + return redirect()->route('studio.groups.show', ['group' => $group]); + } + + public function show(Request $request, Group $group): Response + { + $this->authorize('viewStudio', $group); + + $viewer = $request->user(); + + return Inertia::render('Studio/StudioGroupDashboard', [ + 'title' => $group->name, + 'description' => $group->headline ?: 'Shared publishing overview for this group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $viewer), + 'dashboard' => $this->groups->studioDashboardSummary($group), + 'draftsPendingAction' => $this->groups->studioArtworkPreviewItems($group, 'drafts', 4), + 'recentArtworks' => $this->groups->studioArtworkPreviewItems($group, 'published', 6), + 'recentCollections' => $this->groups->studioCollectionPreviewItems($group, 4), + 'members' => $this->memberships->mapMembers($group, $viewer), + 'recentPosts' => $this->groups->recentPostCards($group, 3), + 'recentProjects' => $this->groups->recentProjectCards($group, $viewer, 3), + 'recentReleases' => $this->groups->recentReleaseCards($group, $viewer, 3), + 'recentChallenges' => $this->groups->recentChallengeCards($group, $viewer, 3), + 'recentEvents' => $this->groups->recentEventCards($group, $viewer, 3), + 'recentActivity' => $this->groups->studioActivityFeed($group, $viewer, 8), + 'recruitment' => $this->groups->recruitmentPayload($group), + 'reputationSummary' => $this->reputation->summary($group), + 'trustSignals' => $this->reputation->trustSignals($group), + 'pendingJoinRequests' => $group->canReviewJoinRequests($viewer) + ? $this->joinRequests->mapRequests($group, $viewer, ['bucket' => 'pending', 'per_page' => 4])['items'] + : [], + 'reviewQueuePreview' => $this->artworkReviews->listing($group, $viewer, ['bucket' => 'submitted', 'per_page' => 4])['items'], + 'recentHistory' => $this->groups->recentHistory($group, 6), + ]); + } + + public function artworks(Request $request, Group $group): Response + { + $this->authorize('viewStudio', $group); + + return Inertia::render('Studio/StudioGroupArtworks', [ + 'title' => $group->name . ' Artworks', + 'description' => 'Browse every artwork published through this shared identity.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->groups->studioArtworkListing($group, $request->only(['bucket', 'q', 'page', 'per_page'])), + 'uploadUrl' => route('upload', ['group' => $group->slug]), + ]); + } + + public function collections(Request $request, Group $group): Response + { + $this->authorize('viewStudio', $group); + + return Inertia::render('Studio/StudioGroupCollections', [ + 'title' => $group->name . ' Collections', + 'description' => 'Manage collections published under this group identity.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'listing' => $this->groups->studioCollectionListing($group, $request->only(['bucket', 'q', 'page', 'per_page'])), + 'createUrl' => route('settings.collections.create', ['group' => $group->slug]), + ]); + } + + public function members(Request $request, Group $group): Response + { + $this->authorize('viewStudio', $group); + $viewer = $request->user(); + $canManageMembers = $group->canManageMembers($viewer); + + return Inertia::render('Studio/StudioGroupMembers', [ + 'title' => $group->name . ' Members', + 'description' => 'Invite, remove, and promote the people who can publish and curate under this group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $viewer), + 'members' => $this->memberships->mapMembers($group, $viewer), + 'canManageMembers' => $canManageMembers, + 'permissionOverrideOptions' => array_map(static fn (string $permission): array => [ + 'value' => $permission, + 'label' => str_replace('_', ' ', $permission), + ], Group::allowedPermissionOverrides()), + 'endpoints' => $canManageMembers ? [ + 'invite' => route('studio.groups.members.store', ['group' => $group]), + 'invitations' => route('studio.groups.invitations', ['group' => $group]), + 'updatePattern' => route('studio.groups.members.update', ['group' => $group, 'member' => '__MEMBER__']), + 'permissionsPattern' => route('studio.groups.members.permissions.update', ['group' => $group, 'member' => '__MEMBER__']), + 'transferPattern' => route('studio.groups.members.transfer', ['group' => $group, 'member' => '__MEMBER__']), + 'deletePattern' => route('studio.groups.members.destroy', ['group' => $group, 'member' => '__MEMBER__']), + ] : null, + ]); + } + + public function invitations(Request $request, Group $group): Response + { + $this->authorize('manageMembers', $group); + + return Inertia::render('Studio/StudioGroupInvitations', [ + 'title' => $group->name . ' Invitations', + 'description' => 'Manage outstanding invites, resend collaboration roles, and review recent invite history for this group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'members' => $this->memberships->mapMembers($group, $request->user()), + 'invitations' => $this->memberships->mapInvitations($group, $request->user()), + 'endpoints' => [ + 'invite' => route('studio.groups.members.store', ['group' => $group]), + 'deletePattern' => route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => '__INVITATION__']), + ], + ]); + } + + public function settings(Request $request, Group $group): Response + { + $this->authorize('update', $group); + + return Inertia::render('Studio/StudioGroupSettings', [ + 'title' => $group->name . ' Settings', + 'description' => 'Update the public presentation and collaboration defaults for this group.', + 'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()), + 'featuredArtworkOptions' => $this->groups->studioFeaturedArtworkOptions($group), + 'visibilityOptions' => [ + ['value' => Group::VISIBILITY_PUBLIC, 'label' => 'Public'], + ['value' => Group::VISIBILITY_UNLISTED, 'label' => 'Unlisted'], + ['value' => Group::VISIBILITY_PRIVATE, 'label' => 'Private'], + ], + 'membershipPolicyOptions' => [ + ['value' => Group::MEMBERSHIP_INVITE_ONLY, 'label' => 'Invite only'], + ], + 'endpoints' => [ + 'update' => route('studio.groups.update', ['group' => $group]), + 'archive' => route('studio.groups.archive', ['group' => $group]), + ], + ]); + } + + public function update(UpdateGroupRequest $request, Group $group): RedirectResponse + { + $this->authorize('update', $group); + + $group = $this->groups->updateGroup($group, $request->validated(), $request->user()); + + return redirect()->route('studio.groups.settings', ['group' => $group]); + } + + public function archive(Request $request, Group $group): RedirectResponse + { + $this->authorize('archive', $group); + + $group = $this->groups->archiveGroup($group, $request->user()); + + return redirect()->route('studio.groups.settings', ['group' => $group]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 1cc789c7..81fcc5c7 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -11,6 +11,7 @@ use App\Models\ContentType; use App\Models\ArtworkVersion; use App\Services\Cdn\ArtworkCdnPurgeService; use App\Services\ArtworkSearchIndexer; +use App\Services\ArtworkAttributionService; use App\Services\TagService; use App\Services\ArtworkVersioningService; use App\Services\Studio\StudioArtworkQueryService; @@ -118,7 +119,7 @@ final class StudioArtworksApiController extends Controller * PUT /api/studio/artworks/{id} * Update artwork details (title, description, visibility). */ - public function update(Request $request, int $id): JsonResponse + public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); @@ -138,8 +139,32 @@ final class StudioArtworksApiController extends Controller 'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', 'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', 'category_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', + 'group' => 'sometimes|nullable|string|max:90', + 'primary_author_user_id' => 'sometimes|nullable|integer|min:1', + 'contributor_user_ids' => 'sometimes|array|max:20', + 'contributor_user_ids.*' => 'integer|min:1', + 'contributor_credits' => 'sometimes|array|max:20', + 'contributor_credits.*.user_id' => 'required|integer|min:1', + 'contributor_credits.*.credit_role' => 'nullable|string|max:80', + 'contributor_credits.*.is_primary' => 'nullable|boolean', ]); + $hasAttributionUpdates = array_key_exists('group', $validated) + || array_key_exists('primary_author_user_id', $validated) + || array_key_exists('contributor_user_ids', $validated) + || array_key_exists('contributor_credits', $validated); + + $attributionPayload = [ + 'group' => $validated['group'] ?? $artwork->group?->slug, + 'primary_author_user_id' => $validated['primary_author_user_id'] ?? $artwork->primary_author_user_id, + 'contributor_user_ids' => $validated['contributor_user_ids'] ?? $artwork->contributors()->pluck('user_id')->all(), + 'contributor_credits' => $validated['contributor_credits'] ?? $artwork->contributors()->get()->map(fn ($contributor): array => [ + 'user_id' => (int) $contributor->user_id, + 'credit_role' => $contributor->credit_role, + 'is_primary' => (bool) $contributor->is_primary, + ])->values()->all(), + ]; + $visibility = (string) ($validated['visibility'] ?? ($artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE))); $mode = (string) ($validated['mode'] ?? ($artwork->artwork_status === 'scheduled' ? 'schedule' : 'now')); $timezone = array_key_exists('timezone', $validated) @@ -165,7 +190,7 @@ final class StudioArtworksApiController extends Controller $tags = $validated['tags'] ?? null; $categoryId = $validated['category_id'] ?? null; $contentTypeId = $validated['content_type_id'] ?? null; - unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone']); + unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']); $validated['visibility'] = $visibility; $validated['artwork_timezone'] = $timezone; @@ -215,6 +240,10 @@ final class StudioArtworksApiController extends Controller } } + if ($hasAttributionUpdates) { + $artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload); + } + // Reindex in Meilisearch try { if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) { @@ -227,7 +256,7 @@ final class StudioArtworksApiController extends Controller } // Reload relationships for response - $artwork->load(['categories.contentType', 'tags']); + $artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']); $primaryCategory = $artwork->categories->first(); return response()->json([ @@ -243,6 +272,14 @@ final class StudioArtworksApiController extends Controller 'artwork_status' => $artwork->artwork_status, 'artwork_timezone' => $artwork->artwork_timezone, 'slug' => $artwork->slug, + 'group_slug' => $artwork->group?->slug, + 'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), + 'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(), + 'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [ + 'user_id' => (int) $contributor->user_id, + 'credit_role' => $contributor->credit_role, + 'is_primary' => (bool) $contributor->is_primary, + ])->values()->all(), 'content_type_id' => $primaryCategory?->contentType?->id, 'category_id' => $primaryCategory?->id, 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index 2c73ed99..29c10202 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Studio; use App\Http\Controllers\Controller; +use App\Models\Group; use App\Models\ContentType; +use App\Services\GroupMembershipService; +use App\Services\GroupService; use App\Services\Studio\CreatorStudioAnalyticsService; use App\Services\Studio\CreatorStudioAssetService; use App\Services\Studio\CreatorStudioCalendarService; @@ -417,11 +420,24 @@ final class StudioController extends Controller */ public function edit(Request $request, int $id): Response { - $artwork = $request->user()->artworks() - ->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist']) + $user = $request->user(); + $artwork = $user->artworks() + ->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']) ->findOrFail($id); $primaryCategory = $artwork->categories->first(); + $availableGroups = app(GroupService::class)->studioOptionsForUser($user); + $membershipService = app(GroupMembershipService::class); + $contributorOptionsByGroup = []; + + foreach ($availableGroups as $groupOption) { + $group = Group::query()->with('members')->where('slug', (string) ($groupOption['slug'] ?? ''))->first(); + if (! $group || ! $group->hasActiveMember($user)) { + continue; + } + + $contributorOptionsByGroup[(string) $group->slug] = $membershipService->contributorOptions($group); + } return Inertia::render('Studio/StudioArtworkEdit', [ 'artwork' => [ @@ -443,6 +459,14 @@ final class StudioController extends Controller 'width' => $artwork->width, 'height' => $artwork->height, 'mime_type' => $artwork->mime_type, + 'group_slug' => $artwork->group?->slug, + 'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), + 'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(), + 'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [ + 'user_id' => (int) $contributor->user_id, + 'credit_role' => $contributor->credit_role, + 'is_primary' => (bool) $contributor->is_primary, + ])->values()->all(), 'content_type_id' => $primaryCategory?->contentType?->id, 'category_id' => $primaryCategory?->id, 'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id, @@ -459,6 +483,8 @@ final class StudioController extends Controller 'requires_reapproval' => (bool) $artwork->requires_reapproval, ], 'contentTypes' => $this->getCategories(), + 'groupOptions' => $availableGroups, + 'contributorOptionsByGroup' => $contributorOptionsByGroup, ]); } diff --git a/app/Http/Controllers/Studio/StudioNewsController.php b/app/Http/Controllers/Studio/StudioNewsController.php new file mode 100644 index 00000000..49d5b570 --- /dev/null +++ b/app/Http/Controllers/Studio/StudioNewsController.php @@ -0,0 +1,403 @@ +authorizeNews($request); + + return Inertia::render('Studio/StudioNewsIndex', [ + 'title' => 'Newsroom', + 'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.', + 'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])), + 'statusOptions' => $this->news->editorialStatusOptions(), + 'typeOptions' => $this->news->articleTypeOptions(), + 'categoryOptions' => $this->news->categoryOptions(), + 'createUrl' => route('studio.news.create'), + 'categoriesUrl' => route('studio.news.categories'), + 'tagsUrl' => route('studio.news.tags'), + ]); + } + + public function create(Request $request): Response + { + $this->authorizeNews($request); + + return Inertia::render('Studio/StudioNewsEditor', [ + 'title' => 'Create article', + 'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.', + 'article' => null, + 'typeOptions' => $this->news->articleTypeOptions(), + 'statusOptions' => $this->news->editorialStatusOptions(), + 'categoryOptions' => $this->news->categoryOptions(), + 'tagOptions' => $this->news->tagOptions(), + 'relationTypeOptions' => $this->news->relationTypeOptions(), + 'storeUrl' => route('studio.news.store'), + 'entitySearchUrl' => route('studio.news.entity-search'), + 'categoriesUrl' => route('studio.news.categories'), + 'tagsUrl' => route('studio.news.tags'), + 'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeNews($request); + + $article = $this->news->storeArticle($request->user(), $this->validateArticle($request)); + + return redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article draft created.'); + } + + public function edit(Request $request, NewsArticle $article): Response + { + $this->authorizeNews($request); + + return Inertia::render('Studio/StudioNewsEditor', [ + 'title' => 'Edit article', + 'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.', + 'article' => $this->news->mapStudioArticle($article, $request->user()), + 'typeOptions' => $this->news->articleTypeOptions(), + 'statusOptions' => $this->news->editorialStatusOptions(), + 'categoryOptions' => $this->news->categoryOptions(), + 'tagOptions' => $this->news->tagOptions(), + 'relationTypeOptions' => $this->news->relationTypeOptions(), + 'updateUrl' => route('studio.news.update', ['article' => $article->id]), + 'previewUrl' => route('studio.news.preview', ['article' => $article->id]), + 'publishUrl' => route('studio.news.publish', ['article' => $article->id]), + 'archiveUrl' => route('studio.news.archive', ['article' => $article->id]), + 'featureUrl' => route('studio.news.feature', ['article' => $article->id]), + 'pinUrl' => route('studio.news.pin', ['article' => $article->id]), + 'entitySearchUrl' => route('studio.news.entity-search'), + 'categoriesUrl' => route('studio.news.categories'), + 'tagsUrl' => route('studio.news.tags'), + ]); + } + + public function preview(Request $request, NewsArticle $article): View + { + $this->authorizeNews($request); + + $article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']); + + $related = NewsArticle::with('author', 'category') + ->published() + ->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id)) + ->where('id', '!=', $article->id) + ->editorialOrder() + ->limit(config('news.related_limit', 4)) + ->get(); + + return view('news.show', [ + 'article' => $article, + 'related' => $related, + 'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), + 'previewMode' => true, + 'previewCanonical' => route('studio.news.preview', ['article' => $article->id]), + 'previewBackUrl' => route('studio.news.edit', ['article' => $article->id]), + ] + $this->news->sidebarData()); + } + + public function update(Request $request, NewsArticle $article): RedirectResponse + { + $this->authorizeNews($request); + + $this->news->updateArticle($article, $request->user(), $this->validateArticle($request, $article)); + + return back()->with('success', 'Article updated.'); + } + + public function publish(Request $request, NewsArticle $article): RedirectResponse + { + $this->authorizeNews($request); + + $this->news->publish($article); + + return back()->with('success', 'Article published.'); + } + + public function archive(Request $request, NewsArticle $article): RedirectResponse + { + $this->authorizeNews($request); + + $this->news->archive($article); + + return back()->with('success', 'Article archived.'); + } + + public function feature(Request $request, NewsArticle $article): RedirectResponse + { + $this->authorizeNews($request); + + $updated = $this->news->toggleFeature($article); + + return back()->with('success', $updated->is_featured ? 'Article featured.' : 'Article removed from featured surface.'); + } + + public function pin(Request $request, NewsArticle $article): RedirectResponse + { + $this->authorizeNews($request); + + $updated = $this->news->togglePin($article); + + return back()->with('success', $updated->is_pinned ? 'Article pinned.' : 'Article unpinned.'); + } + + public function categories(Request $request): Response + { + $this->authorizeNews($request); + + return Inertia::render('Studio/StudioNewsTaxonomies', [ + 'title' => 'News taxonomies', + 'description' => 'Manage News categories and tags used across the editorial surface.', + 'activeTab' => 'categories', + 'categories' => NewsCategory::query() + ->withCount('publishedArticles') + ->ordered() + ->get() + ->map(fn (NewsCategory $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + 'slug' => (string) $category->slug, + 'description' => (string) ($category->description ?? ''), + 'position' => (int) $category->position, + 'is_active' => (bool) $category->is_active, + 'published_count' => (int) $category->published_articles_count, + ]) + ->all(), + 'tags' => $this->tagPayload(), + 'storeCategoryUrl' => route('studio.news.categories.store'), + 'storeTagUrl' => route('studio.news.tags.store'), + 'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']), + 'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']), + ]); + } + + public function tags(Request $request): Response + { + $this->authorizeNews($request); + + return Inertia::render('Studio/StudioNewsTaxonomies', [ + 'title' => 'News taxonomies', + 'description' => 'Manage News categories and tags used across the editorial surface.', + 'activeTab' => 'tags', + 'categories' => NewsCategory::query() + ->withCount('publishedArticles') + ->ordered() + ->get() + ->map(fn (NewsCategory $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + 'slug' => (string) $category->slug, + 'description' => (string) ($category->description ?? ''), + 'position' => (int) $category->position, + 'is_active' => (bool) $category->is_active, + 'published_count' => (int) $category->published_articles_count, + ]) + ->all(), + 'tags' => $this->tagPayload(), + 'storeCategoryUrl' => route('studio.news.categories.store'), + 'storeTagUrl' => route('studio.news.tags.store'), + 'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']), + 'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']), + ]); + } + + public function storeCategory(Request $request): RedirectResponse + { + $this->authorizeNews($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120', 'unique:news_categories,name'], + 'slug' => ['nullable', 'string', 'max:120', 'unique:news_categories,slug'], + 'description' => ['nullable', 'string'], + 'position' => ['nullable', 'integer', 'min:0', 'max:65535'], + 'is_active' => ['nullable', 'boolean'], + ]); + + NewsCategory::query()->create([ + 'name' => trim((string) $validated['name']), + 'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name'])), + 'description' => $validated['description'] ?? null, + 'position' => (int) ($validated['position'] ?? 0), + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + + return back()->with('success', 'Category created.'); + } + + public function updateCategory(Request $request, NewsCategory $category): RedirectResponse + { + $this->authorizeNews($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120', Rule::unique('news_categories', 'name')->ignore($category->id)], + 'slug' => ['nullable', 'string', 'max:120', Rule::unique('news_categories', 'slug')->ignore($category->id)], + 'description' => ['nullable', 'string'], + 'position' => ['nullable', 'integer', 'min:0', 'max:65535'], + 'is_active' => ['nullable', 'boolean'], + ]); + + $category->update([ + 'name' => trim((string) $validated['name']), + 'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name']), (int) $category->id), + 'description' => $validated['description'] ?? null, + 'position' => (int) ($validated['position'] ?? 0), + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + + return back()->with('success', 'Category updated.'); + } + + public function storeTag(Request $request): RedirectResponse + { + $this->authorizeNews($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:80', 'unique:news_tags,name'], + 'slug' => ['nullable', 'string', 'max:80', 'unique:news_tags,slug'], + ]); + + NewsTag::query()->create([ + 'name' => trim((string) $validated['name']), + 'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name'])), + ]); + + return back()->with('success', 'Tag created.'); + } + + public function updateTag(Request $request, NewsTag $tag): RedirectResponse + { + $this->authorizeNews($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:80', Rule::unique('news_tags', 'name')->ignore($tag->id)], + 'slug' => ['nullable', 'string', 'max:80', Rule::unique('news_tags', 'slug')->ignore($tag->id)], + ]); + + $tag->update([ + 'name' => trim((string) $validated['name']), + 'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name']), (int) $tag->id), + ]); + + return back()->with('success', 'Tag updated.'); + } + + public function entitySearch(Request $request): JsonResponse + { + $this->authorizeNews($request); + + $validated = $request->validate([ + 'type' => ['required', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], + 'q' => ['nullable', 'string', 'max:120'], + ]); + + return response()->json([ + 'items' => $this->news->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()), + ]); + } + + private function authorizeNews(Request $request): void + { + abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403); + } + + private function validateArticle(Request $request, ?NewsArticle $article = null): array + { + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:800'], + 'content' => ['required', 'string', 'max:50000'], + 'cover_image' => ['nullable', 'string', 'max:2048'], + 'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))], + 'category_id' => ['nullable', 'integer', 'exists:news_categories,id'], + 'author_id' => ['nullable', 'integer', 'exists:users,id'], + 'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))], + 'published_at' => ['nullable', 'date'], + 'is_featured' => ['nullable', 'boolean'], + 'is_pinned' => ['nullable', 'boolean'], + 'tag_ids' => ['nullable', 'array'], + 'tag_ids.*' => ['integer', 'exists:news_tags,id'], + 'meta_title' => ['nullable', 'string', 'max:255'], + 'meta_description' => ['nullable', 'string', 'max:300'], + 'meta_keywords' => ['nullable', 'string', 'max:255'], + 'canonical_url' => ['nullable', 'url', 'max:2048'], + 'og_title' => ['nullable', 'string', 'max:255'], + 'og_description' => ['nullable', 'string', 'max:300'], + 'og_image' => ['nullable', 'string', 'max:2048'], + 'relations' => ['nullable', 'array', 'max:12'], + 'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], + 'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'], + 'relations.*.context_label' => ['nullable', 'string', 'max:120'], + ]); + + if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) { + throw ValidationException::withMessages([ + 'published_at' => 'Scheduled articles need a publish date and time.', + ]); + } + + return $validated; + } + + private function tagPayload(): array + { + return NewsTag::query() + ->withCount(['articles' => fn ($query) => $query->published()]) + ->orderBy('name') + ->get() + ->map(fn (NewsTag $tag): array => [ + 'id' => (int) $tag->id, + 'name' => (string) $tag->name, + 'slug' => (string) $tag->slug, + 'published_count' => (int) $tag->articles_count, + ]) + ->all(); + } + + private function uniqueTagSlug(string $source, ?int $ignoreId = null): string + { + $base = Str::slug($source); + $slug = $base !== '' ? $base : 'tag'; + $counter = 1; + + $query = NewsTag::query()->where('slug', $slug); + if ($ignoreId !== null) { + $query->where('id', '!=', $ignoreId); + } + + while ($query->exists()) { + $slug = ($base !== '' ? $base : 'tag') . '-' . $counter++; + $query = NewsTag::query()->where('slug', $slug); + if ($ignoreId !== null) { + $query->where('id', '!=', $ignoreId); + } + } + + return $slug; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 007f425c..4316fa77 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -15,6 +15,12 @@ use App\Mail\EmailChangedSecurityAlertMail; use App\Mail\EmailChangeVerificationCodeMail; use App\Models\Artwork; use App\Models\Country; +use App\Models\Group; +use App\Models\GroupContributorStat; +use App\Models\GroupMember; +use App\Models\GroupProjectMember; +use App\Models\GroupRelease; +use App\Models\GroupReleaseContributor; use App\Models\ProfileComment; use App\Models\Story; use App\Models\User; @@ -1196,6 +1202,7 @@ class ProfileController extends Controller ->all(); $achievementSummary = $this->achievements->summary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); + $groupContributionHistory = $this->buildGroupContributionHistory($user); $resolvedInitialTab = $this->normalizeProfileTab($initialTab); $isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null; $activeProfileUrl = $resolvedInitialTab !== null @@ -1269,6 +1276,7 @@ class ProfileController extends Controller 'collections' => $profileCollectionsPayload, 'achievements' => $achievementSummary, 'leaderboardRank' => $leaderboardRank, + 'groupContributionHistory' => $groupContributionHistory, 'countryName' => $countryName, 'isOwner' => $isOwner, 'auth' => $authData, @@ -1315,6 +1323,98 @@ class ProfileController extends Controller return redirect()->to($baseUrl, 301); } + private function buildGroupContributionHistory(User $user): array + { + return GroupContributorStat::query() + ->with(['group.owner.profile']) + ->where('user_id', $user->id) + ->whereHas('group', fn ($query) => $query + ->whereIn('visibility', [Group::VISIBILITY_PUBLIC, Group::VISIBILITY_UNLISTED]) + ->where('status', '!=', Group::LIFECYCLE_SUSPENDED)) + ->orderByDesc('release_count') + ->orderByDesc('credited_artworks_count') + ->limit(8) + ->get() + ->map(function (GroupContributorStat $stat) use ($user): ?array { + $group = $stat->group; + + if (! $group) { + return null; + } + + $member = GroupMember::query() + ->where('group_id', $group->id) + ->where('user_id', $user->id) + ->where('status', Group::STATUS_ACTIVE) + ->first(); + + $roleLabels = collect() + ->merge(GroupReleaseContributor::query() + ->where('user_id', $user->id) + ->whereHas('release', fn ($query) => $query->where('group_id', $group->id)) + ->pluck('role_label')) + ->merge(GroupProjectMember::query() + ->where('user_id', $user->id) + ->whereHas('project', fn ($query) => $query->where('group_id', $group->id)) + ->pluck('role_label')) + ->filter() + ->map(fn ($label): string => trim((string) $label)) + ->filter() + ->unique() + ->values() + ->all(); + + $recentReleaseTitles = GroupRelease::query() + ->where('group_id', $group->id) + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where(function ($query) use ($user): void { + $query->where('lead_user_id', $user->id) + ->orWhere('created_by_user_id', $user->id) + ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $user->id)); + }) + ->orderByDesc('released_at') + ->latest('updated_at') + ->limit(3) + ->pluck('title') + ->map(fn ($title): string => (string) $title) + ->values() + ->all(); + + $joinedAt = $group->isOwnedBy($user) + ? $group->created_at?->toISOString() + : ($member?->accepted_at?->toISOString() ?? $member?->created_at?->toISOString()); + $meta = is_array($stat->reputation_meta_json) ? $stat->reputation_meta_json : []; + + return [ + 'group' => [ + 'id' => (int) $group->id, + 'name' => (string) $group->name, + 'slug' => (string) $group->slug, + 'headline' => $group->headline, + 'avatar_url' => $group->avatarUrl(), + 'profile_url' => $group->publicUrl(), + ], + 'joined_at' => $joinedAt, + 'role' => $group->isOwnedBy($user) + ? Group::ROLE_OWNER + : Group::displayRole($member?->role ?? Group::ROLE_MEMBER), + 'counts' => [ + 'credited_artworks' => (int) $stat->credited_artworks_count, + 'releases' => (int) $stat->release_count, + 'projects' => (int) $stat->project_count, + 'review_actions' => (int) $stat->review_actions_count, + ], + 'trusted_indicator' => (bool) ($meta['trusted_indicator'] ?? false), + 'summary' => $meta['summary'] ?? null, + 'role_labels' => $roleLabels, + 'recent_release_titles' => $recentReleaseTitles, + ]; + }) + ->filter() + ->values() + ->all(); + } + private function resolveFavouriteTable(): ?string { foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { diff --git a/app/Http/Controllers/Web/AccountHelpPageController.php b/app/Http/Controllers/Web/AccountHelpPageController.php new file mode 100644 index 00000000..fb0c7bd7 --- /dev/null +++ b/app/Http/Controllers/Web/AccountHelpPageController.php @@ -0,0 +1,52 @@ +collectionPage( + 'Account Settings Help — Skinbase', + 'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/AccountHelpPage', [ + 'title' => 'Account Settings Help', + 'description' => 'Use this guide when account access already works and you need practical help with settings, identity details, email and password care, or ongoing account maintenance.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'help_auth' => route('help.auth'), + 'help_profile' => route('help.profile'), + 'studio_help' => route('help.studio'), + 'upload_help' => route('help.upload'), + 'help_troubleshooting' => route('help.troubleshooting'), + 'profile_settings' => route('dashboard.profile'), + 'open_studio' => route('studio.index'), + 'login' => route('login'), + 'register' => route('register'), + 'password_request' => route('password.request'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index 65df7da1..d76aa32b 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -11,6 +11,7 @@ use App\Models\ArtworkComment; use App\Services\ContentSanitizer; use App\Services\ThumbnailPresenter; use App\Services\ErrorSuggestionService; +use App\Services\GroupService; use App\Support\Seo\SeoFactory; use App\Support\AvatarUrl; use Illuminate\Support\Carbon; @@ -22,6 +23,8 @@ use Illuminate\View\View; final class ArtworkPageController extends Controller { + public function __construct(private readonly GroupService $groups) {} + public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response { // ── Step 1: check existence including soft-deleted ───────────────── @@ -84,7 +87,7 @@ final class ArtworkPageController extends Controller } // ── Step 2: full load with all relations ─────────────────────────── - $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']) + $artwork = Artwork::with(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']) ->where('id', $id) ->public() ->published() @@ -108,9 +111,15 @@ final class ArtworkPageController extends Controller $thumbSq = ThumbnailPresenter::present($artwork, 'sq'); $artworkData = (new ArtworkResource($artwork))->toArray($request); + $groupSummary = null; + + if ($artwork->group) { + $artwork->group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']); + $groupSummary = $this->groups->mapGroupCard($artwork->group, $request->user()); + } $canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]); - $authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist'; + $authorName = $artwork->group?->name ?: $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist'; $description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…'); $meta = [ @@ -132,13 +141,17 @@ final class ArtworkPageController extends Controller $tagIds = $artwork->tags->pluck('id')->filter()->values(); $related = Artwork::query() - ->with(['user', 'categories.contentType']) + ->with(['user', 'group', 'categories.contentType']) ->whereKeyNot($artwork->id) ->public() ->published() ->where(function ($query) use ($artwork, $categoryIds, $tagIds): void { $query->where('user_id', $artwork->user_id); + if ($artwork->group_id) { + $query->orWhere('group_id', $artwork->group_id); + } + if ($categoryIds->isNotEmpty()) { $query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void { $categoryQuery->whereIn('categories.id', $categoryIds->all()); @@ -166,7 +179,7 @@ final class ArtworkPageController extends Controller return [ 'id' => (int) $item->id, 'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'author' => html_entity_decode((string) ($item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]), 'thumb' => $md['url'] ?? null, 'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w', @@ -237,6 +250,7 @@ final class ArtworkPageController extends Controller 'useUnifiedSeo' => true, 'relatedItems' => $related, 'comments' => $comments, + 'groupSummary' => $groupSummary, ]); } diff --git a/app/Http/Controllers/Web/AuthHelpPageController.php b/app/Http/Controllers/Web/AuthHelpPageController.php new file mode 100644 index 00000000..f96aefa4 --- /dev/null +++ b/app/Http/Controllers/Web/AuthHelpPageController.php @@ -0,0 +1,53 @@ +collectionPage( + 'Signup and Login Help — Skinbase', + 'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/AuthHelpPage', [ + 'title' => 'Signup & Login Help', + 'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'help_profile' => route('help.profile'), + 'studio_help' => route('help.studio'), + 'groups_help' => route('help.groups'), + 'help_account' => route('help.account'), + 'help_troubleshooting' => route('help.troubleshooting'), + 'login' => route('login'), + 'register' => route('register'), + 'password_request' => route('password.request'), + 'verification_notice' => route('verification.notice'), + 'open_studio' => route('studio.index'), + 'profile_settings' => route('dashboard.profile'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/CardsHelpPageController.php b/app/Http/Controllers/Web/CardsHelpPageController.php new file mode 100644 index 00000000..bebcf194 --- /dev/null +++ b/app/Http/Controllers/Web/CardsHelpPageController.php @@ -0,0 +1,50 @@ +collectionPage( + 'Cards Help — Skinbase', + 'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/CardsHelpPage', [ + 'title' => 'Cards Help', + 'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'studio_help' => route('help.studio'), + 'upload_help' => route('help.upload'), + 'groups_help' => route('help.groups'), + 'open_studio' => route('studio.index'), + 'studio_cards' => route('studio.cards.index'), + 'create_card' => route('studio.cards.create'), + 'cards_index' => route('cards.index'), + 'help_profile' => route('help.profile'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/GroupFaqPageController.php b/app/Http/Controllers/Web/GroupFaqPageController.php new file mode 100644 index 00000000..402645a2 --- /dev/null +++ b/app/Http/Controllers/Web/GroupFaqPageController.php @@ -0,0 +1,47 @@ +collectionPage( + 'Groups FAQ — Skinbase', + 'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Group/GroupFaqPage', [ + 'title' => 'Groups FAQ', + 'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.', + 'seo' => $seo, + 'links' => [ + 'groups_directory' => route('groups.index'), + 'create_group' => route('studio.groups.create'), + 'group_studio' => route('studio.groups.index'), + 'full_documentation' => route('help.groups'), + 'quickstart' => route('help.groups.quickstart'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + 'help_home' => route('help'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/GroupHelpPageController.php b/app/Http/Controllers/Web/GroupHelpPageController.php new file mode 100644 index 00000000..c4f2d3f4 --- /dev/null +++ b/app/Http/Controllers/Web/GroupHelpPageController.php @@ -0,0 +1,47 @@ +collectionPage( + 'Groups Guide, Help, and Best Practices — Skinbase', + 'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Group/GroupHelpPage', [ + 'title' => 'Groups Help & Guide', + 'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.', + 'seo' => $seo, + 'links' => [ + 'groups_directory' => route('groups.index'), + 'create_group' => route('studio.groups.create'), + 'group_studio' => route('studio.groups.index'), + 'quickstart' => route('help.groups.quickstart'), + 'faq' => route('help.groups.faq'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + 'help_home' => route('help'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/GroupQuickstartPageController.php b/app/Http/Controllers/Web/GroupQuickstartPageController.php new file mode 100644 index 00000000..116de44d --- /dev/null +++ b/app/Http/Controllers/Web/GroupQuickstartPageController.php @@ -0,0 +1,45 @@ +collectionPage( + 'Groups Quickstart — Skinbase', + 'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Group/GroupQuickstartPage', [ + 'title' => 'Groups Quickstart', + 'description' => 'The fastest way to understand Groups, create one, invite members, and publish your first collaborative artwork with correct contributor credit.', + 'seo' => $seo, + 'links' => [ + 'groups_directory' => route('groups.index'), + 'create_group' => route('studio.groups.create'), + 'group_studio' => route('studio.groups.index'), + 'full_documentation' => route('help.groups'), + 'faq' => route('help.groups.faq'), + 'help_home' => route('help'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/HelpCenterPageController.php b/app/Http/Controllers/Web/HelpCenterPageController.php new file mode 100644 index 00000000..bc520613 --- /dev/null +++ b/app/Http/Controllers/Web/HelpCenterPageController.php @@ -0,0 +1,68 @@ +collectionPage( + 'Help Center — Skinbase', + 'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'website'; + + return Inertia::render('Help/HelpCenterPage', [ + 'title' => 'Help Center', + 'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.', + 'seo' => $seo, + 'links' => [ + 'studio_help' => route('help.studio'), + 'upload_help' => route('help.upload'), + 'groups_documentation' => route('help.groups'), + 'groups_quickstart' => route('help.groups.quickstart'), + 'groups_faq' => route('help.groups.faq'), + 'groups_directory' => route('groups.index'), + 'group_studio' => route('studio.groups.index'), + 'create_group' => route('studio.groups.create'), + 'open_studio' => route('studio.index'), + 'studio_home' => route('studio.index'), + 'studio_content' => route('studio.content'), + 'studio_artworks' => route('studio.artworks'), + 'studio_cards' => route('studio.cards.index'), + 'studio_drafts' => route('studio.drafts'), + 'cards_create' => route('studio.cards.create'), + 'upload' => route('upload'), + 'cards_index' => route('cards.index'), + 'help_cards' => route('help.cards'), + 'help_profile' => route('help.profile'), + 'help_auth' => route('help.auth'), + 'help_account' => route('help.account'), + 'help_troubleshooting' => route('help.troubleshooting'), + 'profile_settings' => route('dashboard.profile'), + 'login' => route('login'), + 'register' => route('register'), + 'password_request' => route('password.request'), + 'help_upload' => route('help', ['q' => 'upload']), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/LeaderboardPageController.php b/app/Http/Controllers/Web/LeaderboardPageController.php index 95a5777a..e5fb290c 100644 --- a/app/Http/Controllers/Web/LeaderboardPageController.php +++ b/app/Http/Controllers/Web/LeaderboardPageController.php @@ -19,17 +19,32 @@ class LeaderboardPageController extends Controller $period = $leaderboards->normalizePeriod((string) $request->query('period', 'weekly')); $type = match ((string) $request->query('type', 'creators')) { 'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK, + 'groups', Leaderboard::TYPE_GROUP => Leaderboard::TYPE_GROUP, 'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY, default => Leaderboard::TYPE_CREATOR, }; + $title = match ($type) { + Leaderboard::TYPE_GROUP => 'Top Groups Leaderboard — Skinbase', + Leaderboard::TYPE_STORY => 'Top Stories Leaderboard — Skinbase', + Leaderboard::TYPE_ARTWORK => 'Top Artworks Leaderboard — Skinbase', + default => 'Top Creators & Artworks Leaderboard — Skinbase', + }; + + $description = match ($type) { + Leaderboard::TYPE_GROUP => 'Track the leading groups across Skinbase by daily, weekly, monthly, and all-time performance.', + Leaderboard::TYPE_STORY => 'Track the leading stories across Skinbase by daily, weekly, monthly, and all-time performance.', + Leaderboard::TYPE_ARTWORK => 'Track the leading artworks across Skinbase by daily, weekly, monthly, and all-time performance.', + default => 'Track the leading creators, groups, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.', + }; + return Inertia::render('Leaderboard/LeaderboardPage', [ 'initialType' => $type, 'initialPeriod' => $period, 'initialData' => $leaderboards->getLeaderboard($type, $period), 'seo' => app(SeoFactory::class)->leaderboardPage( - 'Top Creators & Artworks Leaderboard — Skinbase', - 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.', + $title, + $description, route('leaderboard') )->toArray(), ]); diff --git a/app/Http/Controllers/Web/ProfileHelpPageController.php b/app/Http/Controllers/Web/ProfileHelpPageController.php new file mode 100644 index 00000000..348680d5 --- /dev/null +++ b/app/Http/Controllers/Web/ProfileHelpPageController.php @@ -0,0 +1,51 @@ +collectionPage( + 'Profile Help — Skinbase', + 'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/ProfileHelpPage', [ + 'title' => 'Profile Help', + 'description' => 'Understand your Skinbase profile as your personal public identity, with practical guidance for setup, presentation, profile content, and creator-friendly best practices.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'groups_help' => route('help.groups'), + 'studio_help' => route('help.studio'), + 'upload_help' => route('help.upload'), + 'cards_help' => route('help.cards'), + 'profile_settings' => route('dashboard.profile'), + 'open_studio' => route('studio.index'), + 'help_auth' => route('help.auth'), + 'login' => route('login'), + 'register' => route('register'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/SearchController.php b/app/Http/Controllers/Web/SearchController.php index 1b6d39f4..d84141c7 100644 --- a/app/Http/Controllers/Web/SearchController.php +++ b/app/Http/Controllers/Web/SearchController.php @@ -6,12 +6,17 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Services\ArtworkSearchService; +use App\Services\GroupDiscoveryService; use Illuminate\Http\Request; use Illuminate\View\View; +use cPad\Plugins\News\Models\NewsArticle; final class SearchController extends Controller { - public function __construct(private readonly ArtworkSearchService $search) {} + public function __construct( + private readonly ArtworkSearchService $search, + private readonly GroupDiscoveryService $groups, + ) {} public function index(Request $request): View { @@ -31,12 +36,33 @@ final class SearchController extends Controller ]) : $this->search->popular(24); + $groups = $q !== '' + ? $this->groups->searchCards($q, $request->user(), 6) + : $this->groups->surfaceCards($request->user(), 'featured', 4); + + $news = $q !== '' + ? NewsArticle::query() + ->with(['author:id,username,name', 'category:id,name,slug']) + ->published() + ->where(function ($builder) use ($q): void { + $builder->where('title', 'like', '%' . $q . '%') + ->orWhere('excerpt', 'like', '%' . $q . '%') + ->orWhere('content', 'like', '%' . $q . '%') + ->orWhere('meta_title', 'like', '%' . $q . '%'); + }) + ->editorialOrder() + ->limit(4) + ->get() + : collect(); + return view('search.index', [ 'q' => $q, 'sort' => $sort, + 'groups' => $groups, 'artworks' => $artworks, + 'news' => $news, 'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase', - 'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.', + 'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.', 'page_robots' => 'noindex,follow', ]); } diff --git a/app/Http/Controllers/Web/StudioHelpPageController.php b/app/Http/Controllers/Web/StudioHelpPageController.php new file mode 100644 index 00000000..0fec3be3 --- /dev/null +++ b/app/Http/Controllers/Web/StudioHelpPageController.php @@ -0,0 +1,60 @@ +collectionPage( + 'Studio Help — Skinbase', + 'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/StudioHelpPage', [ + 'title' => 'Studio Help', + 'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'open_studio' => route('studio.index'), + 'studio_content' => route('studio.content'), + 'studio_artworks' => route('studio.artworks'), + 'studio_drafts' => route('studio.drafts'), + 'studio_scheduled' => route('studio.scheduled'), + 'studio_collections' => route('studio.collections'), + 'studio_settings' => route('studio.settings'), + 'studio_cards' => route('studio.cards.index'), + 'create_card' => route('studio.cards.create'), + 'upload' => route('upload'), + 'upload_help' => route('help.upload'), + 'groups_help' => route('help.groups'), + 'groups_quickstart' => route('help.groups.quickstart'), + 'groups_faq' => route('help.groups.faq'), + 'group_studio' => route('studio.groups.index'), + 'help_cards' => route('help.cards'), + 'help_profile' => route('help.profile'), + 'help_auth' => route('help.auth'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/TroubleshootingHelpPageController.php b/app/Http/Controllers/Web/TroubleshootingHelpPageController.php new file mode 100644 index 00000000..9e623531 --- /dev/null +++ b/app/Http/Controllers/Web/TroubleshootingHelpPageController.php @@ -0,0 +1,54 @@ +collectionPage( + 'Troubleshooting Help — Skinbase', + 'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/TroubleshootingHelpPage', [ + 'title' => 'Troubleshooting Help', + 'description' => 'Use diagnosis-first help when something feels broken, blocked, or unclear and you need the fastest next step instead of a long module guide.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'help_auth' => route('help.auth'), + 'help_account' => route('help.account'), + 'help_profile' => route('help.profile'), + 'studio_help' => route('help.studio'), + 'upload_help' => route('help.upload'), + 'groups_help' => route('help.groups'), + 'groups_faq' => route('help.groups.faq'), + 'profile_settings' => route('dashboard.profile'), + 'open_studio' => route('studio.index'), + 'login' => route('login'), + 'register' => route('register'), + 'password_request' => route('password.request'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/UploadHelpPageController.php b/app/Http/Controllers/Web/UploadHelpPageController.php new file mode 100644 index 00000000..e67a6733 --- /dev/null +++ b/app/Http/Controllers/Web/UploadHelpPageController.php @@ -0,0 +1,53 @@ +collectionPage( + 'Upload Help — Skinbase', + 'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'article'; + + return Inertia::render('Help/UploadHelpPage', [ + 'title' => 'Upload Help', + 'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.', + 'seo' => $seo, + 'links' => [ + 'help_home' => route('help'), + 'upload' => route('upload'), + 'studio_help' => route('help.studio'), + 'open_studio' => route('studio.index'), + 'studio_artworks' => route('studio.artworks'), + 'studio_drafts' => route('studio.drafts'), + 'groups_help' => route('help.groups'), + 'groups_quickstart' => route('help.groups.quickstart'), + 'groups_faq' => route('help.groups.faq'), + 'group_studio' => route('studio.groups.index'), + 'help_cards' => route('help.cards'), + 'help_profile' => route('help.profile'), + 'contact_support' => route('contact.show'), + 'report_issue' => route('bug-report'), + ], + 'auth' => [ + 'signed_in' => $request->user() !== null, + ], + ])->rootView('collections'); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 88f623d8..86f3f3ea 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Services\GroupService; use Illuminate\Http\Request; use Inertia\Middleware; @@ -62,11 +63,30 @@ final class HandleInertiaRequests extends Middleware 'user' => $request->user() ? [ 'id' => $request->user()->id, 'name' => $request->user()->name, + 'is_admin' => $request->user()->isAdmin(), + 'is_moderator' => $request->user()->isModerator(), ] : null, ], 'cdn' => [ 'files_url' => config('cdn.files_url'), ], + 'features' => [ + 'groups' => (bool) config('features.groups', true), + 'groups_v1' => (bool) config('features.groups_v1', true), + 'groups_v2' => (bool) config('features.groups_v2', true), + 'group_posts' => (bool) config('features.group_posts', true), + 'group_recruitment' => (bool) config('features.group_recruitment', true), + 'group_join_requests' => (bool) config('features.group_join_requests', true), + 'group_review_queue' => (bool) config('features.group_review_queue', true), + 'group_projects' => (bool) config('features.group_projects', true), + 'group_challenges' => (bool) config('features.group_challenges', true), + 'group_events' => (bool) config('features.group_events', true), + 'group_assets' => (bool) config('features.group_assets', true), + 'group_activity_feed' => (bool) config('features.group_activity_feed', true), + ], + 'studio_groups' => $request->user() + ? app(GroupService::class)->studioOptionsForUser($request->user()) + : [], ]); } } diff --git a/app/Http/Requests/Artworks/ArtworkCreateRequest.php b/app/Http/Requests/Artworks/ArtworkCreateRequest.php index 066a67f5..58bf8e0d 100644 --- a/app/Http/Requests/Artworks/ArtworkCreateRequest.php +++ b/app/Http/Requests/Artworks/ArtworkCreateRequest.php @@ -28,6 +28,7 @@ final class ArtworkCreateRequest extends FormRequest 'tags' => 'nullable|string|max:200', 'license' => 'nullable|boolean', 'is_mature' => 'nullable|boolean', + 'group' => 'nullable|string|max:90', ]; } diff --git a/app/Http/Requests/Collections/StoreCollectionRequest.php b/app/Http/Requests/Collections/StoreCollectionRequest.php index fce58f49..3ee8af55 100644 --- a/app/Http/Requests/Collections/StoreCollectionRequest.php +++ b/app/Http/Requests/Collections/StoreCollectionRequest.php @@ -39,6 +39,7 @@ class StoreCollectionRequest extends FormRequest return [ 'title' => ['required', 'string', 'min:2', 'max:120'], 'slug' => ['required', 'string', 'min:2', 'max:140', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'group' => ['nullable', 'string', 'max:90'], 'type' => ['nullable', 'in:' . implode(',', [ Collection::TYPE_PERSONAL, Collection::TYPE_COMMUNITY, diff --git a/app/Http/Requests/Collections/UpdateCollectionRequest.php b/app/Http/Requests/Collections/UpdateCollectionRequest.php index 99fc8de9..c5efec87 100644 --- a/app/Http/Requests/Collections/UpdateCollectionRequest.php +++ b/app/Http/Requests/Collections/UpdateCollectionRequest.php @@ -45,6 +45,7 @@ class UpdateCollectionRequest extends FormRequest return [ 'title' => ['required', 'string', 'min:2', 'max:120'], 'slug' => ['required', 'string', 'min:2', 'max:140', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'group' => ['nullable', 'string', 'max:90'], 'type' => ['nullable', 'in:' . implode(',', [ Collection::TYPE_PERSONAL, Collection::TYPE_COMMUNITY, diff --git a/app/Http/Requests/Groups/AttachArtworkToGroupChallengeRequest.php b/app/Http/Requests/Groups/AttachArtworkToGroupChallengeRequest.php new file mode 100644 index 00000000..504640ba --- /dev/null +++ b/app/Http/Requests/Groups/AttachArtworkToGroupChallengeRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/AttachArtworkToGroupProjectRequest.php b/app/Http/Requests/Groups/AttachArtworkToGroupProjectRequest.php new file mode 100644 index 00000000..39041e8f --- /dev/null +++ b/app/Http/Requests/Groups/AttachArtworkToGroupProjectRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/AttachArtworkToGroupReleaseRequest.php b/app/Http/Requests/Groups/AttachArtworkToGroupReleaseRequest.php new file mode 100644 index 00000000..da2db966 --- /dev/null +++ b/app/Http/Requests/Groups/AttachArtworkToGroupReleaseRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'artwork_id' => ['required', 'integer'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/AttachAssetToGroupProjectRequest.php b/app/Http/Requests/Groups/AttachAssetToGroupProjectRequest.php new file mode 100644 index 00000000..8ffa06c5 --- /dev/null +++ b/app/Http/Requests/Groups/AttachAssetToGroupProjectRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'asset_id' => ['required', 'integer', 'exists:group_assets,id'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/AttachContributorToGroupReleaseRequest.php b/app/Http/Requests/Groups/AttachContributorToGroupReleaseRequest.php new file mode 100644 index 00000000..2ac276a2 --- /dev/null +++ b/app/Http/Requests/Groups/AttachContributorToGroupReleaseRequest.php @@ -0,0 +1,23 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'user_id' => ['required', 'integer'], + 'role_label' => ['nullable', 'string', 'max:80'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/PinGroupActivityItemRequest.php b/app/Http/Requests/Groups/PinGroupActivityItemRequest.php new file mode 100644 index 00000000..e7ef9a3e --- /dev/null +++ b/app/Http/Requests/Groups/PinGroupActivityItemRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'is_pinned' => ['nullable', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/ReviewGroupArtworkRequest.php b/app/Http/Requests/Groups/ReviewGroupArtworkRequest.php new file mode 100644 index 00000000..9a879022 --- /dev/null +++ b/app/Http/Requests/Groups/ReviewGroupArtworkRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'review_notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/ReviewGroupJoinRequestRequest.php b/app/Http/Requests/Groups/ReviewGroupJoinRequestRequest.php new file mode 100644 index 00000000..06e5b864 --- /dev/null +++ b/app/Http/Requests/Groups/ReviewGroupJoinRequestRequest.php @@ -0,0 +1,24 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'role' => ['nullable', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])], + 'review_notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupAssetRequest.php b/app/Http/Requests/Groups/StoreGroupAssetRequest.php new file mode 100644 index 00000000..5f6f7fea --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupAssetRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'description' => ['nullable', 'string', 'max:40000'], + 'category' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.categories', []))], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.visibility_options', []))], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.statuses', []))], + 'linked_project_id' => ['nullable', 'integer'], + 'is_featured' => ['nullable', 'boolean'], + 'file' => ['required', 'file', 'mimes:' . implode(',', (array) config('groups.assets.allowed_extensions', [])), 'max:' . (int) config('groups.assets.max_upload_kb', 20480)], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupChallengeRequest.php b/app/Http/Requests/Groups/StoreGroupChallengeRequest.php new file mode 100644 index 00000000..0b7250e3 --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupChallengeRequest.php @@ -0,0 +1,37 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'summary' => ['nullable', 'string', 'max:320'], + 'description' => ['nullable', 'string', 'max:40000'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + 'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.visibility_options', []))], + 'participation_scope' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.participation_scopes', []))], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.statuses', []))], + 'start_at' => ['nullable', 'date'], + 'end_at' => ['nullable', 'date', 'after_or_equal:start_at'], + 'rules_text' => ['nullable', 'string', 'max:20000'], + 'submission_instructions' => ['nullable', 'string', 'max:20000'], + 'judging_mode' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.judging_modes', []))], + 'linked_collection_id' => ['nullable', 'integer'], + 'linked_project_id' => ['nullable', 'integer'], + 'featured_artwork_id' => ['nullable', 'integer'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupEventRequest.php b/app/Http/Requests/Groups/StoreGroupEventRequest.php new file mode 100644 index 00000000..07c96cbd --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupEventRequest.php @@ -0,0 +1,38 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'summary' => ['nullable', 'string', 'max:320'], + 'description' => ['nullable', 'string', 'max:40000'], + 'event_type' => ['nullable', 'in:' . implode(',', (array) config('groups.events.types', []))], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.events.visibility_options', []))], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.events.statuses', []))], + 'start_at' => ['nullable', 'date'], + 'end_at' => ['nullable', 'date', 'after_or_equal:start_at'], + 'timezone' => ['nullable', 'string', 'max:80'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + 'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'location' => ['nullable', 'string', 'max:180'], + 'external_url' => ['nullable', 'url', 'max:2048'], + 'linked_project_id' => ['nullable', 'integer'], + 'linked_collection_id' => ['nullable', 'integer'], + 'linked_challenge_id' => ['nullable', 'integer'], + 'is_featured' => ['nullable', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupJoinRequestRequest.php b/app/Http/Requests/Groups/StoreGroupJoinRequestRequest.php new file mode 100644 index 00000000..31dd7bdf --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupJoinRequestRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'message' => ['nullable', 'string', 'max:2000'], + 'portfolio_url' => ['nullable', 'url', 'max:2048'], + 'desired_role' => ['nullable', 'string', 'max:32'], + 'skills_json' => ['nullable', 'array', 'max:12'], + 'skills_json.*' => ['string', 'max:80'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupMemberRequest.php b/app/Http/Requests/Groups/StoreGroupMemberRequest.php new file mode 100644 index 00000000..5ee5587f --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupMemberRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'username' => ['required', 'string', 'max:20'], + 'role' => ['required', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])], + 'note' => ['nullable', 'string', 'max:500'], + 'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:30'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupMilestoneRequest.php b/app/Http/Requests/Groups/StoreGroupMilestoneRequest.php new file mode 100644 index 00000000..c956bd97 --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupMilestoneRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'summary' => ['nullable', 'string', 'max:320'], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.milestones.statuses', []))], + 'due_date' => ['nullable', 'date'], + 'owner_user_id' => ['nullable', 'integer'], + 'notes' => ['nullable', 'string', 'max:40000'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupPostRequest.php b/app/Http/Requests/Groups/StoreGroupPostRequest.php new file mode 100644 index 00000000..05bf113f --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupPostRequest.php @@ -0,0 +1,32 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'type' => ['required', 'in:' . implode(',', [ + GroupPost::TYPE_ANNOUNCEMENT, + GroupPost::TYPE_RELEASE, + GroupPost::TYPE_RECRUITMENT, + GroupPost::TYPE_UPDATE, + ])], + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'excerpt' => ['nullable', 'string', 'max:320'], + 'content' => ['nullable', 'string', 'max:40000'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupProjectRequest.php b/app/Http/Requests/Groups/StoreGroupProjectRequest.php new file mode 100644 index 00000000..f4636d6b --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupProjectRequest.php @@ -0,0 +1,36 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'summary' => ['nullable', 'string', 'max:320'], + 'description' => ['nullable', 'string', 'max:40000'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + 'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.projects.statuses', []))], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.projects.visibility_options', []))], + 'start_date' => ['nullable', 'date'], + 'target_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'lead_user_id' => ['nullable', 'integer'], + 'linked_collection_id' => ['nullable', 'integer'], + 'linked_featured_artwork_id' => ['nullable', 'integer'], + 'pinned_post_id' => ['nullable', 'integer'], + 'member_user_ids' => ['nullable', 'array'], + 'member_user_ids.*' => ['integer'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupReleaseRequest.php b/app/Http/Requests/Groups/StoreGroupReleaseRequest.php new file mode 100644 index 00000000..d5135e21 --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupReleaseRequest.php @@ -0,0 +1,36 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'summary' => ['nullable', 'string', 'max:320'], + 'description' => ['nullable', 'string', 'max:40000'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + 'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.statuses', []))], + 'current_stage' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.stages', []))], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.visibility_options', []))], + 'planned_release_at' => ['nullable', 'date'], + 'lead_user_id' => ['nullable', 'integer'], + 'linked_project_id' => ['nullable', 'integer'], + 'linked_collection_id' => ['nullable', 'integer'], + 'featured_artwork_id' => ['nullable', 'integer'], + 'release_notes' => ['nullable', 'string', 'max:40000'], + 'is_featured' => ['nullable', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/StoreGroupRequest.php b/app/Http/Requests/Groups/StoreGroupRequest.php new file mode 100644 index 00000000..713d0931 --- /dev/null +++ b/app/Http/Requests/Groups/StoreGroupRequest.php @@ -0,0 +1,53 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $name = (string) $this->input('name', ''); + $slug = (string) $this->input('slug', ''); + + if ($slug === '' && $name !== '') { + $slug = Str::slug(Str::limit($name, 90, '')); + } + + $this->merge([ + 'slug' => $slug, + ]); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'min:2', 'max:80'], + 'slug' => ['required', 'string', 'min:2', 'max:90', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'headline' => ['nullable', 'string', 'max:160'], + 'bio' => ['nullable', 'string', 'max:3000'], + 'visibility' => ['required', 'in:' . implode(',', Group::acceptedVisibilityValues())], + 'membership_policy' => ['nullable', 'in:' . implode(',', Group::acceptedMembershipPolicies())], + 'type' => ['nullable', 'string', 'max:80'], + 'founded_at' => ['nullable', 'date'], + 'website_url' => ['nullable', 'url', 'max:2048'], + 'links_json' => ['nullable', 'array', 'max:8'], + 'links_json.*.label' => ['required_with:links_json', 'string', 'max:40'], + 'links_json.*.url' => ['required_with:links_json', 'url', 'max:2048'], + 'avatar_path' => ['nullable', 'string', 'max:2048'], + 'banner_path' => ['nullable', 'string', 'max:2048'], + 'avatar_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'], + 'banner_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupAssetRequest.php b/app/Http/Requests/Groups/UpdateGroupAssetRequest.php new file mode 100644 index 00000000..9f124cbc --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupAssetRequest.php @@ -0,0 +1,28 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:2', 'max:180'], + 'description' => ['nullable', 'string', 'max:40000'], + 'category' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.categories', []))], + 'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.visibility_options', []))], + 'status' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.statuses', []))], + 'linked_project_id' => ['nullable', 'integer'], + 'is_featured' => ['nullable', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupChallengeRequest.php b/app/Http/Requests/Groups/UpdateGroupChallengeRequest.php new file mode 100644 index 00000000..90f4aa48 --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupChallengeRequest.php @@ -0,0 +1,9 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'permission_overrides' => ['required', 'array'], + 'permission_overrides.*.key' => ['required', 'string', Rule::in(Group::permissionKeys())], + 'permission_overrides.*.is_allowed' => ['nullable', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupMemberRequest.php b/app/Http/Requests/Groups/UpdateGroupMemberRequest.php new file mode 100644 index 00000000..1ab36b4a --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupMemberRequest.php @@ -0,0 +1,23 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'role' => ['required', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupMilestoneRequest.php b/app/Http/Requests/Groups/UpdateGroupMilestoneRequest.php new file mode 100644 index 00000000..6760e1c2 --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupMilestoneRequest.php @@ -0,0 +1,9 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'type' => ['sometimes', 'in:' . implode(',', [ + GroupPost::TYPE_ANNOUNCEMENT, + GroupPost::TYPE_RELEASE, + GroupPost::TYPE_RECRUITMENT, + GroupPost::TYPE_UPDATE, + ])], + 'title' => ['sometimes', 'string', 'min:2', 'max:180'], + 'excerpt' => ['nullable', 'string', 'max:320'], + 'content' => ['nullable', 'string', 'max:40000'], + 'cover_path' => ['nullable', 'string', 'max:2048'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupProjectRequest.php b/app/Http/Requests/Groups/UpdateGroupProjectRequest.php new file mode 100644 index 00000000..85fc42ba --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupProjectRequest.php @@ -0,0 +1,9 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'status' => ['required', 'in:' . implode(',', (array) config('groups.projects.statuses', []))], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupRecruitmentRequest.php b/app/Http/Requests/Groups/UpdateGroupRecruitmentRequest.php new file mode 100644 index 00000000..787bb3f0 --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupRecruitmentRequest.php @@ -0,0 +1,35 @@ +user() !== null; + } + + public function rules(): array + { + $allowedRoles = config('groups.recruitment.roles', []); + $allowedSkills = config('groups.recruitment.skills', []); + $allowedContactModes = config('groups.recruitment.contact_modes', ['join_request', 'direct_message', 'external_link']); + $allowedVisibility = config('groups.recruitment.visibility_options', ['public', 'members_only', 'private']); + + return [ + 'is_recruiting' => ['required', 'boolean'], + 'headline' => ['nullable', 'string', 'max:180'], + 'description' => ['nullable', 'string', 'max:4000'], + 'roles_json' => ['nullable', 'array', 'max:12'], + 'roles_json.*' => ['string', 'max:80', 'in:' . implode(',', $allowedRoles)], + 'skills_json' => ['nullable', 'array', 'max:20'], + 'skills_json.*' => ['string', 'max:80', 'in:' . implode(',', $allowedSkills)], + 'contact_mode' => ['nullable', 'string', 'max:32', 'in:' . implode(',', $allowedContactModes)], + 'visibility' => ['nullable', 'in:' . implode(',', $allowedVisibility)], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupReleaseRequest.php b/app/Http/Requests/Groups/UpdateGroupReleaseRequest.php new file mode 100644 index 00000000..7e94e3ba --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupReleaseRequest.php @@ -0,0 +1,9 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'current_stage' => ['required', 'in:' . implode(',', (array) config('groups.releases.stages', []))], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Groups/UpdateGroupRequest.php b/app/Http/Requests/Groups/UpdateGroupRequest.php new file mode 100644 index 00000000..514d8b2c --- /dev/null +++ b/app/Http/Requests/Groups/UpdateGroupRequest.php @@ -0,0 +1,54 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $name = (string) $this->input('name', ''); + $slug = (string) $this->input('slug', ''); + + if ($slug === '' && $name !== '') { + $slug = Str::slug(Str::limit($name, 90, '')); + } + + $this->merge([ + 'slug' => $slug, + ]); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'min:2', 'max:80'], + 'slug' => ['required', 'string', 'min:2', 'max:90', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'headline' => ['nullable', 'string', 'max:160'], + 'bio' => ['nullable', 'string', 'max:3000'], + 'visibility' => ['required', 'in:' . implode(',', Group::acceptedVisibilityValues())], + 'membership_policy' => ['nullable', 'in:' . implode(',', Group::acceptedMembershipPolicies())], + 'type' => ['nullable', 'string', 'max:80'], + 'founded_at' => ['nullable', 'date'], + 'website_url' => ['nullable', 'url', 'max:2048'], + 'links_json' => ['nullable', 'array', 'max:8'], + 'links_json.*.label' => ['required_with:links_json', 'string', 'max:40'], + 'links_json.*.url' => ['required_with:links_json', 'url', 'max:2048'], + 'avatar_path' => ['nullable', 'string', 'max:2048'], + 'banner_path' => ['nullable', 'string', 'max:2048'], + 'avatar_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'], + 'banner_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'], + 'featured_artwork_id' => ['nullable', 'integer', 'min:1'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 2cee4aae..38da7b82 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -14,6 +14,8 @@ class ArtworkResource extends JsonResource */ public function toArray($request): array { + $this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); + $md = ThumbnailPresenter::present($this->resource, 'md'); $lg = ThumbnailPresenter::present($this->resource, 'lg'); $xl = ThumbnailPresenter::present($this->resource, 'xl'); @@ -46,6 +48,7 @@ class ArtworkResource extends JsonResource $isFavorited = false; $isBookmarked = false; $isFollowing = false; + $isFollowingGroup = false; $viewerAward = null; $bookmarksCount = Schema::hasTable('artwork_bookmarks') @@ -87,6 +90,13 @@ class ArtworkResource extends JsonResource } } + if (!empty($this->group?->id) && Schema::hasTable('group_follows')) { + $isFollowingGroup = DB::table('group_follows') + ->where('group_id', (int) $this->group->id) + ->where('user_id', $viewerId) + ->exists(); + } + if (Schema::hasTable('artwork_awards')) { $viewerAward = DB::table('artwork_awards') ->where('user_id', $viewerId) @@ -96,6 +106,62 @@ class ArtworkResource extends JsonResource } $decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $mapUser = static function ($user): ?array { + if (! $user) { + return null; + } + + return [ + 'id' => (int) ($user->id ?? 0), + 'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'username' => (string) ($user->username ?? ''), + 'profile_url' => ! empty($user->username) ? '/@' . $user->username : null, + 'avatar_url' => $user->profile?->avatar_url, + ]; + }; + + $publisher = $this->group + ? [ + 'type' => 'group', + 'id' => (int) $this->group->id, + 'name' => (string) $this->group->name, + 'slug' => (string) $this->group->slug, + 'headline' => (string) ($this->group->headline ?? ''), + 'avatar_url' => $this->group->avatarUrl(), + 'profile_url' => $this->group->publicUrl(), + 'followers_count' => (int) ($this->group->followers_count ?? 0), + 'follow_url' => route('groups.follow', ['group' => $this->group]), + 'unfollow_url' => route('groups.unfollow', ['group' => $this->group]), + ] + : [ + 'type' => 'user', + 'id' => (int) ($this->user?->id ?? 0), + 'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'slug' => (string) ($this->user?->username ?? ''), + 'headline' => '', + 'avatar_url' => $this->user?->profile?->avatar_url, + 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null, + 'followers_count' => $followerCount, + 'follow_url' => null, + 'unfollow_url' => null, + ]; + + $primaryAuthor = $mapUser($this->primaryAuthor ?: $this->user); + $uploadedBy = $mapUser($this->uploadedBy ?: $this->user); + $contributors = $this->contributors + ->map(function ($contributor) use ($mapUser): ?array { + $user = $mapUser($contributor->user); + if (! $user) { + return null; + } + + return array_merge($user, [ + 'credit_role' => $contributor->credit_role, + 'is_primary' => (bool) $contributor->is_primary, + ]); + }) + ->filter() + ->values(); return [ 'id' => (int) $this->id, @@ -131,11 +197,19 @@ class ArtworkResource extends JsonResource 'rank' => (string) ($this->user?->rank ?? 'Newbie'), 'followers_count' => $followerCount, ], + 'publisher' => $publisher, + 'credits' => [ + 'uploaded_by' => $uploadedBy, + 'primary_author' => $primaryAuthor, + 'contributors' => $contributors, + ], 'viewer' => [ 'is_bookmarked' => $isBookmarked, 'is_liked' => $isLiked, 'is_favorited' => $isFavorited, 'is_following_author' => $isFollowing, + 'is_following_group' => $isFollowingGroup, + 'is_following_publisher' => $publisher['type'] === 'group' ? $isFollowingGroup : $isFollowing, 'is_authenticated' => $viewerId > 0, 'id' => $viewerId > 0 ? $viewerId : null, ], diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 3fe79561..f090dc28 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -1,6 +1,8 @@ 'boolean', 'is_mature' => 'boolean', 'published_at' => 'datetime', + 'published_as_type' => 'string', + 'published_as_id' => 'integer', 'publish_at' => 'datetime', 'clip_tags_json' => 'array', 'yolo_objects_json' => 'array', @@ -167,6 +179,51 @@ class Artwork extends Model return $this->belongsTo(User::class); } + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function uploadedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by_user_id'); + } + + public function primaryAuthor(): BelongsTo + { + return $this->belongsTo(User::class, 'primary_author_user_id'); + } + + public function contributors(): HasMany + { + return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order'); + } + + public function isPublishedByGroup(): bool + { + return $this->publishedAsType() === self::PUBLISHED_AS_GROUP; + } + + public function publishedAsType(): string + { + if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) { + return (string) $this->published_as_type; + } + + return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER; + } + + public function publishedAsId(): int + { + if ((int) ($this->published_as_id ?? 0) > 0) { + return (int) $this->published_as_id; + } + + return $this->publishedAsType() === self::PUBLISHED_AS_GROUP + ? (int) ($this->group_id ?? 0) + : (int) $this->user_id; + } + public function translations(): HasMany { return $this->hasMany(ArtworkTranslation::class); @@ -268,7 +325,7 @@ class Artwork extends Model */ public function toSearchableArray(): array { - $this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']); + $this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']); $stat = $this->stats; $awardStat = $this->awardStat; @@ -301,8 +358,9 @@ class Artwork extends Model 'slug' => $this->slug, 'title' => $this->title, 'description' => (string) ($this->description ?? ''), - 'author_id' => $this->user_id, - 'author_name' => $this->user?->name ?? 'Skinbase', + 'author_id' => $this->publishedAsId(), + 'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase', + 'published_as_type' => $this->publishedAsType(), 'category' => $category, 'content_type' => $content_type, 'tags' => $tags, diff --git a/app/Models/ArtworkContributor.php b/app/Models/ArtworkContributor.php new file mode 100644 index 00000000..b8ac97c4 --- /dev/null +++ b/app/Models/ArtworkContributor.php @@ -0,0 +1,37 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 134b3300..4990b2b6 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -11,6 +11,7 @@ use App\Models\CollectionMember; use App\Models\CollectionSave; use App\Models\CollectionSurfacePlacement; use App\Models\CollectionSubmission; +use App\Models\Group; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -120,6 +121,7 @@ class Collection extends Model protected $fillable = [ 'user_id', + 'group_id', 'managed_by_user_id', 'title', 'slug', @@ -263,6 +265,11 @@ class Collection extends Model return $this->belongsTo(User::class); } + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + public function managedBy(): BelongsTo { return $this->belongsTo(User::class, 'managed_by_user_id'); @@ -448,7 +455,17 @@ class Collection extends Model { $userId = $user instanceof User ? $user->id : $user; - return $userId !== null && (int) $userId === (int) $this->user_id; + if ($userId === null) { + return false; + } + + if ((int) ($this->group_id ?? 0) > 0) { + $group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first(); + + return $group?->hasActiveMember((int) $userId) ?? false; + } + + return (int) $userId === (int) $this->user_id; } public function isPubliclyAccessible(): bool @@ -488,6 +505,12 @@ class Collection extends Model public function displayOwnerName(): string { + if ((int) ($this->group_id ?? 0) > 0) { + $group = $this->relationLoaded('group') ? $this->group : $this->group()->first(); + + return (string) ($group?->name ?: 'Skinbase Group'); + } + if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) { return (string) ($this->editorial_owner_label ?: config('collections.editorial.system_owner_label', 'Skinbase Editorial')); } @@ -499,6 +522,10 @@ class Collection extends Model public function displayOwnerUsername(): ?string { + if ((int) ($this->group_id ?? 0) > 0) { + return null; + } + if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) { return null; } @@ -526,6 +553,12 @@ class Collection extends Model return null; } + if ((int) ($this->group_id ?? 0) > 0) { + $group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first(); + + return $group?->activeRoleFor((int) $userId); + } + if ($this->isOwnedBy($userId)) { return self::MEMBER_ROLE_OWNER; } @@ -546,10 +579,13 @@ class Collection extends Model public function canBeManagedBy(User $user): bool { - return in_array($this->activeMemberRoleFor($user), [ - self::MEMBER_ROLE_OWNER, - self::MEMBER_ROLE_EDITOR, - ], true); + if ((int) ($this->group_id ?? 0) > 0) { + $group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first(); + + return $group?->canManageCollections($user) ?? false; + } + + return in_array($this->activeMemberRoleFor($user), [self::MEMBER_ROLE_OWNER, self::MEMBER_ROLE_EDITOR], true); } public function canManageArtworks(User $user): bool @@ -559,6 +595,12 @@ class Collection extends Model public function canManageMembers(User $user): bool { + if ((int) ($this->group_id ?? 0) > 0) { + $group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first(); + + return $group?->canManageMembers($user) ?? false; + } + return $this->isCollaborative() && $this->canBeManagedBy($user); } diff --git a/app/Models/Group.php b/app/Models/Group.php new file mode 100644 index 00000000..b909eeb0 --- /dev/null +++ b/app/Models/Group.php @@ -0,0 +1,868 @@ + 'array', + 'is_verified' => 'boolean', + 'artworks_count' => 'integer', + 'collections_count' => 'integer', + 'followers_count' => 'integer', + 'founded_at' => 'datetime', + 'last_activity_at' => 'datetime', + ]; + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function members(): HasMany + { + return $this->hasMany(GroupMember::class); + } + + public function invitations(): HasMany + { + return $this->hasMany(GroupInvitation::class); + } + + public function follows(): HasMany + { + return $this->hasMany(GroupFollow::class); + } + + public function artworks(): HasMany + { + return $this->hasMany(Artwork::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function joinRequests(): HasMany + { + return $this->hasMany(GroupJoinRequest::class); + } + + public function posts(): HasMany + { + return $this->hasMany(GroupPost::class); + } + + public function recruitmentProfile(): HasOne + { + return $this->hasOne(GroupRecruitmentProfile::class); + } + + public function projects(): HasMany + { + return $this->hasMany(GroupProject::class); + } + + public function releases(): HasMany + { + return $this->hasMany(GroupRelease::class); + } + + public function challenges(): HasMany + { + return $this->hasMany(GroupChallenge::class); + } + + public function events(): HasMany + { + return $this->hasMany(GroupEvent::class); + } + + public function assets(): HasMany + { + return $this->hasMany(GroupAsset::class); + } + + public function activityItems(): HasMany + { + return $this->hasMany(GroupActivityItem::class); + } + + public function contributorStats(): HasMany + { + return $this->hasMany(GroupContributorStat::class); + } + + public function badges(): HasMany + { + return $this->hasMany(GroupBadge::class); + } + + public function memberBadges(): HasMany + { + return $this->hasMany(GroupMemberBadge::class); + } + + public function discoveryMetric(): HasOne + { + return $this->hasOne(GroupDiscoveryMetric::class); + } + + public function historyEntries(): HasMany + { + return $this->hasMany(GroupHistory::class); + } + + public function scopePublic(Builder $query): Builder + { + return $query + ->where('visibility', self::VISIBILITY_PUBLIC) + ->where('status', self::LIFECYCLE_ACTIVE); + } + + public static function acceptedVisibilityValues(): array + { + return [self::VISIBILITY_PUBLIC, self::VISIBILITY_PRIVATE, self::VISIBILITY_UNLISTED]; + } + + public static function acceptedMembershipPolicies(): array + { + return [self::MEMBERSHIP_INVITE_ONLY, self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN]; + } + + public static function normalizeMemberRole(string $role): string + { + $normalized = strtolower(trim($role)); + + return $normalized === self::ROLE_CONTRIBUTOR ? self::ROLE_MEMBER : $normalized; + } + + public static function displayRole(?string $role): ?string + { + if ($role === null) { + return null; + } + + return self::normalizeMemberRole($role) === self::ROLE_MEMBER ? self::ROLE_CONTRIBUTOR : self::normalizeMemberRole($role); + } + + public function isOwnedBy(User|int|null $user): bool + { + $userId = $user instanceof User ? $user->id : $user; + + return $userId !== null && (int) $userId === (int) $this->owner_user_id; + } + + public function isPubliclyVisible(): bool + { + return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true) + && $this->status !== self::LIFECYCLE_SUSPENDED; + } + + public function isOperational(): bool + { + return $this->status === self::LIFECYCLE_ACTIVE; + } + + public function canArchive(User $user): bool + { + return $this->isOwnedBy($user); + } + + public function canViewStudio(User $user): bool + { + if ($this->status === self::LIFECYCLE_SUSPENDED) { + return false; + } + + return $this->hasActiveMember($user); + } + + public function activeRoleFor(User|int|null $user): ?string + { + $userId = $user instanceof User ? $user->id : $user; + + if ($userId === null) { + return null; + } + + if ($this->isOwnedBy($userId)) { + return self::ROLE_OWNER; + } + + $members = $this->relationLoaded('members') + ? $this->members + : $this->members()->where('status', self::STATUS_ACTIVE)->get(); + + return $members + ->first(fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE) + ?->role; + } + + public function hasActiveMember(User|int|null $user): bool + { + return $this->activeRoleFor($user) !== null; + } + + public function activeMembershipFor(User|int|null $user): ?GroupMember + { + $userId = $user instanceof User ? $user->id : $user; + + if ($userId === null || $this->isOwnedBy($userId)) { + return null; + } + + $members = $this->relationLoaded('members') + ? $this->members + : $this->members()->where('status', self::STATUS_ACTIVE)->get(); + + return $members->first( + fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE + ); + } + + public function permissionOverridesFor(User|int|null $user): array + { + if ($this->isOwnedBy($user)) { + return collect(self::allowedPermissionOverrides()) + ->mapWithKeys(fn (string $permission): array => [$permission => true]) + ->all(); + } + + $member = $this->activeMembershipFor($user); + if (! $member) { + return []; + } + + return collect($member->permission_overrides_json ?? []) + ->mapWithKeys(function ($override): array { + if (is_array($override)) { + $key = trim((string) ($override['key'] ?? '')); + if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) { + return []; + } + + return [$key => (bool) ($override['is_allowed'] ?? false)]; + } + + $key = trim((string) $override); + if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) { + return []; + } + + return [$key => true]; + }) + ->all(); + } + + public function hasPermission(User|int|null $user, string $permission): bool + { + return $this->permissionOverridesFor($user)[$permission] ?? false; + } + + public function hasDeniedPermission(User|int|null $user, string $permission): bool + { + $overrides = $this->permissionOverridesFor($user); + + return array_key_exists($permission, $overrides) && $overrides[$permission] === false; + } + + public static function permissionKeys(): array + { + return self::allowedPermissionOverrides(); + } + + public function canBeViewedBy(?User $user): bool + { + if ($this->isPubliclyVisible()) { + return true; + } + + return $user !== null && $this->hasActiveMember($user); + } + + public function canManage(User $user): bool + { + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true); + } + + public function canManageMembers(User $user): bool + { + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true); + } + + public function canPublishArtworks(User $user): bool + { + return $this->isOperational() + && in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true); + } + + public function canCreateArtworkDrafts(User $user): bool + { + return $this->isOperational() && $this->hasActiveMember($user); + } + + public function canSubmitArtworkForReview(User $user): bool + { + return $this->isOperational() && $this->hasActiveMember($user); + } + + public function canReviewSubmissions(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS)) { + return false; + } + + $role = $this->activeRoleFor($user); + + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS); + } + + public function canRequestJoin(?User $user): bool + { + if (! $this->isOperational() || $user === null || $this->hasActiveMember($user)) { + return false; + } + + return in_array($this->membership_policy, [self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN], true); + } + + public function canReviewJoinRequests(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS)) { + return false; + } + + $role = $this->activeRoleFor($user); + + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS); + } + + public function canManageRecruitment(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RECRUITMENT)) { + return false; + } + + $role = $this->activeRoleFor($user); + + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_RECRUITMENT); + } + + public function canManagePosts(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_POSTS)) { + return false; + } + + $role = $this->activeRoleFor($user); + + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_POSTS); + } + + public function canPublishPosts(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_POSTS)) { + return false; + } + + $role = $this->activeRoleFor($user); + + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_PUBLISH_POSTS) + || $this->canManagePosts($user); + } + + public function canPinPosts(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_PIN_POSTS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_PIN_POSTS); + } + + public function canManageMemberPermissions(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS); + } + + public function canManageEvents(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_EVENTS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_EVENTS); + } + + public function canManageChallenges(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_CHALLENGES)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_CHALLENGES); + } + + public function canManageProjects(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_PROJECTS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_PROJECTS); + } + + public function canManageReleases(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RELEASES)) { + return false; + } + + return $this->canManageProjects($user) + || $this->hasPermission($user, self::PERMISSION_MANAGE_RELEASES); + } + + public function canPublishReleases(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_RELEASES)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_PUBLISH_RELEASES) + || $this->canManageReleases($user); + } + + public function canManageMilestones(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MILESTONES)) { + return false; + } + + return $this->canManageProjects($user) + || $this->canManageReleases($user) + || $this->hasPermission($user, self::PERMISSION_MANAGE_MILESTONES); + } + + public function canViewReputationDashboard(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD); + } + + public function canManageBadges(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_BADGES)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_BADGES); + } + + public function canViewInternalTrustMetrics(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS); + } + + public function canFeatureReleases(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_RELEASES)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_FEATURE_RELEASES); + } + + public function canAssignReleaseLead(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD)) { + return false; + } + + return $this->canManageReleases($user) + || $this->hasPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD); + } + + public function canManageAssets(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ASSETS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_ASSETS); + } + + public function canFeatureChallengeEntries(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES); + } + + public function canPublishEventUpdates(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES)) { + return false; + } + + return $this->canManageEvents($user) + || $this->hasPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES); + } + + public function canAttachAssetsToProjects(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS)) { + return false; + } + + return $this->canManageProjects($user) + || $this->hasPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS); + } + + public function canViewInternalAssets(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true) + || $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS); + } + + public function canPinActivity(User $user): bool + { + if (! $this->isOperational()) { + return false; + } + + if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS)) { + return false; + } + + return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true) + || $this->hasPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS); + } + + public static function allowedPermissionOverrides(): array + { + return [ + self::PERMISSION_REVIEW_JOIN_REQUESTS, + self::PERMISSION_REVIEW_SUBMISSIONS, + self::PERMISSION_MANAGE_RECRUITMENT, + self::PERMISSION_MANAGE_POSTS, + self::PERMISSION_PUBLISH_POSTS, + self::PERMISSION_PIN_POSTS, + self::PERMISSION_MANAGE_MEMBER_PERMISSIONS, + self::PERMISSION_MANAGE_EVENTS, + self::PERMISSION_MANAGE_CHALLENGES, + self::PERMISSION_MANAGE_PROJECTS, + self::PERMISSION_MANAGE_RELEASES, + self::PERMISSION_PUBLISH_RELEASES, + self::PERMISSION_MANAGE_MILESTONES, + self::PERMISSION_VIEW_REPUTATION_DASHBOARD, + self::PERMISSION_MANAGE_BADGES, + self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS, + self::PERMISSION_FEATURE_RELEASES, + self::PERMISSION_ASSIGN_RELEASE_LEAD, + self::PERMISSION_MANAGE_ASSETS, + self::PERMISSION_FEATURE_CHALLENGE_ENTRIES, + self::PERMISSION_PUBLISH_EVENT_UPDATES, + self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS, + self::PERMISSION_VIEW_INTERNAL_ASSETS, + self::PERMISSION_MANAGE_ACTIVITY_PINS, + ]; + } + + public function canManageCollections(User $user): bool + { + return $this->isOperational() && $this->canPublishArtworks($user); + } + + public function avatarUrl(): ?string + { + return $this->assetUrl($this->avatar_path); + } + + public function bannerUrl(): ?string + { + return $this->assetUrl($this->banner_path); + } + + public function publicUrl(): string + { + return route('groups.show', ['group' => $this->slug]); + } + + public function shouldBeSearchable(): bool + { + return $this->visibility === self::VISIBILITY_PUBLIC && $this->status === self::LIFECYCLE_ACTIVE; + } + + public function toSearchableArray(): array + { + $recruitment = $this->relationLoaded('recruitmentProfile') + ? $this->recruitmentProfile + : $this->recruitmentProfile()->first(); + + $memberNames = $this->members() + ->with('user:id,name,username') + ->where('status', self::STATUS_ACTIVE) + ->limit(12) + ->get() + ->map(fn (GroupMember $member): string => (string) ($member->user?->name ?: $member->user?->username ?: '')) + ->filter() + ->values() + ->all(); + + return [ + 'id' => (int) $this->id, + 'name' => (string) $this->name, + 'slug' => (string) $this->slug, + 'headline' => (string) ($this->headline ?? ''), + 'bio' => (string) ($this->bio ?? ''), + 'type' => (string) ($this->type ?? ''), + 'visibility' => (string) $this->visibility, + 'status' => (string) ($this->status ?? self::LIFECYCLE_ACTIVE), + 'artworks_count' => (int) ($this->artworks_count ?? 0), + 'followers_count' => (int) ($this->followers_count ?? 0), + 'is_recruiting' => (bool) ($recruitment?->is_recruiting ?? false), + 'recruitment_headline' => (string) ($recruitment?->headline ?? ''), + 'recruitment_roles' => array_values(array_filter($recruitment?->roles_json ?? [])), + 'recruitment_skills' => array_values(array_filter($recruitment?->skills_json ?? [])), + 'release_titles' => $this->releases()->where('visibility', GroupRelease::VISIBILITY_PUBLIC)->latest('published_at')->limit(6)->pluck('title')->filter()->values()->all(), + 'project_titles' => $this->projects()->where('visibility', GroupProject::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(), + 'challenge_titles' => $this->challenges()->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(), + 'event_titles' => $this->events()->where('visibility', GroupEvent::VISIBILITY_PUBLIC)->latest('start_at')->limit(6)->pluck('title')->filter()->values()->all(), + 'badge_keys' => $this->badges()->latest('awarded_at')->limit(6)->pluck('badge_key')->filter()->values()->all(), + 'member_names' => $memberNames, + ]; + } + + private function assetUrl(?string $path): ?string + { + $trimmed = trim((string) $path); + + if ($trimmed === '') { + return null; + } + + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($trimmed, '/'); + } +} \ No newline at end of file diff --git a/app/Models/GroupActivityItem.php b/app/Models/GroupActivityItem.php new file mode 100644 index 00000000..2cca5312 --- /dev/null +++ b/app/Models/GroupActivityItem.php @@ -0,0 +1,45 @@ + 'boolean', + 'occurred_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupAsset.php b/app/Models/GroupAsset.php new file mode 100644 index 00000000..27d837c3 --- /dev/null +++ b/app/Models/GroupAsset.php @@ -0,0 +1,87 @@ + 'boolean', + 'file_meta_json' => 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function linkedProject(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'linked_project_id'); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by_user_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by_user_id'); + } + + public function canBeViewedBy(?User $viewer): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + return match ($this->visibility) { + self::VISIBILITY_PUBLIC_DOWNLOAD => $this->group->canBeViewedBy($viewer), + self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer), + default => $viewer !== null && $this->group->canViewInternalAssets($viewer), + }; + } +} \ No newline at end of file diff --git a/app/Models/GroupBadge.php b/app/Models/GroupBadge.php new file mode 100644 index 00000000..635a621d --- /dev/null +++ b/app/Models/GroupBadge.php @@ -0,0 +1,31 @@ + 'datetime', + 'meta_json' => 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupChallenge.php b/app/Models/GroupChallenge.php new file mode 100644 index 00000000..f5aa4541 --- /dev/null +++ b/app/Models/GroupChallenge.php @@ -0,0 +1,125 @@ + 'datetime', + 'end_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function linkedCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'linked_collection_id'); + } + + public function linkedProject(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'linked_project_id'); + } + + public function featuredArtwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'featured_artwork_id'); + } + + public function artworkLinks(): HasMany + { + return $this->hasMany(GroupChallengeArtwork::class); + } + + public function artworks(): BelongsToMany + { + return $this->belongsToMany(Artwork::class, 'group_challenge_artworks') + ->withPivot(['submitted_by_user_id', 'sort_order']) + ->withTimestamps() + ->orderBy('group_challenge_artworks.sort_order'); + } + + public function canBeViewedBy(?User $viewer): bool + { + if ($this->visibility !== self::VISIBILITY_PRIVATE) { + return $this->group->canBeViewedBy($viewer); + } + + return $viewer !== null && $this->group->canViewStudio($viewer); + } + + public function coverUrl(): ?string + { + $path = trim((string) $this->cover_path); + + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); + } +} \ No newline at end of file diff --git a/app/Models/GroupChallengeArtwork.php b/app/Models/GroupChallengeArtwork.php new file mode 100644 index 00000000..3cf68962 --- /dev/null +++ b/app/Models/GroupChallengeArtwork.php @@ -0,0 +1,36 @@ +belongsTo(GroupChallenge::class, 'group_challenge_id'); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function submitter(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupContributorStat.php b/app/Models/GroupContributorStat.php new file mode 100644 index 00000000..cb8a9dd8 --- /dev/null +++ b/app/Models/GroupContributorStat.php @@ -0,0 +1,44 @@ + 'integer', + 'release_count' => 'integer', + 'project_count' => 'integer', + 'review_actions_count' => 'integer', + 'approved_submissions_count' => 'integer', + 'reputation_meta_json' => 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupDiscoveryMetric.php b/app/Models/GroupDiscoveryMetric.php new file mode 100644 index 00000000..e1fb6e3f --- /dev/null +++ b/app/Models/GroupDiscoveryMetric.php @@ -0,0 +1,38 @@ + 'float', + 'activity_score' => 'float', + 'release_score' => 'float', + 'trust_score' => 'float', + 'collaboration_score' => 'float', + 'last_calculated_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupEvent.php b/app/Models/GroupEvent.php new file mode 100644 index 00000000..900a7c4f --- /dev/null +++ b/app/Models/GroupEvent.php @@ -0,0 +1,118 @@ + 'datetime', + 'end_at' => 'datetime', + 'is_featured' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function linkedProject(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'linked_project_id'); + } + + public function linkedCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'linked_collection_id'); + } + + public function linkedChallenge(): BelongsTo + { + return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id'); + } + + public function canBeViewedBy(?User $viewer): bool + { + return match ($this->visibility) { + self::VISIBILITY_PUBLIC => $this->group->canBeViewedBy($viewer), + self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer), + default => $viewer !== null && $this->group->canViewStudio($viewer), + }; + } + + public function coverUrl(): ?string + { + $path = trim((string) $this->cover_path); + + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); + } +} \ No newline at end of file diff --git a/app/Models/GroupFollow.php b/app/Models/GroupFollow.php new file mode 100644 index 00000000..bb287bbd --- /dev/null +++ b/app/Models/GroupFollow.php @@ -0,0 +1,29 @@ +belongsTo(Group::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupHistory.php b/app/Models/GroupHistory.php new file mode 100644 index 00000000..8006982a --- /dev/null +++ b/app/Models/GroupHistory.php @@ -0,0 +1,44 @@ + 'array', + 'after_json' => 'array', + 'created_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupInvitation.php b/app/Models/GroupInvitation.php new file mode 100644 index 00000000..88fe37f1 --- /dev/null +++ b/app/Models/GroupInvitation.php @@ -0,0 +1,69 @@ + 'datetime', + 'expires_at' => 'datetime', + 'responded_at' => 'datetime', + 'accepted_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'token'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function invitedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_user_id'); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } + + public function sourceGroupMember(): BelongsTo + { + return $this->belongsTo(GroupMember::class, 'source_group_member_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupJoinRequest.php b/app/Models/GroupJoinRequest.php new file mode 100644 index 00000000..98666916 --- /dev/null +++ b/app/Models/GroupJoinRequest.php @@ -0,0 +1,55 @@ + 'array', + 'reviewed_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupMember.php b/app/Models/GroupMember.php new file mode 100644 index 00000000..c34cbdb1 --- /dev/null +++ b/app/Models/GroupMember.php @@ -0,0 +1,51 @@ + 'array', + 'invited_at' => 'datetime', + 'expires_at' => 'datetime', + 'accepted_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupMemberBadge.php b/app/Models/GroupMemberBadge.php new file mode 100644 index 00000000..3d96016d --- /dev/null +++ b/app/Models/GroupMemberBadge.php @@ -0,0 +1,37 @@ + 'datetime', + 'meta_json' => 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupPost.php b/app/Models/GroupPost.php new file mode 100644 index 00000000..911ebafe --- /dev/null +++ b/app/Models/GroupPost.php @@ -0,0 +1,59 @@ + 'boolean', + 'published_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupProject.php b/app/Models/GroupProject.php new file mode 100644 index 00000000..48534b32 --- /dev/null +++ b/app/Models/GroupProject.php @@ -0,0 +1,162 @@ + 'date', + 'target_date' => 'date', + 'released_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(User::class, 'lead_user_id'); + } + + public function linkedCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'linked_collection_id'); + } + + public function featuredArtwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'linked_featured_artwork_id'); + } + + public function pinnedPost(): BelongsTo + { + return $this->belongsTo(GroupPost::class, 'pinned_post_id'); + } + + public function artworkLinks(): HasMany + { + return $this->hasMany(GroupProjectArtwork::class); + } + + public function artworks(): BelongsToMany + { + return $this->belongsToMany(Artwork::class, 'group_project_artworks') + ->withPivot(['sort_order']) + ->withTimestamps() + ->orderBy('group_project_artworks.sort_order'); + } + + public function memberLinks(): HasMany + { + return $this->hasMany(GroupProjectMember::class); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'group_project_members') + ->withPivot(['role_label', 'is_lead']) + ->withTimestamps(); + } + + public function assets(): HasMany + { + return $this->hasMany(GroupAsset::class, 'linked_project_id'); + } + + public function milestones(): HasMany + { + return $this->hasMany(GroupProjectMilestone::class)->orderBy('sort_order'); + } + + public function releases(): HasMany + { + return $this->hasMany(GroupRelease::class, 'linked_project_id'); + } + + public function challenges(): HasMany + { + return $this->hasMany(GroupChallenge::class, 'linked_project_id'); + } + + public function events(): HasMany + { + return $this->hasMany(GroupEvent::class, 'linked_project_id'); + } + + public function canBeViewedBy(?User $viewer): bool + { + if ($this->visibility !== self::VISIBILITY_PRIVATE) { + return $this->group->canBeViewedBy($viewer); + } + + return $viewer !== null && $this->group->canViewStudio($viewer); + } + + public function coverUrl(): ?string + { + $path = trim((string) $this->cover_path); + + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); + } +} \ No newline at end of file diff --git a/app/Models/GroupProjectArtwork.php b/app/Models/GroupProjectArtwork.php new file mode 100644 index 00000000..102db222 --- /dev/null +++ b/app/Models/GroupProjectArtwork.php @@ -0,0 +1,30 @@ +belongsTo(GroupProject::class, 'group_project_id'); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupProjectMember.php b/app/Models/GroupProjectMember.php new file mode 100644 index 00000000..854c602a --- /dev/null +++ b/app/Models/GroupProjectMember.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'group_project_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupProjectMilestone.php b/app/Models/GroupProjectMilestone.php new file mode 100644 index 00000000..a99f9382 --- /dev/null +++ b/app/Models/GroupProjectMilestone.php @@ -0,0 +1,45 @@ + 'date', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'group_project_id'); + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/GroupRecruitmentProfile.php b/app/Models/GroupRecruitmentProfile.php new file mode 100644 index 00000000..4f96ebaa --- /dev/null +++ b/app/Models/GroupRecruitmentProfile.php @@ -0,0 +1,36 @@ + 'boolean', + 'roles_json' => 'array', + 'skills_json' => 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupRelease.php b/app/Models/GroupRelease.php new file mode 100644 index 00000000..e9c679a2 --- /dev/null +++ b/app/Models/GroupRelease.php @@ -0,0 +1,156 @@ + 'datetime', + 'released_at' => 'datetime', + 'published_at' => 'datetime', + 'is_featured' => 'boolean', + ]; + + public function getRouteKeyName(): string + { + return 'slug'; + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(User::class, 'lead_user_id'); + } + + public function linkedProject(): BelongsTo + { + return $this->belongsTo(GroupProject::class, 'linked_project_id'); + } + + public function linkedCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'linked_collection_id'); + } + + public function featuredArtwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'featured_artwork_id'); + } + + public function artworkLinks(): HasMany + { + return $this->hasMany(GroupReleaseArtwork::class)->orderBy('sort_order'); + } + + public function artworks(): BelongsToMany + { + return $this->belongsToMany(Artwork::class, 'group_release_artworks') + ->withPivot(['sort_order']) + ->withTimestamps() + ->orderBy('group_release_artworks.sort_order'); + } + + public function contributorLinks(): HasMany + { + return $this->hasMany(GroupReleaseContributor::class)->orderBy('sort_order'); + } + + public function contributors(): BelongsToMany + { + return $this->belongsToMany(User::class, 'group_release_contributors') + ->withPivot(['role_label', 'sort_order']) + ->withTimestamps(); + } + + public function milestones(): HasMany + { + return $this->hasMany(GroupReleaseMilestone::class)->orderBy('sort_order'); + } + + public function canBeViewedBy(?User $viewer): bool + { + if ($this->visibility !== self::VISIBILITY_PRIVATE) { + return $this->group->canBeViewedBy($viewer); + } + + return $viewer !== null && $this->group->canViewStudio($viewer); + } + + public function coverUrl(): ?string + { + $path = trim((string) $this->cover_path); + + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); + } +} \ No newline at end of file diff --git a/app/Models/GroupReleaseArtwork.php b/app/Models/GroupReleaseArtwork.php new file mode 100644 index 00000000..78533737 --- /dev/null +++ b/app/Models/GroupReleaseArtwork.php @@ -0,0 +1,30 @@ +belongsTo(GroupRelease::class, 'group_release_id'); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupReleaseContributor.php b/app/Models/GroupReleaseContributor.php new file mode 100644 index 00000000..398e3066 --- /dev/null +++ b/app/Models/GroupReleaseContributor.php @@ -0,0 +1,31 @@ +belongsTo(GroupRelease::class, 'group_release_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/GroupReleaseMilestone.php b/app/Models/GroupReleaseMilestone.php new file mode 100644 index 00000000..d261dd88 --- /dev/null +++ b/app/Models/GroupReleaseMilestone.php @@ -0,0 +1,45 @@ + 'date', + ]; + + public function release(): BelongsTo + { + return $this->belongsTo(GroupRelease::class, 'group_release_id'); + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } +} \ No newline at end of file diff --git a/app/Models/Leaderboard.php b/app/Models/Leaderboard.php index bc4e098c..927eabfd 100644 --- a/app/Models/Leaderboard.php +++ b/app/Models/Leaderboard.php @@ -13,6 +13,7 @@ class Leaderboard extends Model public const TYPE_CREATOR = 'creator'; public const TYPE_ARTWORK = 'artwork'; + public const TYPE_GROUP = 'group'; public const TYPE_STORY = 'story'; public const PERIOD_DAILY = 'daily'; diff --git a/app/Models/User.php b/app/Models/User.php index cacbd6b6..4f597117 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,10 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\Group; +use App\Models\GroupFollow; +use App\Models\GroupInvitation; +use App\Models\GroupMember; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -125,6 +129,26 @@ class User extends Authenticatable return $this->hasMany(Collection::class)->latest('updated_at'); } + public function ownedGroups(): HasMany + { + return $this->hasMany(Group::class, 'owner_user_id')->latest('updated_at'); + } + + public function groupMemberships(): HasMany + { + return $this->hasMany(GroupMember::class)->latest('updated_at'); + } + + public function groupInvitations(): HasMany + { + return $this->hasMany(GroupInvitation::class, 'invited_user_id')->latest('updated_at'); + } + + public function groupFollows(): HasMany + { + return $this->hasMany(GroupFollow::class)->latest('updated_at'); + } + public function savedCollectionLists(): HasMany { return $this->hasMany(CollectionSavedList::class, 'user_id')->orderBy('title'); diff --git a/app/Policies/ArtworkPolicy.php b/app/Policies/ArtworkPolicy.php index bcb1506e..8705582c 100644 --- a/app/Policies/ArtworkPolicy.php +++ b/app/Policies/ArtworkPolicy.php @@ -80,6 +80,14 @@ class ArtworkPolicy */ public function update(User $user, Artwork $artwork): bool { + if ((int) ($artwork->group_id ?? 0) > 0) { + if ((int) $artwork->user_id === (int) $user->id && (string) ($artwork->artwork_status ?? '') === 'draft') { + return true; + } + + return $artwork->group?->canPublishArtworks($user) ?? false; + } + return $user->id === $artwork->user_id; } @@ -96,6 +104,14 @@ class ArtworkPolicy */ public function delete(User $user, Artwork $artwork): bool { + if ((int) ($artwork->group_id ?? 0) > 0) { + if ((int) $artwork->user_id === (int) $user->id && (string) ($artwork->artwork_status ?? '') === 'draft') { + return true; + } + + return $artwork->group?->canPublishArtworks($user) ?? false; + } + return $user->id === $artwork->user_id; } @@ -104,6 +120,10 @@ class ArtworkPolicy */ public function restore(User $user, Artwork $artwork): bool { + if ((int) ($artwork->group_id ?? 0) > 0) { + return ($artwork->group?->canPublishArtworks($user) ?? false) || $this->isAdmin($user); + } + return $user->id === $artwork->user_id || $this->isAdmin($user); } diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php index 37bce9f7..39cbfa46 100644 --- a/app/Policies/CollectionPolicy.php +++ b/app/Policies/CollectionPolicy.php @@ -43,6 +43,10 @@ class CollectionPolicy public function delete(User $user, Collection $collection): bool { + if ((int) ($collection->group_id ?? 0) > 0) { + return $collection->group?->canManageCollections($user) ?? false; + } + return $collection->isOwnedBy($user); } diff --git a/app/Policies/GroupPolicy.php b/app/Policies/GroupPolicy.php new file mode 100644 index 00000000..91efc600 --- /dev/null +++ b/app/Policies/GroupPolicy.php @@ -0,0 +1,242 @@ +isAdmin($user)) { + return true; + } + + return null; + } + + public function view(?User $user, Group $group): bool + { + return $group->canBeViewedBy($user); + } + + public function create(?User $user): bool + { + return (bool) $user; + } + + public function update(User $user, Group $group): bool + { + return $group->canManage($user); + } + + public function delete(User $user, Group $group): bool + { + return $group->isOwnedBy($user); + } + + public function manageMembers(User $user, Group $group): bool + { + return $group->canManageMembers($user); + } + + public function inviteMembers(User $user, Group $group): bool + { + return $group->canManageMembers($user); + } + + public function changeRoles(User $user, Group $group): bool + { + return $group->canManageMembers($user); + } + + public function archive(User $user, Group $group): bool + { + return $group->canArchive($user); + } + + public function viewStudio(User $user, Group $group): bool + { + return $group->canViewStudio($user); + } + + public function publishArtworks(User $user, Group $group): bool + { + return $group->canPublishArtworks($user); + } + + public function manageCollections(User $user, Group $group): bool + { + return $group->canManageCollections($user); + } + + public function requestJoin(User $user, Group $group): bool + { + return $group->canRequestJoin($user); + } + + public function reviewJoinRequests(User $user, Group $group): bool + { + return $group->canReviewJoinRequests($user); + } + + public function submitArtworkForReview(User $user, Group $group): bool + { + return $group->canSubmitArtworkForReview($user); + } + + public function reviewSubmissions(User $user, Group $group): bool + { + return $group->canReviewSubmissions($user); + } + + public function manageRecruitment(User $user, Group $group): bool + { + return $group->canManageRecruitment($user); + } + + public function managePosts(User $user, Group $group): bool + { + return $group->canManagePosts($user); + } + + public function publishPosts(User $user, Group $group): bool + { + return $group->canPublishPosts($user); + } + + public function pinPosts(User $user, Group $group): bool + { + return $group->canPinPosts($user); + } + + public function manageMemberPermissions(User $user, Group $group): bool + { + return $group->canManageMemberPermissions($user); + } + + public function manageEvents(User $user, Group $group): bool + { + return $group->canManageEvents($user); + } + + public function manageChallenges(User $user, Group $group): bool + { + return $group->canManageChallenges($user); + } + + public function manageProjects(User $user, Group $group): bool + { + return $group->canManageProjects($user); + } + + public function manageReleases(User $user, Group $group): bool + { + return $group->canManageReleases($user); + } + + public function publishReleases(User $user, Group $group): bool + { + return $group->canPublishReleases($user); + } + + public function moveReleaseStage(User $user, Group $group): bool + { + return $group->canManageReleases($user); + } + + public function manageMilestones(User $user, Group $group): bool + { + return $group->canManageMilestones($user); + } + + public function viewReputationDashboard(User $user, Group $group): bool + { + return $group->canViewReputationDashboard($user); + } + + public function manageBadges(User $user, Group $group): bool + { + return $group->canManageBadges($user); + } + + public function viewInternalTrustMetrics(User $user, Group $group): bool + { + return $group->canViewInternalTrustMetrics($user); + } + + public function featureRelease(User $user, Group $group): bool + { + return $group->canFeatureReleases($user); + } + + public function assignReleaseLead(User $user, Group $group): bool + { + return $group->canAssignReleaseLead($user); + } + + public function manageAssets(User $user, Group $group): bool + { + return $group->canManageAssets($user); + } + + public function viewInternalAssets(User $user, Group $group): bool + { + return $group->canViewInternalAssets($user); + } + + public function featureChallengeEntries(User $user, Group $group): bool + { + return $group->canFeatureChallengeEntries($user); + } + + public function participateInChallenge(User $user, Group $group): bool + { + return $group->isOperational() && $group->canViewStudio($user); + } + + public function publishEventUpdates(User $user, Group $group): bool + { + return $group->canPublishEventUpdates($user); + } + + public function attachAssetsToProjects(User $user, Group $group): bool + { + return $group->canAttachAssetsToProjects($user); + } + + public function viewInternalEvents(User $user, Group $group): bool + { + return $group->canViewStudio($user); + } + + public function viewPrivateProject(User $user, Group $group): bool + { + return $group->canViewStudio($user); + } + + public function pinActivity(User $user, Group $group): bool + { + return $group->canPinActivity($user); + } + + private function isAdmin(User $user): bool + { + if (method_exists($user, 'isAdmin')) { + return (bool) $user->isAdmin(); + } + + if (method_exists($user, 'hasRole')) { + return (bool) $user->hasRole('admin'); + } + + return false; + } +} \ No newline at end of file diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 5d538a80..4edbb96e 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -7,6 +7,7 @@ use App\Models\Artwork; use App\Models\ArtworkAward; use App\Models\ArtworkComment; use App\Models\Collection; +use App\Models\Group; use App\Models\NovaCard; use App\Models\Post; use App\Models\PostComment; @@ -14,6 +15,7 @@ use App\Policies\ArtworkPolicy; use App\Policies\ArtworkAwardPolicy; use App\Policies\ArtworkCommentPolicy; use App\Policies\CollectionPolicy; +use App\Policies\GroupPolicy; use App\Policies\NovaCardPolicy; use App\Policies\PostPolicy; use App\Policies\PostCommentPolicy; @@ -25,6 +27,7 @@ class AuthServiceProvider extends ServiceProvider ArtworkAward::class => ArtworkAwardPolicy::class, ArtworkComment::class => ArtworkCommentPolicy::class, Collection::class => CollectionPolicy::class, + Group::class => GroupPolicy::class, NovaCard::class => NovaCardPolicy::class, Post::class => PostPolicy::class, PostComment::class => PostCommentPolicy::class, diff --git a/app/Services/ArtworkAttributionService.php b/app/Services/ArtworkAttributionService.php new file mode 100644 index 00000000..4534dba6 --- /dev/null +++ b/app/Services/ArtworkAttributionService.php @@ -0,0 +1,148 @@ +group_id ?? 0); + $group = $this->resolveGroup($actor, $attributes, $requireDirectPublish); + $allowedContributorIds = $group ? $this->groupMembers->activeContributorIds($group) : [(int) $actor->id]; + + $primaryAuthorId = $this->resolvePrimaryAuthorId($actor, $attributes, $allowedContributorIds); + $contributorCredits = $this->normalizeContributorCredits($attributes, $allowedContributorIds, $primaryAuthorId); + + DB::transaction(function () use ($artwork, $actor, $group, $primaryAuthorId, $contributorCredits): void { + $artwork->group()->associate($group); + $artwork->uploadedBy()->associate($actor); + $artwork->primaryAuthor()->associate(User::query()->findOrFail($primaryAuthorId)); + $artwork->published_as_type = $group ? Artwork::PUBLISHED_AS_GROUP : Artwork::PUBLISHED_AS_USER; + $artwork->published_as_id = $group?->id ?: (int) $artwork->user_id; + $artwork->save(); + + $artwork->contributors()->delete(); + + foreach ($contributorCredits as $index => $contributorCredit) { + $artwork->contributors()->create([ + 'user_id' => $contributorCredit['user_id'], + 'credit_role' => $contributorCredit['credit_role'], + 'is_primary' => $contributorCredit['is_primary'], + 'sort_order' => $index, + ]); + } + }); + + $artwork->loadMissing(['group.members', 'primaryAuthor.profile', 'contributors.user.profile', 'uploadedBy.profile']); + + $newGroupId = (int) ($artwork->group_id ?? 0); + if ($previousGroupId > 0 && $previousGroupId !== $newGroupId) { + $previousGroup = Group::query()->find($previousGroupId); + if ($previousGroup) { + $this->groups->syncArtworkCount($previousGroup); + } + } + + if ($newGroupId > 0) { + $this->groups->syncArtworkCount($artwork->group); + } + + return $artwork; + } + + private function resolveGroup(User $actor, array $attributes, bool $requireDirectPublish = true): ?Group + { + $groupIdentifier = $attributes['group'] ?? $attributes['group_id'] ?? null; + if ($groupIdentifier === null || $groupIdentifier === '') { + return null; + } + + $group = is_numeric($groupIdentifier) + ? Group::query()->with('members')->findOrFail((int) $groupIdentifier) + : Group::query()->with('members')->where('slug', (string) $groupIdentifier)->firstOrFail(); + + $canUseGroup = $requireDirectPublish + ? $group->canPublishArtworks($actor) + : $group->canCreateArtworkDrafts($actor); + + if (! $canUseGroup && ! $actor->isAdmin()) { + throw ValidationException::withMessages([ + 'group' => $requireDirectPublish + ? 'You are not allowed to publish as this group.' + : 'You are not allowed to submit artwork for this group.', + ]); + } + + return $group; + } + + private function resolvePrimaryAuthorId(User $actor, array $attributes, array $allowedContributorIds): int + { + $primaryAuthorId = isset($attributes['primary_author_user_id']) && is_numeric($attributes['primary_author_user_id']) + ? (int) $attributes['primary_author_user_id'] + : (int) $actor->id; + + if (! in_array($primaryAuthorId, $allowedContributorIds, true)) { + throw ValidationException::withMessages([ + 'primary_author_user_id' => 'The selected primary author is not available for this publishing context.', + ]); + } + + return $primaryAuthorId; + } + + private function normalizeContributorCredits(array $attributes, array $allowedContributorIds, int $primaryAuthorId): array + { + $structuredCredits = collect($attributes['contributor_credits'] ?? []) + ->filter(fn ($credit): bool => is_array($credit) && is_numeric($credit['user_id'] ?? null)) + ->map(function (array $credit): array { + $creditRole = trim((string) ($credit['credit_role'] ?? '')); + + return [ + 'user_id' => (int) $credit['user_id'], + 'credit_role' => $creditRole !== '' ? $creditRole : null, + 'is_primary' => (bool) ($credit['is_primary'] ?? false), + ]; + }) + ->filter(fn (array $credit): bool => in_array($credit['user_id'], $allowedContributorIds, true)) + ->reject(fn (array $credit): bool => $credit['user_id'] === $primaryAuthorId) + ->unique('user_id') + ->values(); + + if ($structuredCredits->isEmpty()) { + $structuredCredits = collect($attributes['contributor_user_ids'] ?? []) + ->filter(fn ($id): bool => is_numeric($id)) + ->map(fn ($id): array => [ + 'user_id' => (int) $id, + 'credit_role' => null, + 'is_primary' => false, + ]) + ->filter(fn (array $credit): bool => in_array($credit['user_id'], $allowedContributorIds, true)) + ->reject(fn (array $credit): bool => $credit['user_id'] === $primaryAuthorId) + ->unique('user_id') + ->values(); + } + + if ($structuredCredits->where('is_primary', true)->count() > 1) { + throw ValidationException::withMessages([ + 'contributor_credits' => 'Only one contributor can be marked as the lead supporting credit.', + ]); + } + + return $structuredCredits->all(); + } +} \ No newline at end of file diff --git a/app/Services/Artworks/ArtworkDraftService.php b/app/Services/Artworks/ArtworkDraftService.php index 1a42ddb0..4496d483 100644 --- a/app/Services/Artworks/ArtworkDraftService.php +++ b/app/Services/Artworks/ArtworkDraftService.php @@ -6,18 +6,33 @@ namespace App\Services\Artworks; use App\DTOs\Artworks\ArtworkDraftResult; use App\Models\Artwork; +use App\Models\Group; +use App\Models\User; +use App\Services\GroupService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; final class ArtworkDraftService { - public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false): ArtworkDraftResult + public function __construct( + private readonly GroupService $groups, + ) { + } + + public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult { - return DB::transaction(function () use ($userId, $title, $description, $categoryId, $isMature) { + return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) { $slug = $this->makeSlug($title); + $group = $this->resolveGroup($user, $groupIdentifier); $artwork = Artwork::create([ - 'user_id' => $userId, + 'user_id' => (int) $user->id, + 'group_id' => $group?->id, + 'uploaded_by_user_id' => (int) $user->id, + 'primary_author_user_id' => (int) $user->id, + 'published_as_type' => $group ? Artwork::PUBLISHED_AS_GROUP : Artwork::PUBLISHED_AS_USER, + 'published_as_id' => $group?->id ?: (int) $user->id, 'title' => $title, 'slug' => $slug, 'description' => $description, @@ -40,10 +55,33 @@ final class ArtworkDraftService $artwork->categories()->sync([$categoryId]); } + if ($group) { + $this->groups->syncArtworkCount($group); + } + return new ArtworkDraftResult((int) $artwork->id, 'draft'); }); } + private function resolveGroup(User $user, string|int|null $groupIdentifier): ?Group + { + if ($groupIdentifier === null || $groupIdentifier === '') { + return null; + } + + $group = is_numeric($groupIdentifier) + ? Group::query()->with('members')->findOrFail((int) $groupIdentifier) + : Group::query()->with('members')->where('slug', (string) $groupIdentifier)->firstOrFail(); + + if (! $group->canCreateArtworkDrafts($user) && ! $user->isAdmin()) { + throw ValidationException::withMessages([ + 'group' => 'You are not allowed to create drafts for this group.', + ]); + } + + return $group; + } + private function makeSlug(string $title): string { $base = Str::slug($title); diff --git a/app/Services/CollectionService.php b/app/Services/CollectionService.php index 18268897..5a833765 100644 --- a/app/Services/CollectionService.php +++ b/app/Services/CollectionService.php @@ -15,6 +15,7 @@ use App\Events\Collections\CollectionUpdated; use App\Events\Collections\SmartCollectionRulesUpdated; use App\Models\Artwork; use App\Models\Collection; +use App\Models\Group; use App\Models\User; use App\Support\AvatarUrl; use App\Services\ThumbnailPresenter; @@ -31,6 +32,7 @@ class CollectionService public function __construct( private readonly SmartCollectionService $smartCollections, private readonly CollectionCollaborationService $collaborators, + private readonly GroupMembershipService $groupMembers, ) { } @@ -39,14 +41,14 @@ class CollectionService return $this->normalizeLayoutModules(null, Collection::TYPE_PERSONAL, true, false); } - public function makeUniqueSlugForUser(User $user, string $source, ?int $ignoreCollectionId = null): string + public function makeUniqueSlugForUser(User $user, string $source, ?int $ignoreCollectionId = null, ?Group $group = null): string { $base = Str::slug(Str::limit($source, 140, '')); $base = $base !== '' ? $base : 'collection'; $slug = $base; $suffix = 2; - while ($this->slugExistsForUser($user, $slug, $ignoreCollectionId)) { + while ($this->slugExistsForUser($user, $slug, $ignoreCollectionId, $group)) { $slug = Str::limit($base, 132, ''); $slug = rtrim($slug, '-'); $slug .= '-' . $suffix; @@ -70,9 +72,10 @@ class CollectionService $collection = new Collection(); $collection->user()->associate($ownership['owner_user']); + $collection->group()->associate($ownership['group']); $collection->managed_by_user_id = $ownership['managed_by_user_id']; $collection->title = (string) $attributes['title']; - $collection->slug = $this->makeUniqueSlugForUser($ownership['owner_user'], (string) ($attributes['slug'] ?? $attributes['title'])); + $collection->slug = $this->makeUniqueSlugForUser($ownership['owner_user'], (string) ($attributes['slug'] ?? $attributes['title']), null, $ownership['group']); $collection->lifecycle_state = (string) ($attributes['lifecycle_state'] ?? Collection::LIFECYCLE_DRAFT); $collection->type = $type; $collection->editorial_owner_mode = $ownership['editorial_owner_mode']; @@ -129,7 +132,7 @@ class CollectionService $collection->collaborators_count = 1; $collection->smart_rules_json = $smartRules; $collection->layout_modules_json = $this->normalizeLayoutModules($attributes['layout_modules_json'] ?? null, $type, $allowComments, $allowSubmissions, false); - $collection->profile_order = $this->nextProfileOrder($ownership['owner_user']); + $collection->profile_order = $this->nextProfileOrder($ownership['owner_user'], $ownership['group']); $collection->last_activity_at = now(); $collection->published_at = $this->resolvePublishedAt($attributes); $collection->unpublished_at = $this->resolveUnpublishedAt($attributes); @@ -179,10 +182,11 @@ class CollectionService $allowComments = array_key_exists('allow_comments', $attributes) ? (bool) $attributes['allow_comments'] : $collection->allow_comments; $collection->user()->associate($ownership['owner_user']); + $collection->group()->associate($ownership['group']); $collection->fill([ 'title' => (string) ($attributes['title'] ?? $collection->title), - 'slug' => $this->makeUniqueSlugForUser($ownership['owner_user'], $slugSource, (int) $collection->id), + 'slug' => $this->makeUniqueSlugForUser($ownership['owner_user'], $slugSource, (int) $collection->id, $ownership['group']), 'lifecycle_state' => (string) ($attributes['lifecycle_state'] ?? $collection->lifecycle_state), 'type' => $type, 'managed_by_user_id' => $ownership['managed_by_user_id'], @@ -541,7 +545,11 @@ class CollectionService ->with(['contentType:id,slug,name']); }, ]) - ->whereIn('user_id', $this->contributorIds($collection)) + ->when( + (int) ($collection->group_id ?? 0) > 0, + fn ($builder) => $builder->where('group_id', (int) $collection->group_id), + fn ($builder) => $builder->whereIn('user_id', $this->contributorIds($collection)) + ) ->whereNull('deleted_at') ->whereNotIn('id', $attachedIds) ->orderByDesc('published_at') @@ -560,17 +568,31 @@ class CollectionService public function getCollectionOptionsForArtwork(User $owner, Artwork $artwork): array { - if ((int) $artwork->user_id !== (int) $owner->id) { + $isPersonalArtwork = (int) ($artwork->group_id ?? 0) < 1; + $group = $artwork->group; + + if ($isPersonalArtwork && (int) $artwork->user_id !== (int) $owner->id) { throw ValidationException::withMessages([ 'artwork_id' => 'You can only manage collections for your own artworks.', ]); } + if (! $isPersonalArtwork && (! $group || ! $group->canManageCollections($owner))) { + throw ValidationException::withMessages([ + 'artwork_id' => 'You can only manage collections for groups you can edit.', + ]); + } + $collections = Collection::query() - ->ownedBy((int) $owner->id) + ->with('group') + ->when( + ! $isPersonalArtwork, + fn ($query) => $query->where('group_id', (int) $artwork->group_id), + fn ($query) => $query->ownedBy((int) $owner->id) + ) ->where('mode', Collection::MODE_MANUAL) ->orderByDesc('updated_at') - ->get(['id', 'user_id', 'title', 'slug', 'visibility', 'mode', 'artworks_count', 'updated_at']); + ->get(['id', 'user_id', 'group_id', 'title', 'slug', 'visibility', 'mode', 'artworks_count', 'updated_at']); if ($collections->isEmpty()) { return []; @@ -586,6 +608,11 @@ class CollectionService return $collections->map(function (Collection $collection) use ($attachedCollectionIds, $owner) { $alreadyAttached = in_array((int) $collection->id, $attachedCollectionIds, true); + $publicUrl = route('profile.collections.show', [ + 'username' => strtolower((string) $owner->username), + 'slug' => $collection->slug, + ]); + return [ 'id' => (int) $collection->id, 'title' => (string) $collection->title, @@ -597,10 +624,7 @@ class CollectionService 'already_attached' => $alreadyAttached, 'attach_url' => route('settings.collections.artworks.attach', ['collection' => $collection->id]), 'manage_url' => route('settings.collections.show', ['collection' => $collection->id]), - 'public_url' => route('profile.collections.show', [ - 'username' => strtolower((string) $owner->username), - 'slug' => $collection->slug, - ]), + 'public_url' => $publicUrl, ]; })->all(); } @@ -1319,13 +1343,19 @@ class CollectionService private function resolveOwnershipContext(User $actor, array $attributes, ?Collection $collection, string $type): array { if ($type !== Collection::TYPE_EDITORIAL) { + $group = $this->resolveGroupContext($actor, $attributes, $collection); $ownerUser = $collection && ! $collection->hasSystemEditorialOwner() && (int) $collection->user_id !== (int) $actor->id ? $collection->user - : $actor; + : ($group?->owner ?: $actor); + + $managedByUserId = $group && (int) $ownerUser->id !== (int) $actor->id + ? (int) $actor->id + : null; return [ 'owner_user' => $ownerUser, - 'managed_by_user_id' => null, + 'group' => $group, + 'managed_by_user_id' => $managedByUserId, 'editorial_owner_mode' => Collection::EDITORIAL_OWNER_CREATOR, 'editorial_owner_user_id' => null, 'editorial_owner_label' => null, @@ -1371,6 +1401,7 @@ class CollectionService return [ 'owner_user' => $ownerUser, + 'group' => null, 'managed_by_user_id' => $managedByUserId, 'editorial_owner_mode' => $ownerMode, 'editorial_owner_user_id' => $editorialOwnerUserId, @@ -1380,6 +1411,26 @@ class CollectionService private function mapCollectionOwnerPayload(Collection $collection): array { + if ((int) ($collection->group_id ?? 0) > 0) { + $group = $collection->relationLoaded('group') ? $collection->group : $collection->group()->with('owner.profile')->first(); + + return [ + 'name' => $group?->name ?: 'Skinbase Group', + 'username' => null, + 'profile_url' => $group ? $group->publicUrl() : null, + 'is_system' => false, + 'mode' => 'group', + 'managed_by_user_id' => $collection->managed_by_user_id ? (int) $collection->managed_by_user_id : null, + 'avatar_url' => $group?->avatarUrl(), + 'group' => $group ? [ + 'id' => (int) $group->id, + 'slug' => (string) $group->slug, + 'name' => (string) $group->name, + 'public_url' => $group->publicUrl(), + ] : null, + ]; + } + $owner = $collection->relationLoaded('user') ? $collection->user : $collection->user()->first(); $username = $collection->displayOwnerUsername(); $avatarUrl = null; @@ -1396,6 +1447,7 @@ class CollectionService 'mode' => $collection->editorial_owner_mode, 'managed_by_user_id' => $collection->managed_by_user_id ? (int) $collection->managed_by_user_id : null, 'avatar_url' => $avatarUrl, + 'group' => null, ]; } @@ -1406,10 +1458,13 @@ class CollectionService return $presented['url'] ?? $artwork->thumbUrl('md'); } - private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null): bool + private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null, ?Group $group = null): bool { return Collection::query() - ->where('user_id', $user->id) + ->when($group !== null, + fn ($query) => $query->where('group_id', $group->id), + fn ($query) => $query->whereNull('group_id')->where('user_id', $user->id) + ) ->where('slug', $slug) ->when($ignoreCollectionId !== null, fn ($query) => $query->where('id', '!=', $ignoreCollectionId)) ->withTrashed() @@ -1421,13 +1476,37 @@ class CollectionService return max(1, (int) config('collections.featured_limit', 3)); } - private function nextProfileOrder(User $user): int + private function nextProfileOrder(User $user, ?Group $group = null): int { return (int) (Collection::query() - ->ownedBy((int) $user->id) + ->when($group !== null, + fn ($query) => $query->where('group_id', $group->id), + fn ($query) => $query->ownedBy((int) $user->id) + ) ->max('profile_order') ?? -1) + 1; } + private function resolveGroupContext(User $actor, array $attributes, ?Collection $collection = null): ?Group + { + $groupIdentifier = $attributes['group'] ?? $attributes['group_id'] ?? ($collection?->group_id ? (string) $collection->group_id : null); + + if ($groupIdentifier === null || $groupIdentifier === '') { + return null; + } + + $group = is_numeric($groupIdentifier) + ? Group::query()->with('members')->findOrFail((int) $groupIdentifier) + : Group::query()->with('members')->where('slug', (string) $groupIdentifier)->firstOrFail(); + + if (! $group->canManageCollections($actor) && ! $actor->isAdmin()) { + throw ValidationException::withMessages([ + 'group' => 'You are not allowed to manage collections for this group.', + ]); + } + + return $group; + } + private function resolvePublishedAt(array $attributes, mixed $fallback = null): ?Carbon { if (! array_key_exists('published_at', $attributes)) { @@ -1463,6 +1542,12 @@ class CollectionService */ private function contributorIds(Collection $collection): array { + if ((int) ($collection->group_id ?? 0) > 0) { + $group = $collection->relationLoaded('group') ? $collection->group : $collection->group()->with('members')->first(); + + return $group ? $this->groupMembers->activeContributorIds($group) : []; + } + return $collection->isCollaborative() ? $this->collaborators->activeContributorIds($collection) : [(int) $collection->user_id]; diff --git a/app/Services/GroupActivityService.php b/app/Services/GroupActivityService.php new file mode 100644 index 00000000..f11ac814 --- /dev/null +++ b/app/Services/GroupActivityService.php @@ -0,0 +1,159 @@ +create([ + 'group_id' => (int) $group->id, + 'type' => $type, + 'visibility' => $visibility, + 'actor_user_id' => $actor?->id, + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'headline' => $headline, + 'summary' => $summary, + 'is_pinned' => false, + 'occurred_at' => now(), + ]); + } + + public function pin(GroupActivityItem $item, User $actor, bool $isPinned = true): GroupActivityItem + { + $item->forceFill([ + 'is_pinned' => $isPinned, + ])->save(); + + app(GroupHistoryService::class)->record( + $item->group, + $actor, + $isPinned ? 'activity_pinned' : 'activity_unpinned', + sprintf('%s group activity item.', $isPinned ? 'Pinned' : 'Unpinned'), + 'group_activity_item', + (int) $item->id, + ['is_pinned' => ! $isPinned], + ['is_pinned' => $isPinned], + ); + + return $item->fresh(['actor']); + } + + public function publicFeed(Group $group, int $limit = 8): array + { + return $this->mapItems( + GroupActivityItem::query() + ->with('actor:id,name,username') + ->where('group_id', $group->id) + ->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC) + ->orderByDesc('is_pinned') + ->orderByDesc('occurred_at') + ->limit(max(1, min(24, $limit))) + ->get(), + $group + ); + } + + public function studioFeed(Group $group, User $viewer, int $limit = 20): array + { + if (! $group->canViewStudio($viewer)) { + return []; + } + + return $this->mapItems( + GroupActivityItem::query() + ->with('actor:id,name,username') + ->where('group_id', $group->id) + ->orderByDesc('is_pinned') + ->orderByDesc('occurred_at') + ->limit(max(1, min(50, $limit))) + ->get(), + $group + ); + } + + private function mapItems(Collection $items, Group $group): array + { + $subjects = $this->loadSubjects($items); + + return $items->map(function (GroupActivityItem $item) use ($group, $subjects): array { + $subject = $subjects[$item->subject_type][$item->subject_id] ?? null; + + return [ + 'id' => (int) $item->id, + 'type' => (string) $item->type, + 'visibility' => (string) $item->visibility, + 'headline' => (string) $item->headline, + 'summary' => $item->summary, + 'is_pinned' => (bool) $item->is_pinned, + 'occurred_at' => $item->occurred_at?->toISOString(), + 'actor' => $item->actor ? [ + 'id' => (int) $item->actor->id, + 'name' => $item->actor->name, + 'username' => $item->actor->username, + ] : null, + 'subject' => $subject ? [ + 'type' => (string) $item->subject_type, + 'id' => (int) $item->subject_id, + 'title' => $subject->title ?? null, + 'url' => $this->subjectUrl($group, (string) $item->subject_type, $subject), + ] : null, + ]; + })->values()->all(); + } + + private function loadSubjects(Collection $items): array + { + $grouped = $items + ->filter(fn (GroupActivityItem $item): bool => $item->subject_id !== null) + ->groupBy('subject_type') + ->map(fn (Collection $chunk): array => $chunk->pluck('subject_id')->map(fn ($id): int => (int) $id)->unique()->values()->all()); + + return [ + 'artwork' => Artwork::query()->whereIn('id', $grouped->get('artwork', []))->get()->keyBy('id')->all(), + 'group_post' => GroupPost::query()->whereIn('id', $grouped->get('group_post', []))->get()->keyBy('id')->all(), + 'group_project' => GroupProject::query()->whereIn('id', $grouped->get('group_project', []))->get()->keyBy('id')->all(), + 'group_release' => GroupRelease::query()->whereIn('id', $grouped->get('group_release', []))->get()->keyBy('id')->all(), + 'group_challenge' => GroupChallenge::query()->whereIn('id', $grouped->get('group_challenge', []))->get()->keyBy('id')->all(), + 'group_event' => GroupEvent::query()->whereIn('id', $grouped->get('group_event', []))->get()->keyBy('id')->all(), + 'group_asset' => GroupAsset::query()->whereIn('id', $grouped->get('group_asset', []))->get()->keyBy('id')->all(), + ]; + } + + private function subjectUrl(Group $group, string $subjectType, object $subject): ?string + { + return match ($subjectType) { + 'artwork' => route('art.show', ['id' => $subject->id, 'slug' => $subject->slug ?: $subject->id]), + 'group_post' => route('groups.posts.show', ['group' => $group, 'post' => $subject]), + 'group_project' => route('groups.projects.show', ['group' => $group, 'project' => $subject]), + 'group_release' => route('groups.releases.show', ['group' => $group, 'release' => $subject]), + 'group_challenge' => route('groups.challenges.show', ['group' => $group, 'challenge' => $subject]), + 'group_event' => route('groups.events.show', ['group' => $group, 'event' => $subject]), + 'group_asset' => route('groups.assets.download', ['group' => $group, 'asset' => $subject]), + default => null, + }; + } +} \ No newline at end of file diff --git a/app/Services/GroupArtworkReviewService.php b/app/Services/GroupArtworkReviewService.php new file mode 100644 index 00000000..25657c3e --- /dev/null +++ b/app/Services/GroupArtworkReviewService.php @@ -0,0 +1,408 @@ +canSubmitArtworkForReview($actor)) { + throw ValidationException::withMessages([ + 'group' => 'You are not allowed to submit artwork for this group.', + ]); + } + + if ($group->status !== Group::LIFECYCLE_ACTIVE) { + throw ValidationException::withMessages([ + 'group' => 'Archived or suspended groups cannot accept new submissions.', + ]); + } + + if ((int) $artwork->user_id !== (int) $actor->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $actor->id) { + throw ValidationException::withMessages([ + 'artwork' => 'You can only submit your own group draft for review.', + ]); + } + + $before = [ + 'group_review_status' => $artwork->group_review_status, + 'artwork_status' => $artwork->artwork_status, + ]; + + $this->applyDraftMetadata($artwork, $actor, $attributes); + $artwork->save(); + $artwork = $this->attribution->apply($artwork->fresh(['group.members']), $actor, $attributes, false); + + $artwork->forceFill([ + 'visibility' => (string) ($attributes['visibility'] ?? $artwork->visibility ?? Artwork::VISIBILITY_PUBLIC), + 'is_public' => false, + 'is_approved' => false, + 'published_at' => null, + 'publish_at' => null, + 'artwork_status' => 'draft', + 'group_review_status' => 'submitted', + 'group_review_submitted_at' => now(), + 'group_reviewed_by_user_id' => null, + 'group_reviewed_at' => null, + 'group_review_notes' => null, + ])->save(); + + $this->syncSearchIndex($artwork); + + $this->history->record( + $group, + $actor, + 'artwork_submitted_for_review', + sprintf('Submitted "%s" for group review.', $artwork->title), + 'artwork', + (int) $artwork->id, + $before, + [ + 'group_review_status' => 'submitted', + 'visibility' => $artwork->visibility, + ], + ); + + foreach ($this->reviewRecipients($group, $actor->id) as $recipient) { + $this->notifications->notifyGroupArtworkSubmittedForReview($recipient, $actor, $group, $artwork); + } + + return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); + } + + public function approve(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork + { + $this->guardReviewAbility($group, $artwork, $actor); + + $before = [ + 'group_review_status' => $artwork->group_review_status, + 'artwork_status' => $artwork->artwork_status, + 'published_at' => optional($artwork->published_at)->toISOString(), + ]; + + $artwork->forceFill([ + 'group_review_status' => 'approved', + 'group_reviewed_by_user_id' => $actor->id, + 'group_reviewed_at' => now(), + 'group_review_notes' => $notes, + 'is_approved' => true, + 'artwork_status' => 'published', + 'published_at' => now(), + 'publish_at' => null, + 'is_public' => ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC) !== Artwork::VISIBILITY_PRIVATE, + ])->save(); + + $this->syncSearchIndex($artwork); + + $this->history->record( + $group, + $actor, + 'artwork_submission_approved', + sprintf('Approved group submission "%s".', $artwork->title), + 'artwork', + (int) $artwork->id, + $before, + [ + 'group_review_status' => 'approved', + 'artwork_status' => 'published', + 'published_at' => optional($artwork->published_at)->toISOString(), + ], + ); + + app(GroupActivityService::class)->record( + $group, + $actor, + 'artwork_published', + 'artwork', + (int) $artwork->id, + sprintf('%s published new artwork: %s', $group->name, $artwork->title), + $notes, + 'public', + ); + + if ($artwork->uploadedBy) { + $this->notifications->notifyGroupArtworkApproved($artwork->uploadedBy, $actor, $group, $artwork); + } + + return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); + } + + public function requestChanges(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork + { + $this->guardReviewAbility($group, $artwork, $actor); + + $before = [ + 'group_review_status' => $artwork->group_review_status, + ]; + + $artwork->forceFill([ + 'group_review_status' => 'needs_changes', + 'group_reviewed_by_user_id' => $actor->id, + 'group_reviewed_at' => now(), + 'group_review_notes' => $notes, + 'is_public' => false, + 'published_at' => null, + 'artwork_status' => 'draft', + ])->save(); + + $this->syncSearchIndex($artwork); + + $this->history->record( + $group, + $actor, + 'artwork_submission_changes_requested', + sprintf('Requested changes for "%s".', $artwork->title), + 'artwork', + (int) $artwork->id, + $before, + ['group_review_status' => 'needs_changes'], + ); + + if ($artwork->uploadedBy) { + $this->notifications->notifyGroupArtworkNeedsChanges($artwork->uploadedBy, $actor, $group, $artwork); + } + + return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); + } + + public function reject(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork + { + $this->guardReviewAbility($group, $artwork, $actor); + + $before = [ + 'group_review_status' => $artwork->group_review_status, + ]; + + $artwork->forceFill([ + 'group_review_status' => 'rejected', + 'group_reviewed_by_user_id' => $actor->id, + 'group_reviewed_at' => now(), + 'group_review_notes' => $notes, + 'is_public' => false, + 'published_at' => null, + 'artwork_status' => 'draft', + ])->save(); + + $this->syncSearchIndex($artwork); + + $this->history->record( + $group, + $actor, + 'artwork_submission_rejected', + sprintf('Rejected group submission "%s".', $artwork->title), + 'artwork', + (int) $artwork->id, + $before, + ['group_review_status' => 'rejected'], + ); + + if ($artwork->uploadedBy) { + $this->notifications->notifyGroupArtworkRejected($artwork->uploadedBy, $actor, $group, $artwork); + } + + return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']); + } + + public function pendingCount(Group $group): int + { + return (int) Artwork::query() + ->where('group_id', $group->id) + ->where('group_review_status', 'submitted') + ->whereNull('deleted_at') + ->count(); + } + + public function listing(Group $group, User $viewer, array $filters = []): array + { + $bucket = (string) ($filters['bucket'] ?? 'submitted'); + $page = max(1, (int) ($filters['page'] ?? 1)); + $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); + $canReviewAll = $group->canReviewSubmissions($viewer); + + $query = Artwork::query() + ->with(['uploadedBy.profile', 'primaryAuthor.profile']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->whereIn('group_review_status', ['submitted', 'needs_changes', 'approved', 'rejected']); + + if (! $canReviewAll) { + $query->where(function ($builder) use ($viewer): void { + $builder->where('uploaded_by_user_id', $viewer->id) + ->orWhere('user_id', $viewer->id); + }); + } + + if ($bucket !== 'all') { + $query->where('group_review_status', $bucket); + } + + $paginator = $query->orderByRaw("CASE group_review_status WHEN 'submitted' THEN 0 WHEN 'needs_changes' THEN 1 ELSE 2 END") + ->orderByDesc('group_review_submitted_at') + ->paginate($perPage, ['*'], 'page', $page); + + return [ + 'items' => collect($paginator->items())->map(fn (Artwork $artwork): array => $this->mapReviewItem($group, $artwork, $viewer))->values()->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + 'filters' => [ + 'bucket' => $bucket, + ], + 'bucket_options' => [ + ['value' => 'submitted', 'label' => 'Submitted'], + ['value' => 'needs_changes', 'label' => 'Needs changes'], + ['value' => 'approved', 'label' => 'Approved'], + ['value' => 'rejected', 'label' => 'Rejected'], + ['value' => 'all', 'label' => 'All'], + ], + 'can_review_all' => $canReviewAll, + ]; + } + + public function mapReviewItem(Group $group, Artwork $artwork, User $viewer): array + { + return [ + 'id' => (int) $artwork->id, + 'title' => (string) $artwork->title, + 'thumb' => $artwork->thumbUrl('sm'), + 'group_review_status' => (string) ($artwork->group_review_status ?: 'none'), + 'group_review_notes' => $artwork->group_review_notes, + 'submitted_at' => $artwork->group_review_submitted_at?->toISOString(), + 'reviewed_at' => $artwork->group_reviewed_at?->toISOString(), + 'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC), + 'uploader' => $artwork->uploadedBy ? [ + 'id' => (int) $artwork->uploadedBy->id, + 'name' => $artwork->uploadedBy->name, + 'username' => $artwork->uploadedBy->username, + ] : null, + 'primary_author' => $artwork->primaryAuthor ? [ + 'id' => (int) $artwork->primaryAuthor->id, + 'name' => $artwork->primaryAuthor->name, + 'username' => $artwork->primaryAuthor->username, + ] : null, + 'urls' => [ + 'edit' => route('studio.artworks.edit', ['id' => $artwork->id]), + 'approve' => route('studio.groups.artworks.approve', ['group' => $group, 'artwork' => $artwork]), + 'reject' => route('studio.groups.artworks.reject', ['group' => $group, 'artwork' => $artwork]), + 'needs_changes' => route('studio.groups.artworks.needs-changes', ['group' => $group, 'artwork' => $artwork]), + ], + 'can_review' => $group->canReviewSubmissions($viewer), + ]; + } + + private function guardReviewAbility(Group $group, Artwork $artwork, User $actor): void + { + if ((int) ($artwork->group_id ?? 0) !== (int) $group->id) { + throw ValidationException::withMessages([ + 'artwork' => 'This artwork does not belong to the selected group.', + ]); + } + + if (! $group->canReviewSubmissions($actor)) { + throw ValidationException::withMessages([ + 'group' => 'You are not allowed to review submissions for this group.', + ]); + } + + if (! in_array((string) $artwork->group_review_status, ['submitted', 'needs_changes'], true)) { + throw ValidationException::withMessages([ + 'artwork' => 'This artwork is not currently awaiting review.', + ]); + } + } + + private function applyDraftMetadata(Artwork $artwork, User $actor, array $validated): void + { + $title = trim((string) ($validated['title'] ?? $artwork->title ?? '')); + if ($title === '') { + $title = 'Untitled artwork'; + } + + $slugBase = Str::slug($title); + if ($slugBase === '') { + $slugBase = 'artwork'; + } + + $artwork->title = $title; + + if (array_key_exists('description', $validated)) { + $artwork->description = $validated['description']; + } + + if (array_key_exists('is_mature', $validated)) { + $artwork->is_mature = (bool) $validated['is_mature']; + } + + $artwork->slug = Str::limit($slugBase, 160, ''); + $artwork->artwork_timezone = $validated['timezone'] ?? $artwork->artwork_timezone; + $artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id; + $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id; + + $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; + if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) { + $artwork->categories()->sync([$categoryId]); + } + + if (array_key_exists('tags', $validated) && is_array($validated['tags'])) { + $tagIds = []; + foreach ($validated['tags'] as $tagSlug) { + $tag = Tag::firstOrCreate( + ['slug' => Str::slug((string) $tagSlug)], + ['name' => (string) $tagSlug, 'is_active' => true, 'usage_count' => 0] + ); + $tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0]; + } + $artwork->tags()->sync($tagIds); + } + } + + private function reviewRecipients(Group $group, int $excludeUserId): array + { + return User::query() + ->whereIn('id', $this->memberships->activeContributorIds($group)) + ->get() + ->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewSubmissions($member)) + ->values() + ->all(); + } + + private function syncSearchIndex(Artwork $artwork): void + { + try { + if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) { + $artwork->searchable(); + } else { + $artwork->unsearchable(); + } + } catch (\Throwable $exception) { + Log::warning('Failed to sync artwork search index for group review workflow', [ + 'artwork_id' => (int) $artwork->id, + 'error' => $exception->getMessage(), + ]); + } + } +} \ No newline at end of file diff --git a/app/Services/GroupAssetService.php b/app/Services/GroupAssetService.php new file mode 100644 index 00000000..38d0e6a3 --- /dev/null +++ b/app/Services/GroupAssetService.php @@ -0,0 +1,243 @@ + 'A file upload is required for group assets.', + ]); + } + + $extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin')); + $filename = (string) Str::uuid() . '.' . $extension; + $directory = 'group-assets/' . (int) $group->id; + $storedPath = $file->storeAs($directory, $filename, self::STORAGE_DISK); + $mime = strtolower((string) ($file->getMimeType() ?: 'application/octet-stream')); + + $asset = GroupAsset::query()->create([ + 'group_id' => (int) $group->id, + 'title' => trim((string) $attributes['title']), + 'description' => $this->nullableString($attributes['description'] ?? null), + 'category' => (string) ($attributes['category'] ?? GroupAsset::CATEGORY_MISC), + 'file_path' => (string) $storedPath, + 'preview_path' => null, + 'visibility' => (string) ($attributes['visibility'] ?? GroupAsset::VISIBILITY_MEMBERS_ONLY), + 'status' => (string) ($attributes['status'] ?? GroupAsset::STATUS_ACTIVE), + 'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null), + 'uploaded_by_user_id' => (int) $actor->id, + 'approved_by_user_id' => $group->canManageAssets($actor) ? (int) $actor->id : null, + 'is_featured' => (bool) ($attributes['is_featured'] ?? false), + 'file_meta_json' => [ + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $mime, + 'size' => (int) $file->getSize(), + 'extension' => $extension, + ], + ]); + + $this->history->record( + $group, + $actor, + 'asset_uploaded', + sprintf('Uploaded asset "%s".', $asset->title), + 'group_asset', + (int) $asset->id, + null, + $asset->only(['title', 'category', 'visibility', 'status']) + ); + + $this->activity->record( + $group, + $actor, + 'asset_uploaded', + 'group_asset', + (int) $asset->id, + sprintf('%s uploaded a new group asset: %s', $actor->name ?: $actor->username ?: 'A member', $asset->title), + $asset->description, + $asset->visibility === GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD ? 'public' : 'internal', + ); + + return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']); + } + + public function update(GroupAsset $asset, User $actor, array $attributes): GroupAsset + { + $before = $asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured']); + $wasActive = $asset->status === GroupAsset::STATUS_ACTIVE; + + $asset->fill([ + 'title' => trim((string) ($attributes['title'] ?? $asset->title)), + 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $asset->description, + 'category' => (string) ($attributes['category'] ?? $asset->category), + 'visibility' => (string) ($attributes['visibility'] ?? $asset->visibility), + 'status' => (string) ($attributes['status'] ?? $asset->status), + 'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($asset->group, $attributes['linked_project_id']) : $asset->linked_project_id, + 'is_featured' => (bool) ($attributes['is_featured'] ?? $asset->is_featured), + 'approved_by_user_id' => (string) ($attributes['status'] ?? $asset->status) === GroupAsset::STATUS_ACTIVE ? (int) $actor->id : $asset->approved_by_user_id, + ])->save(); + + if (! $wasActive && $asset->status === GroupAsset::STATUS_ACTIVE && $asset->uploader && (int) $asset->uploader->id !== (int) $actor->id) { + app(NotificationService::class)->notifyGroupAssetApproved($asset->uploader, $actor, $asset->group, $asset); + } + + $this->history->record( + $asset->group, + $actor, + 'asset_updated', + sprintf('Updated asset "%s".', $asset->title), + 'group_asset', + (int) $asset->id, + $before, + $asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured']) + ); + + return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']); + } + + public function studioListing(Group $group, User $viewer, array $filters = []): array + { + $bucket = (string) ($filters['bucket'] ?? 'all'); + $category = (string) ($filters['category'] ?? 'all'); + $search = trim((string) ($filters['q'] ?? '')); + $page = max(1, (int) ($filters['page'] ?? 1)); + $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); + + $query = GroupAsset::query() + ->with(['uploader.profile', 'approver.profile', 'linkedProject']) + ->where('group_id', $group->id); + + if ($bucket !== 'all') { + $query->where('visibility', $bucket); + } + + if ($category !== 'all') { + $query->where('category', $category); + } + + if ($search !== '') { + $query->where(function ($builder) use ($search): void { + $builder->where('title', 'like', '%' . $search . '%') + ->orWhere('description', 'like', '%' . $search . '%') + ->orWhere('file_meta_json->original_name', 'like', '%' . $search . '%'); + }); + } + + if (! $group->canViewInternalAssets($viewer)) { + $query->whereIn('visibility', [GroupAsset::VISIBILITY_MEMBERS_ONLY, GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD]); + } + + $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); + + return [ + 'items' => collect($paginator->items())->map(fn (GroupAsset $asset): array => $this->mapStudioAsset($asset))->values()->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + 'filters' => [ + 'bucket' => $bucket, + 'category' => $category, + 'q' => $search, + ], + 'bucket_options' => [ + ['value' => 'all', 'label' => 'All'], + ['value' => GroupAsset::VISIBILITY_INTERNAL, 'label' => 'Internal'], + ['value' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'label' => 'Members only'], + ['value' => GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD, 'label' => 'Public download'], + ], + ]; + } + + public function publicListing(Group $group, int $limit = 12): array + { + return GroupAsset::query() + ->with(['uploader.profile', 'linkedProject']) + ->where('group_id', $group->id) + ->where('status', GroupAsset::STATUS_ACTIVE) + ->where('visibility', GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD) + ->latest('updated_at') + ->limit($limit) + ->get() + ->map(fn (GroupAsset $asset): array => $this->mapPublicAsset($asset)) + ->values() + ->all(); + } + + public function downloadResponse(GroupAsset $asset): StreamedResponse + { + $name = (string) ($asset->file_meta_json['original_name'] ?? basename((string) $asset->file_path)); + $mime = (string) ($asset->file_meta_json['mime_type'] ?? 'application/octet-stream'); + + return Storage::disk(self::STORAGE_DISK)->download((string) $asset->file_path, $name, [ + 'Content-Type' => $mime, + ]); + } + + public function mapStudioAsset(GroupAsset $asset): array + { + return [ + 'id' => (int) $asset->id, + 'title' => (string) $asset->title, + 'description' => $asset->description, + 'category' => (string) $asset->category, + 'visibility' => (string) $asset->visibility, + 'status' => (string) $asset->status, + 'is_featured' => (bool) $asset->is_featured, + 'file_meta' => $asset->file_meta_json ?? [], + 'linked_project' => $asset->linkedProject ? ['id' => (int) $asset->linkedProject->id, 'title' => $asset->linkedProject->title] : null, + 'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]), + 'urls' => [ + 'edit' => route('studio.groups.assets.update', ['group' => $asset->group, 'asset' => $asset]), + ], + ]; + } + + public function mapPublicAsset(GroupAsset $asset): array + { + return [ + 'id' => (int) $asset->id, + 'title' => (string) $asset->title, + 'description' => $asset->description, + 'category' => (string) $asset->category, + 'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]), + ]; + } + + private function normalizeProjectId(Group $group, mixed $projectId): ?int + { + $id = (int) $projectId; + return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null; + } + + private function nullableString(mixed $value): ?string + { + $trimmed = trim((string) $value); + return $trimmed !== '' ? $trimmed : null; + } +} \ No newline at end of file diff --git a/app/Services/GroupCardService.php b/app/Services/GroupCardService.php new file mode 100644 index 00000000..a5aa0104 --- /dev/null +++ b/app/Services/GroupCardService.php @@ -0,0 +1,160 @@ +relationLoaded('owner') ? $group->owner : $group->owner()->with('profile')->first(); + $recruitment = $this->recruitment->payloadForGroup($group); + $canManage = $viewer ? $group->canManage($viewer) : false; + $canManageMembers = $viewer ? $group->canManageMembers($viewer) : false; + $canPublishArtworks = $viewer ? $group->canPublishArtworks($viewer) : false; + $canManageCollections = $viewer ? $group->canManageCollections($viewer) : false; + $canRequestJoin = $viewer ? $group->canRequestJoin($viewer) : false; + $canReviewJoinRequests = $viewer ? $group->canReviewJoinRequests($viewer) : false; + $canReviewSubmissions = $viewer ? $group->canReviewSubmissions($viewer) : false; + $canManageRecruitment = $viewer ? $group->canManageRecruitment($viewer) : false; + $canManagePosts = $viewer ? $group->canManagePosts($viewer) : false; + $canPublishPosts = $viewer ? $group->canPublishPosts($viewer) : false; + $canPinPosts = $viewer ? $group->canPinPosts($viewer) : false; + $canManageMemberPermissions = $viewer ? $group->canManageMemberPermissions($viewer) : false; + $canManageProjects = $viewer ? $group->canManageProjects($viewer) : false; + $canManageReleases = $viewer ? $group->canManageReleases($viewer) : false; + $canPublishReleases = $viewer ? $group->canPublishReleases($viewer) : false; + $canManageMilestones = $viewer ? $group->canManageMilestones($viewer) : false; + $canViewReputationDashboard = $viewer ? $group->canViewReputationDashboard($viewer) : false; + $canManageBadges = $viewer ? $group->canManageBadges($viewer) : false; + $canViewInternalTrustMetrics = $viewer ? $group->canViewInternalTrustMetrics($viewer) : false; + $canManageChallenges = $viewer ? $group->canManageChallenges($viewer) : false; + $canManageEvents = $viewer ? $group->canManageEvents($viewer) : false; + $canPublishEventUpdates = $viewer ? $group->canPublishEventUpdates($viewer) : false; + $canManageAssets = $viewer ? $group->canManageAssets($viewer) : false; + $canViewInternalAssets = $viewer ? $group->canViewInternalAssets($viewer) : false; + $canPinActivity = $viewer ? $group->canPinActivity($viewer) : false; + $trustSignals = $this->reputation->trustSignals($group); + $badges = $this->reputation->groupBadges($group, 6); + + return [ + 'id' => (int) $group->id, + 'entity_type' => 'group', + 'name' => (string) $group->name, + 'slug' => (string) $group->slug, + 'headline' => $group->headline, + 'bio_excerpt' => Str::limit((string) ($group->bio ?? ''), 180), + 'visibility' => (string) $group->visibility, + 'status' => (string) ($group->status ?? Group::LIFECYCLE_ACTIVE), + 'membership_policy' => (string) ($group->membership_policy ?? Group::MEMBERSHIP_INVITE_ONLY), + 'type' => $group->type, + 'is_verified' => (bool) $group->is_verified, + 'is_recruiting' => (bool) ($recruitment['is_recruiting'] ?? false), + 'recruitment_headline' => $recruitment['headline'] ?? null, + 'avatar_url' => $group->avatarUrl(), + 'banner_url' => $group->bannerUrl(), + 'owner' => [ + 'id' => (int) ($owner?->id ?? 0), + 'name' => $owner?->name, + 'username' => $owner?->username, + 'avatar_url' => $owner ? AvatarUrl::forUser((int) $owner->id, $owner->profile?->avatar_hash, 72) : null, + 'profile_url' => $owner?->username ? route('profile.show', ['username' => strtolower((string) $owner->username)]) : null, + ], + 'counts' => [ + 'artworks' => (int) $group->artworks_count, + 'collections' => (int) $group->collections_count, + 'followers' => (int) $group->followers_count, + 'members' => $group->relationLoaded('members') + ? (int) $group->members->where('status', Group::STATUS_ACTIVE)->count() + : (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count(), + ], + 'permissions' => [ + 'can_manage' => $canManage, + 'can_manage_members' => $canManageMembers, + 'can_publish_artworks' => $canPublishArtworks, + 'can_manage_collections' => $canManageCollections, + 'can_request_join' => $canRequestJoin, + 'can_review_join_requests' => $canReviewJoinRequests, + 'can_submit_artwork_for_review' => $viewer ? $group->canSubmitArtworkForReview($viewer) : false, + 'can_review_submissions' => $canReviewSubmissions, + 'can_manage_recruitment' => $canManageRecruitment, + 'can_manage_posts' => $canManagePosts, + 'can_publish_posts' => $canPublishPosts, + 'can_pin_posts' => $canPinPosts, + 'can_manage_member_permissions' => $canManageMemberPermissions, + 'can_manage_projects' => $canManageProjects, + 'can_manage_releases' => $canManageReleases, + 'can_publish_releases' => $canPublishReleases, + 'can_manage_milestones' => $canManageMilestones, + 'can_view_reputation_dashboard' => $canViewReputationDashboard, + 'can_manage_badges' => $canManageBadges, + 'can_view_internal_trust_metrics' => $canViewInternalTrustMetrics, + 'can_manage_challenges' => $canManageChallenges, + 'can_manage_events' => $canManageEvents, + 'can_publish_event_updates' => $canPublishEventUpdates, + 'can_manage_assets' => $canManageAssets, + 'can_view_internal_assets' => $canViewInternalAssets, + 'can_pin_activity' => $canPinActivity, + ], + 'trust_signals' => $trustSignals, + 'badges' => $badges, + 'badge_keys' => array_values(array_filter(array_map( + static fn (array $badge): ?string => $badge['key'] ?? null, + $badges, + ))), + 'viewer' => [ + 'role' => $viewer ? $group->activeRoleFor($viewer) : null, + 'role_label' => $viewer ? Group::displayRole($group->activeRoleFor($viewer)) : null, + 'is_following' => $viewer ? $this->follows->isFollowing($group, $viewer) : false, + 'permission_overrides' => $viewer ? $group->permissionOverridesFor($viewer) : [], + ], + 'urls' => [ + 'public' => $group->publicUrl(), + 'studio' => route('studio.groups.show', ['group' => $group]), + 'studio_artworks' => route('studio.groups.artworks', ['group' => $group]), + 'studio_collections' => route('studio.groups.collections', ['group' => $group]), + 'studio_members' => route('studio.groups.members', ['group' => $group]), + 'studio_invitations' => $canManageMembers ? route('studio.groups.invitations', ['group' => $group]) : null, + 'studio_join_requests' => $canReviewJoinRequests ? route('studio.groups.join-requests', ['group' => $group]) : null, + 'studio_review' => $canReviewSubmissions ? route('studio.groups.review', ['group' => $group]) : null, + 'studio_recruitment' => $canManageRecruitment ? route('studio.groups.recruitment', ['group' => $group]) : null, + 'studio_posts' => $canManagePosts ? route('studio.groups.posts.index', ['group' => $group]) : null, + 'studio_projects' => $canManageProjects ? route('studio.groups.projects.index', ['group' => $group]) : null, + 'studio_releases' => $canManageReleases ? route('studio.groups.releases.index', ['group' => $group]) : null, + 'studio_reputation' => $canViewReputationDashboard ? route('studio.groups.reputation', ['group' => $group]) : null, + 'studio_challenges' => $canManageChallenges ? route('studio.groups.challenges.index', ['group' => $group]) : null, + 'studio_events' => ($canManageEvents || $canPublishEventUpdates) ? route('studio.groups.events.index', ['group' => $group]) : null, + 'studio_assets' => ($canManageAssets || $canViewInternalAssets) ? route('studio.groups.assets.index', ['group' => $group]) : null, + 'studio_activity' => route('studio.groups.activity', ['group' => $group]), + 'studio_settings' => $canManage ? route('studio.groups.settings', ['group' => $group]) : null, + 'upload' => ($canPublishArtworks || ($viewer && $group->canCreateArtworkDrafts($viewer))) ? route('upload', ['group' => $group->slug]) : null, + 'collection_create' => $canManageCollections ? route('settings.collections.create', ['group' => $group->slug]) : null, + 'follow' => route('groups.follow', ['group' => $group]), + 'unfollow' => route('groups.unfollow', ['group' => $group]), + 'join_request_store' => $canRequestJoin ? route('groups.join-requests.store', ['group' => $group]) : null, + 'join_request_withdraw_pattern' => $viewer ? route('groups.join-requests.destroy', ['group' => $group, 'joinRequest' => '__JOIN_REQUEST__']) : null, + 'posts' => route('groups.section', ['group' => $group, 'section' => 'posts']), + 'projects' => route('groups.section', ['group' => $group, 'section' => 'projects']), + 'releases' => route('groups.section', ['group' => $group, 'section' => 'releases']), + 'challenges' => route('groups.section', ['group' => $group, 'section' => 'challenges']), + 'events' => route('groups.section', ['group' => $group, 'section' => 'events']), + 'activity' => route('groups.section', ['group' => $group, 'section' => 'activity']), + ], + 'pending_invites_count' => $this->memberships->pendingInviteCount($group), + ]; + } +} \ No newline at end of file diff --git a/app/Services/GroupChallengeService.php b/app/Services/GroupChallengeService.php new file mode 100644 index 00000000..b79e60ad --- /dev/null +++ b/app/Services/GroupChallengeService.php @@ -0,0 +1,407 @@ +media->storeUploadedEntityImage($group, $attributes['cover_file'], 'challenges'); + } + + return GroupChallenge::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), + 'visibility' => (string) ($attributes['visibility'] ?? GroupChallenge::VISIBILITY_PUBLIC), + 'participation_scope' => (string) ($attributes['participation_scope'] ?? GroupChallenge::PARTICIPATION_GROUP_ONLY), + 'status' => (string) ($attributes['status'] ?? GroupChallenge::STATUS_DRAFT), + 'start_at' => $attributes['start_at'] ?? null, + 'end_at' => $attributes['end_at'] ?? null, + 'rules_text' => $this->nullableString($attributes['rules_text'] ?? null), + 'submission_instructions' => $this->nullableString($attributes['submission_instructions'] ?? null), + 'judging_mode' => $this->nullableString($attributes['judging_mode'] ?? null), + 'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null), + 'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null), + 'created_by_user_id' => (int) $actor->id, + 'featured_artwork_id' => null, + ]); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($coverPath); + throw $exception; + } + + $this->history->record( + $group, + $actor, + 'challenge_created', + sprintf('Created challenge "%s".', $challenge->title), + 'group_challenge', + (int) $challenge->id, + null, + $challenge->only(['title', 'status', 'visibility', 'participation_scope']) + ); + + $this->activity->record( + $group, + $actor, + 'challenge_created', + 'group_challenge', + (int) $challenge->id, + sprintf('%s launched a new challenge draft: %s', $actor->name ?: $actor->username ?: 'A member', $challenge->title), + $challenge->summary, + $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal', + ); + + return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']); + } + + public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge + { + $coverPath = null; + $oldCoverPath = $challenge->cover_path; + $before = $challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']); + + try { + DB::transaction(function () use ($challenge, $attributes, &$coverPath): void { + if (($attributes['cover_file'] ?? null) instanceof UploadedFile) { + $coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges'); + } + + $title = trim((string) ($attributes['title'] ?? $challenge->title)); + $challenge->fill([ + 'title' => $title, + 'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug, + 'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $challenge->summary, + 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $challenge->description, + 'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $challenge->cover_path), + 'visibility' => (string) ($attributes['visibility'] ?? $challenge->visibility), + 'participation_scope' => (string) ($attributes['participation_scope'] ?? $challenge->participation_scope), + 'status' => (string) ($attributes['status'] ?? $challenge->status), + 'start_at' => $attributes['start_at'] ?? $challenge->start_at, + 'end_at' => $attributes['end_at'] ?? $challenge->end_at, + 'rules_text' => array_key_exists('rules_text', $attributes) ? $this->nullableString($attributes['rules_text']) : $challenge->rules_text, + 'submission_instructions' => array_key_exists('submission_instructions', $attributes) ? $this->nullableString($attributes['submission_instructions']) : $challenge->submission_instructions, + 'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode, + 'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id, + 'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id, + 'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id, + ])->save(); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($coverPath); + throw $exception; + } + + if ($coverPath !== null && $oldCoverPath !== $challenge->cover_path) { + $this->media->deleteIfManaged($oldCoverPath); + } + + $challenge->refresh(); + + $this->history->record( + $challenge->group, + $actor, + 'challenge_updated', + sprintf('Updated challenge "%s".', $challenge->title), + 'group_challenge', + (int) $challenge->id, + $before, + $challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']) + ); + + return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']); + } + + public function publish(GroupChallenge $challenge, User $actor): GroupChallenge + { + if ($challenge->group->status !== Group::LIFECYCLE_ACTIVE) { + throw ValidationException::withMessages([ + 'group' => 'Archived or suspended groups cannot publish challenges.', + ]); + } + + if (! $challenge->start_at || ! $challenge->end_at || $challenge->end_at->lt($challenge->start_at)) { + throw ValidationException::withMessages([ + 'timeline' => 'Challenges need a valid start and end date before they can be published.', + ]); + } + + $challenge->forceFill([ + 'status' => $challenge->start_at->lte(now()) ? GroupChallenge::STATUS_ACTIVE : GroupChallenge::STATUS_PUBLISHED, + ])->save(); + + $this->history->record( + $challenge->group, + $actor, + 'challenge_published', + sprintf('Published challenge "%s".', $challenge->title), + 'group_challenge', + (int) $challenge->id, + ['status' => GroupChallenge::STATUS_DRAFT], + ['status' => $challenge->status] + ); + + $this->activity->record( + $challenge->group, + $actor, + 'challenge_published', + 'group_challenge', + (int) $challenge->id, + sprintf('%s launched the challenge %s', $challenge->group->name, $challenge->title), + $challenge->summary, + $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal', + ); + + if ($challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC) { + foreach ($challenge->group->follows()->with('user.profile')->get() as $follow) { + if ($follow->user) { + $this->notifications->notifyGroupChallengePublished($follow->user, $actor, $challenge->group, $challenge); + } + } + } + + return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']); + } + + public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge + { + if (! $this->canAttachArtwork($challenge, $artwork, $actor)) { + throw ValidationException::withMessages([ + 'artwork' => 'This artwork is not eligible for this challenge.', + ]); + } + + GroupChallengeArtwork::query()->updateOrCreate( + [ + 'group_challenge_id' => (int) $challenge->id, + 'artwork_id' => (int) $artwork->id, + ], + [ + 'submitted_by_user_id' => (int) $actor->id, + 'sort_order' => (int) $challenge->artworkLinks()->count(), + ] + ); + + $this->history->record( + $challenge->group, + $actor, + 'challenge_artwork_attached', + sprintf('Attached artwork "%s" to challenge "%s".', $artwork->title, $challenge->title), + 'group_challenge', + (int) $challenge->id, + null, + ['artwork_id' => (int) $artwork->id] + ); + + return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']); + } + + public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->visibleQuery($group, $viewer) + ->with(['creator.profile', 'linkedCollection', 'linkedProject']) + ->latest('start_at') + ->limit($limit) + ->get() + ->map(fn (GroupChallenge $challenge): array => $this->mapPublicChallenge($challenge)) + ->values() + ->all(); + } + + public function activeChallenge(Group $group, ?User $viewer = null): ?array + { + $challenge = $this->visibleQuery($group, $viewer) + ->with(['creator.profile', 'linkedCollection', 'linkedProject']) + ->whereIn('status', [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED]) + ->orderByRaw("CASE status WHEN 'active' THEN 0 ELSE 1 END") + ->orderBy('start_at') + ->first(); + + return $challenge ? $this->mapPublicChallenge($challenge) : null; + } + + 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 = GroupChallenge::query() + ->with(['creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']) + ->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 (GroupChallenge $challenge): array => $this->mapStudioChallenge($challenge))->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' => GroupChallenge::STATUS_DRAFT, 'label' => 'Drafts'], + ['value' => GroupChallenge::STATUS_PUBLISHED, 'label' => 'Published'], + ['value' => GroupChallenge::STATUS_ACTIVE, 'label' => 'Active'], + ['value' => GroupChallenge::STATUS_ENDED, 'label' => 'Ended'], + ['value' => GroupChallenge::STATUS_ARCHIVED, 'label' => 'Archived'], + ], + ]; + } + + public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array + { + $challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']); + + return array_merge($this->mapPublicChallenge($challenge), [ + 'description' => $challenge->description, + 'rules_text' => $challenge->rules_text, + 'submission_instructions' => $challenge->submission_instructions, + 'featured_artwork' => $challenge->featuredArtwork ? [ + 'id' => (int) $challenge->featuredArtwork->id, + 'title' => $challenge->featuredArtwork->title, + 'url' => route('art.show', ['id' => $challenge->featuredArtwork->id, 'slug' => $challenge->featuredArtwork->slug ?: $challenge->featuredArtwork->id]), + ] : null, + 'artworks' => $challenge->artworks->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]), + ])->values()->all(), + ]); + } + + public function mapPublicChallenge(GroupChallenge $challenge): array + { + return [ + 'id' => (int) $challenge->id, + 'title' => (string) $challenge->title, + 'slug' => (string) $challenge->slug, + 'summary' => $challenge->summary, + 'status' => (string) $challenge->status, + 'visibility' => (string) $challenge->visibility, + 'participation_scope' => (string) $challenge->participation_scope, + 'cover_url' => $challenge->coverUrl(), + 'start_at' => $challenge->start_at?->toISOString(), + 'end_at' => $challenge->end_at?->toISOString(), + 'rules_text' => $challenge->rules_text, + 'entry_count' => (int) $challenge->artworkLinks()->count(), + 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]), + ]; + } + + public function mapStudioChallenge(GroupChallenge $challenge): array + { + return array_merge($this->mapPublicChallenge($challenge), [ + 'description' => $challenge->description, + 'urls' => [ + 'public' => $challenge->visibility !== GroupChallenge::VISIBILITY_PRIVATE ? route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]) : null, + 'edit' => route('studio.groups.challenges.edit', ['group' => $challenge->group, 'challenge' => $challenge]), + 'publish' => route('studio.groups.challenges.publish', ['group' => $challenge->group, 'challenge' => $challenge]), + 'attach_artwork' => route('studio.groups.challenges.attach-artwork', ['group' => $challenge->group, 'challenge' => $challenge]), + ], + ]); + } + + private function visibleQuery(Group $group, ?User $viewer = null) + { + return GroupChallenge::query() + ->where('group_id', $group->id) + ->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void { + $query->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) + ->where('status', '!=', GroupChallenge::STATUS_DRAFT); + }); + } + + private function canAttachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): bool + { + if ($challenge->participation_scope === GroupChallenge::PARTICIPATION_PUBLIC) { + return (int) $artwork->user_id === (int) $actor->id + || (int) ($artwork->uploaded_by_user_id ?? 0) === (int) $actor->id + || (int) ($artwork->primary_author_user_id ?? 0) === (int) $actor->id + || ((int) $artwork->group_id === (int) $challenge->group_id && $challenge->group->hasActiveMember($actor)); + } + + return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id; + } + + private function makeUniqueSlug(string $source, ?int $ignoreId = null): string + { + $base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge'; + $slug = $base; + $suffix = 2; + + while (GroupChallenge::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) { + $slug = Str::limit($base, 180, '') . '-' . $suffix; + $suffix++; + } + + return $slug; + } + + private function normalizeCollectionId(Group $group, mixed $collectionId): ?int + { + $id = (int) $collectionId; + + return $id > 0 && $group->collections()->where('id', $id)->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 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; + } +} \ No newline at end of file diff --git a/app/Services/GroupDiscoveryService.php b/app/Services/GroupDiscoveryService.php new file mode 100644 index 00000000..56f2dfd0 --- /dev/null +++ b/app/Services/GroupDiscoveryService.php @@ -0,0 +1,300 @@ +releases() + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED) + ->count(); + $recentReleaseCount = (int) $group->releases() + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED) + ->where('released_at', '>=', now()->subDays(60)) + ->count(); + $recentPublicActivity = (int) GroupActivityItem::query() + ->where('group_id', $group->id) + ->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC) + ->where('occurred_at', '>=', now()->subDays(30)) + ->count(); + $publishedArtworks = (int) Artwork::query() + ->where('group_id', $group->id) + ->where('artwork_status', 'published') + ->count(); + $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; + + $freshnessScore = $this->freshnessScore($group); + $activityScore = min(100, ($recentPublicActivity * 12) + ($publishedArtworks * 0.5)); + $releaseScore = min(100, ($publicReleaseCount * 14) + ($recentReleaseCount * 12)); + $collaborationScore = min(100, ($activeMembers * 10) + ($group->contributorStats()->count() * 4)); + $trustScore = $group->status === Group::LIFECYCLE_SUSPENDED + ? 0 + : min(100, 25 + ($group->is_verified ? 20 : 0) + ($publicReleaseCount * 10) + ($publishedArtworks * 0.35) + ($group->followers_count * 0.2)); + + return GroupDiscoveryMetric::query()->updateOrCreate( + ['group_id' => (int) $group->id], + [ + 'freshness_score' => $freshnessScore, + 'activity_score' => round($activityScore, 2), + 'release_score' => round($releaseScore, 2), + 'collaboration_score' => round($collaborationScore, 2), + 'trust_score' => round($trustScore, 2), + 'last_calculated_at' => now(), + ] + ); + } + + public function publicListing(?User $viewer, string $surface = 'featured', int $page = 1, int $perPage = 24): LengthAwarePaginator + { + $groups = $this->publicGroupBaseQuery()->get(); + + $sorted = $this->sortGroups($groups, $surface); + $page = max(1, $page); + $perPage = max(1, min($perPage, 48)); + $slice = $sorted->forPage($page, $perPage)->values(); + + return new LengthAwarePaginator($slice, $sorted->count(), $perPage, $page, [ + 'path' => request()->url(), + 'query' => request()->query(), + ]); + } + + public function spotlightCard(?User $viewer = null, string $surface = 'featured'): ?array + { + return $this->surfaceCards($viewer, $surface, 1)[0] ?? null; + } + + public function surfaceCards(?User $viewer = null, string $surface = 'featured', int $limit = 6): array + { + return $this->sortGroups($this->publicGroupBaseQuery()->get(), $surface) + ->take(max(1, $limit)) + ->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer)) + ->values() + ->all(); + } + + public function searchCards(string $query, ?User $viewer = null, int $limit = 8): array + { + $normalized = mb_strtolower(trim($query)); + + if (mb_strlen($normalized) < 2) { + return []; + } + + $groups = $this->publicGroupBaseQuery() + ->where(function (Builder $builder) use ($normalized): void { + $builder->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(slug) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(bio) LIKE ?', ['%' . $normalized . '%']) + ->orWhereHas('recruitmentProfile', function (Builder $recruitmentQuery) use ($normalized): void { + $recruitmentQuery->whereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(roles_json) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(skills_json) LIKE ?', ['%' . $normalized . '%']); + }) + ->orWhereHas('releases', function (Builder $releaseQuery) use ($normalized): void { + $releaseQuery->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED) + ->where(function (Builder $nestedQuery) use ($normalized): void { + $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(release_notes) LIKE ?', ['%' . $normalized . '%']); + }); + }) + ->orWhereHas('projects', function (Builder $projectQuery) use ($normalized): void { + $projectQuery->where('visibility', GroupProject::VISIBILITY_PUBLIC) + ->where(function (Builder $nestedQuery) use ($normalized): void { + $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); + }); + }) + ->orWhereHas('challenges', function (Builder $challengeQuery) use ($normalized): void { + $challengeQuery->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) + ->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE]) + ->where(function (Builder $nestedQuery) use ($normalized): void { + $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); + }); + }) + ->orWhereHas('events', function (Builder $eventQuery) use ($normalized): void { + $eventQuery->where('visibility', GroupEvent::VISIBILITY_PUBLIC) + ->where('status', GroupEvent::STATUS_PUBLISHED) + ->where(function (Builder $nestedQuery) use ($normalized): void { + $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); + }); + }) + ->orWhereHas('badges', function (Builder $badgeQuery) use ($normalized): void { + $badgeQuery->whereRaw('LOWER(badge_key) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw("LOWER(REPLACE(badge_key, '_', ' ')) LIKE ?", ['%' . $normalized . '%']); + }) + ->orWhereHas('members.user', function (Builder $userQuery) use ($normalized): void { + $userQuery->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%']) + ->orWhereRaw('LOWER(username) LIKE ?', ['%' . $normalized . '%']); + }); + }) + ->limit(max($limit * 3, 12)) + ->get(); + + return $groups + ->sortByDesc(fn (Group $group): float => $this->searchWeight($group, $normalized)) + ->take(max(1, $limit)) + ->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer)) + ->values() + ->all(); + } + + public function publicGroupCount(): int + { + return Group::query()->public()->count(); + } + + public function availableSurfaces(): array + { + return [ + ['value' => 'featured', 'label' => 'Featured'], + ['value' => 'recruiting', 'label' => 'Recruiting'], + ['value' => 'new_rising', 'label' => 'New & Rising'], + ['value' => 'trusted', 'label' => 'Trusted'], + ['value' => 'recent_releases', 'label' => 'Recent releases'], + ['value' => 'featured_projects', 'label' => 'Featured projects'], + ['value' => 'current_challenges', 'label' => 'Current challenges'], + ['value' => 'upcoming_events', 'label' => 'Upcoming events'], + ]; + } + + private function publicGroupBaseQuery(): Builder + { + return Group::query() + ->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']) + ->withCount([ + 'members as active_members_count' => fn (Builder $query) => $query->where('status', Group::STATUS_ACTIVE), + 'releases as public_releases_count' => fn (Builder $query) => $query + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED), + 'releases as recent_public_releases_count' => fn (Builder $query) => $query + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED) + ->where('released_at', '>=', now()->subDays(60)), + 'projects as public_projects_count' => fn (Builder $query) => $query + ->where('visibility', GroupProject::VISIBILITY_PUBLIC) + ->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_REVIEW, GroupProject::STATUS_RELEASED]), + 'challenges as active_public_challenges_count' => fn (Builder $query) => $query + ->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) + ->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE]), + 'events as upcoming_public_events_count' => fn (Builder $query) => $query + ->where('visibility', GroupEvent::VISIBILITY_PUBLIC) + ->where('status', GroupEvent::STATUS_PUBLISHED) + ->where('start_at', '>=', now()), + 'activityItems as public_activity_30d_count' => fn (Builder $query) => $query + ->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC) + ->where('occurred_at', '>=', now()->subDays(30)), + 'contributorStats as contributor_stats_count', + ]) + ->withMax([ + 'releases as latest_public_release_at' => fn (Builder $query) => $query + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where('status', GroupRelease::STATUS_RELEASED), + ], 'released_at') + ->public(); + } + + private function sortGroups(Collection $groups, string $surface): Collection + { + return (match ($surface) { + 'recent_releases' => $groups->sortByDesc(fn (Group $group): string => (string) ($group->latest_public_release_at ?? '')), + 'featured_projects' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->public_projects_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'collaboration') + $this->discoveryWeight($group, 'activity')), + 'current_challenges' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->active_public_challenges_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'freshness') + $this->discoveryWeight($group, 'activity')), + 'upcoming_events' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->upcoming_public_events_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'trust')), + 'recruiting' => $groups->sortByDesc(fn (Group $group): float => (($group->recruitmentProfile?->is_recruiting ?? false) ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + ($group->followers_count * 0.03)), + 'new_rising' => $groups->sortByDesc(fn (Group $group): float => ($this->freshnessScore($group) * 1.2) + min(20, max(0, 50 - ((int) $group->followers_count / 2)))), + 'trusted' => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'release')), + default => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'collaboration')), + })->values(); + } + + private function searchWeight(Group $group, string $query): float + { + $name = mb_strtolower((string) $group->name); + $slug = mb_strtolower((string) $group->slug); + $headline = mb_strtolower((string) ($group->headline ?? '')); + $bio = mb_strtolower((string) ($group->bio ?? '')); + + $exact = $name === $query || $slug === $query ? 1800 : 0; + $prefix = str_starts_with($name, $query) || str_starts_with($slug, $query) ? 600 : 0; + $contains = str_contains($name, $query) || str_contains($slug, $query) ? 240 : 0; + $descriptive = str_contains($headline, $query) || str_contains($bio, $query) ? 90 : 0; + + return $exact + + $prefix + + $contains + + $descriptive + + ($this->discoveryWeight($group, 'trust') * 1.25) + + $this->discoveryWeight($group, 'activity') + + ($this->discoveryWeight($group, 'release') * 0.8) + + ((float) ($group->followers_count ?? 0) * 0.08) + + (($group->recruitmentProfile?->is_recruiting ?? false) ? 15 : 0); + } + + private function discoveryWeight(Group $group, string $dimension): float + { + $metric = $group->relationLoaded('discoveryMetric') ? $group->discoveryMetric : $group->discoveryMetric()->first(); + + if (! $metric) { + $metric = $this->refresh($group); + } + + return match ($dimension) { + 'activity' => (float) $metric->activity_score, + 'release' => (float) $metric->release_score, + 'collaboration' => (float) $metric->collaboration_score, + 'freshness' => (float) $metric->freshness_score, + default => (float) $metric->trust_score, + }; + } + + private function freshnessScore(Group $group): float + { + if (! $group->last_activity_at) { + return 20.0; + } + + $days = $group->last_activity_at->diffInDays(now()); + + return match (true) { + $days <= 7 => 100.0, + $days <= 14 => 80.0, + $days <= 30 => 60.0, + $days <= 60 => 40.0, + default => 20.0, + }; + } +} \ No newline at end of file diff --git a/app/Services/GroupEventService.php b/app/Services/GroupEventService.php new file mode 100644 index 00000000..de79e129 --- /dev/null +++ b/app/Services/GroupEventService.php @@ -0,0 +1,345 @@ +media->storeUploadedEntityImage($group, $attributes['cover_file'], 'events'); + } + + return GroupEvent::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), + 'event_type' => (string) ($attributes['event_type'] ?? GroupEvent::TYPE_LAUNCH), + 'visibility' => (string) ($attributes['visibility'] ?? GroupEvent::VISIBILITY_PUBLIC), + 'start_at' => $attributes['start_at'] ?? null, + 'end_at' => $attributes['end_at'] ?? null, + 'timezone' => (string) ($attributes['timezone'] ?? 'UTC'), + 'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null), + 'location' => $this->nullableString($attributes['location'] ?? null), + 'external_url' => $this->nullableString($attributes['external_url'] ?? null), + 'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null), + 'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null), + 'linked_challenge_id' => $this->normalizeChallengeId($group, $attributes['linked_challenge_id'] ?? null), + 'status' => (string) ($attributes['status'] ?? GroupEvent::STATUS_DRAFT), + 'is_featured' => (bool) ($attributes['is_featured'] ?? false), + 'created_by_user_id' => (int) $actor->id, + 'published_at' => null, + ]); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($coverPath); + throw $exception; + } + + $this->history->record( + $group, + $actor, + 'event_created', + sprintf('Created event "%s".', $event->title), + 'group_event', + (int) $event->id, + null, + $event->only(['title', 'event_type', 'visibility', 'status']) + ); + + return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']); + } + + public function update(GroupEvent $event, User $actor, array $attributes): GroupEvent + { + $coverPath = null; + $oldCoverPath = $event->cover_path; + $shouldNotifyFollowers = $event->status === GroupEvent::STATUS_PUBLISHED && $event->visibility === GroupEvent::VISIBILITY_PUBLIC; + $before = $event->only(['title', 'summary', 'description', 'event_type', 'visibility', 'start_at', 'end_at', 'timezone', 'location', 'external_url', 'linked_project_id', 'linked_collection_id', 'linked_challenge_id', 'status', 'is_featured']); + + try { + DB::transaction(function () use ($event, $attributes, &$coverPath): void { + if (($attributes['cover_file'] ?? null) instanceof UploadedFile) { + $coverPath = $this->media->storeUploadedEntityImage($event->group, $attributes['cover_file'], 'events'); + } + + $title = trim((string) ($attributes['title'] ?? $event->title)); + $event->fill([ + 'title' => $title, + 'slug' => $title !== $event->title ? $this->makeUniqueSlug($title, (int) $event->id) : $event->slug, + 'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $event->summary, + 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $event->description, + 'event_type' => (string) ($attributes['event_type'] ?? $event->event_type), + 'visibility' => (string) ($attributes['visibility'] ?? $event->visibility), + 'start_at' => $attributes['start_at'] ?? $event->start_at, + 'end_at' => $attributes['end_at'] ?? $event->end_at, + 'timezone' => (string) ($attributes['timezone'] ?? $event->timezone), + 'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $event->cover_path), + 'location' => array_key_exists('location', $attributes) ? $this->nullableString($attributes['location']) : $event->location, + 'external_url' => array_key_exists('external_url', $attributes) ? $this->nullableString($attributes['external_url']) : $event->external_url, + 'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($event->group, $attributes['linked_project_id']) : $event->linked_project_id, + 'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($event->group, $attributes['linked_collection_id']) : $event->linked_collection_id, + 'linked_challenge_id' => array_key_exists('linked_challenge_id', $attributes) ? $this->normalizeChallengeId($event->group, $attributes['linked_challenge_id']) : $event->linked_challenge_id, + 'status' => (string) ($attributes['status'] ?? $event->status), + 'is_featured' => (bool) ($attributes['is_featured'] ?? $event->is_featured), + ])->save(); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($coverPath); + throw $exception; + } + + if ($coverPath !== null && $oldCoverPath !== $event->cover_path) { + $this->media->deleteIfManaged($oldCoverPath); + } + + $event->refresh(); + + $this->history->record( + $event->group, + $actor, + 'event_updated', + sprintf('Updated event "%s".', $event->title), + 'group_event', + (int) $event->id, + $before, + $event->only(['title', 'summary', 'description', 'event_type', 'visibility', 'start_at', 'end_at', 'timezone', 'location', 'external_url', 'linked_project_id', 'linked_collection_id', 'linked_challenge_id', 'status', 'is_featured']) + ); + + if ($shouldNotifyFollowers && $event->status === GroupEvent::STATUS_PUBLISHED && $event->visibility === GroupEvent::VISIBILITY_PUBLIC) { + foreach ($event->group->follows()->with('user.profile')->get() as $follow) { + if ($follow->user) { + $this->notifications->notifyGroupEventUpdated($follow->user, $actor, $event->group, $event); + } + } + } + + return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']); + } + + public function publish(GroupEvent $event, User $actor): GroupEvent + { + if ($event->group->status !== Group::LIFECYCLE_ACTIVE) { + throw ValidationException::withMessages([ + 'group' => 'Archived or suspended groups cannot publish events.', + ]); + } + + if (! $event->start_at || ($event->end_at && $event->end_at->lt($event->start_at))) { + throw ValidationException::withMessages([ + 'start_at' => 'Events need a valid start date before they can be published.', + ]); + } + + $event->forceFill([ + 'status' => GroupEvent::STATUS_PUBLISHED, + 'published_at' => now(), + ])->save(); + + $this->history->record( + $event->group, + $actor, + 'event_published', + sprintf('Published event "%s".', $event->title), + 'group_event', + (int) $event->id, + ['status' => GroupEvent::STATUS_DRAFT], + ['status' => GroupEvent::STATUS_PUBLISHED] + ); + + $this->activity->record( + $event->group, + $actor, + 'event_published', + 'group_event', + (int) $event->id, + sprintf('%s announced an event: %s', $event->group->name, $event->title), + $event->summary, + $event->visibility === GroupEvent::VISIBILITY_PUBLIC ? 'public' : 'internal', + ); + + if ($event->visibility === GroupEvent::VISIBILITY_PUBLIC) { + foreach ($event->group->follows()->with('user.profile')->get() as $follow) { + if ($follow->user) { + $this->notifications->notifyGroupEventPublished($follow->user, $actor, $event->group, $event); + } + } + } + + return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']); + } + + public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->visibleQuery($group, $viewer) + ->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']) + ->latest('start_at') + ->limit($limit) + ->get() + ->map(fn (GroupEvent $event): array => $this->mapPublicEvent($event)) + ->values() + ->all(); + } + + public function upcomingEvent(Group $group, ?User $viewer = null): ?array + { + $event = $this->visibleQuery($group, $viewer) + ->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']) + ->where('start_at', '>=', now()->subDay()) + ->orderBy('start_at') + ->first(); + + return $event ? $this->mapPublicEvent($event) : null; + } + + 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 = GroupEvent::query() + ->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']) + ->where('group_id', $group->id); + + if ($bucket !== 'all') { + $query->where('status', $bucket); + } + + $paginator = $query->latest('start_at')->paginate($perPage, ['*'], 'page', $page); + + return [ + 'items' => collect($paginator->items())->map(fn (GroupEvent $event): array => $this->mapStudioEvent($event))->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' => GroupEvent::STATUS_DRAFT, 'label' => 'Drafts'], + ['value' => GroupEvent::STATUS_PUBLISHED, 'label' => 'Published'], + ['value' => GroupEvent::STATUS_ARCHIVED, 'label' => 'Archived'], + ['value' => GroupEvent::STATUS_CANCELLED, 'label' => 'Cancelled'], + ], + ]; + } + + public function detailPayload(GroupEvent $event): array + { + $event->loadMissing(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']); + + return array_merge($this->mapPublicEvent($event), [ + 'description' => $event->description, + 'location' => $event->location, + 'external_url' => $event->external_url, + ]); + } + + public function mapPublicEvent(GroupEvent $event): array + { + return [ + 'id' => (int) $event->id, + 'title' => (string) $event->title, + 'slug' => (string) $event->slug, + 'summary' => $event->summary, + 'event_type' => (string) $event->event_type, + 'status' => (string) $event->status, + 'visibility' => (string) $event->visibility, + 'cover_url' => $event->coverUrl(), + 'start_at' => $event->start_at?->toISOString(), + 'end_at' => $event->end_at?->toISOString(), + 'timezone' => (string) $event->timezone, + 'location' => $event->location, + 'external_url' => $event->external_url, + 'is_featured' => (bool) $event->is_featured, + 'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]), + ]; + } + + public function mapStudioEvent(GroupEvent $event): array + { + return array_merge($this->mapPublicEvent($event), [ + 'description' => $event->description, + 'urls' => [ + 'public' => $event->visibility === GroupEvent::VISIBILITY_PUBLIC ? route('groups.events.show', ['group' => $event->group, 'event' => $event]) : null, + 'edit' => route('studio.groups.events.edit', ['group' => $event->group, 'event' => $event]), + 'publish' => route('studio.groups.events.publish', ['group' => $event->group, 'event' => $event]), + ], + ]); + } + + private function visibleQuery(Group $group, ?User $viewer = null) + { + return GroupEvent::query() + ->where('group_id', $group->id) + ->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void { + $query->where('visibility', GroupEvent::VISIBILITY_PUBLIC) + ->where('status', GroupEvent::STATUS_PUBLISHED); + }); + } + + private function makeUniqueSlug(string $source, ?int $ignoreId = null): string + { + $base = Str::slug(Str::limit($source, 150, '')) ?: 'event'; + $slug = $base; + $suffix = 2; + + while (GroupEvent::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) { + $slug = Str::limit($base, 180, '') . '-' . $suffix; + $suffix++; + } + + return $slug; + } + + 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 normalizeChallengeId(Group $group, mixed $challengeId): ?int + { + $id = (int) $challengeId; + return $id > 0 && $group->challenges()->where('id', $id)->exists() ? $id : null; + } + + private function nullableString(mixed $value): ?string + { + $trimmed = trim((string) $value); + return $trimmed !== '' ? $trimmed : null; + } +} \ No newline at end of file diff --git a/app/Services/GroupFollowService.php b/app/Services/GroupFollowService.php new file mode 100644 index 00000000..1fb1b82e --- /dev/null +++ b/app/Services/GroupFollowService.php @@ -0,0 +1,65 @@ +hasActiveMember($user)) { + return false; + } + + return DB::transaction(function () use ($group, $user): bool { + $created = GroupFollow::query()->firstOrCreate([ + 'group_id' => $group->id, + 'user_id' => $user->id, + ]); + + $this->syncFollowerCount($group); + + return $created->wasRecentlyCreated; + }); + } + + public function unfollow(Group $group, User $user): bool + { + $deleted = GroupFollow::query() + ->where('group_id', $group->id) + ->where('user_id', $user->id) + ->delete() > 0; + + if ($deleted) { + $this->syncFollowerCount($group); + } + + return $deleted; + } + + public function isFollowing(Group $group, ?User $user): bool + { + if (! $user) { + return false; + } + + return GroupFollow::query() + ->where('group_id', $group->id) + ->where('user_id', $user->id) + ->exists(); + } + + public function syncFollowerCount(Group $group): void + { + $group->forceFill([ + 'followers_count' => GroupFollow::query()->where('group_id', $group->id)->count(), + 'last_activity_at' => now(), + ])->save(); + } +} \ No newline at end of file diff --git a/app/Services/GroupHistoryService.php b/app/Services/GroupHistoryService.php new file mode 100644 index 00000000..2bd41b03 --- /dev/null +++ b/app/Services/GroupHistoryService.php @@ -0,0 +1,60 @@ +create([ + 'group_id' => $group->id, + 'actor_user_id' => $actor?->id, + 'action_type' => $actionType, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'summary' => $summary, + 'before_json' => $before, + 'after_json' => $after, + 'created_at' => now(), + ]); + } + + public function recentFor(Group $group, int $limit = 12): array + { + return GroupHistory::query() + ->with('actor:id,username,name') + ->where('group_id', $group->id) + ->orderByDesc('created_at') + ->limit(max(1, min(50, $limit))) + ->get() + ->map(fn (GroupHistory $entry): array => [ + 'id' => (int) $entry->id, + 'action_type' => (string) $entry->action_type, + 'target_type' => $entry->target_type, + 'target_id' => $entry->target_id ? (int) $entry->target_id : null, + 'summary' => $entry->summary, + 'created_at' => $entry->created_at?->toISOString(), + 'actor' => $entry->actor ? [ + 'id' => (int) $entry->actor->id, + 'username' => $entry->actor->username, + 'name' => $entry->actor->name, + ] : null, + ]) + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/Services/GroupJoinRequestService.php b/app/Services/GroupJoinRequestService.php new file mode 100644 index 00000000..2d777983 --- /dev/null +++ b/app/Services/GroupJoinRequestService.php @@ -0,0 +1,366 @@ +canRequestJoin($actor)) { + throw ValidationException::withMessages([ + 'group' => 'This group is not accepting join requests.', + ]); + } + + if ($group->hasActiveMember($actor)) { + throw ValidationException::withMessages([ + 'group' => 'You are already a member of this group.', + ]); + } + + $pendingRequest = GroupJoinRequest::query() + ->where('group_id', $group->id) + ->where('user_id', $actor->id) + ->whereIn('status', [ + GroupJoinRequest::STATUS_PENDING, + ]) + ->exists(); + + if ($pendingRequest) { + throw ValidationException::withMessages([ + 'group' => 'You already have a pending request for this group.', + ]); + } + + $request = GroupJoinRequest::query()->create([ + 'group_id' => $group->id, + 'user_id' => $actor->id, + 'message' => $attributes['message'] ?? null, + 'portfolio_url' => $attributes['portfolio_url'] ?? null, + 'desired_role' => isset($attributes['desired_role']) + ? Group::normalizeMemberRole((string) $attributes['desired_role']) + : null, + 'skills_json' => $attributes['skills_json'] ?? null, + 'status' => GroupJoinRequest::STATUS_PENDING, + 'expires_at' => now()->addDays(max(3, (int) config('groups.join_requests.expires_after_days', 21))), + ]); + + $this->history->record( + $group, + $actor, + 'join_request_submitted', + sprintf('%s requested to join the group.', $actor->name ?: $actor->username ?: 'A user'), + 'group_join_request', + (int) $request->id, + null, + [ + 'desired_role' => $request->desired_role, + 'portfolio_url' => $request->portfolio_url, + ], + ); + + foreach ($this->reviewRecipients($group, $actor->id) as $recipient) { + $this->notifications->notifyGroupJoinRequestReceived($recipient, $actor, $group, $request); + } + + if ($group->membership_policy === Group::MEMBERSHIP_OPEN) { + GroupMember::query()->updateOrCreate( + [ + 'group_id' => $group->id, + 'user_id' => $actor->id, + ], + [ + 'invited_by_user_id' => $group->owner_user_id, + 'role' => Group::normalizeMemberRole((string) ($request->desired_role ?: Group::ROLE_MEMBER)), + 'status' => Group::STATUS_ACTIVE, + 'note' => 'Auto-approved by open membership policy.', + 'invited_at' => now(), + 'accepted_at' => now(), + 'revoked_at' => null, + ], + ); + + $request->forceFill([ + 'status' => GroupJoinRequest::STATUS_APPROVED, + 'review_notes' => 'Auto-approved by open membership policy.', + 'reviewed_at' => now(), + ])->save(); + + $this->history->record( + $group, + $actor, + 'join_request_auto_approved', + 'Auto-approved join request because the group uses open membership.', + 'group_join_request', + (int) $request->id, + ['status' => GroupJoinRequest::STATUS_PENDING], + ['status' => GroupJoinRequest::STATUS_APPROVED], + ); + + return $request->fresh(['group', 'user.profile']); + } + + return $request->fresh(['group', 'user.profile']); + } + + public function approve(GroupJoinRequest $request, User $actor, ?string $role = null, ?string $notes = null): GroupJoinRequest + { + $group = $request->group()->with('members')->firstOrFail(); + + if (! $group->canReviewJoinRequests($actor)) { + throw ValidationException::withMessages([ + 'request' => 'You are not allowed to review join requests for this group.', + ]); + } + + if ($request->status !== GroupJoinRequest::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'request' => 'Only pending join requests can be approved.', + ]); + } + + $resolvedRole = Group::normalizeMemberRole((string) ($role ?: $request->desired_role ?: Group::ROLE_MEMBER)); + if (! in_array($resolvedRole, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) { + $resolvedRole = Group::ROLE_MEMBER; + } + + DB::transaction(function () use ($group, $request, $actor, $resolvedRole, $notes): void { + GroupMember::query()->updateOrCreate( + [ + 'group_id' => $group->id, + 'user_id' => $request->user_id, + ], + [ + 'invited_by_user_id' => $actor->id, + 'role' => $resolvedRole, + 'status' => Group::STATUS_ACTIVE, + 'note' => $notes, + 'invited_at' => now(), + 'accepted_at' => now(), + 'revoked_at' => null, + ], + ); + + $request->forceFill([ + 'status' => GroupJoinRequest::STATUS_APPROVED, + 'reviewed_by_user_id' => $actor->id, + 'review_notes' => $notes, + 'reviewed_at' => now(), + ])->save(); + }); + + $request->refresh(); + + $this->history->record( + $group, + $actor, + 'join_request_approved', + sprintf('Approved %s to join the group.', $request->user?->name ?: $request->user?->username ?: 'a user'), + 'group_join_request', + (int) $request->id, + ['status' => GroupJoinRequest::STATUS_PENDING], + ['status' => GroupJoinRequest::STATUS_APPROVED, 'role' => $resolvedRole], + ); + + app(GroupActivityService::class)->record( + $group, + $actor, + 'member_joined', + 'group_join_request', + (int) $request->id, + sprintf('%s joined %s', $request->user?->name ?: $request->user?->username ?: 'A member', $group->name), + 'Membership approved through group join requests.', + 'public', + ); + + $this->notifications->notifyGroupJoinRequestApproved($request->user, $actor, $group, $resolvedRole, $request); + + return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']); + } + + public function reject(GroupJoinRequest $request, User $actor, ?string $notes = null): GroupJoinRequest + { + $group = $request->group()->with('members')->firstOrFail(); + + if (! $group->canReviewJoinRequests($actor)) { + throw ValidationException::withMessages([ + 'request' => 'You are not allowed to review join requests for this group.', + ]); + } + + if ($request->status !== GroupJoinRequest::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'request' => 'Only pending join requests can be rejected.', + ]); + } + + $request->forceFill([ + 'status' => GroupJoinRequest::STATUS_REJECTED, + 'reviewed_by_user_id' => $actor->id, + 'review_notes' => $notes, + 'reviewed_at' => now(), + ])->save(); + + $this->history->record( + $group, + $actor, + 'join_request_rejected', + sprintf('Rejected join request from %s.', $request->user?->name ?: $request->user?->username ?: 'a user'), + 'group_join_request', + (int) $request->id, + ['status' => GroupJoinRequest::STATUS_PENDING], + ['status' => GroupJoinRequest::STATUS_REJECTED], + ); + + $this->notifications->notifyGroupJoinRequestRejected($request->user, $actor, $group, $request); + + return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']); + } + + public function withdraw(GroupJoinRequest $request, User $actor): GroupJoinRequest + { + if ((int) $request->user_id !== (int) $actor->id || $request->status !== GroupJoinRequest::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'request' => 'This join request cannot be withdrawn.', + ]); + } + + $request->forceFill([ + 'status' => GroupJoinRequest::STATUS_WITHDRAWN, + 'reviewed_at' => now(), + ])->save(); + + $this->history->record( + $request->group, + $actor, + 'join_request_withdrawn', + 'Join request withdrawn.', + 'group_join_request', + (int) $request->id, + ['status' => GroupJoinRequest::STATUS_PENDING], + ['status' => GroupJoinRequest::STATUS_WITHDRAWN], + ); + + return $request->fresh(['group', 'user.profile']); + } + + public function pendingCount(Group $group): int + { + return (int) GroupJoinRequest::query() + ->where('group_id', $group->id) + ->where('status', GroupJoinRequest::STATUS_PENDING) + ->count(); + } + + public function currentRequestFor(Group $group, ?User $viewer): ?array + { + if (! $viewer) { + return null; + } + + $request = GroupJoinRequest::query() + ->where('group_id', $group->id) + ->where('user_id', $viewer->id) + ->latest('created_at') + ->first(); + + return $request ? $this->mapRequest($request) : null; + } + + public function mapRequests(Group $group, ?User $viewer = null, array $filters = []): array + { + $bucket = (string) ($filters['bucket'] ?? 'pending'); + $page = max(1, (int) ($filters['page'] ?? 1)); + $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); + + $query = GroupJoinRequest::query() + ->with(['user.profile', 'reviewedBy.profile']) + ->where('group_id', $group->id); + + if ($bucket !== 'all') { + $query->where('status', $bucket); + } + + $paginator = $query->latest('created_at')->paginate($perPage, ['*'], 'page', $page); + + return [ + 'items' => collect($paginator->items())->map(fn (GroupJoinRequest $request): array => $this->mapRequest($request, $group, $viewer))->values()->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + 'filters' => [ + 'bucket' => $bucket, + ], + 'bucket_options' => [ + ['value' => 'pending', 'label' => 'Pending'], + ['value' => 'approved', 'label' => 'Approved'], + ['value' => 'rejected', 'label' => 'Rejected'], + ['value' => 'withdrawn', 'label' => 'Withdrawn'], + ['value' => 'all', 'label' => 'All'], + ], + ]; + } + + public function mapRequest(GroupJoinRequest $request, ?Group $group = null, ?User $viewer = null): array + { + $resolvedGroup = $group ?: $request->group; + + return [ + 'id' => (int) $request->id, + 'status' => (string) $request->status, + 'message' => $request->message, + 'portfolio_url' => $request->portfolio_url, + 'desired_role' => $request->desired_role, + 'desired_role_label' => Group::displayRole($request->desired_role), + 'skills' => array_values(array_filter($request->skills_json ?? [])), + 'review_notes' => $request->review_notes, + 'created_at' => $request->created_at?->toISOString(), + 'reviewed_at' => $request->reviewed_at?->toISOString(), + 'expires_at' => $request->expires_at?->toISOString(), + 'user' => $request->user ? [ + 'id' => (int) $request->user->id, + 'name' => $request->user->name, + 'username' => $request->user->username, + 'avatar_url' => AvatarUrl::forUser((int) $request->user->id, $request->user->profile?->avatar_hash, 72), + 'profile_url' => route('profile.show', ['username' => strtolower((string) $request->user->username)]), + ] : null, + 'reviewed_by' => $request->reviewedBy ? [ + 'id' => (int) $request->reviewedBy->id, + 'name' => $request->reviewedBy->name, + 'username' => $request->reviewedBy->username, + ] : null, + 'can_approve' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING, + 'can_reject' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING, + ]; + } + + private function reviewRecipients(Group $group, int $excludeUserId): array + { + return User::query() + ->whereIn('id', $this->memberships->activeContributorIds($group)) + ->get() + ->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewJoinRequests($member)) + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/Services/GroupMediaService.php b/app/Services/GroupMediaService.php new file mode 100644 index 00000000..8de535e5 --- /dev/null +++ b/app/Services/GroupMediaService.php @@ -0,0 +1,102 @@ +storeUploadedEntityImage($group, $file, $variant === 'banner' ? 'banner' : 'avatar'); + } + + public function storeUploadedEntityImage(Group $group, UploadedFile $file, string $section): string + { + $mime = strtolower((string) ($file->getMimeType() ?: '')); + $extension = $this->safeExtension($file, $mime); + $path = sprintf( + 'groups/%d/%s/%s.%s', + (int) $group->id, + trim($section) !== '' ? trim($section) : 'media', + (string) Str::uuid(), + $extension, + ); + + $stream = fopen((string) ($file->getRealPath() ?: $file->getPathname()), 'rb'); + if ($stream === false) { + throw new \RuntimeException('Unable to open uploaded group image.'); + } + + try { + $written = Storage::disk($this->diskName())->put($path, $stream, [ + 'visibility' => 'public', + 'CacheControl' => 'public, max-age=31536000, immutable', + 'ContentType' => $mime !== '' ? $mime : $this->mimeTypeForExtension($extension), + ]); + } finally { + fclose($stream); + } + + if ($written !== true) { + throw new \RuntimeException('Unable to store uploaded group image.'); + } + + return $path; + } + + public function deleteIfManaged(?string $path): void + { + $trimmed = trim((string) $path); + + if ($trimmed === '' || str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return; + } + + if (! str_starts_with($trimmed, 'groups/')) { + return; + } + + Storage::disk($this->diskName())->delete($trimmed); + } + + private function diskName(): string + { + return (string) config('uploads.object_storage.disk', 's3'); + } + + private function safeExtension(UploadedFile $file, string $mime): string + { + $extension = strtolower((string) $file->getClientOriginalExtension()); + + if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) { + throw new \RuntimeException('Unsupported group image upload type.'); + } + + return match ($extension) { + 'jpg', 'jpeg' => 'jpg', + 'png' => 'png', + default => 'webp', + }; + } + + private function mimeTypeForExtension(string $extension): string + { + return match ($extension) { + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + default => 'image/webp', + }; + } +} \ No newline at end of file diff --git a/app/Services/GroupMembershipService.php b/app/Services/GroupMembershipService.php new file mode 100644 index 00000000..ba1cc34e --- /dev/null +++ b/app/Services/GroupMembershipService.php @@ -0,0 +1,762 @@ +where('group_id', $group->id) + ->where('role', Group::ROLE_OWNER) + ->where('user_id', '!=', $group->owner_user_id) + ->update([ + 'role' => Group::ROLE_ADMIN, + 'updated_at' => now(), + ]); + + GroupMember::query()->updateOrCreate( + [ + 'group_id' => $group->id, + 'user_id' => $group->owner_user_id, + ], + [ + 'invited_by_user_id' => $group->owner_user_id, + 'role' => Group::ROLE_OWNER, + 'status' => Group::STATUS_ACTIVE, + 'invited_at' => now(), + 'expires_at' => null, + 'accepted_at' => now(), + 'revoked_at' => null, + ] + ); + } + + public function inviteMember(Group $group, User $actor, User $invitee, string $role, ?string $note = null, ?int $expiresInDays = null): GroupInvitation + { + $this->guardManageMembers($group, $actor); + $this->expirePendingInvites(); + $role = Group::normalizeMemberRole($role); + + if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) { + throw ValidationException::withMessages([ + 'role' => 'Choose a valid group role.', + ]); + } + + if ($group->isOwnedBy($invitee)) { + throw ValidationException::withMessages([ + 'username' => 'The group owner is already a member.', + ]); + } + + $existingMembership = GroupMember::query() + ->where('group_id', $group->id) + ->where('user_id', $invitee->id) + ->where('status', Group::STATUS_ACTIVE) + ->exists(); + + if ($existingMembership) { + throw ValidationException::withMessages([ + 'username' => 'This user is already an active member of the group.', + ]); + } + + $invitation = DB::transaction(function () use ($group, $actor, $invitee, $role, $note, $expiresInDays): GroupInvitation { + $now = now(); + + GroupInvitation::query() + ->where('group_id', $group->id) + ->where('invited_user_id', $invitee->id) + ->where('status', GroupInvitation::STATUS_PENDING) + ->update([ + 'status' => GroupInvitation::STATUS_REVOKED, + 'responded_at' => $now, + 'revoked_at' => $now, + 'updated_at' => $now, + ]); + + $invitation = GroupInvitation::query()->create([ + 'group_id' => $group->id, + 'invited_user_id' => $invitee->id, + 'invited_by_user_id' => $actor->id, + 'role' => $role, + 'status' => GroupInvitation::STATUS_PENDING, + 'token' => Str::random(64), + 'note' => $note, + 'invited_at' => $now, + 'expires_at' => $now->copy()->addDays(max(1, (int) ($expiresInDays ?? config('groups.invites.expires_after_days', 7)))), + 'responded_at' => null, + 'accepted_at' => null, + 'revoked_at' => null, + ]); + + return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']); + }); + + $this->notifications->notifyGroupInvite($invitee, $actor, $group, $role, $invitation); + + return $invitation; + } + + public function acceptInvitation(GroupInvitation $invitation, User $user): GroupMember + { + $this->expireInvitationIfNeeded($invitation); + + if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'invitation' => 'This invitation cannot be accepted.', + ]); + } + + $member = DB::transaction(function () use ($invitation): GroupMember { + $acceptedAt = now(); + + $member = GroupMember::query()->updateOrCreate( + [ + 'group_id' => $invitation->group_id, + 'user_id' => $invitation->invited_user_id, + ], + [ + 'invited_by_user_id' => $invitation->invited_by_user_id, + 'role' => $invitation->role, + 'status' => Group::STATUS_ACTIVE, + 'note' => $invitation->note, + 'invited_at' => $invitation->invited_at ?? $acceptedAt, + 'expires_at' => null, + 'accepted_at' => $acceptedAt, + 'revoked_at' => null, + ] + ); + + $invitation->forceFill([ + 'status' => GroupInvitation::STATUS_ACCEPTED, + 'responded_at' => $acceptedAt, + 'accepted_at' => $acceptedAt, + 'revoked_at' => null, + ])->save(); + + $this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_ACTIVE, $acceptedAt); + + GroupInvitation::query() + ->where('group_id', $invitation->group_id) + ->where('invited_user_id', $invitation->invited_user_id) + ->where('status', GroupInvitation::STATUS_PENDING) + ->where('id', '!=', $invitation->id) + ->update([ + 'status' => GroupInvitation::STATUS_REVOKED, + 'responded_at' => $acceptedAt, + 'revoked_at' => $acceptedAt, + 'updated_at' => $acceptedAt, + ]); + + return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']); + }); + + $recipient = $member->invitedBy ?: $member->group?->owner; + if ($recipient) { + $this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group); + } + + if ($member->group) { + app(GroupActivityService::class)->record( + $member->group, + $user, + 'member_joined', + 'group_member', + (int) $member->id, + sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name), + 'Accepted a group invitation.', + 'public', + ); + } + + return $member; + } + + public function declineInvitation(GroupInvitation $invitation, User $user): GroupInvitation + { + $this->expireInvitationIfNeeded($invitation); + + if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'invitation' => 'This invitation cannot be declined.', + ]); + } + + $declinedAt = now(); + + $invitation->forceFill([ + 'status' => GroupInvitation::STATUS_DECLINED, + 'responded_at' => $declinedAt, + 'accepted_at' => null, + 'revoked_at' => null, + ])->save(); + + $this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $declinedAt); + + return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']); + } + + public function acceptLegacyInvite(GroupMember $member, User $user): GroupMember + { + $this->expireMemberIfNeeded($member); + + if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'member' => 'This invitation cannot be accepted.', + ]); + } + + $acceptedAt = now(); + + $member->forceFill([ + 'status' => Group::STATUS_ACTIVE, + 'expires_at' => null, + 'accepted_at' => $acceptedAt, + 'revoked_at' => null, + ])->save(); + + GroupInvitation::query() + ->where('source_group_member_id', $member->id) + ->where('status', GroupInvitation::STATUS_PENDING) + ->update([ + 'status' => GroupInvitation::STATUS_ACCEPTED, + 'responded_at' => $acceptedAt, + 'accepted_at' => $acceptedAt, + 'revoked_at' => null, + 'updated_at' => $acceptedAt, + ]); + + $recipient = $member->invitedBy ?: $member->group?->owner; + if ($recipient) { + $this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group); + } + + if ($member->group) { + app(GroupActivityService::class)->record( + $member->group, + $user, + 'member_joined', + 'group_member', + (int) $member->id, + sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name), + 'Accepted a legacy group invitation.', + 'public', + ); + } + + return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']); + } + + public function declineLegacyInvite(GroupMember $member, User $user): GroupMember + { + $this->expireMemberIfNeeded($member); + + if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'member' => 'This invitation cannot be declined.', + ]); + } + + $declinedAt = now(); + + $member->forceFill([ + 'status' => Group::STATUS_REVOKED, + 'accepted_at' => null, + 'revoked_at' => $declinedAt, + ])->save(); + + GroupInvitation::query() + ->where('source_group_member_id', $member->id) + ->where('status', GroupInvitation::STATUS_PENDING) + ->update([ + 'status' => GroupInvitation::STATUS_DECLINED, + 'responded_at' => $declinedAt, + 'accepted_at' => null, + 'updated_at' => $declinedAt, + ]); + + return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']); + } + + public function updateMemberRole(GroupMember $member, User $actor, string $role): GroupMember + { + $this->guardManageMembers($member->group, $actor); + $role = Group::normalizeMemberRole($role); + + if ($member->role === Group::ROLE_OWNER) { + throw ValidationException::withMessages([ + 'member' => 'The group owner role cannot be changed.', + ]); + } + + if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) { + throw ValidationException::withMessages([ + 'role' => 'Choose a valid group role.', + ]); + } + + $member->forceFill(['role' => $role])->save(); + $this->notifications->notifyGroupRoleChanged($member->user, $actor, $member->group, Group::displayRole($role) ?? $role); + + return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']); + } + + public function updatePermissionOverrides(GroupMember $member, User $actor, array $overrides): GroupMember + { + if (! $member->group->canManageMemberPermissions($actor) && ! $actor->isAdmin()) { + throw ValidationException::withMessages([ + 'member' => 'You are not allowed to manage group member permissions.', + ]); + } + + if ($member->role === Group::ROLE_OWNER) { + throw ValidationException::withMessages([ + 'member' => 'The group owner already has full permissions.', + ]); + } + + $normalized = collect($overrides) + ->map(function ($override): ?array { + if (is_string($override)) { + $key = trim($override); + + return $key !== '' && in_array($key, Group::allowedPermissionOverrides(), true) + ? ['key' => $key, 'is_allowed' => true] + : null; + } + + if (! is_array($override)) { + return null; + } + + $key = trim((string) ($override['key'] ?? '')); + if ($key === '' || ! in_array($key, Group::allowedPermissionOverrides(), true)) { + return null; + } + + return [ + 'key' => $key, + 'is_allowed' => (bool) ($override['is_allowed'] ?? false), + ]; + }) + ->filter() + ->unique(fn (array $override): string => $override['key']) + ->values() + ->all(); + + $member->forceFill([ + 'permission_overrides_json' => $normalized, + ])->save(); + + return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']); + } + + public function revokeMember(GroupMember $member, User $actor): void + { + $this->guardManageMembers($member->group, $actor); + + $wasActiveMember = $member->status === Group::STATUS_ACTIVE; + + if ($member->role === Group::ROLE_OWNER) { + throw ValidationException::withMessages([ + 'member' => 'The group owner cannot be removed.', + ]); + } + + $member->forceFill([ + 'status' => Group::STATUS_REVOKED, + 'expires_at' => null, + 'revoked_at' => now(), + ])->save(); + + if ($wasActiveMember) { + $this->notifications->notifyGroupMemberRemoved($member->user, $actor, $member->group); + } + } + + public function revokeInvitation(GroupInvitation $invitation, User $actor): GroupInvitation + { + $this->guardManageMembers($invitation->group, $actor); + + if ($invitation->status !== GroupInvitation::STATUS_PENDING) { + throw ValidationException::withMessages([ + 'invitation' => 'Only pending invitations can be revoked.', + ]); + } + + $revokedAt = now(); + + $invitation->forceFill([ + 'status' => GroupInvitation::STATUS_REVOKED, + 'responded_at' => $revokedAt, + 'revoked_at' => $revokedAt, + ])->save(); + + $this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $revokedAt); + + return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']); + } + + public function transferOwnership(Group $group, GroupMember $member, User $actor): Group + { + if (! $group->isOwnedBy($actor) && ! $actor->isAdmin()) { + throw ValidationException::withMessages([ + 'member' => 'Only the group owner can transfer ownership.', + ]); + } + + if ((int) $member->group_id !== (int) $group->id) { + throw ValidationException::withMessages([ + 'member' => 'This member does not belong to the selected group.', + ]); + } + + if ($member->status !== Group::STATUS_ACTIVE) { + throw ValidationException::withMessages([ + 'member' => 'Only active members can become the new owner.', + ]); + } + + return DB::transaction(function () use ($group, $member): Group { + GroupMember::query() + ->where('group_id', $group->id) + ->where('user_id', $group->owner_user_id) + ->update([ + 'role' => Group::ROLE_ADMIN, + 'updated_at' => now(), + ]); + + $member->forceFill([ + 'role' => Group::ROLE_OWNER, + 'status' => Group::STATUS_ACTIVE, + 'expires_at' => null, + 'accepted_at' => $member->accepted_at ?? now(), + ])->save(); + + $group->forceFill([ + 'owner_user_id' => $member->user_id, + 'last_activity_at' => now(), + ])->save(); + + $this->ensureOwnerMembership($group->fresh()); + + return $group->fresh(['owner.profile']); + }); + } + + public function expirePendingInvites(): int + { + $expired = GroupInvitation::query() + ->with('sourceGroupMember') + ->where('status', GroupInvitation::STATUS_PENDING) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->get(); + + foreach ($expired as $invitation) { + $expiredAt = now(); + $invitation->forceFill([ + 'status' => GroupInvitation::STATUS_EXPIRED, + 'responded_at' => $expiredAt, + 'revoked_at' => $expiredAt, + ])->save(); + + $this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt); + } + + return $expired->count(); + } + + public function activeContributorIds(Group $group): array + { + $activeIds = $group->members() + ->where('status', Group::STATUS_ACTIVE) + ->whereIn('role', [Group::ROLE_OWNER, Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER]) + ->pluck('user_id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if (! in_array((int) $group->owner_user_id, $activeIds, true)) { + $activeIds[] = (int) $group->owner_user_id; + } + + return array_values(array_unique($activeIds)); + } + + public function mapMembers(Group $group, ?User $viewer = null): array + { + $this->expirePendingInvites(); + + $members = $group->members() + ->with(['user.profile', 'invitedBy.profile']) + ->where('status', Group::STATUS_ACTIVE) + ->orderByRaw("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'editor' THEN 2 ELSE 3 END") + ->orderBy('created_at') + ->get(); + + return $members->map(fn (GroupMember $member): array => $this->mapMemberRow($member, $group, $viewer))->all(); + } + + public function mapInvitations(Group $group, ?User $viewer = null): array + { + $this->expirePendingInvites(); + + return $group->invitations() + ->with(['invitedUser.profile', 'invitedBy.profile']) + ->whereIn('status', [ + GroupInvitation::STATUS_PENDING, + GroupInvitation::STATUS_REVOKED, + GroupInvitation::STATUS_DECLINED, + GroupInvitation::STATUS_EXPIRED, + ]) + ->orderByDesc('invited_at') + ->orderByDesc('updated_at') + ->get() + ->map(fn (GroupInvitation $invitation): array => $this->mapInvitationRow($invitation, $group, $viewer)) + ->values() + ->all(); + } + + public function pendingInviteCount(Group $group): int + { + $this->expirePendingInvites(); + + return (int) $group->invitations() + ->where('status', GroupInvitation::STATUS_PENDING) + ->count(); + } + + public function pendingInvitationsForUser(User $user): array + { + $this->expirePendingInvites(); + + return $user->groupInvitations() + ->with(['group.owner.profile', 'group.members', 'invitedBy.profile']) + ->where('status', GroupInvitation::STATUS_PENDING) + ->orderByDesc('invited_at') + ->get() + ->map(fn (GroupInvitation $invitation): array => [ + 'id' => (int) $invitation->id, + 'group' => $invitation->group ? [ + 'id' => (int) $invitation->group->id, + 'name' => (string) $invitation->group->name, + 'slug' => (string) $invitation->group->slug, + 'avatar_url' => $invitation->group->avatarUrl(), + 'counts' => [ + 'artworks' => (int) $invitation->group->artworks_count, + 'collections' => (int) $invitation->group->collections_count, + 'followers' => (int) $invitation->group->followers_count, + ], + ] : null, + 'role' => Group::displayRole((string) $invitation->role) ?? (string) $invitation->role, + 'invited_at' => $invitation->invited_at?->toISOString(), + 'expires_at' => $invitation->expires_at?->toISOString(), + 'accept_url' => route('studio.groups.invitations.accept', ['invitation' => $invitation]), + 'decline_url' => route('studio.groups.invitations.decline', ['invitation' => $invitation]), + 'invited_by' => $invitation->invitedBy ? [ + 'name' => $invitation->invitedBy->name, + 'username' => $invitation->invitedBy->username, + ] : null, + ]) + ->values() + ->all(); + } + + public function contributorOptions(Group $group): array + { + return User::query() + ->with('profile:user_id,avatar_hash') + ->whereIn('id', $this->activeContributorIds($group)) + ->orderBy('username') + ->get() + ->map(fn (User $user): array => [ + 'id' => (int) $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64), + ]) + ->values() + ->all(); + } + + private function guardManageMembers(Group $group, User $actor): void + { + if (! $group->canManageMembers($actor) && ! $actor->isAdmin()) { + throw ValidationException::withMessages([ + 'group' => 'You are not allowed to manage this group.', + ]); + } + } + + private function expireMemberIfNeeded(GroupMember $member): void + { + if ($member->status !== Group::STATUS_PENDING || ! $member->expires_at || $member->expires_at->isFuture()) { + return; + } + + $member->forceFill([ + 'status' => Group::STATUS_REVOKED, + 'revoked_at' => Carbon::now(), + ])->save(); + + GroupInvitation::query() + ->where('source_group_member_id', $member->id) + ->where('status', GroupInvitation::STATUS_PENDING) + ->update([ + 'status' => GroupInvitation::STATUS_EXPIRED, + 'responded_at' => now(), + 'revoked_at' => now(), + 'updated_at' => now(), + ]); + } + + private function expireInvitationIfNeeded(GroupInvitation $invitation): void + { + if ($invitation->status !== GroupInvitation::STATUS_PENDING || ! $invitation->expires_at || $invitation->expires_at->isFuture()) { + return; + } + + $expiredAt = Carbon::now(); + + $invitation->forceFill([ + 'status' => GroupInvitation::STATUS_EXPIRED, + 'responded_at' => $expiredAt, + 'revoked_at' => $expiredAt, + ])->save(); + + $this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt); + } + + private function mapMemberRow(GroupMember $member, Group $group, ?User $viewer = null): array + { + $user = $member->user; + + return [ + 'id' => (int) $member->id, + 'user_id' => (int) $member->user_id, + 'role' => (string) $member->role, + 'role_label' => Group::displayRole((string) $member->role), + 'status' => (string) $member->status, + 'permission_overrides' => collect($member->permission_overrides_json ?? []) + ->map(function ($override): ?array { + if (is_array($override)) { + $key = trim((string) ($override['key'] ?? '')); + + return $key !== '' ? ['key' => $key, 'is_allowed' => (bool) ($override['is_allowed'] ?? false)] : null; + } + + $key = trim((string) $override); + + return $key !== '' ? ['key' => $key, 'is_allowed' => true] : null; + }) + ->filter() + ->values() + ->all(), + 'note' => $member->note, + 'invited_at' => $member->invited_at?->toISOString(), + 'expires_at' => $member->expires_at?->toISOString(), + 'accepted_at' => $member->accepted_at?->toISOString(), + 'is_expired' => $member->status === Group::STATUS_REVOKED && $member->expires_at !== null && $member->expires_at->lte(now()) && $member->accepted_at === null, + 'can_accept' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING, + 'can_decline' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING, + 'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $member->role !== Group::ROLE_OWNER, + 'can_transfer' => $viewer !== null + && $group->isOwnedBy($viewer) + && $member->status === Group::STATUS_ACTIVE + && $member->role !== Group::ROLE_OWNER, + 'can_manage_permissions' => $viewer !== null + && $group->canManageMemberPermissions($viewer) + && $member->status === Group::STATUS_ACTIVE + && $member->role !== Group::ROLE_OWNER, + 'user' => [ + 'id' => (int) $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72), + 'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]), + ], + 'invited_by' => $member->invitedBy ? [ + 'id' => (int) $member->invitedBy->id, + 'username' => $member->invitedBy->username, + 'name' => $member->invitedBy->name, + ] : null, + ]; + } + + private function mapInvitationRow(GroupInvitation $invitation, Group $group, ?User $viewer = null): array + { + $user = $invitation->invitedUser; + $displayStatus = $invitation->status === GroupInvitation::STATUS_PENDING ? GroupInvitation::STATUS_PENDING : Group::STATUS_REVOKED; + + return [ + 'id' => (int) $invitation->id, + 'user_id' => (int) $invitation->invited_user_id, + 'role' => (string) $invitation->role, + 'role_label' => Group::displayRole((string) $invitation->role), + 'status' => $displayStatus, + 'status_raw' => (string) $invitation->status, + 'note' => $invitation->note, + 'invited_at' => $invitation->invited_at?->toISOString(), + 'expires_at' => $invitation->expires_at?->toISOString(), + 'accepted_at' => $invitation->accepted_at?->toISOString(), + 'is_expired' => $invitation->status === GroupInvitation::STATUS_EXPIRED, + 'can_accept' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING, + 'can_decline' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING, + 'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $invitation->status === GroupInvitation::STATUS_PENDING, + 'can_transfer' => false, + 'accept_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.accept', ['invitation' => $invitation]) : null, + 'decline_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.decline', ['invitation' => $invitation]) : null, + 'revoke_url' => $viewer !== null && $group->canManageMembers($viewer) ? route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => $invitation]) : null, + 'user' => $user ? [ + 'id' => (int) $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72), + 'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]), + ] : null, + 'invited_by' => $invitation->invitedBy ? [ + 'id' => (int) $invitation->invitedBy->id, + 'username' => $invitation->invitedBy->username, + 'name' => $invitation->invitedBy->name, + ] : null, + ]; + } + + private function syncLegacyMemberFromInvitation(GroupInvitation $invitation, string $memberStatus, Carbon $timestamp): void + { + if (! $invitation->source_group_member_id) { + return; + } + + $member = $invitation->sourceGroupMember()->first(); + if (! $member) { + return; + } + + $member->forceFill([ + 'status' => $memberStatus, + 'expires_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $member->expires_at, + 'accepted_at' => $memberStatus === Group::STATUS_ACTIVE ? $timestamp : null, + 'revoked_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $timestamp, + ])->save(); + } +} \ No newline at end of file diff --git a/app/Services/GroupPostService.php b/app/Services/GroupPostService.php new file mode 100644 index 00000000..0d6133ca --- /dev/null +++ b/app/Services/GroupPostService.php @@ -0,0 +1,359 @@ +create([ + 'group_id' => $group->id, + 'author_user_id' => $actor->id, + 'type' => (string) ($attributes['type'] ?? GroupPost::TYPE_ANNOUNCEMENT), + 'title' => trim((string) $attributes['title']), + 'slug' => $this->makeUniqueSlug((string) $attributes['title']), + 'excerpt' => $this->normalizeExcerpt($attributes['excerpt'] ?? null, $attributes['content'] ?? null), + 'content' => $this->sanitizeContent($attributes['content'] ?? null), + 'cover_path' => $attributes['cover_path'] ?? null, + 'status' => GroupPost::STATUS_DRAFT, + 'is_pinned' => false, + 'published_at' => null, + ]); + + $this->history->record( + $group, + $actor, + 'post_created', + sprintf('Created draft post "%s".', $post->title), + 'group_post', + (int) $post->id, + null, + $post->only(['type', 'title', 'status']), + ); + + return $post->fresh(['group', 'author.profile']); + } + + public function update(GroupPost $post, User $actor, array $attributes): GroupPost + { + $before = $post->only(['type', 'title', 'excerpt', 'content', 'cover_path', 'status', 'is_pinned']); + + $title = trim((string) ($attributes['title'] ?? $post->title)); + + $post->fill([ + 'type' => (string) ($attributes['type'] ?? $post->type), + 'title' => $title, + 'slug' => $title !== $post->title ? $this->makeUniqueSlug($title, (int) $post->id) : $post->slug, + 'excerpt' => array_key_exists('excerpt', $attributes) + ? $this->normalizeExcerpt($attributes['excerpt'], $attributes['content'] ?? $post->content) + : $post->excerpt, + 'content' => array_key_exists('content', $attributes) + ? $this->sanitizeContent($attributes['content']) + : $post->content, + 'cover_path' => array_key_exists('cover_path', $attributes) ? ($attributes['cover_path'] ?: null) : $post->cover_path, + ])->save(); + + $this->history->record( + $post->group, + $actor, + 'post_updated', + sprintf('Updated post "%s".', $post->title), + 'group_post', + (int) $post->id, + $before, + $post->only(['type', 'title', 'excerpt', 'content', 'cover_path', 'status', 'is_pinned']), + ); + + return $post->fresh(['group', 'author.profile']); + } + + public function publish(GroupPost $post, User $actor): GroupPost + { + if ($post->group->status !== Group::LIFECYCLE_ACTIVE) { + throw ValidationException::withMessages([ + 'group' => 'Archived or suspended groups cannot publish posts.', + ]); + } + + if (trim((string) $post->title) === '') { + throw ValidationException::withMessages([ + 'title' => 'A published post needs a title.', + ]); + } + + $before = $post->only(['status', 'published_at']); + + $post->forceFill([ + 'status' => GroupPost::STATUS_PUBLISHED, + 'published_at' => now(), + ])->save(); + + $this->history->record( + $post->group, + $actor, + 'post_published', + sprintf('Published post "%s".', $post->title), + 'group_post', + (int) $post->id, + $before, + ['status' => GroupPost::STATUS_PUBLISHED, 'published_at' => $post->published_at?->toISOString()], + ); + + app(GroupActivityService::class)->record( + $post->group, + $actor, + 'post_published', + 'group_post', + (int) $post->id, + sprintf('%s published a new group post: %s', $post->group->name, $post->title), + $post->excerpt, + 'public', + ); + + foreach ($post->group->follows()->with('user')->get() as $follow) { + if ($follow->user) { + $this->notifications->notifyGroupPostPublished($follow->user, $actor, $post->group, $post); + } + } + + return $post->fresh(['group', 'author.profile']); + } + + public function pin(GroupPost $post, User $actor, bool $isPinned = true): GroupPost + { + DB::transaction(function () use ($post, $actor, $isPinned): void { + if ($isPinned) { + GroupPost::query() + ->where('group_id', $post->group_id) + ->where('id', '!=', $post->id) + ->where('is_pinned', true) + ->update([ + 'is_pinned' => false, + 'updated_at' => now(), + ]); + } + + $before = ['is_pinned' => (bool) $post->is_pinned]; + + $post->forceFill([ + 'is_pinned' => $isPinned, + ])->save(); + + $this->history->record( + $post->group, + $actor, + $isPinned ? 'post_pinned' : 'post_unpinned', + sprintf('%s post "%s".', $isPinned ? 'Pinned' : 'Unpinned', $post->title), + 'group_post', + (int) $post->id, + $before, + ['is_pinned' => $isPinned], + ); + }); + + return $post->fresh(['group', 'author.profile']); + } + + public function archive(GroupPost $post, User $actor): GroupPost + { + $before = $post->only(['status', 'is_pinned']); + + $post->forceFill([ + 'status' => GroupPost::STATUS_ARCHIVED, + 'is_pinned' => false, + ])->save(); + + $this->history->record( + $post->group, + $actor, + 'post_archived', + sprintf('Archived post "%s".', $post->title), + 'group_post', + (int) $post->id, + $before, + ['status' => GroupPost::STATUS_ARCHIVED, 'is_pinned' => false], + ); + + return $post->fresh(['group', 'author.profile']); + } + + public function publicPosts(Group $group, int $limit = 12): array + { + return GroupPost::query() + ->with('author.profile') + ->where('group_id', $group->id) + ->where('status', GroupPost::STATUS_PUBLISHED) + ->orderByDesc('is_pinned') + ->orderByDesc('published_at') + ->limit($limit) + ->get() + ->map(fn (GroupPost $post): array => $this->mapPublicPost($group, $post)) + ->values() + ->all(); + } + + public function pinnedPost(Group $group): ?array + { + $post = GroupPost::query() + ->with('author.profile') + ->where('group_id', $group->id) + ->where('status', GroupPost::STATUS_PUBLISHED) + ->where('is_pinned', true) + ->latest('published_at') + ->first(); + + return $post ? $this->mapPublicPost($group, $post) : null; + } + + public function recentPosts(Group $group, int $limit = 3): array + { + return GroupPost::query() + ->with('author.profile') + ->where('group_id', $group->id) + ->where('status', GroupPost::STATUS_PUBLISHED) + ->latest('published_at') + ->limit($limit) + ->get() + ->map(fn (GroupPost $post): array => $this->mapPublicPost($group, $post)) + ->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 = GroupPost::query() + ->with('author.profile') + ->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 (GroupPost $post): array => $this->mapStudioPost($group, $post))->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' => GroupPost::STATUS_DRAFT, 'label' => 'Drafts'], + ['value' => GroupPost::STATUS_PUBLISHED, 'label' => 'Published'], + ['value' => GroupPost::STATUS_ARCHIVED, 'label' => 'Archived'], + ], + ]; + } + + public function mapPublicPost(Group $group, GroupPost $post): array + { + return [ + 'id' => (int) $post->id, + 'type' => (string) $post->type, + 'title' => (string) $post->title, + 'slug' => (string) $post->slug, + 'excerpt' => $post->excerpt, + 'content' => $post->content, + 'cover_url' => $post->cover_path, + 'is_pinned' => (bool) $post->is_pinned, + 'published_at' => $post->published_at?->toISOString(), + 'author' => $post->author ? [ + 'id' => (int) $post->author->id, + 'name' => $post->author->name, + 'username' => $post->author->username, + ] : null, + 'url' => route('groups.posts.show', ['group' => $group, 'post' => $post]), + ]; + } + + public function mapStudioPost(Group $group, GroupPost $post): array + { + return [ + 'id' => (int) $post->id, + 'type' => (string) $post->type, + 'title' => (string) $post->title, + 'excerpt' => $post->excerpt, + 'content' => $post->content, + 'cover_url' => $post->cover_path, + 'status' => (string) $post->status, + 'is_pinned' => (bool) $post->is_pinned, + 'published_at' => $post->published_at?->toISOString(), + 'updated_at' => $post->updated_at?->toISOString(), + 'author' => $post->author ? [ + 'id' => (int) $post->author->id, + 'name' => $post->author->name, + 'username' => $post->author->username, + ] : null, + 'urls' => [ + 'public' => $post->status === GroupPost::STATUS_PUBLISHED ? route('groups.posts.show', ['group' => $group, 'post' => $post]) : null, + 'edit' => route('studio.groups.posts.edit', ['group' => $group, 'post' => $post]), + 'publish' => route('studio.groups.posts.publish', ['group' => $group, 'post' => $post]), + 'pin' => route('studio.groups.posts.pin', ['group' => $group, 'post' => $post]), + 'archive' => route('studio.groups.posts.archive', ['group' => $group, 'post' => $post]), + ], + ]; + } + + private function sanitizeContent(?string $content): ?string + { + $trimmed = trim(strip_tags((string) ($content ?? ''))); + + return $trimmed !== '' ? $trimmed : null; + } + + private function normalizeExcerpt(?string $excerpt, ?string $content): ?string + { + $trimmed = trim((string) ($excerpt ?? '')); + if ($trimmed !== '') { + return Str::limit($trimmed, 320, ''); + } + + $body = $this->sanitizeContent($content); + + return $body ? Str::limit($body, 280) : null; + } + + private function makeUniqueSlug(string $source, ?int $ignorePostId = null): string + { + $base = Str::slug(Str::limit($source, 180, '')); + $base = $base !== '' ? $base : 'group-post'; + $slug = $base; + $suffix = 2; + + while (GroupPost::query() + ->where('slug', $slug) + ->when($ignorePostId !== null, fn ($query) => $query->where('id', '!=', $ignorePostId)) + ->exists()) { + $slug = Str::limit($base, 172, '') . '-' . $suffix; + $suffix++; + } + + return $slug; + } +} \ No newline at end of file diff --git a/app/Services/GroupProjectService.php b/app/Services/GroupProjectService.php new file mode 100644 index 00000000..d50ef330 --- /dev/null +++ b/app/Services/GroupProjectService.php @@ -0,0 +1,696 @@ +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; + } +} \ No newline at end of file diff --git a/app/Services/GroupRecruitmentService.php b/app/Services/GroupRecruitmentService.php new file mode 100644 index 00000000..a2f99640 --- /dev/null +++ b/app/Services/GroupRecruitmentService.php @@ -0,0 +1,109 @@ +firstOrNew([ + 'group_id' => $group->id, + ]); + + $before = $profile->exists ? $profile->only([ + 'is_recruiting', + 'headline', + 'description', + 'roles_json', + 'skills_json', + 'contact_mode', + 'visibility', + ]) : null; + + $profile->fill([ + 'is_recruiting' => (bool) ($attributes['is_recruiting'] ?? false), + 'headline' => $attributes['headline'] ?? null, + 'description' => $attributes['description'] ?? null, + 'roles_json' => $this->normalizeList($attributes['roles_json'] ?? []), + 'skills_json' => $this->normalizeList($attributes['skills_json'] ?? []), + 'contact_mode' => $attributes['contact_mode'] ?? null, + 'visibility' => (string) ($attributes['visibility'] ?? 'public'), + ])->save(); + + if ($profile->is_recruiting && $profile->visibility === 'public') { + foreach ($group->follows()->with('user.profile')->get() as $follow) { + if ($follow->user) { + $this->notifications->notifyGroupRecruitmentUpdated($follow->user, $actor, $group, $profile); + } + } + } + + $this->history->record( + $group, + $actor, + 'recruitment_updated', + $profile->is_recruiting ? 'Enabled or updated recruitment profile.' : 'Disabled recruitment profile.', + 'group_recruitment_profile', + (int) $profile->id, + $before, + $profile->only([ + 'is_recruiting', + 'headline', + 'description', + 'roles_json', + 'skills_json', + 'contact_mode', + 'visibility', + ]), + ); + + return $profile->fresh(); + } + + public function payloadForGroup(Group $group): ?array + { + $profile = $group->relationLoaded('recruitmentProfile') + ? $group->recruitmentProfile + : $group->recruitmentProfile()->first(); + + if (! $profile) { + return null; + } + + return [ + 'id' => (int) $profile->id, + 'is_recruiting' => (bool) $profile->is_recruiting, + 'headline' => $profile->headline, + 'description' => $profile->description, + 'roles' => array_values(array_filter($profile->roles_json ?? [])), + 'skills' => array_values(array_filter($profile->skills_json ?? [])), + 'contact_mode' => $profile->contact_mode, + 'visibility' => $profile->visibility, + 'updated_at' => $profile->updated_at?->toISOString(), + 'roles_options' => config('groups.recruitment.roles', []), + 'skills_options' => config('groups.recruitment.skills', []), + ]; + } + + private function normalizeList(array $items): array + { + return collect($items) + ->map(fn ($item): string => trim((string) $item)) + ->filter() + ->unique() + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/Services/GroupReleaseService.php b/app/Services/GroupReleaseService.php new file mode 100644 index 00000000..fecd8f1e --- /dev/null +++ b/app/Services/GroupReleaseService.php @@ -0,0 +1,817 @@ +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; + } +} \ No newline at end of file diff --git a/app/Services/GroupReputationService.php b/app/Services/GroupReputationService.php new file mode 100644 index 00000000..ae2053cc --- /dev/null +++ b/app/Services/GroupReputationService.php @@ -0,0 +1,568 @@ +contributorUserIds($group); + + GroupContributorStat::query() + ->where('group_id', $group->id) + ->whereNotIn('user_id', $userIds) + ->delete(); + + foreach ($userIds as $userId) { + GroupContributorStat::query()->updateOrCreate( + [ + 'group_id' => (int) $group->id, + 'user_id' => $userId, + ], + $this->statPayload($group, $userId) + ); + } + + $this->awardGroupBadges($group); + $this->awardMemberBadges($group); + } + + public function topContributors(Group $group, int $limit = 6): array + { + return GroupContributorStat::query() + ->with(['user.profile']) + ->where('group_id', $group->id) + ->orderByDesc('release_count') + ->orderByDesc('credited_artworks_count') + ->orderByDesc('review_actions_count') + ->limit(max(1, min(24, $limit))) + ->get() + ->map(fn (GroupContributorStat $stat): array => $this->mapContributorStat($group, $stat)) + ->values() + ->all(); + } + + public function summary(Group $group): array + { + $stats = GroupContributorStat::query()->where('group_id', $group->id); + + return [ + 'top_contributors' => $this->topContributors($group, 8), + 'counts' => [ + 'contributors' => (clone $stats)->count(), + 'release_contributors' => (clone $stats)->where('release_count', '>', 0)->count(), + 'reliable_reviewers' => (clone $stats)->where('review_actions_count', '>=', 5)->count(), + 'trusted_contributors' => (clone $stats)->where('approved_submissions_count', '>=', 3)->count(), + 'group_badges' => (int) $group->badges()->count(), + 'member_badges' => (int) $group->memberBadges()->count(), + ], + 'recent_badges' => $this->groupBadges($group, 8), + 'member_badge_unlocks' => $this->recentMemberBadges($group, 8), + ]; + } + + public function trustSignals(Group $group): array + { + $releaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count(); + $recentReleaseCount = (int) $group->releases() + ->where('status', GroupRelease::STATUS_RELEASED) + ->where('released_at', '>=', now()->subDays(45)) + ->count(); + $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; + $approvedArtworks = (int) Artwork::query() + ->where('group_id', $group->id) + ->where('group_review_status', 'approved') + ->count(); + + $signals = []; + + if ($group->is_verified) { + $signals[] = [ + 'key' => 'verified', + 'label' => 'Verified', + 'tone' => 'sky', + 'reason' => 'This group has a verified or official identity on Nova.', + ]; + } + + if ($group->last_activity_at && $group->last_activity_at->greaterThanOrEqualTo(now()->subDays(14))) { + $signals[] = [ + 'key' => 'active', + 'label' => 'Active', + 'tone' => 'emerald', + 'reason' => 'The group has posted or published work recently.', + ]; + } + + if ($recentReleaseCount > 0) { + $signals[] = [ + 'key' => 'release_active', + 'label' => 'Release Active', + 'tone' => 'amber', + 'reason' => 'The group has published a release in the last 45 days.', + ]; + } + + if ($releaseCount >= 2 && $approvedArtworks >= 6) { + $signals[] = [ + 'key' => 'trusted', + 'label' => 'Trusted', + 'tone' => 'sky', + 'reason' => 'Trust is earned through repeated releases and approved contributions.', + ]; + } + + if ($activeMembers >= 4) { + $signals[] = [ + 'key' => 'collaborative', + 'label' => 'Collaborative', + 'tone' => 'violet', + 'reason' => 'Several active members are contributing to this group.', + ]; + } + + if (($group->recruitmentProfile?->is_recruiting ?? false) === true) { + $signals[] = [ + 'key' => 'recruiting', + 'label' => 'Recruiting', + 'tone' => 'emerald', + 'reason' => 'The group is currently open to new collaborators.', + ]; + } + + if ($signals === []) { + $signals[] = [ + 'key' => 'new_rising', + 'label' => 'New & Rising', + 'tone' => 'amber', + 'reason' => 'This group is still early, but active enough to remain discoverable.', + ]; + } + + return $signals; + } + + public function groupBadges(Group $group, int $limit = 6): array + { + return $group->badges() + ->latest('awarded_at') + ->limit(max(1, min(24, $limit))) + ->get() + ->map(fn (GroupBadge $badge): array => [ + 'key' => (string) $badge->badge_key, + 'label' => $this->badgeLabel('group', (string) $badge->badge_key), + 'awarded_at' => $badge->awarded_at?->toISOString(), + 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('group', (string) $badge->badge_key), + ]) + ->values() + ->all(); + } + + public function memberBadges(Group $group, User|int $user, int $limit = 4): array + { + $userId = $user instanceof User ? (int) $user->id : (int) $user; + + return GroupMemberBadge::query() + ->where('group_id', $group->id) + ->where('user_id', $userId) + ->latest('awarded_at') + ->limit(max(1, min(12, $limit))) + ->get() + ->map(fn (GroupMemberBadge $badge): array => [ + 'key' => (string) $badge->badge_key, + 'label' => $this->badgeLabel('member', (string) $badge->badge_key), + 'awarded_at' => $badge->awarded_at?->toISOString(), + 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key), + ]) + ->values() + ->all(); + } + + private function recentMemberBadges(Group $group, int $limit): array + { + return GroupMemberBadge::query() + ->with('user.profile') + ->where('group_id', $group->id) + ->latest('awarded_at') + ->limit(max(1, min(24, $limit))) + ->get() + ->map(fn (GroupMemberBadge $badge): array => [ + 'user' => [ + 'id' => (int) $badge->user_id, + 'name' => $badge->user?->name, + 'username' => $badge->user?->username, + 'avatar_url' => $badge->user ? AvatarUrl::forUser((int) $badge->user->id, $badge->user->profile?->avatar_hash, 72) : null, + ], + 'badge' => [ + 'key' => (string) $badge->badge_key, + 'label' => $this->badgeLabel('member', (string) $badge->badge_key), + 'awarded_at' => $badge->awarded_at?->toISOString(), + 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key), + ], + ]) + ->values() + ->all(); + } + + private function contributorUserIds(Group $group): array + { + return collect([(int) $group->owner_user_id]) + ->merge($group->members()->where('status', Group::STATUS_ACTIVE)->pluck('user_id')) + ->merge($group->releases()->pluck('lead_user_id')) + ->merge($group->releases()->pluck('created_by_user_id')) + ->merge(GroupReleaseContributor::query() + ->whereIn('group_release_id', $group->releases()->pluck('id')) + ->pluck('user_id')) + ->merge($group->projects()->pluck('lead_user_id')) + ->merge($group->projects()->pluck('created_by_user_id')) + ->merge($group->projects()->with('memberLinks')->get()->flatMap(fn (GroupProject $project) => $project->memberLinks->pluck('user_id'))) + ->merge(Artwork::query()->where('group_id', $group->id)->pluck('primary_author_user_id')) + ->merge(Artwork::query()->where('group_id', $group->id)->pluck('uploaded_by_user_id')) + ->merge(Artwork::query()->where('group_id', $group->id)->pluck('group_reviewed_by_user_id')) + ->filter(fn ($id): bool => (int) $id > 0) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values() + ->all(); + } + + private function statPayload(Group $group, int $userId): array + { + $creditedArtworksCount = Artwork::query() + ->where('group_id', $group->id) + ->where(function ($query) use ($userId): void { + $query->where('primary_author_user_id', $userId) + ->orWhere('uploaded_by_user_id', $userId) + ->orWhereHas('contributors', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); + }) + ->count(); + + $releaseCount = GroupRelease::query() + ->where('group_id', $group->id) + ->where(function ($query) use ($userId): void { + $query->where('lead_user_id', $userId) + ->orWhere('created_by_user_id', $userId) + ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); + }) + ->count(); + + $projectCount = GroupProject::query() + ->where('group_id', $group->id) + ->where(function ($query) use ($userId): void { + $query->where('lead_user_id', $userId) + ->orWhere('created_by_user_id', $userId) + ->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId)); + }) + ->count(); + + $reviewActionsCount = Artwork::query() + ->where('group_id', $group->id) + ->where('group_reviewed_by_user_id', $userId) + ->count(); + + $approvedSubmissionsCount = Artwork::query() + ->where('group_id', $group->id) + ->where('uploaded_by_user_id', $userId) + ->where('group_review_status', 'approved') + ->count(); + + return [ + 'credited_artworks_count' => $creditedArtworksCount, + 'release_count' => $releaseCount, + 'project_count' => $projectCount, + 'review_actions_count' => $reviewActionsCount, + 'approved_submissions_count' => $approvedSubmissionsCount, + 'reputation_meta_json' => $this->reputationMeta($creditedArtworksCount, $releaseCount, $projectCount, $reviewActionsCount, $approvedSubmissionsCount), + ]; + } + + private function reputationMeta(int $creditedArtworks, int $releaseCount, int $projectCount, int $reviewActions, int $approvedSubmissions): array + { + $creativeLevel = $this->levelLabel($creditedArtworks, [1 => 'Emerging', 5 => 'Established', 12 => 'Trusted']); + $collaborationLevel = $this->levelLabel($projectCount + $releaseCount, [1 => 'Active', 4 => 'Reliable', 8 => 'Core']); + $publishingLevel = $this->levelLabel($releaseCount + $approvedSubmissions, [1 => 'Contributing', 4 => 'Reliable', 8 => 'Trusted']); + $leadershipLevel = $this->levelLabel($reviewActions, [1 => 'Reviewing', 5 => 'Reliable Reviewer', 12 => 'Leadership']); + + return [ + 'trusted_indicator' => $approvedSubmissions >= 3 || $releaseCount >= 2 || $reviewActions >= 5, + 'summary' => trim(implode(' • ', array_filter([$creativeLevel, $collaborationLevel, $publishingLevel, $reviewActions > 0 ? $leadershipLevel : null]))), + 'dimensions' => [ + 'creative_contribution' => [ + 'label' => $creativeLevel, + 'value' => $creditedArtworks, + 'reason' => 'Based on credited artworks and visible contributions in this group.', + ], + 'collaboration_reliability' => [ + 'label' => $collaborationLevel, + 'value' => $projectCount + $releaseCount, + 'reason' => 'Based on projects, releases, and consistent participation.', + ], + 'publishing_trust' => [ + 'label' => $publishingLevel, + 'value' => $releaseCount + $approvedSubmissions, + 'reason' => 'Based on published releases and approved submissions.', + ], + 'review_leadership_trust' => [ + 'label' => $reviewActions > 0 ? $leadershipLevel : 'Not enough review activity yet', + 'value' => $reviewActions, + 'reason' => 'Based on review actions and approval responsibility inside the group.', + ], + ], + ]; + } + + private function mapContributorStat(Group $group, GroupContributorStat $stat): array + { + $meta = $stat->reputation_meta_json ?? []; + + return [ + 'user' => [ + 'id' => (int) $stat->user_id, + 'name' => $stat->user?->name, + 'username' => $stat->user?->username, + 'avatar_url' => $stat->user ? AvatarUrl::forUser((int) $stat->user->id, $stat->user->profile?->avatar_hash, 72) : null, + 'profile_url' => $stat->user?->username ? route('profile.show', ['username' => strtolower((string) $stat->user->username)]) : null, + ], + 'joined_at' => $this->memberJoinedAt($group, $stat->user_id), + 'counts' => [ + 'credited_artworks' => (int) $stat->credited_artworks_count, + 'releases' => (int) $stat->release_count, + 'projects' => (int) $stat->project_count, + 'review_actions' => (int) $stat->review_actions_count, + 'approved_submissions' => (int) $stat->approved_submissions_count, + ], + 'summary' => $meta['summary'] ?? null, + 'trusted_indicator' => (bool) ($meta['trusted_indicator'] ?? false), + 'dimensions' => $meta['dimensions'] ?? [], + 'badges' => $this->memberBadges($group, (int) $stat->user_id), + 'last_active_contribution_at' => $this->lastActiveContributionAt($group, (int) $stat->user_id), + ]; + } + + private function awardGroupBadges(Group $group): void + { + $publicReleaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count(); + $publishedArtworksCount = (int) Artwork::query()->where('group_id', $group->id)->where('artwork_status', 'published')->count(); + $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; + $eventsCount = (int) $group->events()->where('status', 'published')->count(); + $challengeCount = (int) $group->challenges()->whereIn('status', ['published', 'active'])->count(); + + $this->awardGroupBadge($group, 'first_release', $publicReleaseCount >= 1); + $this->awardGroupBadge($group, 'ten_releases', $publicReleaseCount >= 10); + $this->awardGroupBadge($group, 'hundred_published_artworks', $publishedArtworksCount >= 100); + $this->awardGroupBadge($group, 'community_favorite', (int) $group->followers_count >= 25); + $this->awardGroupBadge($group, 'consistent_activity', $group->last_activity_at?->greaterThanOrEqualTo(now()->subDays(30)) === true); + $this->awardGroupBadge($group, 'event_host', $eventsCount >= 3); + $this->awardGroupBadge($group, 'challenge_organizer', $challengeCount >= 2); + $this->awardGroupBadge($group, 'collaborative_group', $activeMembers >= 4 && $publicReleaseCount >= 1); + $this->awardGroupBadge($group, 'trusted_group', $publicReleaseCount >= 2 && $publishedArtworksCount >= 12); + } + + private function awardMemberBadges(Group $group): void + { + $stats = GroupContributorStat::query()->where('group_id', $group->id)->get(); + + foreach ($stats as $stat) { + $this->awardMemberBadge($group, (int) $stat->user_id, 'first_group_contribution', (int) $stat->credited_artworks_count >= 1); + $this->awardMemberBadge($group, (int) $stat->user_id, 'ten_group_contributions', (int) $stat->credited_artworks_count >= 10); + $this->awardMemberBadge($group, (int) $stat->user_id, 'release_contributor', (int) $stat->release_count >= 1); + $this->awardMemberBadge($group, (int) $stat->user_id, 'project_lead', GroupProject::query()->where('group_id', $group->id)->where('lead_user_id', $stat->user_id)->exists()); + $this->awardMemberBadge($group, (int) $stat->user_id, 'reliable_reviewer', (int) $stat->review_actions_count >= 5); + $this->awardMemberBadge($group, (int) $stat->user_id, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5); + $this->awardMemberBadge($group, (int) $stat->user_id, 'founding_member', $this->isFoundingMember($group, (int) $stat->user_id)); + $this->awardMemberBadge($group, (int) $stat->user_id, 'asset_builder', $group->assets()->where('uploaded_by_user_id', $stat->user_id)->count() >= 3); + } + } + + private function awardGroupBadge(Group $group, string $badgeKey, bool $shouldAward): void + { + if (! $shouldAward) { + return; + } + + $badge = GroupBadge::query()->firstOrCreate( + [ + 'group_id' => (int) $group->id, + 'badge_key' => $badgeKey, + ], + [ + 'awarded_at' => now(), + 'meta_json' => ['reason' => $this->badgeReason('group', $badgeKey)], + ] + ); + + if ($badge->wasRecentlyCreated) { + $badgeLabel = $this->badgeLabel('group', $badgeKey); + $url = route('studio.groups.reputation', ['group' => $group]); + + foreach ($this->badgeManagerRecipients($group) as $recipient) { + $this->notifications->notifyGroupBadgeEarned($recipient, $group, $badgeLabel, $url); + } + } + } + + private function awardMemberBadge(Group $group, int $userId, string $badgeKey, bool $shouldAward): void + { + if (! $shouldAward) { + return; + } + + $badge = GroupMemberBadge::query()->firstOrCreate( + [ + 'group_id' => (int) $group->id, + 'user_id' => $userId, + 'badge_key' => $badgeKey, + ], + [ + 'awarded_at' => now(), + 'meta_json' => ['reason' => $this->badgeReason('member', $badgeKey)], + ] + ); + + if ($badge->wasRecentlyCreated) { + $recipient = User::query()->find($userId); + + if ($recipient) { + $this->notifications->notifyGroupMemberBadgeEarned( + $recipient, + $group, + $this->badgeLabel('member', $badgeKey), + route('groups.show', ['group' => $group]) + ); + } + } + } + + private function badgeManagerRecipients(Group $group): Collection + { + $owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->first(); + $admins = $group->members() + ->with('user.profile') + ->where('status', Group::STATUS_ACTIVE) + ->where('role', Group::ROLE_ADMIN) + ->get() + ->pluck('user'); + + return collect([$owner]) + ->merge($admins) + ->filter(fn ($user): bool => $user instanceof User) + ->unique(fn (User $user): int => (int) $user->id) + ->values(); + } + + private function badgeLabel(string $scope, string $badgeKey): string + { + return (string) config(sprintf('groups.badges.%s.%s', $scope, $badgeKey), str_replace('_', ' ', $badgeKey)); + } + + private function badgeReason(string $scope, string $badgeKey): string + { + return match ($scope . ':' . $badgeKey) { + 'group:first_release' => 'Earned by publishing a first release.', + 'group:ten_releases' => 'Earned by publishing ten releases.', + 'group:hundred_published_artworks' => 'Earned by publishing one hundred group artworks.', + 'group:community_favorite' => 'Earned by sustained follower interest.', + 'group:consistent_activity' => 'Earned by staying active over recent weeks.', + 'group:event_host' => 'Earned by hosting multiple published events.', + 'group:challenge_organizer' => 'Earned by running multiple challenges.', + 'group:collaborative_group' => 'Earned by keeping several contributors active and releasing together.', + 'group:trusted_group' => 'Earned through repeated public releases and approved work.', + 'member:first_group_contribution' => 'Earned by making a first credited contribution to the group.', + 'member:ten_group_contributions' => 'Earned by making ten credited group contributions.', + 'member:release_contributor' => 'Earned by contributing to a group release.', + 'member:project_lead' => 'Earned by leading a group project.', + 'member:reliable_reviewer' => 'Earned through repeated group review actions.', + 'member:long_term_collaborator' => 'Earned through consistent long-term collaboration.', + 'member:founding_member' => 'Earned by helping the group from its early formation stage.', + 'member:asset_builder' => 'Earned by supplying multiple shared group assets.', + default => 'Earned through visible group activity.', + }; + } + + private function memberJoinedAt(Group $group, int $userId): ?string + { + if ((int) $group->owner_user_id === $userId) { + return $group->created_at?->toISOString(); + } + + $member = GroupMember::query() + ->where('group_id', $group->id) + ->where('user_id', $userId) + ->first(); + + return $member?->accepted_at?->toISOString() ?? $member?->created_at?->toISOString(); + } + + private function lastActiveContributionAt(Group $group, int $userId): ?string + { + $timestamps = collect([ + Artwork::query()->where('group_id', $group->id)->where('uploaded_by_user_id', $userId)->max('updated_at'), + GroupProject::query()->where('group_id', $group->id)->where('updated_at', '!=', null)->where(function ($query) use ($userId): void { + $query->where('lead_user_id', $userId) + ->orWhere('created_by_user_id', $userId) + ->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId)); + })->max('updated_at'), + GroupRelease::query()->where('group_id', $group->id)->where(function ($query) use ($userId): void { + $query->where('lead_user_id', $userId) + ->orWhere('created_by_user_id', $userId) + ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); + })->max('updated_at'), + ])->filter(); + + $latest = $timestamps->sortDesc()->first(); + + return $latest ? CarbonImmutable::parse((string) $latest)->toISOString() : null; + } + + private function isFoundingMember(Group $group, int $userId): bool + { + if ((int) $group->owner_user_id === $userId) { + return true; + } + + $member = GroupMember::query() + ->where('group_id', $group->id) + ->where('user_id', $userId) + ->first(); + + if (! $member?->accepted_at || ! $group->created_at) { + return false; + } + + return $member->accepted_at->lessThanOrEqualTo($group->created_at->copy()->addDays(30)); + } + + private function levelLabel(int $value, array $thresholds): string + { + $label = 'New'; + + foreach ($thresholds as $threshold => $candidate) { + if ($value >= $threshold) { + $label = $candidate; + } + } + + return $label; + } +} \ No newline at end of file diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php new file mode 100644 index 00000000..0c57fc5c --- /dev/null +++ b/app/Services/GroupService.php @@ -0,0 +1,834 @@ +where('slug', $slug) + ->when($ignoreGroupId !== null, fn ($query) => $query->where('id', '!=', $ignoreGroupId)) + ->exists()) { + $slug = Str::limit($base, 84, '') . '-' . $suffix; + $suffix++; + } + + return $slug; + } + + public function createGroup(User $owner, array $attributes): Group + { + $storedAvatarPath = null; + $storedBannerPath = null; + + try { + return DB::transaction(function () use ($owner, $attributes, &$storedAvatarPath, &$storedBannerPath): Group { + $group = new Group(); + $group->owner()->associate($owner); + $group->featured_artwork_id = null; + $group->is_verified = false; + $group->founded_at = $attributes['founded_at'] ?? null; + $group->name = (string) $attributes['name']; + $group->slug = $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name'])); + $group->headline = $attributes['headline'] ?? null; + $group->bio = $attributes['bio'] ?? null; + $group->type = $attributes['type'] ?? null; + $group->visibility = (string) ($attributes['visibility'] ?? Group::VISIBILITY_PUBLIC); + $group->status = Group::LIFECYCLE_ACTIVE; + $group->membership_policy = (string) ($attributes['membership_policy'] ?? Group::MEMBERSHIP_INVITE_ONLY); + $group->website_url = $attributes['website_url'] ?? null; + $group->links_json = $this->normalizeLinks($attributes['links_json'] ?? []); + $group->avatar_path = $this->normalizeMediaPath($attributes['avatar_path'] ?? null); + $group->banner_path = $this->normalizeMediaPath($attributes['banner_path'] ?? null); + $group->artworks_count = 0; + $group->collections_count = 0; + $group->followers_count = 0; + $group->last_activity_at = now(); + $group->save(); + + if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) { + $storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar'); + $group->avatar_path = $storedAvatarPath; + } + + if (($attributes['banner_file'] ?? null) instanceof UploadedFile) { + $storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner'); + $group->banner_path = $storedBannerPath; + } + + if ($storedAvatarPath !== null || $storedBannerPath !== null) { + $group->save(); + } + + $this->memberships->ensureOwnerMembership($group); + + return $group->fresh(['owner.profile']); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($storedAvatarPath); + $this->media->deleteIfManaged($storedBannerPath); + + throw $exception; + } + } + + public function updateGroup(Group $group, array $attributes, User $actor): Group + { + $storedAvatarPath = null; + $storedBannerPath = null; + $obsoleteAvatarPath = null; + $obsoleteBannerPath = null; + + try { + $updatedGroup = DB::transaction(function () use ($group, $attributes, $actor, &$storedAvatarPath, &$storedBannerPath, &$obsoleteAvatarPath, &$obsoleteBannerPath): Group { + $originalAvatarPath = $group->avatar_path; + $originalBannerPath = $group->banner_path; + + $group->fill([ + 'name' => (string) ($attributes['name'] ?? $group->name), + 'slug' => $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name'] ?? $group->slug), (int) $group->id), + 'headline' => $attributes['headline'] ?? null, + 'bio' => $attributes['bio'] ?? null, + 'type' => $attributes['type'] ?? $group->type, + 'visibility' => (string) ($attributes['visibility'] ?? $group->visibility), + 'membership_policy' => (string) ($attributes['membership_policy'] ?? $group->membership_policy ?? Group::MEMBERSHIP_INVITE_ONLY), + 'founded_at' => $attributes['founded_at'] ?? $group->founded_at, + 'website_url' => $attributes['website_url'] ?? null, + 'links_json' => $this->normalizeLinks($attributes['links_json'] ?? $group->links_json ?? []), + 'avatar_path' => array_key_exists('avatar_path', $attributes) ? $this->normalizeMediaPath($attributes['avatar_path']) : $group->avatar_path, + 'banner_path' => array_key_exists('banner_path', $attributes) ? $this->normalizeMediaPath($attributes['banner_path']) : $group->banner_path, + 'featured_artwork_id' => $this->normalizeFeaturedArtworkId($group, $attributes['featured_artwork_id'] ?? $group->featured_artwork_id), + 'last_activity_at' => now(), + ]); + $group->save(); + + if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) { + $storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar'); + $group->avatar_path = $storedAvatarPath; + } + + if (($attributes['banner_file'] ?? null) instanceof UploadedFile) { + $storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner'); + $group->banner_path = $storedBannerPath; + } + + if ($storedAvatarPath !== null || $storedBannerPath !== null) { + $group->save(); + } + + $this->memberships->ensureOwnerMembership($group); + + $obsoleteAvatarPath = $originalAvatarPath !== $group->avatar_path ? $originalAvatarPath : null; + $obsoleteBannerPath = $originalBannerPath !== $group->banner_path ? $originalBannerPath : null; + + return $group->fresh(['owner.profile']); + }); + } catch (\Throwable $exception) { + $this->media->deleteIfManaged($storedAvatarPath); + $this->media->deleteIfManaged($storedBannerPath); + + throw $exception; + } + + $this->media->deleteIfManaged($obsoleteAvatarPath); + $this->media->deleteIfManaged($obsoleteBannerPath); + + return $updatedGroup; + } + + public function syncArtworkCount(Group $group): void + { + $group->forceFill([ + 'artworks_count' => Artwork::query() + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->count(), + 'last_activity_at' => now(), + ])->save(); + } + + public function syncCollectionCount(Group $group): void + { + $group->forceFill([ + 'collections_count' => Collection::query() + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->count(), + 'last_activity_at' => now(), + ])->save(); + } + + public function studioOptionsForUser(User $user): array + { + $groups = Group::query() + ->with(['owner.profile', 'members']) + ->where('status', '!=', Group::LIFECYCLE_SUSPENDED) + ->where(function ($query) use ($user): void { + $query->where('owner_user_id', $user->id) + ->orWhereHas('members', function ($memberQuery) use ($user): void { + $memberQuery->where('user_id', $user->id) + ->where('status', Group::STATUS_ACTIVE); + }); + }) + ->orderBy('name') + ->get(); + + return $groups->map(function (Group $group) use ($user): array { + $canPublishArtworks = $group->canPublishArtworks($user); + $canSubmitArtworkForReview = $group->canSubmitArtworkForReview($user); + $canManageReleases = $group->canManageReleases($user); + $canViewReputation = $group->canViewReputationDashboard($user); + + return [ + 'id' => (int) $group->id, + 'name' => (string) $group->name, + 'slug' => (string) $group->slug, + 'role' => $group->activeRoleFor($user), + 'role_label' => Group::displayRole($group->activeRoleFor($user)), + 'status' => (string) ($group->status ?? Group::LIFECYCLE_ACTIVE), + 'avatar_url' => $group->avatarUrl(), + 'artworks_count' => (int) $group->artworks_count, + 'collections_count' => (int) $group->collections_count, + 'followers_count' => (int) $group->followers_count, + 'permissions' => [ + 'can_publish_artworks' => $canPublishArtworks, + 'can_submit_artwork_for_review' => $canSubmitArtworkForReview, + ], + 'public_url' => $group->publicUrl(), + 'studio_url' => route('studio.groups.show', ['group' => $group]), + 'studio_artworks_url' => route('studio.groups.artworks', ['group' => $group]), + 'studio_collections_url' => route('studio.groups.collections', ['group' => $group]), + 'studio_members_url' => route('studio.groups.members', ['group' => $group]), + 'studio_invitations_url' => route('studio.groups.invitations', ['group' => $group]), + 'studio_join_requests_url' => route('studio.groups.join-requests', ['group' => $group]), + 'studio_review_url' => route('studio.groups.review', ['group' => $group]), + 'studio_recruitment_url' => route('studio.groups.recruitment', ['group' => $group]), + 'studio_posts_url' => route('studio.groups.posts.index', ['group' => $group]), + 'studio_settings_url' => route('studio.groups.settings', ['group' => $group]), + 'studio_projects_url' => route('studio.groups.projects.index', ['group' => $group]), + 'studio_releases_url' => $canManageReleases ? route('studio.groups.releases.index', ['group' => $group]) : null, + 'studio_challenges_url' => route('studio.groups.challenges.index', ['group' => $group]), + 'studio_events_url' => route('studio.groups.events.index', ['group' => $group]), + 'studio_assets_url' => route('studio.groups.assets.index', ['group' => $group]), + 'studio_reputation_url' => $canViewReputation ? route('studio.groups.reputation', ['group' => $group]) : null, + 'studio_activity_url' => route('studio.groups.activity', ['group' => $group]), + 'upload_url' => ($canPublishArtworks || $canSubmitArtworkForReview) ? route('upload', ['group' => $group->slug]) : null, + 'collection_create_url' => route('settings.collections.create', ['group' => $group->slug]), + ]; + })->values()->all(); + } + + public function mapGroupCard(Group $group, ?User $viewer = null): array + { + return $this->cards->mapGroupCard($group, $viewer); + } + + public function mapGroupDetail(Group $group, ?User $viewer = null): array + { + $recruitment = $this->recruitment->payloadForGroup($group); + + return array_merge($this->mapGroupCard($group, $viewer), [ + 'website_url' => $group->website_url, + 'bio' => $group->bio, + 'links' => $this->normalizeLinks($group->links_json ?? []), + 'avatar_path' => $group->avatar_path, + 'banner_path' => $group->banner_path, + 'featured_artwork_id' => $group->featured_artwork_id ? (int) $group->featured_artwork_id : null, + 'founded_at' => $group->founded_at?->toISOString(), + 'last_activity_at' => $group->last_activity_at?->toISOString(), + 'created_at' => $group->created_at?->toISOString(), + 'current_join_request' => $this->joinRequests->currentRequestFor($group, $viewer), + 'recruitment' => $recruitment, + 'pinned_post' => $this->posts->pinnedPost($group), + 'featured_release' => $this->releases->featuredRelease($group, $viewer), + 'featured_project' => $this->projects->featuredProject($group, $viewer), + 'active_challenge' => $this->challenges->activeChallenge($group, $viewer), + 'upcoming_event' => $this->events->upcomingEvent($group, $viewer), + 'badge_showcase' => $this->reputation->groupBadges($group, 8), + 'top_contributors' => $this->reputation->topContributors($group, 6), + 'trust_signals' => $this->reputation->trustSignals($group), + ]); + } + + public function recentPostCards(Group $group, int $limit = 3): array + { + return $this->posts->recentPosts($group, $limit); + } + + public function recentProjectCards(Group $group, ?User $viewer = null, int $limit = 3): array + { + return $this->projects->publicListing($group, $viewer, $limit); + } + + public function recentReleaseCards(Group $group, ?User $viewer = null, int $limit = 3): array + { + return $this->releases->publicListing($group, $viewer, $limit); + } + + public function recentChallengeCards(Group $group, ?User $viewer = null, int $limit = 3): array + { + return $this->challenges->publicListing($group, $viewer, $limit); + } + + public function recentEventCards(Group $group, ?User $viewer = null, int $limit = 3): array + { + return $this->events->publicListing($group, $viewer, $limit); + } + + public function publicProjectListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->projects->publicListing($group, $viewer, $limit); + } + + public function publicReleaseListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->releases->publicListing($group, $viewer, $limit); + } + + public function publicChallengeListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->challenges->publicListing($group, $viewer, $limit); + } + + public function publicEventListing(Group $group, ?User $viewer = null, int $limit = 12): array + { + return $this->events->publicListing($group, $viewer, $limit); + } + + public function publicAssetListing(Group $group, int $limit = 12): array + { + return $this->assets->publicListing($group, $limit); + } + + public function publicActivityFeed(Group $group, int $limit = 8): array + { + return $this->activity->publicFeed($group, $limit); + } + + public function studioActivityFeed(Group $group, User $viewer, int $limit = 20): array + { + return $this->activity->studioFeed($group, $viewer, $limit); + } + + public function publicPostListing(Group $group, int $limit = 12): array + { + return $this->posts->publicPosts($group, $limit); + } + + public function recruitmentPayload(Group $group): ?array + { + return $this->recruitment->payloadForGroup($group); + } + + public function recentHistory(Group $group, int $limit = 8): array + { + return $this->history->recentFor($group, $limit); + } + + public function featuredArtworkCards(Group $group, int $limit = 4): array + { + $query = Artwork::query() + ->with(['user.profile', 'group', 'primaryAuthor.profile']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at'); + + if ((int) ($group->featured_artwork_id ?? 0) > 0) { + $featuredArtwork = (clone $query) + ->where('id', (int) $group->featured_artwork_id) + ->first(); + + $remaining = (clone $query) + ->where('id', '!=', (int) $group->featured_artwork_id) + ->latest('published_at') + ->limit(max($limit - ($featuredArtwork ? 1 : 0), 0)) + ->get(); + + return collect([$featuredArtwork]) + ->filter() + ->concat($remaining) + ->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork)) + ->values() + ->all(); + } + + return (clone $query) + ->latest('published_at') + ->limit($limit) + ->get() + ->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork)) + ->values() + ->all(); + } + + public function featuredCollectionCards(Group $group, ?User $viewer = null, int $limit = 3): array + { + $collections = Collection::query() + ->with(['user.profile', 'group', 'coverArtwork']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->where('is_featured', true) + ->latest('featured_at') + ->latest('updated_at') + ->limit($limit) + ->get() + ->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible()) + ->values(); + + return $this->collections->mapCollectionCardPayloads($collections, false, $viewer); + } + + public function mapLeadershipPreview(array $members, array $owner, int $limit = 4): array + { + $leadership = collect($members) + ->filter(fn (array $member): bool => in_array((string) ($member['role'] ?? ''), [Group::ROLE_OWNER, Group::ROLE_ADMIN], true)) + ->map(function (array $member): array { + return [ + 'id' => (int) ($member['user']['id'] ?? 0), + 'name' => $member['user']['name'] ?? null, + 'username' => $member['user']['username'] ?? null, + 'avatar_url' => $member['user']['avatar_url'] ?? null, + 'profile_url' => $member['user']['profile_url'] ?? null, + 'role' => (string) ($member['role'] ?? ''), + 'role_label' => $member['role_label'] ?? Group::displayRole((string) ($member['role'] ?? '')), + ]; + }) + ->unique('id') + ->values(); + + if ($leadership->isEmpty() && ! empty($owner['id'])) { + $leadership = collect([array_merge($owner, [ + 'role' => Group::ROLE_OWNER, + 'role_label' => Group::displayRole(Group::ROLE_OWNER), + ])]); + } + + return $leadership->take($limit)->all(); + } + + public function archiveGroup(Group $group, User $actor): Group + { + if (! $group->canArchive($actor) && ! $actor->isAdmin()) { + abort(403); + } + + $group->forceFill([ + 'status' => Group::LIFECYCLE_ARCHIVED, + 'last_activity_at' => now(), + ])->save(); + + return $group->fresh(['owner.profile', 'members']); + } + + public function publicArtworkCards(Group $group, int $limit = 18): array + { + return Artwork::query() + ->with(['user.profile', 'group', 'primaryAuthor.profile']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at') + ->latest('published_at') + ->limit($limit) + ->get() + ->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork)) + ->values() + ->all(); + } + + public function publicCollectionCards(Group $group, ?User $viewer = null, int $limit = 12): array + { + $collections = Collection::query() + ->with(['user.profile', 'group', 'coverArtwork']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->latest('updated_at') + ->limit($limit) + ->get() + ->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible()) + ->values(); + + return $this->collections->mapCollectionCardPayloads($collections, false, $viewer); + } + + private function mapPublicArtworkCard(Artwork $artwork): array + { + return [ + 'id' => (int) $artwork->id, + 'title' => (string) $artwork->title, + 'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]), + 'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'), + 'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork), + 'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist', + 'published_at' => $artwork->published_at?->toISOString(), + ]; + } + + public function studioDashboardSummary(Group $group): array + { + $artworkQuery = Artwork::query() + ->where('group_id', $group->id) + ->whereNull('deleted_at'); + + $collectionQuery = Collection::query() + ->where('group_id', $group->id) + ->whereNull('deleted_at'); + + return [ + 'draft_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'draft')->count(), + 'scheduled_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'scheduled')->count(), + 'published_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'published')->count(), + 'pending_reviews_count' => (clone $artworkQuery)->where('group_review_status', 'submitted')->count(), + 'draft_collections_count' => (clone $collectionQuery) + ->where(function ($builder): void { + $builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT) + ->orWhere('workflow_state', Collection::WORKFLOW_DRAFT) + ->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW); + }) + ->count(), + 'active_members_count' => (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count(), + 'pending_invites_count' => $this->memberships->pendingInviteCount($group), + 'pending_join_requests_count' => $this->joinRequests->pendingCount($group), + 'published_posts_count' => (int) $group->posts()->where('status', \App\Models\GroupPost::STATUS_PUBLISHED)->count(), + 'is_recruiting' => (bool) ($this->recruitment->payloadForGroup($group)['is_recruiting'] ?? false), + 'projects_count' => (int) $group->projects()->count(), + 'releases_count' => (int) $group->releases()->count(), + 'published_releases_count' => (int) $group->releases()->where('status', \App\Models\GroupRelease::STATUS_RELEASED)->count(), + 'active_challenges_count' => (int) $group->challenges()->whereIn('status', ['published', 'active'])->count(), + 'events_count' => (int) $group->events()->count(), + 'assets_count' => (int) $group->assets()->count(), + 'activity_count' => (int) $group->activityItems()->count(), + 'group_badges_count' => (int) $group->badges()->count(), + 'member_badges_count' => (int) $group->memberBadges()->count(), + 'trust_score' => (float) ($group->discoveryMetric?->trust_score ?? 0), + ]; + } + + public function studioArtworkPreviewItems(Group $group, string $bucket = 'all', int $limit = 6): array + { + $query = Artwork::query() + ->with(['user.profile', 'primaryAuthor.profile', 'stats']) + ->where('group_id', $group->id) + ->whereNull('deleted_at'); + + if ($bucket === 'drafts') { + $query->where('artwork_status', 'draft'); + } elseif ($bucket === 'scheduled') { + $query->where('artwork_status', 'scheduled'); + } elseif ($bucket === 'published') { + $query->where('artwork_status', 'published'); + } + + return $query->latest('updated_at') + ->limit($limit) + ->get() + ->map(fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork)) + ->values() + ->all(); + } + + public function studioFeaturedArtworkOptions(Group $group, int $limit = 24): array + { + return Artwork::query() + ->with(['user.profile', 'primaryAuthor.profile']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->where('artwork_status', 'published') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at') + ->latest('published_at') + ->limit($limit) + ->get() + ->map(fn (Artwork $artwork): array => [ + 'id' => (int) $artwork->id, + 'title' => (string) $artwork->title, + 'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username, + 'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'), + 'published_at' => $artwork->published_at?->toISOString(), + ]) + ->values() + ->all(); + } + + public function studioCollectionPreviewItems(Group $group, int $limit = 6): array + { + return Collection::query() + ->with(['user.profile', 'group', 'coverArtwork']) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->latest('updated_at') + ->limit($limit) + ->get() + ->map(fn (Collection $collection): array => $this->mapStudioCollectionItem($collection)) + ->values() + ->all(); + } + + public function studioArtworkListing(Group $group, array $filters = []): array + { + $bucket = (string) ($filters['bucket'] ?? 'all'); + $search = trim((string) ($filters['q'] ?? '')); + $page = max(1, (int) ($filters['page'] ?? 1)); + $perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48); + + $query = Artwork::query() + ->with(['user.profile', 'primaryAuthor.profile']) + ->where('group_id', $group->id) + ->whereNull('deleted_at'); + + if ($bucket === 'drafts') { + $query->where('artwork_status', 'draft'); + } elseif ($bucket === 'scheduled') { + $query->where('artwork_status', 'scheduled'); + } elseif ($bucket === 'published') { + $query->where('artwork_status', 'published'); + } + + if ($search !== '') { + $query->where(function ($builder) use ($search): void { + $builder->where('title', 'like', '%' . $search . '%') + ->orWhere('description', 'like', '%' . $search . '%'); + }); + } + + $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); + + return $this->mapStudioListing( + $paginator, + fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork), + [ + ['value' => 'all', 'label' => 'All'], + ['value' => 'published', 'label' => 'Published'], + ['value' => 'drafts', 'label' => 'Drafts'], + ['value' => 'scheduled', 'label' => 'Scheduled'], + ], + $bucket, + $search + ); + } + + public function studioCollectionListing(Group $group, array $filters = []): array + { + $bucket = (string) ($filters['bucket'] ?? 'all'); + $search = trim((string) ($filters['q'] ?? '')); + $page = max(1, (int) ($filters['page'] ?? 1)); + $perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48); + + $query = Collection::query() + ->with(['user.profile', 'group', 'coverArtwork']) + ->where('group_id', $group->id) + ->whereNull('deleted_at'); + + if ($bucket === 'drafts') { + $query->where(function ($builder): void { + $builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT) + ->orWhere('workflow_state', Collection::WORKFLOW_DRAFT) + ->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW); + }); + } elseif ($bucket === 'scheduled') { + $query->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED); + } elseif ($bucket === 'published') { + $query->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED]); + } + + if ($search !== '') { + $query->where(function ($builder) use ($search): void { + $builder->where('title', 'like', '%' . $search . '%') + ->orWhere('description', 'like', '%' . $search . '%'); + }); + } + + $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); + + return $this->mapStudioListing( + $paginator, + fn (Collection $collection): array => $this->mapStudioCollectionItem($collection), + [ + ['value' => 'all', 'label' => 'All'], + ['value' => 'published', 'label' => 'Published'], + ['value' => 'drafts', 'label' => 'Drafts'], + ['value' => 'scheduled', 'label' => 'Scheduled'], + ], + $bucket, + $search + ); + } + + private function mapStudioListing(LengthAwarePaginator $paginator, callable $mapper, array $bucketOptions, string $bucket, string $search): array + { + return [ + 'items' => collect($paginator->items())->map($mapper)->values()->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + 'filters' => [ + 'bucket' => $bucket, + 'q' => $search, + 'sort' => 'updated_desc', + ], + 'module_options' => [], + 'bucket_options' => $bucketOptions, + 'sort_options' => [ + ['value' => 'updated_desc', 'label' => 'Recently updated'], + ], + 'advanced_filters' => [], + 'default_view' => 'grid', + ]; + } + + private function mapStudioArtworkItem(Artwork $artwork): array + { + $status = (string) ($artwork->artwork_status ?: ($artwork->published_at ? 'published' : 'draft')); + + return [ + 'id' => 'artworks:' . (int) $artwork->id, + 'numeric_id' => (int) $artwork->id, + 'module' => 'artworks', + 'module_label' => 'Artworks', + 'module_icon' => 'fa-solid fa-images', + 'title' => (string) $artwork->title, + 'subtitle' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username, + 'description' => $artwork->description, + 'status' => $status, + 'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PRIVATE), + 'image_url' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'), + 'preview_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]), + 'view_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]), + 'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]), + 'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]), + 'analytics_url' => route('studio.artworks.analytics', ['id' => $artwork->id]), + 'created_at' => $artwork->created_at?->toISOString(), + 'updated_at' => $artwork->updated_at?->toISOString(), + 'published_at' => $artwork->published_at?->toISOString(), + 'metrics' => [ + 'views' => (int) ($artwork->stats?->views ?? 0), + 'appreciation' => (int) ($artwork->stats?->favorites ?? 0), + 'comments' => (int) $artwork->comments()->count(), + ], + 'actions' => [], + ]; + } + + private function mapStudioCollectionItem(Collection $collection): array + { + $mapped = $this->collections->mapCollectionCardPayloads([$collection->loadMissing(['user.profile', 'group', 'coverArtwork'])], true)[0]; + $status = $mapped['lifecycle_state'] === Collection::LIFECYCLE_FEATURED ? 'published' : ($mapped['lifecycle_state'] ?? 'draft'); + + return [ + 'id' => 'collections:' . (int) $collection->id, + 'numeric_id' => (int) $collection->id, + 'module' => 'collections', + 'module_label' => 'Collections', + 'module_icon' => 'fa-solid fa-layer-group', + 'title' => (string) $mapped['title'], + 'subtitle' => $mapped['subtitle'] ?: ucfirst((string) ($mapped['type'] ?? 'collection')), + 'description' => $mapped['summary'] ?: $mapped['description'], + 'status' => $status, + 'visibility' => (string) $mapped['visibility'], + 'image_url' => $mapped['cover_image'], + 'preview_url' => $mapped['url'], + 'view_url' => $mapped['url'], + 'edit_url' => $mapped['edit_url'] ?: $mapped['manage_url'], + 'manage_url' => $mapped['manage_url'], + 'analytics_url' => route('settings.collections.analytics', ['collection' => $collection->id]), + 'created_at' => ($mapped['published_at'] ?? null) ?: ($mapped['updated_at'] ?? null), + 'updated_at' => $mapped['updated_at'] ?? null, + 'published_at' => $mapped['published_at'] ?? null, + 'metrics' => [ + 'views' => (int) ($mapped['views_count'] ?? 0), + 'appreciation' => (int) (($mapped['likes_count'] ?? 0) + ($mapped['followers_count'] ?? 0)), + 'comments' => (int) ($mapped['comments_count'] ?? 0), + ], + 'actions' => [], + ]; + } + + private function normalizeLinks(mixed $links): array + { + $items = is_array($links) ? $links : []; + + return collect($items) + ->filter(fn ($item): bool => is_array($item)) + ->map(function (array $item): array { + return [ + 'label' => trim((string) ($item['label'] ?? '')), + 'url' => trim((string) ($item['url'] ?? '')), + ]; + }) + ->filter(fn (array $item): bool => $item['label'] !== '' && $item['url'] !== '') + ->values() + ->all(); + } + + private function normalizeMediaPath(mixed $path): ?string + { + $trimmed = trim((string) $path); + + return $trimmed !== '' ? $trimmed : null; + } + + private function normalizeFeaturedArtworkId(Group $group, mixed $featuredArtworkId): ?int + { + $id = (int) $featuredArtworkId; + + if ($id <= 0) { + return null; + } + + $exists = Artwork::query() + ->where('id', $id) + ->where('group_id', $group->id) + ->whereNull('deleted_at') + ->where('artwork_status', 'published') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at') + ->exists(); + + return $exists ? $id : null; + } +} \ No newline at end of file diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index be280490..57fc923e 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services; use App\Models\Artwork; +use App\Models\Leaderboard; use App\Models\Tag; use App\Services\ArtworkSearchService; use App\Services\EarlyGrowth\EarlyGrowth; @@ -14,10 +15,12 @@ use App\Services\UserPreferenceService; use App\Support\AvatarUrl; use App\Models\Collection as CollectionModel; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Database\QueryException; +use cPad\Plugins\News\Models\NewsArticle; /** * HomepageService @@ -45,6 +48,8 @@ final class HomepageService private readonly CollectionDiscoveryService $collectionDiscovery, private readonly CollectionService $collectionService, private readonly CollectionSurfaceService $collectionSurfaces, + private readonly GroupDiscoveryService $groupDiscovery, + private readonly LeaderboardService $leaderboards, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -65,6 +70,7 @@ final class HomepageService 'collections_trending' => $this->getTrendingCollections(), 'collections_editorial' => $this->getEditorialCollections(), 'collections_community' => $this->getCommunityCollections(), + 'groups' => $this->getHomepageGroups(), 'tags' => $this->getPopularTags(), 'creators' => $this->getCreatorSpotlight(), 'news' => $this->getNews(), @@ -101,6 +107,7 @@ final class HomepageService 'collections_trending' => $this->getTrendingCollections(), 'collections_editorial' => $this->getEditorialCollections(), 'collections_community' => $this->getCommunityCollections(), + 'groups' => $this->getHomepageGroups($user), 'by_tags' => $this->getByTags($prefs['top_tags'] ?? []), 'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []), 'suggested_creators' => $this->getSuggestedCreators($user, $prefs), @@ -236,6 +243,21 @@ final class HomepageService ); } + public function getHomepageGroups(?\App\Models\User $viewer = null): array + { + $featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4); + $spotlight = $featured[0] ?? null; + + return [ + 'spotlight' => $spotlight, + 'featured' => $featured, + 'recruiting' => $this->groupDiscovery->surfaceCards($viewer, 'recruiting', 4), + 'rising' => $this->groupDiscovery->surfaceCards($viewer, 'new_rising', 4), + 'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5), + 'count' => $this->groupDiscovery->publicGroupCount(), + ]; + } + // ───────────────────────────────────────────────────────────────────────── // Sections // ───────────────────────────────────────────────────────────────────────── @@ -515,6 +537,24 @@ final class HomepageService { return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array { try { + $articles = NewsArticle::query() + ->with('category') + ->published() + ->editorialOrder() + ->limit($limit) + ->get(); + + if ($articles->isNotEmpty()) { + return $articles->map(fn (NewsArticle $article) => [ + 'id' => $article->id, + 'title' => $article->title, + 'date' => $article->published_at, + 'url' => route('news.show', ['slug' => $article->slug]), + 'eyebrow' => $article->category?->name ?: $article->type_label, + 'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120), + ])->values()->all(); + } + $items = DB::table('forum_threads as t') ->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id') ->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug') @@ -528,10 +568,10 @@ final class HomepageService ->get(); return $items->map(fn ($row) => [ - 'id' => $row->id, + 'id' => $row->id, 'title' => $row->title, - 'date' => $row->created_at, - 'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'), + 'date' => $row->created_at, + 'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'), ])->values()->all(); } catch (QueryException $e) { Log::warning('HomepageService::getNews DB error', [ diff --git a/app/Services/LeaderboardService.php b/app/Services/LeaderboardService.php index e9de320e..ad2c868e 100644 --- a/app/Services/LeaderboardService.php +++ b/app/Services/LeaderboardService.php @@ -6,6 +6,7 @@ namespace App\Services; use App\Models\Artwork; use App\Models\ArtworkMetricSnapshotHourly; +use App\Models\Group; use App\Models\Leaderboard; use App\Models\Story; use App\Models\StoryLike; @@ -22,6 +23,11 @@ class LeaderboardService private const CREATOR_STORE_LIMIT = 10000; private const ENTITY_STORE_LIMIT = 500; + public function __construct( + private readonly GroupReputationService $groupReputation, + ) { + } + public function calculateCreatorLeaderboard(string $period): int { $normalizedPeriod = $this->normalizePeriod($period); @@ -52,6 +58,16 @@ class LeaderboardService return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); } + public function calculateGroupLeaderboard(string $period): int + { + $normalizedPeriod = $this->normalizePeriod($period); + $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME + ? $this->allTimeGroupRows() + : $this->windowedGroupRows($this->periodStart($normalizedPeriod)); + + return $this->persistRows(Leaderboard::TYPE_GROUP, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); + } + public function refreshAll(): array { $results = []; @@ -59,12 +75,14 @@ class LeaderboardService foreach ([ Leaderboard::TYPE_CREATOR, Leaderboard::TYPE_ARTWORK, + Leaderboard::TYPE_GROUP, Leaderboard::TYPE_STORY, ] as $type) { foreach ($this->periods() as $period) { $results[$type][$period] = match ($type) { Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period), Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period), + Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period), Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period), }; } @@ -83,14 +101,12 @@ class LeaderboardService $this->cacheKey($normalizedType, $normalizedPeriod, $limit), self::CACHE_TTL_SECONDS, function () use ($normalizedType, $normalizedPeriod, $limit): array { - $items = Leaderboard::query() - ->where('type', $normalizedType) - ->where('period', $normalizedPeriod) - ->orderByDesc('score') - ->orderBy('entity_id') - ->limit($limit) - ->get(['entity_id', 'score']) - ->values(); + $items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit); + + if ($items->isEmpty()) { + $this->generateLeaderboard($normalizedType, $normalizedPeriod); + $items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit); + } if ($items->isEmpty()) { return [ @@ -103,6 +119,7 @@ class LeaderboardService $entities = match ($normalizedType) { Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()), Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()), + Leaderboard::TYPE_GROUP => $this->groupEntities($items->pluck('entity_id')->all()), Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()), }; @@ -186,11 +203,34 @@ class LeaderboardService return match (strtolower(trim($type))) { 'creator', 'creators' => Leaderboard::TYPE_CREATOR, 'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK, + 'group', 'groups' => Leaderboard::TYPE_GROUP, 'story', 'stories' => Leaderboard::TYPE_STORY, default => Leaderboard::TYPE_CREATOR, }; } + private function leaderboardRows(string $type, string $period, int $limit): Collection + { + return Leaderboard::query() + ->where('type', $type) + ->where('period', $period) + ->orderByDesc('score') + ->orderBy('entity_id') + ->limit($limit) + ->get(['entity_id', 'score']) + ->values(); + } + + private function generateLeaderboard(string $type, string $period): void + { + match ($type) { + Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period), + Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period), + Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period), + Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period), + }; + } + private function periodStart(string $period): CarbonImmutable { $now = CarbonImmutable::now(); @@ -465,6 +505,194 @@ class LeaderboardService ->values(); } + private function allTimeGroupRows(): Collection + { + $members = DB::table('group_members') + ->select('group_id', DB::raw('COUNT(*) as members_count')) + ->where('status', Group::STATUS_ACTIVE) + ->groupBy('group_id'); + + $releases = DB::table('group_releases') + ->select('group_id', DB::raw('COUNT(*) as releases_count')) + ->where('visibility', 'public') + ->where('status', 'released') + ->groupBy('group_id'); + + $projects = DB::table('group_projects') + ->select('group_id', DB::raw('COUNT(*) as projects_count')) + ->where('visibility', 'public') + ->whereIn('status', ['active', 'review', 'released']) + ->groupBy('group_id'); + + $challenges = DB::table('group_challenges') + ->select('group_id', DB::raw('COUNT(*) as challenges_count')) + ->where('visibility', 'public') + ->whereIn('status', ['published', 'active']) + ->groupBy('group_id'); + + $events = DB::table('group_events') + ->select('group_id', DB::raw('COUNT(*) as events_count')) + ->where('visibility', 'public') + ->where('status', 'published') + ->groupBy('group_id'); + + $activity = DB::table('group_activity_items') + ->select('group_id', DB::raw('COUNT(*) as activity_count')) + ->where('visibility', 'public') + ->groupBy('group_id'); + + return Group::query() + ->from('groups') + ->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id') + ->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id') + ->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id') + ->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id') + ->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id') + ->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id') + ->public() + ->select([ + 'groups.id', + 'groups.followers_count', + 'groups.artworks_count', + 'groups.collections_count', + 'groups.is_verified', + DB::raw('COALESCE(members.members_count, 0) as members_count'), + DB::raw('COALESCE(releases.releases_count, 0) as releases_count'), + DB::raw('COALESCE(projects.projects_count, 0) as projects_count'), + DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'), + DB::raw('COALESCE(events.events_count, 0) as events_count'), + DB::raw('COALESCE(activity.activity_count, 0) as activity_count'), + ]) + ->get() + ->map(function ($row): array { + $score = ((int) $row->followers_count * 8) + + ((int) $row->artworks_count * 10) + + ((int) $row->collections_count * 6) + + ((int) $row->members_count * 20) + + ((int) $row->releases_count * 30) + + ((int) $row->projects_count * 24) + + ((int) $row->challenges_count * 18) + + ((int) $row->events_count * 14) + + ((int) $row->activity_count * 4) + + ((bool) $row->is_verified ? 120 : 0); + + return [ + 'entity_id' => (int) $row->id, + 'score' => $score, + ]; + }) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function windowedGroupRows(CarbonImmutable $start): Collection + { + $follows = DB::table('group_follows') + ->select('group_id', DB::raw('COUNT(*) as follows_count')) + ->where('created_at', '>=', $start) + ->groupBy('group_id'); + + $artworks = DB::table('artworks') + ->select('group_id', DB::raw('COUNT(*) as artworks_count')) + ->whereNotNull('group_id') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->where('published_at', '>=', $start) + ->groupBy('group_id'); + + $releases = DB::table('group_releases') + ->select('group_id', DB::raw('COUNT(*) as releases_count')) + ->where('visibility', 'public') + ->where('status', 'released') + ->where('released_at', '>=', $start) + ->groupBy('group_id'); + + $projects = DB::table('group_projects') + ->select('group_id', DB::raw('COUNT(*) as projects_count')) + ->where('visibility', 'public') + ->whereIn('status', ['active', 'review', 'released']) + ->where('updated_at', '>=', $start) + ->groupBy('group_id'); + + $challenges = DB::table('group_challenges') + ->select('group_id', DB::raw('COUNT(*) as challenges_count')) + ->where('visibility', 'public') + ->whereIn('status', ['published', 'active']) + ->where(function ($query) use ($start): void { + $query->where('updated_at', '>=', $start) + ->orWhere('start_at', '>=', $start) + ->orWhere('created_at', '>=', $start); + }) + ->groupBy('group_id'); + + $events = DB::table('group_events') + ->select('group_id', DB::raw('COUNT(*) as events_count')) + ->where('visibility', 'public') + ->where('status', 'published') + ->where(function ($query) use ($start): void { + $query->where('published_at', '>=', $start) + ->orWhere('start_at', '>=', $start) + ->orWhere('updated_at', '>=', $start); + }) + ->groupBy('group_id'); + + $activity = DB::table('group_activity_items') + ->select('group_id', DB::raw('COUNT(*) as activity_count')) + ->where('visibility', 'public') + ->where('occurred_at', '>=', $start) + ->groupBy('group_id'); + + $members = DB::table('group_members') + ->select('group_id', DB::raw('COUNT(*) as members_count')) + ->where('status', Group::STATUS_ACTIVE) + ->groupBy('group_id'); + + return Group::query() + ->from('groups') + ->leftJoinSub($follows, 'follows', 'follows.group_id', '=', 'groups.id') + ->leftJoinSub($artworks, 'artworks', 'artworks.group_id', '=', 'groups.id') + ->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id') + ->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id') + ->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id') + ->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id') + ->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id') + ->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id') + ->public() + ->select([ + 'groups.id', + 'groups.is_verified', + DB::raw('COALESCE(follows.follows_count, 0) as follows_count'), + DB::raw('COALESCE(artworks.artworks_count, 0) as artworks_count'), + DB::raw('COALESCE(releases.releases_count, 0) as releases_count'), + DB::raw('COALESCE(projects.projects_count, 0) as projects_count'), + DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'), + DB::raw('COALESCE(events.events_count, 0) as events_count'), + DB::raw('COALESCE(activity.activity_count, 0) as activity_count'), + DB::raw('COALESCE(members.members_count, 0) as members_count'), + ]) + ->get() + ->map(function ($row): array { + $score = ((int) $row->follows_count * 18) + + ((int) $row->artworks_count * 16) + + ((int) $row->releases_count * 34) + + ((int) $row->projects_count * 22) + + ((int) $row->challenges_count * 20) + + ((int) $row->events_count * 16) + + ((int) $row->activity_count * 6) + + ((int) $row->members_count * 8) + + ((bool) $row->is_verified ? 45 : 0); + + return [ + 'entity_id' => (int) $row->id, + 'score' => $score, + ]; + }) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder { return ArtworkMetricSnapshotHourly::query() @@ -472,15 +700,26 @@ class LeaderboardService ->where('snapshots.bucket_hour', '>=', $start) ->select([ 'snapshots.artwork_id', - DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'), - DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'), - DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'), - DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'), + DB::raw($this->nonNegativeSnapshotDelta('views_count', 'views_delta')), + DB::raw($this->nonNegativeSnapshotDelta('downloads_count', 'downloads_delta')), + DB::raw($this->nonNegativeSnapshotDelta('favourites_count', 'favourites_delta')), + DB::raw($this->nonNegativeSnapshotDelta('comments_count', 'comments_delta')), ]) ->groupBy('snapshots.artwork_id') ->toBase(); } + private function nonNegativeSnapshotDelta(string $column, string $alias): string + { + $delta = sprintf('MAX(snapshots.%1$s) - MIN(snapshots.%1$s)', $column); + + if (DB::connection()->getDriverName() === 'sqlite') { + return sprintf('CASE WHEN %1$s > 0 THEN %1$s ELSE 0 END as %2$s', $delta, $alias); + } + + return sprintf('GREATEST(%s, 0) as %s', $delta, $alias); + } + private function creatorEntities(array $ids): array { return User::query() @@ -550,4 +789,34 @@ class LeaderboardService ]) ->all(); } + + private function groupEntities(array $ids): array + { + return Group::query() + ->with(['owner.profile', 'recruitmentProfile', 'badges', 'members']) + ->whereIn('id', $ids) + ->public() + ->get() + ->mapWithKeys(function (Group $group): array { + return [ + (int) $group->id => [ + 'id' => (int) $group->id, + 'type' => Leaderboard::TYPE_GROUP, + 'name' => (string) $group->name, + 'headline' => (string) ($group->headline ?? ''), + 'url' => $group->publicUrl(), + 'avatar' => $group->avatarUrl(), + 'image' => $group->bannerUrl() ?: $group->avatarUrl(), + 'followers_count' => (int) ($group->followers_count ?? 0), + 'artworks_count' => (int) ($group->artworks_count ?? 0), + 'collections_count' => (int) ($group->collections_count ?? 0), + 'members_count' => (int) $group->members->where('status', Group::STATUS_ACTIVE)->count(), + 'is_recruiting' => (bool) ($group->recruitmentProfile?->is_recruiting ?? false), + 'trust_signals' => $this->groupReputation->trustSignals($group), + 'badges' => $this->groupReputation->groupBadges($group, 3), + ], + ]; + }) + ->all(); + } } diff --git a/app/Services/News/NewsService.php b/app/Services/News/NewsService.php new file mode 100644 index 00000000..b8fd29ea --- /dev/null +++ b/app/Services/News/NewsService.php @@ -0,0 +1,802 @@ + 'Group', + self::RELATION_ARTWORK => 'Artwork', + self::RELATION_COLLECTION => 'Collection', + self::RELATION_RELEASE => 'Release', + self::RELATION_PROJECT => 'Project', + self::RELATION_CHALLENGE => 'Challenge', + self::RELATION_EVENT => 'Event', + self::RELATION_USER => 'Profile', + ]; + + public function articleTypeOptions(): array + { + return \collect(NewsArticle::TYPE_LABELS) + ->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label]) + ->values() + ->all(); + } + + public function editorialStatusOptions(): array + { + return [ + ['value' => NewsArticle::EDITORIAL_STATUS_DRAFT, 'label' => 'Draft'], + ['value' => NewsArticle::EDITORIAL_STATUS_IN_REVIEW, 'label' => 'In review'], + ['value' => NewsArticle::EDITORIAL_STATUS_SCHEDULED, 'label' => 'Scheduled'], + ['value' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, 'label' => 'Published'], + ['value' => NewsArticle::EDITORIAL_STATUS_ARCHIVED, 'label' => 'Archived'], + ]; + } + + public function relationTypeOptions(): array + { + return \collect(self::RELATION_LABELS) + ->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label]) + ->values() + ->all(); + } + + public function categoryOptions(): array + { + return NewsCategory::query() + ->ordered() + ->get(['id', 'name']) + ->map(fn (NewsCategory $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + ]) + ->all(); + } + + public function tagOptions(): array + { + return NewsTag::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (NewsTag $tag): array => [ + 'id' => (int) $tag->id, + 'name' => (string) $tag->name, + ]) + ->all(); + } + + public function sidebarData(): array + { + return [ + 'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(), + 'trending' => NewsArticle::published() + ->with('category') + ->orderByDesc('views') + ->limit(config('news.trending_limit', 5)) + ->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']), + 'tags' => NewsTag::whereHas('articles', fn ($query) => $query->published())->orderBy('name')->get(), + ]; + } + + public function studioListing(array $filters = []): array + { + $query = NewsArticle::query() + ->with(['author:id,username,name', 'category:id,name,slug', 'tags:id,name,slug']) + ->editorialOrder(); + + $status = trim((string) ($filters['status'] ?? '')); + $type = trim((string) ($filters['type'] ?? '')); + $categoryId = (int) ($filters['category_id'] ?? 0); + $search = trim((string) ($filters['q'] ?? '')); + $perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15))); + + if ($status !== '') { + $query->where('editorial_status', $status); + } + + if ($type !== '') { + $query->where('type', $type); + } + + if ($categoryId > 0) { + $query->where('category_id', $categoryId); + } + + if ($search !== '') { + $query->where(function (Builder $builder) use ($search): void { + $builder->where('title', 'like', '%' . $search . '%') + ->orWhere('excerpt', 'like', '%' . $search . '%') + ->orWhere('content', 'like', '%' . $search . '%') + ->orWhere('meta_title', 'like', '%' . $search . '%'); + }); + } + + $paginator = $query->paginate($perPage)->withQueryString(); + + return [ + 'items' => $paginator->getCollection()->map(fn (NewsArticle $article): array => $this->mapStudioListItem($article))->all(), + 'meta' => $this->paginationMeta($paginator), + 'filters' => [ + 'q' => $search, + 'status' => $status, + 'type' => $type, + 'category_id' => $categoryId > 0 ? $categoryId : '', + 'per_page' => $perPage, + ], + ]; + } + + public function mapStudioArticle(NewsArticle $article, ?User $viewer = null): array + { + $article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']); + + return [ + 'id' => (int) $article->id, + 'title' => (string) $article->title, + 'slug' => (string) $article->slug, + 'excerpt' => (string) ($article->excerpt ?? ''), + 'content' => (string) ($article->content ?? ''), + 'cover_image' => (string) ($article->cover_image ?? ''), + 'cover_url' => $article->cover_url, + 'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT), + 'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT), + 'published_at' => \optional($article->published_at)?->toIso8601String(), + 'is_featured' => (bool) $article->is_featured, + 'is_pinned' => (bool) ($article->is_pinned ?? false), + 'category_id' => $article->category_id ? (int) $article->category_id : null, + 'author_id' => (int) $article->author_id, + 'author' => $article->author ? $this->mapUserLookupResult($article->author) : null, + 'tag_ids' => $article->tags->pluck('id')->map(fn (mixed $id): int => (int) $id)->all(), + 'meta_title' => (string) ($article->meta_title ?? ''), + 'meta_description' => (string) ($article->meta_description ?? ''), + 'meta_keywords' => (string) ($article->meta_keywords ?? ''), + 'canonical_url' => (string) ($article->canonical_url ?? ''), + 'og_title' => (string) ($article->og_title ?? ''), + 'og_description' => (string) ($article->og_description ?? ''), + 'og_image' => (string) ($article->og_image ?? ''), + 'relations' => $article->relatedEntities + ->map(fn (NewsArticleRelation $relation): array => [ + 'entity_type' => (string) $relation->entity_type, + 'entity_id' => (int) $relation->entity_id, + 'context_label' => (string) ($relation->context_label ?? ''), + 'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer), + ]) + ->values() + ->all(), + ]; + } + + public function storeArticle(User $editor, array $data): NewsArticle + { + $article = new NewsArticle(); + $article->author_id = (int) ($data['author_id'] ?? $editor->id); + + return $this->persistArticle($article, $editor, $data); + } + + public function updateArticle(NewsArticle $article, User $editor, array $data): NewsArticle + { + return $this->persistArticle($article, $editor, $data); + } + + public function publish(NewsArticle $article): NewsArticle + { + $article->forceFill([ + 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, + 'status' => 'published', + 'published_at' => $article->published_at ?? \now(), + ])->save(); + + return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); + } + + public function archive(NewsArticle $article): NewsArticle + { + $article->forceFill([ + 'editorial_status' => NewsArticle::EDITORIAL_STATUS_ARCHIVED, + 'status' => 'draft', + ])->save(); + + return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); + } + + public function toggleFeature(NewsArticle $article): NewsArticle + { + $article->forceFill(['is_featured' => ! $article->is_featured])->save(); + + return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); + } + + public function togglePin(NewsArticle $article): NewsArticle + { + $article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save(); + + return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); + } + + public function searchEntities(string $type, string $query, ?User $viewer = null): array + { + $type = trim(Str::lower($type)); + $query = trim($query); + + return match ($type) { + self::RELATION_GROUP => $this->searchGroups($query, $viewer), + self::RELATION_ARTWORK => $this->searchArtworks($query), + self::RELATION_COLLECTION => $this->searchCollections($query, $viewer), + self::RELATION_RELEASE => $this->searchReleases($query, $viewer), + self::RELATION_PROJECT => $this->searchProjects($query, $viewer), + self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer), + self::RELATION_EVENT => $this->searchEvents($query, $viewer), + self::RELATION_USER => $this->searchUsers($query), + default => [], + }; + } + + public function resolveRelatedEntities(NewsArticle $article, ?User $viewer = null): array + { + $article->loadMissing('relatedEntities'); + + return $article->relatedEntities + ->map(fn (NewsArticleRelation $relation): ?array => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''))) + ->filter() + ->values() + ->all(); + } + + public function syncRelations(NewsArticle $article, array $relations): void + { + $normalized = \collect($relations) + ->map(function (array $relation): ?array { + $entityType = trim(Str::lower((string) ($relation['entity_type'] ?? ''))); + $entityId = (int) ($relation['entity_id'] ?? 0); + + if (! array_key_exists($entityType, self::RELATION_LABELS) || $entityId < 1) { + return null; + } + + return [ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''), + ]; + }) + ->filter() + ->unique(fn (array $relation): string => $relation['entity_type'] . ':' . $relation['entity_id']) + ->values(); + + $article->relatedEntities()->delete(); + + foreach ($normalized as $index => $relation) { + $article->relatedEntities()->create([ + 'entity_type' => $relation['entity_type'], + 'entity_id' => $relation['entity_id'], + 'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null, + 'sort_order' => $index, + ]); + } + } + + private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle + { + $title = trim((string) ($data['title'] ?? $article->title ?? 'Untitled News Article')); + if ($title === '') { + $title = 'Untitled News Article'; + } + + $editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT)); + $publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at); + $authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id); + + $article->fill([ + 'title' => $title, + 'slug' => $this->resolveSlug($title, $article, $data), + 'excerpt' => $this->nullableText($data['excerpt'] ?? null), + 'content' => (string) ($data['content'] ?? ''), + 'cover_image' => $this->nullableText($data['cover_image'] ?? null), + 'type' => (string) ($data['type'] ?? NewsArticle::TYPE_ANNOUNCEMENT), + 'author_id' => $authorId, + 'category_id' => ! empty($data['category_id']) ? (int) $data['category_id'] : null, + 'editorial_status' => $editorialStatus, + 'status' => $this->legacyStatusFor($editorialStatus), + 'published_at' => $publishedAt, + 'is_featured' => (bool) ($data['is_featured'] ?? false), + 'is_pinned' => (bool) ($data['is_pinned'] ?? false), + 'meta_title' => $this->nullableText($data['meta_title'] ?? null), + 'meta_description' => $this->nullableText($data['meta_description'] ?? null), + 'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null), + 'canonical_url' => $this->nullableText($data['canonical_url'] ?? null), + 'og_title' => $this->nullableText($data['og_title'] ?? null), + 'og_description' => $this->nullableText($data['og_description'] ?? null), + 'og_image' => $this->nullableText($data['og_image'] ?? null), + ]); + + $article->save(); + + $article->tags()->sync(\collect($data['tag_ids'] ?? [])->map(fn (mixed $id): int => (int) $id)->filter()->all()); + $this->syncRelations($article, $data['relations'] ?? []); + + return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']); + } + + private function mapStudioListItem(NewsArticle $article): array + { + return [ + 'id' => (int) $article->id, + 'title' => (string) $article->title, + 'slug' => (string) $article->slug, + 'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT), + 'type_label' => (string) $article->type_label, + 'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT), + 'published_at' => \optional($article->published_at)?->toIso8601String(), + 'cover_url' => $article->cover_url, + 'author_name' => (string) ($article->author?->name ?? 'Skinbase'), + 'category_name' => (string) ($article->category?->name ?? ''), + 'is_featured' => (bool) $article->is_featured, + 'is_pinned' => (bool) ($article->is_pinned ?? false), + 'views' => (int) $article->views, + 'edit_url' => route('studio.news.edit', ['article' => $article->id]), + 'preview_url' => route('studio.news.preview', ['article' => $article->id]), + 'public_url' => route('news.show', ['slug' => $article->slug]), + ]; + } + + private function paginationMeta(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ]; + } + + private function resolveSlug(string $title, NewsArticle $article, array $data): string + { + $requested = trim(Str::slug((string) ($data['slug'] ?? ''))); + + if ($requested !== '' && $requested !== (string) $article->slug) { + return NewsArticle::generateUniqueSlug($requested, $article->exists ? (int) $article->id : null); + } + + if ($article->exists && trim((string) $article->slug) !== '') { + return (string) $article->slug; + } + + return NewsArticle::generateUniqueSlug($title, $article->exists ? (int) $article->id : null); + } + + private function normalizeEditorialStatus(string $status): string + { + return in_array($status, array_column($this->editorialStatusOptions(), 'value'), true) + ? $status + : NewsArticle::EDITORIAL_STATUS_DRAFT; + } + + private function normalizePublishedAt(string $editorialStatus, mixed $value): ?Carbon + { + if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_PUBLISHED) { + return $value ? Carbon::parse((string) $value) : \now(); + } + + if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_SCHEDULED) { + return $value ? Carbon::parse((string) $value) : \now()->addHour(); + } + + if ($value instanceof Carbon) { + return $value; + } + + return $value ? Carbon::parse((string) $value) : null; + } + + private function legacyStatusFor(string $editorialStatus): string + { + return match ($editorialStatus) { + NewsArticle::EDITORIAL_STATUS_PUBLISHED => 'published', + NewsArticle::EDITORIAL_STATUS_SCHEDULED => 'scheduled', + default => 'draft', + }; + } + + private function nullableText(mixed $value): ?string + { + $text = trim((string) ($value ?? '')); + + return $text === '' ? null : $text; + } + + private function searchGroups(string $query, ?User $viewer): array + { + return Group::query() + ->with('owner') + ->where('visibility', Group::VISIBILITY_PUBLIC) + ->when($query !== '', function (Builder $builder) use ($query): void { + $builder->where(function (Builder $nested) use ($query): void { + $nested->where('name', 'like', '%' . $query . '%') + ->orWhere('slug', 'like', '%' . $query . '%') + ->orWhere('headline', 'like', '%' . $query . '%'); + }); + }) + ->orderByDesc('followers_count') + ->limit(8) + ->get() + ->map(fn (Group $group): ?array => $this->resolveGroupPreview((int) $group->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchArtworks(string $query): array + { + return Artwork::query() + ->with(['user.profile']) + ->where('artwork_status', 'published') + ->where('visibility', Artwork::VISIBILITY_PUBLIC) + ->when($query !== '', function (Builder $builder) use ($query): void { + $builder->where(function (Builder $nested) use ($query): void { + $nested->where('title', 'like', '%' . $query . '%') + ->orWhere('slug', 'like', '%' . $query . '%') + ->orWhere('description', 'like', '%' . $query . '%'); + }); + }) + ->orderByDesc('views') + ->limit(8) + ->get() + ->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, '')) + ->filter() + ->values() + ->all(); + } + + private function searchCollections(string $query, ?User $viewer): array + { + return Collection::query() + ->with(['user', 'coverArtwork']) + ->public() + ->when($query !== '', function (Builder $builder) use ($query): void { + $builder->where(function (Builder $nested) use ($query): void { + $nested->where('title', 'like', '%' . $query . '%') + ->orWhere('slug', 'like', '%' . $query . '%') + ->orWhere('summary', 'like', '%' . $query . '%'); + }); + }) + ->orderByDesc('followers_count') + ->limit(8) + ->get() + ->map(fn (Collection $collection): ?array => $this->resolveCollectionPreview((int) $collection->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchReleases(string $query, ?User $viewer): array + { + return GroupRelease::query() + ->with('group') + ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) + ->orderByDesc('published_at') + ->limit(8) + ->get() + ->map(fn (GroupRelease $release): ?array => $this->resolveReleasePreview((int) $release->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchProjects(string $query, ?User $viewer): array + { + return GroupProject::query() + ->with('group') + ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) + ->orderByDesc('updated_at') + ->limit(8) + ->get() + ->map(fn (GroupProject $project): ?array => $this->resolveProjectPreview((int) $project->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchChallenges(string $query, ?User $viewer): array + { + return GroupChallenge::query() + ->with('group') + ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) + ->orderByDesc('start_at') + ->limit(8) + ->get() + ->map(fn (GroupChallenge $challenge): ?array => $this->resolveChallengePreview((int) $challenge->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchEvents(string $query, ?User $viewer): array + { + return GroupEvent::query() + ->with('group') + ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) + ->orderByDesc('start_at') + ->limit(8) + ->get() + ->map(fn (GroupEvent $event): ?array => $this->resolveEventPreview((int) $event->id, $viewer, '')) + ->filter() + ->values() + ->all(); + } + + private function searchUsers(string $query): array + { + return User::query() + ->with('profile') + ->when($query !== '', function (Builder $builder) use ($query): void { + $builder->where(function (Builder $nested) use ($query): void { + $nested->where('username', 'like', '%' . $query . '%') + ->orWhere('name', 'like', '%' . $query . '%'); + }); + }) + ->orderBy('username') + ->limit(8) + ->get() + ->map(fn (User $user): array => $this->mapUserLookupResult($user)) + ->values() + ->all(); + } + + private function resolveEntityPreview(string $type, int $entityId, ?User $viewer = null, string $contextLabel = ''): ?array + { + return match ($type) { + self::RELATION_GROUP => $this->resolveGroupPreview($entityId, $viewer, $contextLabel), + self::RELATION_ARTWORK => $this->resolveArtworkPreview($entityId, $contextLabel), + self::RELATION_COLLECTION => $this->resolveCollectionPreview($entityId, $viewer, $contextLabel), + self::RELATION_RELEASE => $this->resolveReleasePreview($entityId, $viewer, $contextLabel), + self::RELATION_PROJECT => $this->resolveProjectPreview($entityId, $viewer, $contextLabel), + self::RELATION_CHALLENGE => $this->resolveChallengePreview($entityId, $viewer, $contextLabel), + self::RELATION_EVENT => $this->resolveEventPreview($entityId, $viewer, $contextLabel), + self::RELATION_USER => $this->resolveUserPreview($entityId, $contextLabel), + default => null, + }; + } + + private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $group = Group::query()->with('owner')->find($entityId); + + if (! $group || ! $group->canBeViewedBy($viewer)) { + return null; + } + + return [ + 'id' => (int) $group->id, + 'entity_type' => self::RELATION_GROUP, + 'entity_label' => self::RELATION_LABELS[self::RELATION_GROUP], + 'title' => (string) $group->name, + 'subtitle' => '@' . $group->slug, + 'description' => Str::limit((string) ($group->headline ?: $group->bio ?: ''), 120), + 'url' => $group->publicUrl(), + 'image' => $group->bannerUrl(), + 'avatar' => $group->avatarUrl(), + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related Group', + 'meta' => array_values(array_filter([ + (int) $group->artworks_count > 0 ? number_format((int) $group->artworks_count) . ' artworks' : null, + (int) $group->followers_count > 0 ? number_format((int) $group->followers_count) . ' followers' : null, + ])), + ]; + } + + private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array + { + $artwork = Artwork::query()->with(['user.profile'])->find($entityId); + + if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) { + return null; + } + + return [ + 'id' => (int) $artwork->id, + 'entity_type' => self::RELATION_ARTWORK, + 'entity_label' => self::RELATION_LABELS[self::RELATION_ARTWORK], + 'title' => (string) ($artwork->title ?: 'Untitled artwork'), + 'subtitle' => $artwork->user?->username ? '@' . $artwork->user->username : null, + 'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120), + 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]), + 'image' => $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md'), + 'avatar' => null, + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Mentioned artwork', + 'meta' => array_values(array_filter([ + (int) $artwork->views > 0 ? number_format((int) $artwork->views) . ' views' : null, + $artwork->categories()->first()?->name, + ])), + ]; + } + + private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $collection = Collection::query()->with(['user', 'coverArtwork'])->find($entityId); + + if (! $collection || ! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) { + return null; + } + + return [ + 'id' => (int) $collection->id, + 'entity_type' => self::RELATION_COLLECTION, + 'entity_label' => self::RELATION_LABELS[self::RELATION_COLLECTION], + 'title' => (string) $collection->title, + 'subtitle' => '@' . $collection->user->username, + 'description' => Str::limit((string) ($collection->summary ?: $collection->description ?: ''), 120), + 'url' => route('profile.collections.show', ['username' => $collection->user->username, 'slug' => $collection->slug]), + 'image' => $collection->coverArtwork?->thumbUrl('lg') ?? $collection->coverArtwork?->thumbUrl('md'), + 'avatar' => null, + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured collection', + 'meta' => array_values(array_filter([ + (int) $collection->artworks_count > 0 ? number_format((int) $collection->artworks_count) . ' items' : null, + (int) $collection->followers_count > 0 ? number_format((int) $collection->followers_count) . ' followers' : null, + ])), + ]; + } + + private function resolveReleasePreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $release = GroupRelease::query()->with('group')->find($entityId); + + if (! $release || ! $release->group || ! $release->canBeViewedBy($viewer)) { + return null; + } + + return [ + 'id' => (int) $release->id, + 'entity_type' => self::RELATION_RELEASE, + 'entity_label' => self::RELATION_LABELS[self::RELATION_RELEASE], + 'title' => (string) $release->title, + 'subtitle' => (string) $release->group->name, + 'description' => Str::limit((string) ($release->summary ?: $release->description ?: ''), 120), + 'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]), + 'image' => $release->coverUrl(), + 'avatar' => $release->group->avatarUrl(), + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured release', + 'meta' => array_values(array_filter([ + $release->published_at?->format('d M Y'), + Str::headline((string) $release->status), + ])), + ]; + } + + private function resolveProjectPreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $project = GroupProject::query()->with('group')->find($entityId); + + if (! $project || ! $project->group || ! $project->canBeViewedBy($viewer)) { + return null; + } + + return [ + 'id' => (int) $project->id, + 'entity_type' => self::RELATION_PROJECT, + 'entity_label' => self::RELATION_LABELS[self::RELATION_PROJECT], + 'title' => (string) $project->title, + 'subtitle' => (string) $project->group->name, + 'description' => Str::limit((string) ($project->summary ?: $project->description ?: ''), 120), + 'url' => route('groups.projects.show', ['group' => $project->group, 'project' => $project]), + 'image' => $project->coverUrl(), + 'avatar' => $project->group->avatarUrl(), + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related project', + 'meta' => array_values(array_filter([ + Str::headline((string) $project->status), + $project->target_date?->format('d M Y'), + ])), + ]; + } + + private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $challenge = GroupChallenge::query()->with('group')->find($entityId); + + if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy($viewer)) { + return null; + } + + return [ + 'id' => (int) $challenge->id, + 'entity_type' => self::RELATION_CHALLENGE, + 'entity_label' => self::RELATION_LABELS[self::RELATION_CHALLENGE], + 'title' => (string) $challenge->title, + 'subtitle' => (string) $challenge->group->name, + 'description' => Str::limit((string) ($challenge->summary ?: $challenge->description ?: ''), 120), + 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]), + 'image' => $challenge->coverUrl(), + 'avatar' => $challenge->group->avatarUrl(), + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Join this challenge', + 'meta' => array_values(array_filter([ + $challenge->start_at?->format('d M Y'), + Str::headline((string) $challenge->status), + ])), + ]; + } + + private function resolveEventPreview(int $entityId, ?User $viewer, string $contextLabel): ?array + { + $event = GroupEvent::query()->with('group')->find($entityId); + + if (! $event || ! $event->group || ! $event->canBeViewedBy($viewer)) { + return null; + } + + return [ + 'id' => (int) $event->id, + 'entity_type' => self::RELATION_EVENT, + 'entity_label' => self::RELATION_LABELS[self::RELATION_EVENT], + 'title' => (string) $event->title, + 'subtitle' => (string) $event->group->name, + 'description' => Str::limit((string) ($event->summary ?: $event->description ?: ''), 120), + 'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]), + 'image' => $event->coverUrl(), + 'avatar' => $event->group->avatarUrl(), + 'context_label' => $contextLabel !== '' ? $contextLabel : 'Upcoming event', + 'meta' => array_values(array_filter([ + $event->start_at?->format('d M Y H:i'), + Str::headline((string) $event->event_type), + ])), + ]; + } + + private function resolveUserPreview(int $entityId, string $contextLabel): ?array + { + $user = User::query()->with('profile')->find($entityId); + + if (! $user || trim((string) $user->username) === '') { + return null; + } + + return $this->mapUserLookupResult($user, $contextLabel !== '' ? $contextLabel : 'Meet the creator'); + } + + private function mapUserLookupResult(User $user, string $contextLabel = 'Profile'): array + { + return [ + 'id' => (int) $user->id, + 'entity_type' => self::RELATION_USER, + 'entity_label' => self::RELATION_LABELS[self::RELATION_USER], + 'title' => (string) ($user->name ?: $user->username), + 'subtitle' => $user->username ? '@' . $user->username : null, + 'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120), + 'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null, + 'image' => null, + 'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96), + 'context_label' => $contextLabel, + 'meta' => [], + ]; + } +} \ No newline at end of file diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index fc140705..d249fbff 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Models\GroupInvitation; use App\Models\Notification; use App\Models\User; use App\Support\AvatarUrl; @@ -60,6 +61,98 @@ final class NotificationService ]); } + public function notifyGroupInvite(User $recipient, User $actor, \App\Models\Group $group, string $role, ?GroupInvitation $invitation = null): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_invite', + 'data' => [ + 'type' => 'group_invite', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' invited you to join ' . $group->name, + 'url' => route('studio.groups.index'), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'role' => $role, + 'invitation_token' => $invitation?->token, + 'accept_url' => $invitation ? route('studio.groups.invitations.accept', ['invitation' => $invitation]) : null, + ], + ]); + } + + public function notifyGroupInviteAccepted(User $recipient, User $actor, \App\Models\Group $group): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_invite_accepted', + 'data' => [ + 'type' => 'group_invite_accepted', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' accepted your invite to ' . $group->name, + 'url' => route('studio.groups.members', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + ], + ]); + } + + public function notifyGroupRoleChanged(User $recipient, User $actor, \App\Models\Group $group, string $role): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_role_changed', + 'data' => [ + 'type' => 'group_role_changed', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' changed your role in ' . $group->name . ' to ' . $role, + 'url' => route('studio.groups.members', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'role' => $role, + ], + ]); + } + + public function notifyGroupMemberRemoved(User $recipient, User $actor, \App\Models\Group $group): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_member_removed', + 'data' => [ + 'type' => 'group_member_removed', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' removed you from ' . $group->name, + 'url' => route('studio.groups.index'), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + ], + ]); + } + public function notifyCollectionSubmission(User $recipient, User $actor, \App\Models\Collection $collection, \App\Models\Artwork $artwork): ?Notification { if ($recipient->id === $actor->id) { @@ -104,6 +197,598 @@ final class NotificationService ]); } + public function notifyGroupJoinRequestReceived(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupJoinRequest $request): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_join_request_received', + 'data' => [ + 'type' => 'group_join_request_received', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' requested to join ' . $group->name, + 'url' => route('studio.groups.join-requests', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'join_request_id' => (int) $request->id, + ], + ]); + } + + public function notifyGroupJoinRequestApproved(User $recipient, User $actor, \App\Models\Group $group, string $role, \App\Models\GroupJoinRequest $request): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_join_request_approved', + 'data' => [ + 'type' => 'group_join_request_approved', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' approved your request to join ' . $group->name, + 'url' => route('studio.groups.show', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'role' => $role, + 'join_request_id' => (int) $request->id, + ], + ]); + } + + public function notifyGroupJoinRequestRejected(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupJoinRequest $request): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_join_request_rejected', + 'data' => [ + 'type' => 'group_join_request_rejected', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' declined your request to join ' . $group->name, + 'url' => route('groups.show', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'join_request_id' => (int) $request->id, + ], + ]); + } + + public function notifyGroupArtworkSubmittedForReview(User $recipient, User $actor, \App\Models\Group $group, \App\Models\Artwork $artwork): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_artwork_submitted_for_review', + 'data' => [ + 'type' => 'group_artwork_submitted_for_review', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' submitted "' . $artwork->title . '" for review in ' . $group->name, + 'url' => route('studio.groups.review', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'artwork_id' => (int) $artwork->id, + ], + ]); + } + + public function notifyGroupArtworkApproved(User $recipient, User $actor, \App\Models\Group $group, \App\Models\Artwork $artwork): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_artwork_approved', + 'data' => [ + 'type' => 'group_artwork_approved', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' approved your group submission "' . $artwork->title . '".', + 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'artwork_id' => (int) $artwork->id, + ], + ]); + } + + public function notifyGroupArtworkNeedsChanges(User $recipient, User $actor, \App\Models\Group $group, \App\Models\Artwork $artwork): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_artwork_needs_changes', + 'data' => [ + 'type' => 'group_artwork_needs_changes', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' requested changes for your group submission "' . $artwork->title . '".', + 'url' => route('studio.artworks.edit', ['id' => $artwork->id]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'artwork_id' => (int) $artwork->id, + ], + ]); + } + + public function notifyGroupArtworkRejected(User $recipient, User $actor, \App\Models\Group $group, \App\Models\Artwork $artwork): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_artwork_rejected', + 'data' => [ + 'type' => 'group_artwork_rejected', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' rejected your group submission "' . $artwork->title . '".', + 'url' => route('studio.artworks.edit', ['id' => $artwork->id]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'artwork_id' => (int) $artwork->id, + ], + ]); + } + + public function notifyGroupPostPublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupPost $post): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_post_published', + 'data' => [ + 'type' => 'group_post_published', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' published a new post: ' . $post->title, + 'url' => route('groups.posts.show', ['group' => $group, 'post' => $post]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_post_id' => (int) $post->id, + ], + ]); + } + + public function notifyGroupRecruitmentUpdated(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRecruitmentProfile $profile): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_recruitment_updated', + 'data' => [ + 'type' => 'group_recruitment_updated', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' updated its recruitment profile' . ($profile->headline ? ': ' . $profile->headline : '.'), + 'url' => route('groups.show', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'recruitment_profile_id' => (int) $profile->id, + ], + ]); + } + + public function notifyGroupProjectReleased(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupProject $project): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_project_released', + 'data' => [ + 'type' => 'group_project_released', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' released a project: ' . $project->title, + 'url' => route('groups.projects.show', ['group' => $group, 'project' => $project]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_project_id' => (int) $project->id, + ], + ]); + } + + public function notifyGroupProjectStatusChanged(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupProject $project): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_project_status_changed', + 'data' => [ + 'type' => 'group_project_status_changed', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' updated project status for ' . $project->title . ' to ' . $project->status, + 'url' => route('groups.projects.show', ['group' => $group, 'project' => $project]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_project_id' => (int) $project->id, + ], + ]); + } + + public function notifyGroupReleaseStageChanged(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRelease $release): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_release_stage_changed', + 'data' => [ + 'type' => 'group_release_stage_changed', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' moved release ' . $release->title . ' to ' . str_replace('_', ' ', $release->current_stage), + 'url' => route('groups.releases.show', ['group' => $group, 'release' => $release]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_release_id' => (int) $release->id, + ], + ]); + } + + public function notifyGroupReleasePublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRelease $release): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_release_published', + 'data' => [ + 'type' => 'group_release_published', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' published a release: ' . $release->title, + 'url' => route('groups.releases.show', ['group' => $group, 'release' => $release]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_release_id' => (int) $release->id, + ], + ]); + } + + public function notifyGroupReleaseContributorAdded(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRelease $release, ?string $roleLabel = null): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_release_contributor_added', + 'data' => [ + 'type' => 'group_release_contributor_added', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' added you to the release ' . $release->title . ($roleLabel ? ' as ' . $roleLabel : ''), + 'url' => route('groups.releases.show', ['group' => $group, 'release' => $release]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_release_id' => (int) $release->id, + 'role_label' => $roleLabel, + ], + ]); + } + + public function notifyGroupReleaseScheduled(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRelease $release): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + $scheduledFor = $release->planned_release_at?->format('M j, Y'); + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_release_scheduled', + 'data' => [ + 'type' => 'group_release_scheduled', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' scheduled the release ' . $release->title . ($scheduledFor ? ' for ' . $scheduledFor : ''), + 'url' => route('groups.releases.show', ['group' => $group, 'release' => $release]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_release_id' => (int) $release->id, + 'planned_release_at' => $release->planned_release_at?->toISOString(), + ], + ]); + } + + public function notifyGroupMilestoneAssigned(User $recipient, User $actor, \App\Models\Group $group, string $contextType, string $contextTitle, string $milestoneTitle, string $url): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_milestone_assigned', + 'data' => [ + 'type' => 'group_milestone_assigned', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' assigned you the milestone ' . $milestoneTitle . ' in ' . $contextType . ' ' . $contextTitle, + 'url' => $url, + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'context_type' => $contextType, + 'context_title' => $contextTitle, + 'milestone_title' => $milestoneTitle, + ], + ]); + } + + public function notifyGroupMilestoneDueSoon(User $recipient, User $actor, \App\Models\Group $group, string $contextType, string $contextTitle, string $milestoneTitle, ?string $dueDate, string $url): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_milestone_due_soon', + 'data' => [ + 'type' => 'group_milestone_due_soon', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => 'The milestone ' . $milestoneTitle . ' in ' . $contextType . ' ' . $contextTitle . ' is due soon' . ($dueDate ? ' on ' . $dueDate : ''), + 'url' => $url, + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'context_type' => $contextType, + 'context_title' => $contextTitle, + 'milestone_title' => $milestoneTitle, + 'due_date' => $dueDate, + ], + ]); + } + + public function notifyGroupBadgeEarned(User $recipient, \App\Models\Group $group, string $badgeLabel, ?string $url = null): ?Notification + { + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_badge_earned', + 'data' => [ + 'type' => 'group_badge_earned', + 'message' => ($group->name ?: 'Your group') . ' earned the badge ' . $badgeLabel, + 'url' => $url ?: route('groups.show', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'badge_label' => $badgeLabel, + ], + ]); + } + + public function notifyGroupMemberBadgeEarned(User $recipient, \App\Models\Group $group, string $badgeLabel, ?string $url = null): ?Notification + { + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_member_badge_earned', + 'data' => [ + 'type' => 'group_member_badge_earned', + 'message' => 'You earned the badge ' . $badgeLabel . ' in ' . ($group->name ?: 'a group'), + 'url' => $url ?: route('groups.show', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'badge_label' => $badgeLabel, + ], + ]); + } + + public function notifyFeaturedReleasePromoted(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupRelease $release): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'featured_group_release_promoted', + 'data' => [ + 'type' => 'featured_group_release_promoted', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' featured the release ' . $release->title, + 'url' => route('groups.releases.show', ['group' => $group, 'release' => $release]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_release_id' => (int) $release->id, + ], + ]); + } + + public function notifyGroupChallengePublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupChallenge $challenge): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_challenge_published', + 'data' => [ + 'type' => 'group_challenge_published', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' launched a challenge: ' . $challenge->title, + 'url' => route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_challenge_id' => (int) $challenge->id, + ], + ]); + } + + public function notifyGroupEventPublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupEvent $event): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_event_published', + 'data' => [ + 'type' => 'group_event_published', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' announced an event: ' . $event->title, + 'url' => route('groups.events.show', ['group' => $group, 'event' => $event]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_event_id' => (int) $event->id, + ], + ]); + } + + public function notifyGroupEventUpdated(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupEvent $event): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + if ($recipient->profile && $recipient->profile->follower_notifications === false) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_event_updated', + 'data' => [ + 'type' => 'group_event_updated', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($group->name ?: 'A group') . ' updated an event: ' . $event->title, + 'url' => route('groups.events.show', ['group' => $group, 'event' => $event]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_event_id' => (int) $event->id, + ], + ]); + } + + public function notifyGroupAssetApproved(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupAsset $asset): ?Notification + { + if ($recipient->id === $actor->id) { + return null; + } + + return Notification::query()->create([ + 'user_id' => (int) $recipient->id, + 'type' => 'group_asset_approved', + 'data' => [ + 'type' => 'group_asset_approved', + 'actor_id' => (int) $actor->id, + 'actor_name' => $actor->name, + 'actor_username' => $actor->username, + 'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' approved your group asset ' . $asset->title, + 'url' => route('studio.groups.assets.index', ['group' => $group]), + 'group_slug' => (string) $group->slug, + 'group_name' => (string) $group->name, + 'group_asset_id' => (int) $asset->id, + ], + ]); + } + public function notifyNovaCardComment(User $recipient, User $actor, \App\Models\NovaCard $card, \App\Models\NovaCardComment $comment): ?Notification { if ($recipient->id === $actor->id) { diff --git a/app/Services/Sitemaps/Builders/NewsSitemapBuilder.php b/app/Services/Sitemaps/Builders/NewsSitemapBuilder.php index f6f8eef4..4fcf60ae 100644 --- a/app/Services/Sitemaps/Builders/NewsSitemapBuilder.php +++ b/app/Services/Sitemaps/Builders/NewsSitemapBuilder.php @@ -9,6 +9,8 @@ use App\Services\Sitemaps\SitemapUrl; use App\Services\Sitemaps\SitemapUrlBuilder; use DateTimeInterface; use cPad\Plugins\News\Models\NewsArticle; +use cPad\Plugins\News\Models\NewsCategory; +use cPad\Plugins\News\Models\NewsTag; final class NewsSitemapBuilder extends AbstractSitemapBuilder { @@ -23,20 +25,40 @@ final class NewsSitemapBuilder extends AbstractSitemapBuilder public function items(): array { - return NewsArticle::query() + $articles = NewsArticle::query() ->published() ->orderBy('id') ->cursor() ->map(fn (NewsArticle $article): ?SitemapUrl => $this->urls->news($article)) ->filter() - ->values() - ->all(); + ->values(); + + $categories = NewsCategory::query() + ->active() + ->whereHas('publishedArticles') + ->ordered() + ->cursor() + ->map(fn (NewsCategory $category): ?SitemapUrl => $this->urls->newsCategory($category)) + ->filter() + ->values(); + + $tags = NewsTag::query() + ->whereHas('articles', fn ($query) => $query->published()) + ->orderBy('name') + ->cursor() + ->map(fn (NewsTag $tag): ?SitemapUrl => $this->urls->newsTag($tag)) + ->filter() + ->values(); + + return $articles->concat($categories)->concat($tags)->values()->all(); } public function lastModified(): ?DateTimeInterface { - return $this->dateTime(NewsArticle::query() - ->published() - ->max('updated_at')); + return $this->dateTime(\collect([ + NewsArticle::query()->published()->max('updated_at'), + NewsCategory::query()->active()->whereHas('publishedArticles')->max('updated_at'), + NewsTag::query()->whereHas('articles', fn ($query) => $query->published())->max('updated_at'), + ])->filter()->max()); } } \ No newline at end of file diff --git a/app/Services/Sitemaps/SitemapUrlBuilder.php b/app/Services/Sitemaps/SitemapUrlBuilder.php index 5f9707c1..227758bc 100644 --- a/app/Services/Sitemaps/SitemapUrlBuilder.php +++ b/app/Services/Sitemaps/SitemapUrlBuilder.php @@ -19,6 +19,8 @@ use cPad\Plugins\Forum\Models\ForumBoard; use cPad\Plugins\Forum\Models\ForumCategory; use cPad\Plugins\Forum\Models\ForumTopic; use cPad\Plugins\News\Models\NewsArticle; +use cPad\Plugins\News\Models\NewsCategory; +use cPad\Plugins\News\Models\NewsTag; use Illuminate\Support\Str; final class SitemapUrlBuilder extends AbstractSitemapBuilder @@ -161,6 +163,30 @@ final class SitemapUrlBuilder extends AbstractSitemapBuilder ); } + public function newsCategory(NewsCategory $category): ?SitemapUrl + { + if (trim((string) $category->slug) === '') { + return null; + } + + return new SitemapUrl( + route('news.category', ['slug' => $category->slug]), + $this->newest($category->updated_at, $category->created_at), + ); + } + + public function newsTag(NewsTag $tag): ?SitemapUrl + { + if (trim((string) $tag->slug) === '') { + return null; + } + + return new SitemapUrl( + route('news.tag', ['slug' => $tag->slug]), + $this->newest($tag->updated_at, $tag->created_at), + ); + } + public function forumIndex(): SitemapUrl { return new SitemapUrl(route('forum.index')); diff --git a/app/Support/Moderation/ReportTargetResolver.php b/app/Support/Moderation/ReportTargetResolver.php index 27133be2..55ffd7b1 100644 --- a/app/Support/Moderation/ReportTargetResolver.php +++ b/app/Support/Moderation/ReportTargetResolver.php @@ -10,6 +10,8 @@ use App\Models\Collection; use App\Models\CollectionComment; use App\Models\CollectionSubmission; use App\Models\ConversationParticipant; +use App\Models\Group; +use App\Models\GroupPost; use App\Models\Message; use App\Models\NovaCard; use App\Models\NovaCardChallenge; @@ -38,6 +40,8 @@ class ReportTargetResolver 'message', 'conversation', 'user', + 'group', + 'group_post', 'story', 'story_comment', 'artwork_comment', @@ -87,6 +91,22 @@ class ReportTargetResolver User::query()->findOrFail($targetId); return; + case 'group': + $group = Group::query()->findOrFail($targetId); + abort_unless($group->canBeViewedBy($user), 403, 'You are not allowed to report this group.'); + return; + + case 'group_post': + $post = GroupPost::query()->with('group')->findOrFail($targetId); + abort_unless( + $post->group !== null + && $post->status === GroupPost::STATUS_PUBLISHED + && $post->group->canBeViewedBy($user), + 403, + 'You are not allowed to report this group post.' + ); + return; + case 'story': Story::query()->findOrFail($targetId); return; @@ -164,6 +184,8 @@ class ReportTargetResolver try { return match ($report->target_type) { + 'group' => $this->summarizeGroup($report->target_id), + 'group_post' => $this->summarizeGroupPost($report->target_id), 'nova_card' => $this->summarizeNovaCard($report->target_id), 'nova_card_comment' => $this->summarizeNovaCardComment($report->target_id), 'nova_card_challenge' => $this->summarizeNovaCardChallenge($report->target_id), @@ -185,6 +207,38 @@ class ReportTargetResolver }; } + private function summarizeGroup(int $targetId): array + { + $group = Group::query()->with('owner')->findOrFail($targetId); + + return [ + 'exists' => true, + 'type' => 'group', + 'id' => (int) $group->id, + 'label' => (string) $group->name, + 'subtitle' => trim(sprintf('@%s%s', (string) $group->owner?->username, $group->headline ? ' • '.$group->headline : '')), + 'public_url' => $group->publicUrl(), + 'moderation_url' => null, + 'moderation_target' => null, + ]; + } + + private function summarizeGroupPost(int $targetId): array + { + $post = GroupPost::query()->with(['group.owner'])->findOrFail($targetId); + + return [ + 'exists' => $post->group !== null, + 'type' => 'group_post', + 'id' => (int) $post->id, + 'label' => (string) $post->title, + 'subtitle' => trim(sprintf('%s • %s', (string) $post->group?->name, ucfirst((string) $post->type))), + 'public_url' => $post->group ? route('groups.posts.show', ['group' => $post->group, 'post' => $post]) : null, + 'moderation_url' => null, + 'moderation_target' => null, + ]; + } + private function summarizeNovaCard(int $targetId): array { $card = NovaCard::query() diff --git a/config/features.php b/config/features.php index 00611cf2..072cd9ec 100644 --- a/config/features.php +++ b/config/features.php @@ -2,5 +2,23 @@ return [ 'uploads_v2' => (bool) env('SKINBASE_UPLOADS_V2', true), + 'groups' => (bool) env('SKINBASE_GROUPS_V1', true), + 'groups_v1' => (bool) env('SKINBASE_GROUPS_V1', true), + 'groups_v2' => (bool) env('SKINBASE_GROUPS_V2', true), + 'group_posts' => (bool) env('SKINBASE_GROUP_POSTS', true), + 'group_recruitment' => (bool) env('SKINBASE_GROUP_RECRUITMENT', true), + 'group_join_requests' => (bool) env('SKINBASE_GROUP_JOIN_REQUESTS', true), + 'group_review_queue' => (bool) env('SKINBASE_GROUP_REVIEW_QUEUE', true), + 'group_projects' => (bool) env('SKINBASE_GROUP_PROJECTS', true), + 'group_challenges' => (bool) env('SKINBASE_GROUP_CHALLENGES', true), + 'group_events' => (bool) env('SKINBASE_GROUP_EVENTS', true), + 'group_assets' => (bool) env('SKINBASE_GROUP_ASSETS', true), + 'group_activity_feed' => (bool) env('SKINBASE_GROUP_ACTIVITY_FEED', true), + 'groups_v4' => (bool) env('SKINBASE_GROUPS_V4', true), + 'group_releases' => (bool) env('SKINBASE_GROUP_RELEASES', true), + 'group_milestones' => (bool) env('SKINBASE_GROUP_MILESTONES', true), + 'group_reputation' => (bool) env('SKINBASE_GROUP_REPUTATION', true), + 'group_badges' => (bool) env('SKINBASE_GROUP_BADGES', true), + 'group_discovery_v2' => (bool) env('SKINBASE_GROUP_DISCOVERY_V2', true), 'similarity_vector' => (bool) env('SIMILARITY_VECTOR_ENABLED', false), ]; diff --git a/config/groups.php b/config/groups.php new file mode 100644 index 00000000..9cc149c3 --- /dev/null +++ b/config/groups.php @@ -0,0 +1,121 @@ + [ + 'roles' => [ + 'Animator', + 'Composer', + 'Concept Artist', + 'Curator', + 'Designer', + 'Developer', + 'Editor', + 'Illustrator', + 'Moderator', + 'Photographer', + 'Pixel Artist', + 'Producer', + '3D Artist', + 'Sound Designer', + 'Texture Artist', + 'UI Artist', + 'Wallpaper Creator', + 'Writer', + ], + 'skills' => [ + 'After Effects', + 'Aseprite', + 'Blender', + 'Color Grading', + 'Compositing', + 'Figma', + 'Illustrator', + 'Lighting', + 'Photoshop', + 'Pixel Art', + 'Premiere Pro', + 'Procreate', + 'Retouching', + 'Sound Design', + 'Storyboarding', + 'Substance Painter', + 'Typography', + 'UI Design', + ], + 'contact_modes' => [ + 'join_request', + 'direct_message', + 'external_link', + ], + 'visibility_options' => [ + 'public', + 'members_only', + 'private', + ], + ], + 'projects' => [ + 'statuses' => ['planned', 'active', 'review', 'released', 'archived'], + 'visibility_options' => ['public', 'unlisted', 'private'], + 'handoff_states' => ['waiting_for_review', 'waiting_for_assets', 'waiting_for_packaging', 'waiting_for_final_approval'], + ], + 'releases' => [ + 'statuses' => ['planned', 'in_progress', 'internal_review', 'scheduled', 'released', 'archived', 'cancelled'], + 'stages' => ['concept', 'production', 'review', 'packaging', 'approval', 'publishing', 'released'], + 'visibility_options' => ['public', 'unlisted', 'private'], + ], + 'milestones' => [ + 'statuses' => ['pending', 'active', 'blocked', 'completed', 'cancelled'], + ], + 'challenges' => [ + 'statuses' => ['draft', 'published', 'active', 'ended', 'archived'], + 'visibility_options' => ['public', 'unlisted', 'private'], + 'participation_scopes' => ['group_only', 'invite_only', 'public'], + 'judging_modes' => ['curated', 'community_vote', 'staff_pick'], + ], + 'events' => [ + 'types' => ['launch', 'challenge', 'livestream', 'meetup', 'milestone', 'showcase', 'internal_session', 'release_window'], + 'statuses' => ['draft', 'published', 'archived', 'cancelled'], + 'visibility_options' => ['public', 'members_only', 'private'], + ], + 'assets' => [ + 'categories' => ['logo', 'brand', 'palette', 'watermark', 'template', 'reference', 'source_pack', 'promo', 'misc'], + 'visibility_options' => ['internal', 'members_only', 'public_download'], + 'statuses' => ['active', 'archived'], + 'allowed_mime_types' => [ + 'application/pdf', + 'application/postscript', + 'application/zip', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', + ], + 'allowed_extensions' => ['pdf', 'psd', 'svg', 'zip', 'png', 'jpg', 'jpeg', 'webp'], + 'max_upload_kb' => 20480, + ], + 'badges' => [ + 'group' => [ + 'first_release' => 'First Release', + 'ten_releases' => '10 Releases', + 'hundred_published_artworks' => '100 Published Artworks', + 'recruiting_success' => 'Recruiting Success', + 'community_favorite' => 'Community Favorite', + 'consistent_activity' => 'Consistent Activity', + 'event_host' => 'Event Host', + 'challenge_organizer' => 'Challenge Organizer', + 'collaborative_group' => 'Collaborative Group', + 'trusted_group' => 'Trusted Group', + ], + 'member' => [ + 'first_group_contribution' => 'First Group Contribution', + 'ten_group_contributions' => '10 Group Contributions', + 'release_contributor' => 'Release Contributor', + 'project_lead' => 'Project Lead', + 'reliable_reviewer' => 'Reliable Reviewer', + 'challenge_winner' => 'Challenge Winner', + 'long_term_collaborator' => 'Long-Term Collaborator', + 'founding_member' => 'Founding Member', + 'asset_builder' => 'Asset Builder', + ], + ], +]; diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php new file mode 100644 index 00000000..539df597 --- /dev/null +++ b/database/factories/GroupFactory.php @@ -0,0 +1,44 @@ +faker->unique()->company(); + + return [ + 'owner_user_id' => User::factory(), + 'name' => $name, + 'slug' => Str::slug($name), + 'headline' => $this->faker->sentence(6), + 'bio' => $this->faker->paragraph(), + 'visibility' => Group::VISIBILITY_PUBLIC, + 'status' => Group::LIFECYCLE_ACTIVE, + 'membership_policy' => Group::MEMBERSHIP_INVITE_ONLY, + 'website_url' => $this->faker->optional()->url(), + 'links_json' => [], + 'avatar_path' => null, + 'banner_path' => null, + 'artworks_count' => 0, + 'collections_count' => 0, + 'followers_count' => 0, + 'last_activity_at' => now(), + ]; + } + + public function private(): self + { + return $this->state(fn (): array => [ + 'visibility' => Group::VISIBILITY_PRIVATE, + ]); + } +} \ No newline at end of file diff --git a/database/migrations/2026_04_04_000001_create_groups_tables.php b/database/migrations/2026_04_04_000001_create_groups_tables.php new file mode 100644 index 00000000..34c22a57 --- /dev/null +++ b/database/migrations/2026_04_04_000001_create_groups_tables.php @@ -0,0 +1,96 @@ +id(); + $table->foreignId('owner_user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('name', 80); + $table->string('slug', 90)->unique(); + $table->string('headline', 160)->nullable(); + $table->text('bio')->nullable(); + $table->enum('visibility', ['public', 'private'])->default('public'); + $table->string('website_url', 2048)->nullable(); + $table->json('links_json')->nullable(); + $table->string('avatar_path')->nullable(); + $table->string('banner_path')->nullable(); + $table->unsignedInteger('artworks_count')->default(0); + $table->unsignedInteger('collections_count')->default(0); + $table->unsignedInteger('followers_count')->default(0); + $table->timestamp('last_activity_at')->nullable(); + $table->timestamps(); + + $table->index(['visibility', 'followers_count']); + $table->index(['owner_user_id', 'created_at']); + }); + + Schema::create('group_members', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->foreignId('invited_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->enum('role', ['owner', 'admin', 'editor', 'member'])->default('member'); + $table->enum('status', ['pending', 'active', 'revoked'])->default('pending'); + $table->text('note')->nullable(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'user_id'], 'group_members_group_user_unique'); + $table->index(['group_id', 'status', 'role'], 'group_members_status_role_idx'); + }); + + Schema::create('group_follows', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['group_id', 'user_id'], 'group_follows_group_user_unique'); + }); + + Schema::create('artwork_contributors', function (Blueprint $table): void { + $table->id(); + $table->foreignId('artwork_id') + ->constrained('artworks') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['artwork_id', 'user_id'], 'artwork_contributors_artwork_user_unique'); + $table->index(['artwork_id', 'sort_order'], 'artwork_contributors_sort_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_contributors'); + Schema::dropIfExists('group_follows'); + Schema::dropIfExists('group_members'); + Schema::dropIfExists('groups'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000002_add_group_fields_to_artworks_and_collections.php b/database/migrations/2026_04_04_000002_add_group_fields_to_artworks_and_collections.php new file mode 100644 index 00000000..557008c4 --- /dev/null +++ b/database/migrations/2026_04_04_000002_add_group_fields_to_artworks_and_collections.php @@ -0,0 +1,56 @@ +foreignId('group_id') + ->nullable() + ->after('user_id') + ->constrained('groups') + ->nullOnDelete(); + $table->foreignId('uploaded_by_user_id') + ->nullable() + ->after('group_id') + ->constrained('users') + ->nullOnDelete(); + $table->foreignId('primary_author_user_id') + ->nullable() + ->after('uploaded_by_user_id') + ->constrained('users') + ->nullOnDelete(); + + $table->index(['group_id', 'published_at'], 'artworks_group_published_idx'); + }); + + Schema::table('collections', function (Blueprint $table): void { + $table->foreignId('group_id') + ->nullable() + ->after('user_id') + ->constrained('groups') + ->nullOnDelete(); + + $table->index(['group_id', 'visibility'], 'collections_group_visibility_idx'); + }); + } + + public function down(): void + { + Schema::table('collections', function (Blueprint $table): void { + $table->dropIndex('collections_group_visibility_idx'); + $table->dropConstrainedForeignId('group_id'); + }); + + Schema::table('artworks', function (Blueprint $table): void { + $table->dropIndex('artworks_group_published_idx'); + $table->dropConstrainedForeignId('primary_author_user_id'); + $table->dropConstrainedForeignId('uploaded_by_user_id'); + $table->dropConstrainedForeignId('group_id'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000003_expand_groups_v1_schema.php b/database/migrations/2026_04_04_000003_expand_groups_v1_schema.php new file mode 100644 index 00000000..27398039 --- /dev/null +++ b/database/migrations/2026_04_04_000003_expand_groups_v1_schema.php @@ -0,0 +1,83 @@ +string('status', 24)->default('active')->after('visibility'); + } + + if (! Schema::hasColumn('groups', 'membership_policy')) { + $table->string('membership_policy', 32)->default('invite_only')->after('status'); + } + + if (! Schema::hasColumn('groups', 'type')) { + $table->string('type', 80)->nullable()->after('bio'); + } + + if (! Schema::hasColumn('groups', 'founded_at')) { + $table->timestamp('founded_at')->nullable()->after('owner_user_id'); + } + + if (! Schema::hasColumn('groups', 'featured_artwork_id')) { + $table->foreignId('featured_artwork_id')->nullable()->after('owner_user_id')->constrained('artworks')->nullOnDelete(); + } + + if (! Schema::hasColumn('groups', 'is_verified')) { + $table->boolean('is_verified')->default(false)->after('featured_artwork_id'); + } + + if (! Schema::hasColumn('groups', 'deleted_at')) { + $table->softDeletes(); + } + }); + + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement("ALTER TABLE `groups` MODIFY `visibility` ENUM('public','private','unlisted') NOT NULL DEFAULT 'public'"); + } + } + + public function down(): void + { + if (DB::connection()->getDriverName() === 'mysql') { + DB::statement("ALTER TABLE `groups` MODIFY `visibility` ENUM('public','private') NOT NULL DEFAULT 'public'"); + } + + Schema::table('groups', function (Blueprint $table): void { + if (Schema::hasColumn('groups', 'deleted_at')) { + $table->dropSoftDeletes(); + } + + if (Schema::hasColumn('groups', 'is_verified')) { + $table->dropColumn('is_verified'); + } + + if (Schema::hasColumn('groups', 'featured_artwork_id')) { + $table->dropConstrainedForeignId('featured_artwork_id'); + } + + if (Schema::hasColumn('groups', 'founded_at')) { + $table->dropColumn('founded_at'); + } + + if (Schema::hasColumn('groups', 'type')) { + $table->dropColumn('type'); + } + + if (Schema::hasColumn('groups', 'membership_policy')) { + $table->dropColumn('membership_policy'); + } + + if (Schema::hasColumn('groups', 'status')) { + $table->dropColumn('status'); + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000004_create_group_invitations_table.php b/database/migrations/2026_04_04_000004_create_group_invitations_table.php new file mode 100644 index 00000000..c7113c6d --- /dev/null +++ b/database/migrations/2026_04_04_000004_create_group_invitations_table.php @@ -0,0 +1,89 @@ +id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('invited_user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->foreignId('invited_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->foreignId('source_group_member_id') + ->nullable() + ->constrained('group_members') + ->nullOnDelete(); + $table->enum('role', ['admin', 'editor', 'member'])->default('member'); + $table->enum('status', [ + GroupInvitation::STATUS_PENDING, + GroupInvitation::STATUS_ACCEPTED, + GroupInvitation::STATUS_DECLINED, + GroupInvitation::STATUS_REVOKED, + GroupInvitation::STATUS_EXPIRED, + ])->default(GroupInvitation::STATUS_PENDING); + $table->string('token', 80)->unique(); + $table->text('note')->nullable(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('responded_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'role'], 'group_invitations_status_role_idx'); + $table->index(['invited_user_id', 'status'], 'group_invitations_user_status_idx'); + }); + + if (! Schema::hasTable('group_members')) { + return; + } + + DB::table('group_members') + ->whereIn('status', ['pending', 'revoked']) + ->orderBy('id') + ->get() + ->each(function (object $member): void { + $status = match ((string) $member->status) { + 'pending' => GroupInvitation::STATUS_PENDING, + default => GroupInvitation::STATUS_REVOKED, + }; + + DB::table('group_invitations')->insert([ + 'group_id' => (int) $member->group_id, + 'invited_user_id' => (int) $member->user_id, + 'invited_by_user_id' => $member->invited_by_user_id ? (int) $member->invited_by_user_id : null, + 'source_group_member_id' => (int) $member->id, + 'role' => (string) $member->role, + 'status' => $status, + 'token' => Str::random(64), + 'note' => $member->note, + 'invited_at' => $member->invited_at, + 'expires_at' => $member->expires_at, + 'responded_at' => $member->accepted_at ?? $member->revoked_at, + 'accepted_at' => null, + 'revoked_at' => $member->revoked_at, + 'created_at' => $member->created_at ?? now(), + 'updated_at' => $member->updated_at ?? now(), + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('group_invitations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000005_add_published_as_fields_to_artworks.php b/database/migrations/2026_04_04_000005_add_published_as_fields_to_artworks.php new file mode 100644 index 00000000..7ef352d7 --- /dev/null +++ b/database/migrations/2026_04_04_000005_add_published_as_fields_to_artworks.php @@ -0,0 +1,45 @@ +string('published_as_type', 16) + ->nullable() + ->after('primary_author_user_id'); + $table->unsignedBigInteger('published_as_id') + ->nullable() + ->after('published_as_type'); + + $table->index(['published_as_type', 'published_as_id'], 'artworks_published_as_idx'); + }); + + DB::table('artworks') + ->whereNotNull('group_id') + ->update([ + 'published_as_type' => 'group', + 'published_as_id' => DB::raw('group_id'), + ]); + + DB::table('artworks') + ->whereNull('published_as_id') + ->update([ + 'published_as_type' => 'user', + 'published_as_id' => DB::raw('user_id'), + ]); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table): void { + $table->dropIndex('artworks_published_as_idx'); + $table->dropColumn(['published_as_type', 'published_as_id']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000006_add_credit_metadata_to_artwork_contributors_table.php b/database/migrations/2026_04_04_000006_add_credit_metadata_to_artwork_contributors_table.php new file mode 100644 index 00000000..a19752aa --- /dev/null +++ b/database/migrations/2026_04_04_000006_add_credit_metadata_to_artwork_contributors_table.php @@ -0,0 +1,23 @@ +string('credit_role', 80)->nullable()->after('user_id'); + $table->boolean('is_primary')->default(false)->after('credit_role'); + }); + } + + public function down(): void + { + Schema::table('artwork_contributors', function (Blueprint $table): void { + $table->dropColumn(['credit_role', 'is_primary']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000007_add_groups_v2_schema.php b/database/migrations/2026_04_04_000007_add_groups_v2_schema.php new file mode 100644 index 00000000..138e1cc7 --- /dev/null +++ b/database/migrations/2026_04_04_000007_add_groups_v2_schema.php @@ -0,0 +1,163 @@ +id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->text('message')->nullable(); + $table->string('portfolio_url', 2048)->nullable(); + $table->string('desired_role', 32)->nullable(); + $table->json('skills_json')->nullable(); + $table->string('status', 24)->default('pending'); + $table->foreignId('reviewed_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->text('review_notes')->nullable(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'created_at'], 'group_join_requests_group_status_idx'); + $table->index(['group_id', 'user_id', 'status'], 'group_join_requests_group_user_status_idx'); + }); + + Schema::create('group_posts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('author_user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('type', 32)->default('announcement'); + $table->string('title', 180); + $table->string('slug', 190)->unique(); + $table->string('excerpt', 320)->nullable(); + $table->longText('content')->nullable(); + $table->string('cover_path', 2048)->nullable(); + $table->string('status', 24)->default('draft'); + $table->boolean('is_pinned')->default(false); + $table->timestamp('published_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'published_at'], 'group_posts_group_status_published_idx'); + $table->index(['group_id', 'is_pinned', 'published_at'], 'group_posts_group_pinned_idx'); + }); + + Schema::create('group_recruitment_profiles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->boolean('is_recruiting')->default(false); + $table->string('headline', 180)->nullable(); + $table->text('description')->nullable(); + $table->json('roles_json')->nullable(); + $table->json('skills_json')->nullable(); + $table->string('contact_mode', 32)->nullable(); + $table->string('visibility', 24)->default('public'); + $table->timestamps(); + + $table->unique('group_id', 'group_recruitment_profiles_group_unique'); + }); + + Schema::create('group_histories', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id') + ->constrained('groups') + ->cascadeOnDelete(); + $table->foreignId('actor_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->string('action_type', 64); + $table->string('target_type', 64)->nullable(); + $table->unsignedBigInteger('target_id')->nullable(); + $table->string('summary', 255)->nullable(); + $table->json('before_json')->nullable(); + $table->json('after_json')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['group_id', 'action_type', 'created_at'], 'group_histories_group_action_idx'); + $table->index(['group_id', 'target_type', 'target_id'], 'group_histories_target_idx'); + }); + + Schema::table('group_members', function (Blueprint $table): void { + if (! Schema::hasColumn('group_members', 'permission_overrides_json')) { + $table->json('permission_overrides_json')->nullable()->after('note'); + } + }); + + Schema::table('artworks', function (Blueprint $table): void { + if (! Schema::hasColumn('artworks', 'group_review_status')) { + $table->string('group_review_status', 24)->default('none')->after('artwork_status'); + } + + if (! Schema::hasColumn('artworks', 'group_review_submitted_at')) { + $table->timestamp('group_review_submitted_at')->nullable()->after('group_review_status'); + } + + if (! Schema::hasColumn('artworks', 'group_reviewed_by_user_id')) { + $table->foreignId('group_reviewed_by_user_id') + ->nullable() + ->after('group_review_submitted_at') + ->constrained('users') + ->nullOnDelete(); + } + + if (! Schema::hasColumn('artworks', 'group_reviewed_at')) { + $table->timestamp('group_reviewed_at')->nullable()->after('group_reviewed_by_user_id'); + } + + if (! Schema::hasColumn('artworks', 'group_review_notes')) { + $table->text('group_review_notes')->nullable()->after('group_reviewed_at'); + } + + $table->index(['group_id', 'group_review_status', 'group_review_submitted_at'], 'artworks_group_review_queue_idx'); + }); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table): void { + if (Schema::hasColumn('artworks', 'group_reviewed_by_user_id')) { + $table->dropConstrainedForeignId('group_reviewed_by_user_id'); + } + + if (Schema::hasColumn('artworks', 'group_review_status')) { + $table->dropIndex('artworks_group_review_queue_idx'); + $table->dropColumn([ + 'group_review_status', + 'group_review_submitted_at', + 'group_reviewed_at', + 'group_review_notes', + ]); + } + }); + + Schema::table('group_members', function (Blueprint $table): void { + if (Schema::hasColumn('group_members', 'permission_overrides_json')) { + $table->dropColumn('permission_overrides_json'); + } + }); + + Schema::dropIfExists('group_histories'); + Schema::dropIfExists('group_recruitment_profiles'); + Schema::dropIfExists('group_posts'); + Schema::dropIfExists('group_join_requests'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_04_000008_add_groups_v3_schema.php b/database/migrations/2026_04_04_000008_add_groups_v3_schema.php new file mode 100644 index 00000000..fad7687c --- /dev/null +++ b/database/migrations/2026_04_04_000008_add_groups_v3_schema.php @@ -0,0 +1,180 @@ +id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('slug', 190)->unique(); + $table->string('summary', 320)->nullable(); + $table->longText('description')->nullable(); + $table->string('cover_path', 2048)->nullable(); + $table->string('status', 24)->default('planned'); + $table->string('visibility', 24)->default('public'); + $table->date('start_date')->nullable(); + $table->date('target_date')->nullable(); + $table->timestamp('released_at')->nullable(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('lead_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete(); + $table->foreignId('linked_featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->foreignId('pinned_post_id')->nullable()->constrained('group_posts')->nullOnDelete(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'visibility'], 'group_projects_group_status_visibility_idx'); + $table->index(['group_id', 'released_at'], 'group_projects_group_released_idx'); + }); + + Schema::create('group_project_artworks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['group_project_id', 'artwork_id'], 'group_project_artworks_unique'); + $table->index(['group_project_id', 'sort_order'], 'group_project_artworks_sort_idx'); + }); + + Schema::create('group_project_members', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role_label', 80)->nullable(); + $table->boolean('is_lead')->default(false); + $table->timestamps(); + + $table->unique(['group_project_id', 'user_id'], 'group_project_members_unique'); + $table->index(['group_project_id', 'is_lead'], 'group_project_members_lead_idx'); + }); + + Schema::create('group_challenges', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('slug', 190)->unique(); + $table->string('summary', 320)->nullable(); + $table->longText('description')->nullable(); + $table->string('cover_path', 2048)->nullable(); + $table->string('visibility', 24)->default('public'); + $table->string('participation_scope', 24)->default('group_only'); + $table->string('status', 24)->default('draft'); + $table->timestamp('start_at')->nullable(); + $table->timestamp('end_at')->nullable(); + $table->text('rules_text')->nullable(); + $table->text('submission_instructions')->nullable(); + $table->string('judging_mode', 32)->nullable(); + $table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete(); + $table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'visibility'], 'group_challenges_group_status_visibility_idx'); + $table->index(['group_id', 'start_at', 'end_at'], 'group_challenges_group_window_idx'); + }); + + Schema::create('group_challenge_artworks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_challenge_id')->constrained('group_challenges')->cascadeOnDelete(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('submitted_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['group_challenge_id', 'artwork_id'], 'group_challenge_artworks_unique'); + $table->index(['group_challenge_id', 'sort_order'], 'group_challenge_artworks_sort_idx'); + }); + + Schema::create('group_events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('slug', 190)->unique(); + $table->string('summary', 320)->nullable(); + $table->longText('description')->nullable(); + $table->string('event_type', 32)->default('launch'); + $table->string('visibility', 24)->default('public'); + $table->timestamp('start_at')->nullable(); + $table->timestamp('end_at')->nullable(); + $table->string('timezone', 80)->default('UTC'); + $table->string('cover_path', 2048)->nullable(); + $table->string('location', 180)->nullable(); + $table->string('external_url', 2048)->nullable(); + $table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete(); + $table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete(); + $table->foreignId('linked_challenge_id')->nullable()->constrained('group_challenges')->nullOnDelete(); + $table->string('status', 24)->default('draft'); + $table->boolean('is_featured')->default(false); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'visibility'], 'group_events_group_status_visibility_idx'); + $table->index(['group_id', 'start_at'], 'group_events_group_start_idx'); + }); + + Schema::create('group_assets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('title', 180); + $table->text('description')->nullable(); + $table->string('category', 32)->default('misc'); + $table->string('file_path', 2048); + $table->string('preview_path', 2048)->nullable(); + $table->string('visibility', 24)->default('members_only'); + $table->string('status', 24)->default('active'); + $table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete(); + $table->foreignId('uploaded_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->boolean('is_featured')->default(false); + $table->json('file_meta_json')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'visibility', 'status'], 'group_assets_group_visibility_status_idx'); + $table->index(['group_id', 'category'], 'group_assets_group_category_idx'); + }); + + Schema::create('group_activity_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('type', 48); + $table->string('visibility', 24)->default('public'); + $table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('subject_type', 64); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->string('headline', 255); + $table->string('summary', 500)->nullable(); + $table->boolean('is_pinned')->default(false); + $table->timestamp('occurred_at'); + $table->timestamps(); + + $table->index(['group_id', 'visibility', 'occurred_at'], 'group_activity_items_group_visibility_occurred_idx'); + $table->index(['group_id', 'type', 'occurred_at'], 'group_activity_items_group_type_occurred_idx'); + $table->index(['group_id', 'subject_type', 'subject_id'], 'group_activity_items_subject_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('group_activity_items'); + Schema::dropIfExists('group_assets'); + Schema::dropIfExists('group_events'); + Schema::dropIfExists('group_challenge_artworks'); + Schema::dropIfExists('group_challenges'); + Schema::dropIfExists('group_project_members'); + Schema::dropIfExists('group_project_artworks'); + Schema::dropIfExists('group_projects'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_05_000000_add_groups_v4_schema.php b/database/migrations/2026_04_05_000000_add_groups_v4_schema.php new file mode 100644 index 00000000..80444e0b --- /dev/null +++ b/database/migrations/2026_04_05_000000_add_groups_v4_schema.php @@ -0,0 +1,164 @@ +id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('slug', 190)->unique(); + $table->string('summary', 320)->nullable(); + $table->longText('description')->nullable(); + $table->string('cover_path', 2048)->nullable(); + $table->string('status', 32)->default('planned'); + $table->string('current_stage', 32)->default('concept'); + $table->string('visibility', 24)->default('public'); + $table->timestamp('planned_release_at')->nullable(); + $table->timestamp('released_at')->nullable(); + $table->foreignId('lead_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete(); + $table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete(); + $table->foreignId('featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->longText('release_notes')->nullable(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->boolean('is_featured')->default(false); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['group_id', 'status', 'visibility'], 'group_releases_group_status_visibility_idx'); + $table->index(['group_id', 'current_stage'], 'group_releases_group_stage_idx'); + $table->index(['group_id', 'planned_release_at'], 'group_releases_group_planned_idx'); + $table->index(['group_id', 'released_at'], 'group_releases_group_released_idx'); + }); + + Schema::create('group_release_artworks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['group_release_id', 'artwork_id'], 'group_release_artworks_unique'); + $table->index(['group_release_id', 'sort_order'], 'group_release_artworks_sort_idx'); + }); + + Schema::create('group_release_contributors', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role_label', 80)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['group_release_id', 'user_id'], 'group_release_contributors_unique'); + $table->index(['group_release_id', 'sort_order'], 'group_release_contributors_sort_idx'); + }); + + Schema::create('group_project_milestones', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('summary', 320)->nullable(); + $table->string('status', 24)->default('pending'); + $table->date('due_date')->nullable(); + $table->foreignId('owner_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['group_project_id', 'status'], 'group_project_milestones_project_status_idx'); + $table->index(['group_project_id', 'due_date'], 'group_project_milestones_project_due_idx'); + }); + + Schema::create('group_release_milestones', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete(); + $table->string('title', 180); + $table->string('summary', 320)->nullable(); + $table->string('status', 24)->default('pending'); + $table->date('due_date')->nullable(); + $table->foreignId('owner_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['group_release_id', 'status'], 'group_release_milestones_release_status_idx'); + $table->index(['group_release_id', 'due_date'], 'group_release_milestones_release_due_idx'); + }); + + Schema::create('group_contributor_stats', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('credited_artworks_count')->default(0); + $table->unsignedInteger('release_count')->default(0); + $table->unsignedInteger('project_count')->default(0); + $table->unsignedInteger('review_actions_count')->default(0); + $table->unsignedInteger('approved_submissions_count')->default(0); + $table->json('reputation_meta_json')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'user_id'], 'group_contributor_stats_group_user_unique'); + $table->index(['group_id', 'release_count'], 'group_contributor_stats_group_release_idx'); + $table->index(['group_id', 'credited_artworks_count'], 'group_contributor_stats_group_artworks_idx'); + }); + + Schema::create('group_badges', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->string('badge_key', 80); + $table->timestamp('awarded_at'); + $table->json('meta_json')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'badge_key'], 'group_badges_group_badge_unique'); + }); + + Schema::create('group_member_badges', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('badge_key', 80); + $table->timestamp('awarded_at'); + $table->json('meta_json')->nullable(); + $table->timestamps(); + + $table->unique(['group_id', 'user_id', 'badge_key'], 'group_member_badges_group_user_badge_unique'); + $table->index(['group_id', 'user_id'], 'group_member_badges_group_user_idx'); + }); + + Schema::create('group_discovery_metrics', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('groups')->cascadeOnDelete(); + $table->decimal('freshness_score', 8, 2)->default(0); + $table->decimal('activity_score', 8, 2)->default(0); + $table->decimal('release_score', 8, 2)->default(0); + $table->decimal('trust_score', 8, 2)->default(0); + $table->decimal('collaboration_score', 8, 2)->default(0); + $table->timestamp('last_calculated_at')->nullable(); + $table->timestamps(); + + $table->unique('group_id', 'group_discovery_metrics_group_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('group_discovery_metrics'); + Schema::dropIfExists('group_member_badges'); + Schema::dropIfExists('group_badges'); + Schema::dropIfExists('group_contributor_stats'); + Schema::dropIfExists('group_release_milestones'); + Schema::dropIfExists('group_project_milestones'); + Schema::dropIfExists('group_release_contributors'); + Schema::dropIfExists('group_release_artworks'); + Schema::dropIfExists('group_releases'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_05_000001_enhance_news_module_for_nova.php b/database/migrations/2026_04_05_000001_enhance_news_module_for_nova.php new file mode 100644 index 00000000..a8c52bbf --- /dev/null +++ b/database/migrations/2026_04_05_000001_enhance_news_module_for_nova.php @@ -0,0 +1,120 @@ +addNewsArticleColumns(); + $this->backfillNewsArticleColumns(); + } + + if (! Schema::hasTable('news_article_relations')) { + Schema::create('news_article_relations', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('article_id')->index(); + $table->string('entity_type', 40)->index(); + $table->unsignedBigInteger('entity_id')->index(); + $table->string('context_label', 120)->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->foreign('article_id')->references('id')->on('news_articles')->onDelete('cascade'); + $table->unique(['article_id', 'entity_type', 'entity_id'], 'news_article_relations_unique'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('news_article_relations'); + + if (! Schema::hasTable('news_articles')) { + return; + } + + $columns = [ + 'editorial_status', + 'type', + 'is_pinned', + 'canonical_url', + ]; + + $existing = array_values(array_filter($columns, static fn (string $column): bool => Schema::hasColumn('news_articles', $column))); + + if ($existing !== []) { + Schema::table('news_articles', function (Blueprint $table) use ($existing): void { + $table->dropColumn($existing); + }); + } + } + + private function addNewsArticleColumns(): void + { + $needsTableChange = false; + + foreach (['editorial_status', 'type', 'is_pinned', 'canonical_url'] as $column) { + if (! Schema::hasColumn('news_articles', $column)) { + $needsTableChange = true; + break; + } + } + + if (! $needsTableChange) { + return; + } + + Schema::table('news_articles', function (Blueprint $table): void { + if (! Schema::hasColumn('news_articles', 'editorial_status')) { + $table->string('editorial_status', 30)->default('draft')->after('status')->index(); + } + + if (! Schema::hasColumn('news_articles', 'type')) { + $table->string('type', 40)->default('announcement')->after('cover_image')->index(); + } + + if (! Schema::hasColumn('news_articles', 'is_pinned')) { + $table->boolean('is_pinned')->default(false)->after('is_featured')->index(); + } + + if (! Schema::hasColumn('news_articles', 'canonical_url')) { + $table->string('canonical_url')->nullable()->after('meta_description'); + } + }); + } + + private function backfillNewsArticleColumns(): void + { + DB::table('news_articles') + ->select(['id', 'status', 'editorial_status', 'type']) + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $payload = []; + + if (property_exists($row, 'editorial_status') && ($row->editorial_status === null || $row->editorial_status === '')) { + $payload['editorial_status'] = match ((string) $row->status) { + 'published' => 'published', + 'scheduled' => 'scheduled', + default => 'draft', + }; + } + + if (property_exists($row, 'type') && ($row->type === null || $row->type === '')) { + $payload['type'] = 'announcement'; + } + + if ($payload !== []) { + DB::table('news_articles')->where('id', $row->id)->update($payload); + } + } + }); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4a3652e7..8c5e86d1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use Database\Seeders\NovaCardCategorySeeder; use Database\Seeders\NovaCardDemoSeeder; use Database\Seeders\NovaCardTemplateSeeder; +use Database\Seeders\NewsLaunchSeeder; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -24,6 +25,7 @@ class DatabaseSeeder extends Seeder NovaCardCategorySeeder::class, NovaCardTemplateSeeder::class, NovaCardDemoSeeder::class, + NewsLaunchSeeder::class, ]); User::factory()->create([ diff --git a/database/seeders/NewsLaunchSeeder.php b/database/seeders/NewsLaunchSeeder.php new file mode 100644 index 00000000..8f3a5fd3 --- /dev/null +++ b/database/seeders/NewsLaunchSeeder.php @@ -0,0 +1,173 @@ +firstWhere('email', 'newsroom@skinbase.local'); + + if (! $author) { + $author = new User(); + $author->forceFill([ + 'name' => 'Skinbase Editorial', + 'username' => 'skinbaseeditorial', + 'email' => 'newsroom@skinbase.local', + 'password' => Hash::make('password'), + 'role' => 'moderator', + ])->save(); + $author = User::query()->firstWhere('email', 'newsroom@skinbase.local'); + } + + if (! $author) { + return; + } + + $categories = [ + 'platform' => $this->upsertCategory('Platform', 'platform', 'Product updates, roadmap notes, and feature launches.'), + 'groups' => $this->upsertCategory('Groups', 'groups', 'Stories about collaborative publishing, teams, and shared identity.'), + 'tutorials' => $this->upsertCategory('Tutorials', 'tutorials', 'Practical guides for getting more from Nova.'), + 'spotlight' => $this->upsertCategory('Spotlight', 'spotlight', 'Featured creators, groups, and standout work.'), + 'releases' => $this->upsertCategory('Releases', 'releases', 'Coverage for launches, drops, and project milestones.'), + ]; + + $tags = [ + 'nova' => $this->upsertTag('Nova', 'nova'), + 'tutorial' => $this->upsertTag('Tutorial', 'tutorial'), + 'groups' => $this->upsertTag('Groups', 'groups'), + 'release' => $this->upsertTag('Release', 'release'), + 'spotlight' => $this->upsertTag('Spotlight', 'spotlight'), + 'platform-update' => $this->upsertTag('Platform Update', 'platform-update'), + ]; + + $articles = [ + [ + 'slug' => 'welcome-to-skinbase-nova', + 'title' => 'Welcome to Skinbase Nova', + 'type' => NewsArticle::TYPE_PLATFORM_UPDATE, + 'category' => $categories['platform'], + 'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.', + 'content' => "# Welcome to Skinbase Nova\n\nSkinbase Nova brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.", + 'tags' => [$tags['nova'], $tags['platform-update']], + 'days_ago' => 10, + 'featured' => true, + 'pinned' => true, + ], + [ + 'slug' => 'introducing-groups-in-nova', + 'title' => 'Introducing Groups in Nova', + 'type' => NewsArticle::TYPE_ANNOUNCEMENT, + 'category' => $categories['groups'], + 'excerpt' => 'Groups give collaborative identities a real home across publishing, releases, events, and project storytelling.', + 'content' => "# Introducing Groups\n\nGroups let creators publish under a shared identity while still keeping contributor context visible.\n\n## Why it matters\n\nGroups are more than labels. They support:\n\n- shared publishing\n- releases and milestones\n- recruiting and discovery\n- public storytelling\n\nThat makes Nova feel much more alive than a simple profile-only platform.", + 'tags' => [$tags['groups'], $tags['nova']], + 'days_ago' => 9, + 'featured' => true, + 'pinned' => false, + ], + [ + 'slug' => 'how-studio-works-in-nova', + 'title' => 'How Studio Works in Nova', + 'type' => NewsArticle::TYPE_TUTORIAL, + 'category' => $categories['tutorials'], + 'excerpt' => 'A practical walkthrough of drafts, scheduling, content editing, and publishing flow inside Studio.', + 'content' => "# How Studio Works\n\nStudio is the control layer for publishing in Nova.\n\n## Start with drafts\n\nUse Studio to prepare content, tune metadata, and move work through review before publishing.\n\n## Move with intent\n\nThe best results come from treating Studio as a workflow, not just a form. Draft, refine, preview, then publish.", + 'tags' => [$tags['tutorial'], $tags['nova']], + 'days_ago' => 7, + 'featured' => false, + 'pinned' => false, + ], + [ + 'slug' => 'how-to-upload-your-first-artwork', + 'title' => 'How to Upload Your First Artwork', + 'type' => NewsArticle::TYPE_TUTORIAL, + 'category' => $categories['tutorials'], + 'excerpt' => 'A short guide to cleaner uploads, stronger metadata, and better first impressions.', + 'content' => "# Upload Your First Artwork\n\nA strong upload starts with the basics:\n\n- a clear title\n- a readable description\n- accurate categories and tags\n- a polished thumbnail or cover\n\nGood publishing habits make discovery, search, and editorial coverage work better for you.", + 'tags' => [$tags['tutorial']], + 'days_ago' => 6, + 'featured' => false, + 'pinned' => false, + ], + [ + 'slug' => 'creator-spotlight-building-a-recognizable-profile', + 'title' => 'Creator Spotlight: Building a Recognizable Profile', + 'type' => NewsArticle::TYPE_SPOTLIGHT, + 'category' => $categories['spotlight'], + 'excerpt' => 'Profiles work better when identity, consistency, and a clear body of work all point in the same direction.', + 'content' => "# Creator Spotlight\n\nGreat profiles are memorable because they feel intentional.\n\n## Strong profile signals\n\n- consistent visual identity\n- complete bio and links\n- a curated set of standout uploads\n- a clear publishing rhythm\n\nNova rewards clarity and consistency.", + 'tags' => [$tags['spotlight']], + 'days_ago' => 4, + 'featured' => false, + 'pinned' => false, + ], + [ + 'slug' => 'release-roundup-whats-new-this-week', + 'title' => 'Release Roundup: What\'s New This Week', + 'type' => NewsArticle::TYPE_RELEASE, + 'category' => $categories['releases'], + 'excerpt' => 'A compact editorial roundup of recent launches, notable drops, and community momentum.', + 'content' => "# Release Roundup\n\nThis week\'s standout launches show why Nova needs editorial context alongside uploads.\n\nRelease coverage helps people discover:\n\n- the work itself\n- the team behind it\n- related projects and collections\n- where the story continues next", + 'tags' => [$tags['release'], $tags['spotlight']], + 'days_ago' => 2, + 'featured' => false, + 'pinned' => false, + ], + ]; + + foreach ($articles as $article) { + $record = NewsArticle::query()->firstOrNew(['slug' => $article['slug']]); + $record->forceFill([ + 'title' => $article['title'], + 'slug' => $article['slug'], + 'excerpt' => $article['excerpt'], + 'content' => $article['content'], + 'author_id' => $author->id, + 'category_id' => $article['category']->id, + 'type' => $article['type'], + 'status' => 'published', + 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, + 'published_at' => Carbon::now()->subDays($article['days_ago'])->setTime(10, 0), + 'is_featured' => $article['featured'], + 'is_pinned' => $article['pinned'], + 'meta_title' => $article['title'], + 'meta_description' => $article['excerpt'], + 'deleted_at' => null, + ])->save(); + + $record->tags()->sync(array_map(static fn (NewsTag $tag): int => (int) $tag->id, $article['tags'])); + } + } + + private function upsertCategory(string $name, string $slug, string $description): NewsCategory + { + return NewsCategory::query()->updateOrCreate( + ['slug' => $slug], + [ + 'name' => $name, + 'description' => $description, + 'position' => 0, + 'is_active' => true, + ] + ); + } + + private function upsertTag(string $name, string $slug): NewsTag + { + return NewsTag::query()->updateOrCreate( + ['slug' => $slug], + ['name' => $name] + ); + } +} \ No newline at end of file diff --git a/resources/js/Layouts/StudioLayout.jsx b/resources/js/Layouts/StudioLayout.jsx index b87e84f2..8d50255b 100644 --- a/resources/js/Layouts/StudioLayout.jsx +++ b/resources/js/Layouts/StudioLayout.jsx @@ -2,12 +2,13 @@ import React, { useEffect, useState } from 'react' import { Link, usePage } from '@inertiajs/react' import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents' -const navGroups = [ +const baseNavGroups = [ { label: 'Creator Studio', items: [ { label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' }, { label: 'Search', href: '/studio/search', icon: 'fa-solid fa-magnifying-glass' }, + { label: 'Groups', href: '/studio/groups', icon: 'fa-solid fa-people-group' }, ], }, { @@ -67,13 +68,82 @@ const navGroups = [ }, ] -const quickCreateItems = [ +const baseQuickCreateItems = [ { label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' }, { label: 'Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' }, { label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' }, ] +const STUDIO_CONTEXT_STORAGE_KEY = 'sb.studio.last-context' +const RESTORABLE_STUDIO_PATHS = ['/studio', '/studio/artworks', '/studio/collections', '/studio/settings'] + +function supportsStudioContextRestore(pathname) { + return RESTORABLE_STUDIO_PATHS.includes(pathname) +} + +function studioRouteKeyForPath(pathname) { + if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return 'studio_artworks_url' + if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return 'studio_collections_url' + if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return 'studio_settings_url' + if (pathname.endsWith('/members')) return 'studio_members_url' + if (pathname.endsWith('/invitations')) return 'studio_invitations_url' + + return 'studio_url' +} + +function nestedRouteKeyFor(topLevelRouteKey) { + return topLevelRouteKey.replace(/_url$/, '') +} + +function groupStudioUrlForPath(group, pathname) { + if (!group) return '/studio' + + const routeKey = studioRouteKeyForPath(pathname) + const nestedRouteKey = nestedRouteKeyFor(routeKey) + + return group[routeKey] || group.urls?.[nestedRouteKey] || group.studio_url || group.urls?.studio || '/studio' +} + +function personalStudioUrlForPath(pathname) { + if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return '/studio/artworks' + if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return '/studio/collections' + if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return '/studio/settings' + + return '/studio' +} + +function persistStudioContext(slug) { + if (typeof window === 'undefined') return + + try { + window.sessionStorage.setItem(STUDIO_CONTEXT_STORAGE_KEY, slug || '') + } catch { + // Ignore storage failures so Studio navigation keeps working. + } +} + +function readPersistedStudioContext() { + if (typeof window === 'undefined') return null + + try { + return window.sessionStorage.getItem(STUDIO_CONTEXT_STORAGE_KEY) + } catch { + return null + } +} + +function navigateToStudioUrl(targetUrl) { + if (typeof window === 'undefined' || !targetUrl) return + + if (typeof window.location?.assign === 'function') { + window.location.assign(targetUrl) + return + } + + window.location.href = targetUrl +} + function NavLink({ item, active }) { return ( { + if (!canManageNews || group.label !== 'Content') { + return group + } + + return { + ...group, + items: [ + ...group.items, + { label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' }, + ], + } + }) + + const quickCreateItems = (canManageNews + ? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }] + : baseQuickCreateItems + ).map((item) => { + if (currentGroup?.urls && item.label === 'Artwork') { + return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href } + } + + if (currentGroup?.urls && item.label === 'Collection') { + return { ...item, href: `/settings/collections/create?group=${currentGroup.slug}` } + } + + return item + }) useEffect(() => { const moduleKey = studioModule(pathname) @@ -111,6 +213,31 @@ export default function StudioLayout({ children, title, subtitle, actions }) { }) }, [pathname]) + useEffect(() => { + if (!currentGroup?.slug) return + + persistStudioContext(currentGroup.slug) + }, [currentGroup?.slug]) + + useEffect(() => { + if (currentGroup || !supportsStudioContextRestore(pathname)) return + + const storedSlug = readPersistedStudioContext() + if (!storedSlug) return + + const nextGroup = studioGroups.find((group) => group.slug === storedSlug) + if (!nextGroup) { + persistStudioContext('') + return + } + + const targetUrl = groupStudioUrlForPath(nextGroup, pathname) + + if (targetUrl && targetUrl !== pathname) { + navigateToStudioUrl(targetUrl) + } + }, [currentGroup, pathname, studioGroups]) + const isActive = (href) => { if (href === '/studio') return pathname === '/studio' return pathname.startsWith(href) @@ -127,6 +254,19 @@ export default function StudioLayout({ children, title, subtitle, actions }) { }) } + const handleContextChange = (nextSlug) => { + persistStudioContext(nextSlug) + + const nextGroup = studioGroups.find((group) => group.slug === nextSlug) + const targetUrl = nextGroup + ? groupStudioUrlForPath(nextGroup, pathname) + : personalStudioUrlForPath(pathname) + + if (targetUrl !== pathname) { + navigateToStudioUrl(targetUrl) + } + } + return (
@@ -151,14 +291,31 @@ export default function StudioLayout({ children, title, subtitle, actions }) { className="absolute left-0 top-0 bottom-0 w-80 overflow-y-auto border-r border-white/10 bg-slate-950 p-4 pt-20" onClick={(event) => event.stopPropagation()} > - setMobileOpen(false)} onQuickCreate={handleQuickCreateClick} /> + setMobileOpen(false)} + onQuickCreate={handleQuickCreateClick} + onContextChange={handleContextChange} + />
)}
@@ -168,9 +325,11 @@ export default function StudioLayout({ children, title, subtitle, actions }) {

Creator Studio

{title &&

{title}

} {subtitle &&

{subtitle}

} + {currentGroup ?

Group context: {currentGroup.name}

: null}
+ {studioGroups.length > 0 ? : null} {actions}
: null} +
+
+ {post.type ? {post.type} : null} + {post.is_pinned ? Pinned : null} +
+

{post.title}

+
{post.author?.name || post.author?.username || group.name} • {post.published_at ? new Date(post.published_at).toLocaleString() : 'Recently'}
+ {post.excerpt ?

{post.excerpt}

: null} +
{post.content || ''}
+ + + {recentPosts.length > 0 ? ( +
+

More from {group.name}

+
+ {recentPosts.filter((item) => item.id !== post.id).map((item) => ( + +
{item.type}
+
{item.title}
+

{item.excerpt || 'Read the full post.'}

+
+ ))} +
+
+ ) : null} +
+ + ) +} \ No newline at end of file diff --git a/resources/js/Pages/Group/GroupProjectShow.jsx b/resources/js/Pages/Group/GroupProjectShow.jsx new file mode 100644 index 00000000..746b948e --- /dev/null +++ b/resources/js/Pages/Group/GroupProjectShow.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import { usePage } from '@inertiajs/react' +import SeoHead from '../../components/seo/SeoHead' + +function ArtworkGrid({ artworks }) { + if (!Array.isArray(artworks) || artworks.length === 0) { + return

No linked artworks yet.

+ } + + return ( +
+ {artworks.map((artwork) => ( + + {artwork.thumb ? {artwork.title} : null} +
+

{artwork.title}

+

{artwork.author || 'Artwork'}

+
+
+ ))} +
+ ) +} + +export default function GroupProjectShow() { + const { props } = usePage() + const group = props.group || {} + const project = props.project || {} + + return ( +
+ +
+
+ {project.cover_url ? {project.title} :
} +
+
+ {group.name} + {project.status} + {project.visibility} +
+

{project.title}

+ {project.summary ?

{project.summary}

: null} +
+ {project.start_date ? Started {new Date(project.start_date).toLocaleDateString()} : null} + {project.target_date ? Target {new Date(project.target_date).toLocaleDateString()} : null} + {project.released_at ? Released {new Date(project.released_at).toLocaleDateString()} : null} + {project.lead?.name || project.lead?.username ? Lead: {project.lead?.name || project.lead?.username} : null} +
+
+
+ +
+
+

Overview

+

{project.description || 'No long-form description yet.'}

+ {Array.isArray(project.milestones) && project.milestones.length > 0 ?
{project.milestones.map((milestone) =>
{milestone.title}
{milestone.status}
{milestone.summary ?

{milestone.summary}

: null}{milestone.owner?.name || milestone.owner?.username ?
Owner: {milestone.owner?.name || milestone.owner?.username}
: null}
)}
: null} + +
+ +
+
+

Pipeline

+
This project currently has {project.counts?.milestones || 0} milestones and is linked to {project.release_count || project.counts?.releases || 0} releases.
+
+ {Array.isArray(project.assets) && project.assets.length > 0 ? ( +
+

Assets

+
+ {project.assets.map((asset) => ( + +
{asset.title}
+
{asset.category} • {asset.visibility}
+
+ ))} +
+
+ ) : null} + + {Array.isArray(project.team) && project.team.length > 0 ? ( +
+

Team

+
+ {project.team.map((member) => ( +
+
{member.name || member.username}
+
{member.role_label || (member.is_lead ? 'Lead' : 'Contributor')}
+
+ ))} +
+
+ ) : null} + + {project.pinned_post ? ( +
+

Pinned update

+ {project.pinned_post.title} +
+ ) : null} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Group/GroupQuickstartPage.jsx b/resources/js/Pages/Group/GroupQuickstartPage.jsx new file mode 100644 index 00000000..46da85ae --- /dev/null +++ b/resources/js/Pages/Group/GroupQuickstartPage.jsx @@ -0,0 +1,322 @@ +import React from 'react' +import { usePage } from '@inertiajs/react' +import DocsCallout from '../../components/docs/DocsCallout' +import DocsSection from '../../components/docs/DocsSection' +import DocsSidebarNav from '../../components/docs/DocsSidebarNav' +import DocsStepList from '../../components/docs/DocsStepList' +import QuickstartChecklist from '../../components/docs/QuickstartChecklist' +import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps' +import SeoHead from '../../components/seo/SeoHead' +import { + COMPARISON_CARDS, + COMMON_MISTAKES, + CREATE_STEPS, + CREDIT_TERMS, + FIRST_WEEK_BEST_PRACTICES, + GOOD_FIT, + NEXT_STEPS, + NOT_NEEDED_YET, + PUBLISH_STEPS, + QUICK_CHECKLIST, + ROLE_CARDS, + SECTION_ITEMS, + SETUP_TASKS, +} from './groupQuickstartContent' + +function HeroStat({ label, value, note }) { + return ( +
+
{label}
+
{value}
+

{note}

+
+ ) +} + +function ComparisonCard({ card }) { + return ( +
+
+ +
+

{card.title}

+
    + {card.bullets.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ) +} + +function SimpleListCard({ title, eyebrow, items, tone = 'sky' }) { + const toneClass = tone === 'emerald' + ? 'border-emerald-300/15 bg-emerald-400/10 text-emerald-50' + : 'border-white/10 bg-black/20 text-white' + + return ( +
+

{eyebrow}

+

{title}

+
    + {items.map((item) => ( +
  • + + + + {item} +
  • + ))} +
+
+ ) +} + +function RoleCard({ role }) { + return ( +
+
+ {role.role} +
+

{role.summary}

+

{role.note}

+
+ ) +} + +function CompactGrid({ items }) { + return ( +
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+ ) +} + +function CreditCard({ item }) { + return ( +
+
{item.label}
+
{item.value}
+

{item.note}

+
+ ) +} + +export default function GroupQuickstartPage() { + const { props } = usePage() + const links = props.links || {} + const nextSteps = NEXT_STEPS.map((item) => ({ + ...item, + href: links[item.linkKey] || '#', + })) + + const jsonLd = [ + { + '@context': 'https://schema.org', + '@type': 'Article', + headline: props.title, + description: props.description, + url: props.seo?.canonical, + author: { + '@type': 'Organization', + name: 'Skinbase', + }, + about: ['Groups', 'Quickstart', 'Collaborative publishing', 'Contributor credit'], + }, + ] + + return ( +
+ + +
+
+
+
+

Groups quickstart

+

Get started with Groups fast and publish together without losing individual credit.

+

This quickstart is the fast path from curiosity to first success. It shows what a Group is, when to use one, how to invite the right people, and how to publish your first Group artwork with contributor credit handled properly.

+ + {links.faq ? ( + + ) : null} +
+ +
+ + + +
+
+
+ +
+ + +
+ +
+ {COMPARISON_CARDS.map((card) => )} +
+ +
+ + Group and personal publishing can coexist. A Group gives the team a shared identity, but it should not erase the people behind the work. + +
+
+ + +
+ + +
+
+ + + + +
+ + You do not need perfect branding or a complex team structure on day one. You need a clear name, a usable page, and the right first members. + +
+
+ + + + + + +
+ {ROLE_CARDS.map((role) => )} +
+ +
+ + Invite your first members, assign only the roles they need right now, and avoid advanced permission tuning until the team has real workflow pressure. + + + If you need permission overrides later, you can add them later. The quickstart path is deliberately simpler than the full feature set. + +
+
+ + + + +
+ + Confirm whether you are publishing as your personal profile or as the Group. That one check prevents a lot of cleanup later. + +
+
+ + +
+ {CREDIT_TERMS.map((item) => )} +
+ +
+ + Review contributor credit before every first release, first Group artwork, or first major collaborative drop. Do not leave attribution as an afterthought. + +
+
+ + + + + + +
+ {COMMON_MISTAKES.map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+ +
+ + + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Group/GroupReleaseShow.jsx b/resources/js/Pages/Group/GroupReleaseShow.jsx new file mode 100644 index 00000000..7e44fb51 --- /dev/null +++ b/resources/js/Pages/Group/GroupReleaseShow.jsx @@ -0,0 +1,110 @@ +import React from 'react' +import { usePage } from '@inertiajs/react' +import SeoHead from '../../components/seo/SeoHead' + +function ArtworkGrid({ artworks }) { + if (!Array.isArray(artworks) || artworks.length === 0) { + return

No linked artworks yet.

+ } + + return ( + + ) +} + +export default function GroupReleaseShow() { + const { props } = usePage() + const group = props.group || {} + const release = props.release || {} + const contributors = Array.isArray(release.contributors) ? release.contributors : [] + const milestones = Array.isArray(release.milestones) ? release.milestones : [] + + return ( +
+ +
+
+ {release.cover_url ? {release.title} :
} +
+
+ {group.name} + {release.status ? {release.status} : null} + {release.current_stage ? {release.current_stage} : null} +
+

{release.title}

+ {release.summary ?

{release.summary}

: null} +
+ {release.released_at ? Released {new Date(release.released_at).toLocaleDateString()} : null} + {release.planned_release_at ? Planned {new Date(release.planned_release_at).toLocaleDateString()} : null} + {release.lead?.name || release.lead?.username ? Lead: {release.lead?.name || release.lead?.username} : null} + {release.counts?.artworks || 0} artworks + {release.counts?.contributors || 0} contributors + {release.counts?.milestones || 0} milestones +
+
+
+ +
+
+

Overview

+

{release.description || 'No long-form release description yet.'}

+ {release.release_notes ?
Release notes
{release.release_notes}
: null} + +
+ +
+
+

Links

+
+ {release.linked_project?.url ?
{release.linked_project.title}
Linked project
: null} + {release.linked_collection?.url ?
{release.linked_collection.title}
Linked collection
: null} + {release.featured_artwork ?
{release.featured_artwork.title}
Featured artwork
: null} +
+
+ +
+

Contributors

+
+ {contributors.length > 0 ? contributors.map((contributor) => ( +
+ {contributor.avatar_url ? {contributor.name :
} +
+
{contributor.name || contributor.username}
+
{contributor.role_label || 'Contributor'}
+
+
+ )) :

No contributor credits yet.

} +
+
+ +
+

Milestones

+
+ {milestones.length > 0 ? milestones.map((milestone) => ( +
+
+
{milestone.title}
+ {milestone.status} +
+ {milestone.summary ?

{milestone.summary}

: null} +
{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}
+
+ )) :

No milestones defined yet.

} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Group/GroupShow.jsx b/resources/js/Pages/Group/GroupShow.jsx new file mode 100644 index 00000000..48401f0f --- /dev/null +++ b/resources/js/Pages/Group/GroupShow.jsx @@ -0,0 +1,987 @@ +import React, { useState } from 'react' +import { router, usePage } from '@inertiajs/react' +import SeoHead from '../../components/seo/SeoHead' +import useWebShare from '../../hooks/useWebShare' + +function normalizeText(value) { + return String(value || '').trim().toLowerCase() +} + +function formatCompactNumber(value) { + return Number(value ?? 0).toLocaleString() +} + +function formatDateLabel(value) { + if (!value) return null + + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) +} + +function websiteLabel(url) { + if (!url) return null + + try { + const parsed = new URL(url.startsWith('http') ? url : `https://${url}`) + return parsed.hostname + } catch { + return String(url).replace(/^https?:\/\//, '') + } +} + +const SECTION_TABS = [ + { id: 'overview', label: 'Overview', icon: 'fa-compass' }, + { id: 'artworks', label: 'Artworks', icon: 'fa-images' }, + { id: 'collections', label: 'Collections', icon: 'fa-layer-group' }, + { id: 'posts', label: 'Posts', icon: 'fa-newspaper' }, + { id: 'projects', label: 'Projects', icon: 'fa-diagram-project' }, + { id: 'releases', label: 'Releases', icon: 'fa-rocket' }, + { id: 'challenges', label: 'Challenges', icon: 'fa-trophy' }, + { id: 'events', label: 'Events', icon: 'fa-calendar-days' }, + { id: 'activity', label: 'Activity', icon: 'fa-bolt' }, + { id: 'members', label: 'Members', icon: 'fa-users' }, + { id: 'about', label: 'About', icon: 'fa-id-card' }, +] + +function sectionHref(baseUrl, tab) { + return tab === 'overview' ? baseUrl : `${baseUrl}/${tab}` +} + +function GroupTabs({ baseUrl, activeSection }) { + return ( +
+ +
+ ) +} + +function GroupHero({ + group, + recruitment, + trustSignals, + following, + followersCount, + currentJoinRequest, + shareLabel, + onToggleFollow, + onJoinRequest, + onWithdrawJoinRequest, + onShare, + onReport, + reportEndpoint, +}) { + const activeSignals = Array.isArray(trustSignals) ? trustSignals.slice(0, 3) : [] + const joinDate = formatDateLabel(group.founded_at || group.created_at) + const heroStats = [ + { label: 'Followers', value: formatCompactNumber(followersCount) }, + { label: 'Members', value: formatCompactNumber(group.counts?.members) }, + { label: 'Artworks', value: formatCompactNumber(group.counts?.artworks) }, + { label: 'Collections', value: formatCompactNumber(group.counts?.collections) }, + ] + + return ( +
+